test: add auth routes integration tests
Made-with: Cursor
This commit is contained in:
46
tests/helpers/build-test-app.ts
Normal file
46
tests/helpers/build-test-app.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import Fastify, { FastifyInstance } from 'fastify';
|
||||
import { AppError } from '../../src/utils/errors.js';
|
||||
import { authRoutes } from '../../src/routes/auth.js';
|
||||
import type { MockDb } from '../test-utils.js';
|
||||
import { createMockDb } from '../test-utils.js';
|
||||
|
||||
/**
|
||||
* Build a minimal Fastify app for auth route integration tests.
|
||||
* Uses mock db and rate limit options (no actual rate limiting).
|
||||
*/
|
||||
export async function buildAuthTestApp(mockDb?: MockDb): Promise<FastifyInstance> {
|
||||
const db = mockDb ?? createMockDb();
|
||||
|
||||
const app = Fastify({
|
||||
logger: false,
|
||||
requestIdHeader: 'x-request-id',
|
||||
requestIdLogLabel: 'requestId',
|
||||
});
|
||||
|
||||
app.setErrorHandler((err: unknown, request, reply) => {
|
||||
const error = err as Error & { statusCode?: number; validation?: unknown };
|
||||
if (err instanceof AppError) {
|
||||
return reply.status(err.statusCode).send(err.toJSON());
|
||||
}
|
||||
if (error.validation) {
|
||||
return reply.status(422).send({
|
||||
error: { code: 'VALIDATION_ERROR', message: 'Validation failed', details: error.validation },
|
||||
});
|
||||
}
|
||||
return reply.status(500).send({ error: { code: 'INTERNAL_ERROR', message: error.message } });
|
||||
});
|
||||
|
||||
app.decorate('db', db);
|
||||
app.decorate('rateLimitOptions', {
|
||||
login: { max: 100, timeWindow: '1 minute' },
|
||||
register: { max: 100, timeWindow: '1 hour' },
|
||||
forgotPassword: { max: 100, timeWindow: '1 hour' },
|
||||
verifyEmail: { max: 100, timeWindow: '15 minutes' },
|
||||
apiAuthed: { max: 100, timeWindow: '1 minute' },
|
||||
apiGuest: { max: 100, timeWindow: '1 minute' },
|
||||
});
|
||||
|
||||
await app.register(authRoutes, { prefix: '/auth' });
|
||||
|
||||
return app;
|
||||
}
|
||||
265
tests/integration/auth.routes.test.ts
Normal file
265
tests/integration/auth.routes.test.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { buildAuthTestApp } from '../helpers/build-test-app.js';
|
||||
import {
|
||||
createMockDb,
|
||||
selectChain,
|
||||
insertChain,
|
||||
updateChain,
|
||||
deleteChain,
|
||||
} from '../test-utils.js';
|
||||
|
||||
vi.mock('../../src/utils/password.js', () => ({
|
||||
hashPassword: vi.fn().mockResolvedValue('hashed-password'),
|
||||
verifyPassword: vi.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/utils/jwt.js', () => ({
|
||||
signAccessToken: vi.fn().mockResolvedValue('access-token'),
|
||||
signRefreshToken: vi.fn().mockResolvedValue('refresh-token'),
|
||||
verifyToken: vi.fn(),
|
||||
isRefreshPayload: vi.fn(),
|
||||
hashToken: vi.fn((t: string) => `hash-${t}`),
|
||||
}));
|
||||
|
||||
vi.mock('node:crypto', () => ({
|
||||
randomBytes: vi.fn(() => ({ toString: () => 'abc123' })),
|
||||
randomUUID: vi.fn(() => 'uuid-session-1'),
|
||||
}));
|
||||
|
||||
import { isRefreshPayload, verifyToken } from '../../src/utils/jwt.js';
|
||||
|
||||
describe('Auth routes integration', () => {
|
||||
let app: Awaited<ReturnType<typeof buildAuthTestApp>>;
|
||||
let mockDb: ReturnType<typeof createMockDb>;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
mockDb = createMockDb();
|
||||
app = await buildAuthTestApp(mockDb as never);
|
||||
});
|
||||
|
||||
describe('POST /auth/register', () => {
|
||||
it('returns 201 with userId and verificationCode when registration succeeds', async () => {
|
||||
(mockDb.select as ReturnType<typeof vi.fn>)
|
||||
.mockReturnValueOnce(selectChain([]))
|
||||
.mockReturnValueOnce(selectChain([]));
|
||||
(mockDb.insert as ReturnType<typeof vi.fn>)
|
||||
.mockReturnValueOnce(insertChain([{ id: 'user-123' }]))
|
||||
.mockReturnValueOnce(insertChain([]));
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/auth/register',
|
||||
payload: {
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
nickname: 'tester',
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.userId).toBe('user-123');
|
||||
expect(body.message).toContain('verify your email');
|
||||
expect(body.verificationCode).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns 409 when email is already taken', async () => {
|
||||
(mockDb.select as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
||||
selectChain([{ id: 'existing', email: 'test@example.com' }])
|
||||
);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/auth/register',
|
||||
payload: {
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
nickname: 'tester',
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(409);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.error?.code).toBe('EMAIL_TAKEN');
|
||||
});
|
||||
|
||||
it('returns 422 when validation fails', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/auth/register',
|
||||
payload: {
|
||||
email: 'short',
|
||||
password: '123', // too short
|
||||
nickname: 'x', // too short
|
||||
},
|
||||
});
|
||||
|
||||
expect([400, 422]).toContain(res.statusCode);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.error?.code ?? body.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /auth/login', () => {
|
||||
it('returns tokens when credentials are valid', async () => {
|
||||
const mockUser = {
|
||||
id: 'user-1',
|
||||
email: 'test@example.com',
|
||||
passwordHash: 'hashed',
|
||||
};
|
||||
(mockDb.select as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
||||
selectChain([mockUser])
|
||||
);
|
||||
(mockDb.insert as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
||||
insertChain([])
|
||||
);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/auth/login',
|
||||
payload: { email: 'test@example.com', password: 'password123' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.accessToken).toBe('access-token');
|
||||
expect(body.refreshToken).toBe('refresh-token');
|
||||
});
|
||||
|
||||
it('returns 401 when credentials are invalid', async () => {
|
||||
(mockDb.select as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
||||
selectChain([])
|
||||
);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/auth/login',
|
||||
payload: { email: 'unknown@example.com', password: 'wrong' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /auth/logout', () => {
|
||||
it('returns 204 on success', async () => {
|
||||
(mockDb.delete as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
||||
deleteChain()
|
||||
);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/auth/logout',
|
||||
payload: { refreshToken: 'some-token' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(204);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /auth/refresh', () => {
|
||||
it('returns new tokens when refresh token is valid', async () => {
|
||||
vi.mocked(verifyToken).mockResolvedValueOnce({
|
||||
sub: 'user-1',
|
||||
sid: 'sid-1',
|
||||
type: 'refresh',
|
||||
} as never);
|
||||
vi.mocked(isRefreshPayload).mockReturnValueOnce(true);
|
||||
(mockDb.select as ReturnType<typeof vi.fn>)
|
||||
.mockReturnValueOnce(selectChain([{ id: 'sess-1', userId: 'user-1' }]))
|
||||
.mockReturnValueOnce(selectChain([{ id: 'user-1', email: 'test@example.com' }]));
|
||||
(mockDb.delete as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
||||
deleteChain()
|
||||
);
|
||||
(mockDb.insert as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
||||
insertChain([])
|
||||
);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/auth/refresh',
|
||||
payload: { refreshToken: 'valid-refresh-token' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.accessToken).toBe('access-token');
|
||||
expect(body.refreshToken).toBe('refresh-token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /auth/verify-email', () => {
|
||||
it('returns success when code is valid', async () => {
|
||||
const mockCode = {
|
||||
id: 'code-1',
|
||||
userId: 'user-1',
|
||||
code: 'ABC123',
|
||||
expiresAt: new Date(Date.now() + 60000),
|
||||
};
|
||||
(mockDb.select as ReturnType<typeof vi.fn>)
|
||||
.mockReturnValueOnce(selectChain([mockCode]))
|
||||
.mockReturnValueOnce(selectChain([{ id: 'user-1', emailVerifiedAt: null }]));
|
||||
(mockDb.update as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
||||
updateChain([{ id: 'user-1' }])
|
||||
);
|
||||
(mockDb.delete as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
||||
deleteChain()
|
||||
);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/auth/verify-email',
|
||||
payload: { userId: 'user-1', code: 'ABC123' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /auth/forgot-password', () => {
|
||||
it('returns 200 with generic message', async () => {
|
||||
(mockDb.select as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
||||
selectChain([{ id: 'user-1', email: 'test@example.com' }])
|
||||
);
|
||||
(mockDb.insert as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
||||
insertChain([])
|
||||
);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/auth/forgot-password',
|
||||
payload: { email: 'test@example.com' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /auth/reset-password', () => {
|
||||
it('returns 200 when token is valid', async () => {
|
||||
const mockRecord = {
|
||||
id: 'rec-1',
|
||||
userId: 'user-1',
|
||||
expiresAt: new Date(Date.now() + 60000),
|
||||
};
|
||||
(mockDb.select as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
||||
selectChain([mockRecord])
|
||||
);
|
||||
(mockDb.update as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
||||
updateChain([{ id: 'user-1' }])
|
||||
);
|
||||
(mockDb.delete as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
||||
deleteChain()
|
||||
);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/auth/reset-password',
|
||||
payload: { token: 'valid-token', newPassword: 'newPassword123' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user