552 lines
15 KiB
TypeScript
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);
|
|
}
|