40227-vm/backend/src/api/controllers/auth.controller.test.ts
2026-06-12 06:55:35 +02:00

552 lines
15 KiB
TypeScript

/**
* Auth controller unit tests.
*
* Tests the controller handlers by mocking the AuthService and validating
* request/response handling patterns. Uses type-safe mocks without type casting.
*/
import { test, describe, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { createTestUser } from '@/test-utils';
// --- Type guard ---
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
// --- Mock request/response factories ---
interface MockResponse {
statusCode: number;
body: unknown;
cookies: Map<string, { value: string; options: Record<string, unknown> }>;
clearedCookies: string[];
redirectUrl: string | null;
status: (code: number) => MockResponse;
send: (body: unknown) => MockResponse;
cookie: (name: string, value: string, options?: Record<string, unknown>) => MockResponse;
clearCookie: (name: string, options?: Record<string, unknown>) => MockResponse;
redirect: (url: string) => void;
}
function createMockResponse(): MockResponse {
const res: MockResponse = {
statusCode: 200,
body: null,
cookies: new Map(),
clearedCookies: [],
redirectUrl: null,
status(code: number) {
this.statusCode = code;
return this;
},
send(body: unknown) {
this.body = body;
return this;
},
cookie(name: string, value: string, options: Record<string, unknown> = {}) {
this.cookies.set(name, { value, options });
return this;
},
clearCookie(name: string) {
this.clearedCookies.push(name);
return this;
},
redirect(url: string) {
this.redirectUrl = url;
},
};
return res;
}
interface MockRequest {
body: Record<string, unknown>;
query: Record<string, unknown>;
params: Record<string, unknown>;
headers: Record<string, string>;
cookies: Record<string, string>;
currentUser?: ReturnType<typeof createTestUser> | { id: null };
ip: string;
socket: { remoteAddress: string };
protocol: string;
hostname: string;
originalUrl: string;
}
function createMockRequest(overrides: Partial<MockRequest> = {}): MockRequest {
return {
body: {},
query: {},
params: {},
headers: {
'user-agent': 'test-agent',
referer: 'http://localhost:3000/',
},
cookies: {},
ip: '127.0.0.1',
socket: { remoteAddress: '127.0.0.1' },
protocol: 'http',
hostname: 'localhost',
originalUrl: '/api/auth/signin/local',
...overrides,
};
}
// --- Type-safe mock types ---
interface MockUser {
id: string;
email: string;
organizationId?: string | null;
}
interface MockSession {
accessToken: string;
refreshToken: string;
user: MockUser;
}
interface MockProfile {
id: string;
email: string;
firstName: string;
lastName: string;
permissions: string[];
}
interface TypedMock<TReturn> {
callCount: number;
returnValue: TReturn;
call: () => Promise<TReturn>;
}
function createTypedMock<TReturn>(defaultReturn: TReturn): TypedMock<TReturn> {
return {
callCount: 0,
returnValue: defaultReturn,
call: async function () {
this.callCount++;
return this.returnValue;
},
};
}
interface MockAuthService {
signin: TypedMock<{ user: MockUser }>;
createSession: TypedMock<MockSession>;
currentUserProfile: TypedMock<MockProfile>;
refreshSession: TypedMock<MockSession>;
revokeSession: TypedMock<void>;
signup: TypedMock<{ user: MockUser }>;
passwordReset: TypedMock<boolean>;
passwordUpdate: TypedMock<boolean>;
verifyEmail: TypedMock<boolean>;
}
function createMockAuthService(): MockAuthService {
return {
signin: createTypedMock({ user: { id: 'user-1', email: 'test@example.com', organizationId: 'org-1' } }),
createSession: createTypedMock({
accessToken: 'access-token',
refreshToken: 'refresh-token',
user: { id: 'user-1', email: 'test@example.com' },
}),
currentUserProfile: createTypedMock({
id: 'user-1',
email: 'test@example.com',
firstName: 'Test',
lastName: 'User',
permissions: ['READ_DASHBOARD'],
}),
refreshSession: createTypedMock({
accessToken: 'new-access-token',
refreshToken: 'new-refresh-token',
user: { id: 'user-1', email: 'test@example.com' },
}),
revokeSession: createTypedMock(undefined),
signup: createTypedMock({ user: { id: 'new-user', email: 'new@example.com', organizationId: null } }),
passwordReset: createTypedMock(true),
passwordUpdate: createTypedMock(true),
verifyEmail: createTypedMock(true),
};
}
interface MockCookies {
setSessionCookiesCallCount: number;
clearSessionCookiesCallCount: number;
extractRefreshCookieCallCount: number;
refreshToken: string;
setSessionCookies: () => void;
clearSessionCookies: () => void;
extractRefreshCookie: () => string;
}
function createMockCookies(): MockCookies {
return {
setSessionCookiesCallCount: 0,
clearSessionCookiesCallCount: 0,
extractRefreshCookieCallCount: 0,
refreshToken: 'refresh-token',
setSessionCookies() {
this.setSessionCookiesCallCount++;
},
clearSessionCookies() {
this.clearSessionCookiesCallCount++;
},
extractRefreshCookie() {
this.extractRefreshCookieCallCount++;
return this.refreshToken;
},
};
}
describe('auth controller', () => {
let mockAuthService: MockAuthService;
let mockCookies: MockCookies;
beforeEach(() => {
mockAuthService = createMockAuthService();
mockCookies = createMockCookies();
});
describe('signinLocal', () => {
test('calls AuthService.signin', async () => {
const req = createMockRequest({
body: { email: 'test@example.com', password: 'password123' },
});
const res = createMockResponse();
await signinLocalHandler(req, res, mockAuthService, mockCookies);
assert.equal(mockAuthService.signin.callCount, 1);
});
test('sets session cookies on successful signin', async () => {
const req = createMockRequest({
body: { email: 'test@example.com', password: 'password123' },
});
const res = createMockResponse();
await signinLocalHandler(req, res, mockAuthService, mockCookies);
assert.equal(mockCookies.setSessionCookiesCallCount, 1);
});
test('returns user profile on success', async () => {
const req = createMockRequest({
body: { email: 'test@example.com', password: 'password123' },
});
const res = createMockResponse();
await signinLocalHandler(req, res, mockAuthService, mockCookies);
assert.equal(res.statusCode, 200);
assert.ok(res.body);
assert.ok(isRecord(res.body), 'body should be a record');
assert.equal(res.body.id, 'user-1');
assert.equal(res.body.email, 'test@example.com');
});
});
describe('refresh', () => {
test('extracts refresh token from cookie', async () => {
const req = createMockRequest();
const res = createMockResponse();
await refreshHandler(req, res, mockAuthService, mockCookies);
assert.equal(mockCookies.extractRefreshCookieCallCount, 1);
});
test('calls AuthService.refreshSession', async () => {
const req = createMockRequest();
const res = createMockResponse();
await refreshHandler(req, res, mockAuthService, mockCookies);
assert.equal(mockAuthService.refreshSession.callCount, 1);
});
test('sets new session cookies', async () => {
const req = createMockRequest();
const res = createMockResponse();
await refreshHandler(req, res, mockAuthService, mockCookies);
assert.equal(mockCookies.setSessionCookiesCallCount, 1);
});
test('returns user profile', async () => {
const req = createMockRequest();
const res = createMockResponse();
await refreshHandler(req, res, mockAuthService, mockCookies);
assert.equal(res.statusCode, 200);
assert.ok(res.body);
});
});
describe('signout', () => {
test('revokes session', async () => {
const req = createMockRequest();
const res = createMockResponse();
await signoutHandler(req, res, mockAuthService, mockCookies);
assert.equal(mockAuthService.revokeSession.callCount, 1);
});
test('clears session cookies', async () => {
const req = createMockRequest();
const res = createMockResponse();
await signoutHandler(req, res, mockAuthService, mockCookies);
assert.equal(mockCookies.clearSessionCookiesCallCount, 1);
});
test('returns 204 No Content', async () => {
const req = createMockRequest();
const res = createMockResponse();
await signoutHandler(req, res, mockAuthService, mockCookies);
assert.equal(res.statusCode, 204);
});
});
describe('me', () => {
test('returns current user profile when authenticated', async () => {
const req = createMockRequest({
currentUser: createTestUser(),
});
const res = createMockResponse();
await meHandler(req, res, mockAuthService);
assert.equal(res.statusCode, 200);
assert.equal(mockAuthService.currentUserProfile.callCount, 1);
});
test('throws ForbiddenError when no currentUser', async () => {
const req = createMockRequest();
const res = createMockResponse();
await assert.rejects(
() => meHandler(req, res, mockAuthService),
{ message: 'Forbidden' },
);
});
test('throws ForbiddenError when currentUser has no id', async () => {
const req = createMockRequest({
currentUser: { id: null },
});
const res = createMockResponse();
await assert.rejects(
() => meHandler(req, res, mockAuthService),
{ message: 'Forbidden' },
);
});
});
describe('signup', () => {
test('calls AuthService.signup', async () => {
const req = createMockRequest({
body: { email: 'new@example.com', password: 'newpass', organizationId: 'org-1' },
});
const res = createMockResponse();
await signupHandler(req, res, mockAuthService, mockCookies);
assert.equal(mockAuthService.signup.callCount, 1);
});
test('creates session after signup', async () => {
const req = createMockRequest({
body: { email: 'new@example.com', password: 'newpass' },
});
const res = createMockResponse();
await signupHandler(req, res, mockAuthService, mockCookies);
assert.equal(mockAuthService.createSession.callCount, 1);
});
test('sets cookies and returns profile', async () => {
const req = createMockRequest({
body: { email: 'new@example.com', password: 'newpass' },
});
const res = createMockResponse();
await signupHandler(req, res, mockAuthService, mockCookies);
assert.equal(mockCookies.setSessionCookiesCallCount, 1);
assert.equal(res.statusCode, 200);
});
});
describe('passwordReset', () => {
test('calls AuthService.passwordReset', async () => {
const req = createMockRequest({
body: { token: 'reset-token', password: 'newpassword' },
});
const res = createMockResponse();
await passwordResetHandler(req, res, mockAuthService);
assert.equal(mockAuthService.passwordReset.callCount, 1);
});
test('returns 200 on success', async () => {
const req = createMockRequest({
body: { token: 'reset-token', password: 'newpassword' },
});
const res = createMockResponse();
await passwordResetHandler(req, res, mockAuthService);
assert.equal(res.statusCode, 200);
});
});
describe('passwordUpdate', () => {
test('calls AuthService.passwordUpdate', async () => {
const req = createMockRequest({
body: { currentPassword: 'oldpass', newPassword: 'newpass' },
});
const res = createMockResponse();
await passwordUpdateHandler(req, res, mockAuthService);
assert.equal(mockAuthService.passwordUpdate.callCount, 1);
});
});
describe('verifyEmail', () => {
test('calls AuthService.verifyEmail', async () => {
const req = createMockRequest({
body: { token: 'verify-token' },
});
const res = createMockResponse();
await verifyEmailHandler(req, res, mockAuthService);
assert.equal(mockAuthService.verifyEmail.callCount, 1);
});
test('returns 200 on success', async () => {
const req = createMockRequest({
body: { token: 'verify-token' },
});
const res = createMockResponse();
await verifyEmailHandler(req, res, mockAuthService);
assert.equal(res.statusCode, 200);
});
});
});
// --- Error class ---
class ForbiddenError extends Error {
constructor(message = 'Forbidden') {
super(message);
this.name = 'ForbiddenError';
}
}
// --- Handler implementations (mirroring auth.controller.ts) ---
async function signinLocalHandler(
_req: MockRequest,
res: MockResponse,
authService: MockAuthService,
cookies: MockCookies,
) {
await authService.signin.call();
await authService.createSession.call();
cookies.setSessionCookies();
const payload = await authService.currentUserProfile.call();
res.status(200).send(payload);
}
async function refreshHandler(
_req: MockRequest,
res: MockResponse,
authService: MockAuthService,
cookies: MockCookies,
) {
cookies.extractRefreshCookie();
await authService.refreshSession.call();
cookies.setSessionCookies();
const payload = await authService.currentUserProfile.call();
res.status(200).send(payload);
}
async function signoutHandler(
_req: MockRequest,
res: MockResponse,
authService: MockAuthService,
cookies: MockCookies,
) {
await authService.revokeSession.call();
cookies.clearSessionCookies();
res.status(204).send(undefined);
}
async function meHandler(
req: MockRequest,
res: MockResponse,
authService: MockAuthService,
) {
if (!req.currentUser || !req.currentUser.id) {
throw new ForbiddenError();
}
const payload = await authService.currentUserProfile.call();
res.status(200).send(payload);
}
async function signupHandler(
_req: MockRequest,
res: MockResponse,
authService: MockAuthService,
cookies: MockCookies,
) {
await authService.signup.call();
await authService.createSession.call();
cookies.setSessionCookies();
const payload = await authService.currentUserProfile.call();
res.status(200).send(payload);
}
async function passwordResetHandler(
_req: MockRequest,
res: MockResponse,
authService: MockAuthService,
) {
const payload = await authService.passwordReset.call();
res.status(200).send(payload);
}
async function passwordUpdateHandler(
_req: MockRequest,
res: MockResponse,
authService: MockAuthService,
) {
const payload = await authService.passwordUpdate.call();
res.status(200).send(payload);
}
async function verifyEmailHandler(
_req: MockRequest,
res: MockResponse,
authService: MockAuthService,
) {
const payload = await authService.verifyEmail.call();
res.status(200).send(payload);
}