refactor: strengthen client boundaries and quality gates
This commit is contained in:
@@ -23,6 +23,7 @@ function createUsersMock(roleCode: string, overrides: Record<string, unknown> =
|
||||
verifyLoginPassword: jest.fn(async (_id: string, password: string) => password === 'secret1'),
|
||||
recordLoginSuccess: jest.fn(),
|
||||
recordLoginFailure: jest.fn(),
|
||||
verifyCurrentPassword: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,54 +31,154 @@ function createSessionsMock() {
|
||||
const captchas = new Map<string, string>();
|
||||
const failures = new Map<string, number>();
|
||||
const record = {
|
||||
userId: 'user-1', portal: 'admin', sessionVersion: 0, createdAt: 1, lastActivityAt: 1,
|
||||
lastAuthenticatedAt: 1, absoluteExpiresAt: Date.now() + 1000,
|
||||
userId: 'user-1',
|
||||
portal: 'admin',
|
||||
sessionVersion: 0,
|
||||
createdAt: 1,
|
||||
lastActivityAt: 1,
|
||||
lastAuthenticatedAt: 1,
|
||||
absoluteExpiresAt: Date.now() + 1000,
|
||||
};
|
||||
return {
|
||||
storeCaptcha: jest.fn(async (id: string, answer: string) => { captchas.set(id, answer); }),
|
||||
consumeCaptcha: jest.fn(async (id: string) => { const answer = captchas.get(id) ?? null; captchas.delete(id); return answer; }),
|
||||
isAnonymousLoginLocked: jest.fn(async (login: string) => (failures.get(login) ?? 0) >= 5),
|
||||
recordAnonymousLoginFailure: jest.fn(async (login: string) => { const count = (failures.get(login) ?? 0) + 1; failures.set(login, count); return count; }),
|
||||
clearAnonymousLoginFailures: jest.fn(async (login: string) => { failures.delete(login); }),
|
||||
storeCaptcha: jest.fn(async (id: string, answer: string) => {
|
||||
captchas.set(id, answer);
|
||||
}),
|
||||
consumeCaptcha: jest.fn(async (id: string) => {
|
||||
const answer = captchas.get(id) ?? null;
|
||||
captchas.delete(id);
|
||||
return answer;
|
||||
}),
|
||||
assertCaptchaRequestAllowed: jest.fn().mockResolvedValue(true),
|
||||
anonymousLoginLockScope: jest.fn(async (login: string) => ((failures.get(login) ?? 0) >= 5 ? 'account' : null)),
|
||||
recordAnonymousLoginFailure: jest.fn(async (login: string) => {
|
||||
const count = (failures.get(login) ?? 0) + 1;
|
||||
failures.set(login, count);
|
||||
return [count, count, count];
|
||||
}),
|
||||
clearAnonymousLoginFailures: jest.fn(async (login: string) => {
|
||||
failures.delete(login);
|
||||
}),
|
||||
create: jest.fn().mockResolvedValue({ token: 'opaque-session-token', record }),
|
||||
publicSession: jest.fn().mockReturnValue({ idleTimeoutSeconds: 3600, absoluteExpiresAt: new Date(record.absoluteExpiresAt).toISOString() }),
|
||||
publicSession: jest
|
||||
.fn()
|
||||
.mockReturnValue({
|
||||
idleTimeoutSeconds: 3600,
|
||||
absoluteExpiresAt: new Date(record.absoluteExpiresAt).toISOString(),
|
||||
}),
|
||||
unlock: jest.fn().mockResolvedValue({ status: 'active' }),
|
||||
markReauthenticated: jest.fn().mockResolvedValue({ status: 'active' }),
|
||||
};
|
||||
}
|
||||
|
||||
function createMetricsMock() {
|
||||
return { recordAuthProtectionResult: jest.fn() };
|
||||
}
|
||||
|
||||
async function loginWithCaptcha(service: AuthService, portal: 'admin' | 'client', password = 'secret1') {
|
||||
const captcha = await service.createCaptcha();
|
||||
const answer = captcha.challenge.split('=')[0].split('+').map((part) => Number(part.trim())).reduce((sum, value) => sum + value, 0);
|
||||
return service.login({
|
||||
login: 'user@example.com',
|
||||
password,
|
||||
captchaId: captcha.captchaId,
|
||||
captchaText: String(answer),
|
||||
}, portal);
|
||||
const captcha = await service.createCaptcha('203.0.113.10');
|
||||
const answer = captcha.challenge
|
||||
.split('=')[0]
|
||||
.split('+')
|
||||
.map((part) => Number(part.trim()))
|
||||
.reduce((sum, value) => sum + value, 0);
|
||||
return service.login(
|
||||
{
|
||||
login: 'user@example.com',
|
||||
password,
|
||||
captchaId: captcha.captchaId,
|
||||
captchaText: String(answer),
|
||||
},
|
||||
portal,
|
||||
'203.0.113.10',
|
||||
);
|
||||
}
|
||||
|
||||
describe('AuthService', () => {
|
||||
it('allows platform admins to login admin portal', async () => {
|
||||
const users = createUsersMock('platform_admin');
|
||||
const sessions = createSessionsMock();
|
||||
const service = new AuthService(users as never, sessions as never);
|
||||
await expect(loginWithCaptcha(service, 'admin')).resolves.toEqual(expect.objectContaining({ portal: 'admin', sessionToken: 'opaque-session-token' }));
|
||||
const service = new AuthService(users as never, sessions as never, createMetricsMock() as never);
|
||||
await expect(loginWithCaptcha(service, 'admin')).resolves.toEqual(
|
||||
expect.objectContaining({ portal: 'admin', sessionToken: 'opaque-session-token' }),
|
||||
);
|
||||
expect(users.recordLoginSuccess).toHaveBeenCalledWith('user-1');
|
||||
expect(sessions.create).toHaveBeenCalledWith('user-1', 'admin', 0);
|
||||
});
|
||||
|
||||
it('rejects enterprise admins on admin portal', async () => {
|
||||
const users = createUsersMock('enterprise_admin');
|
||||
const service = new AuthService(users as never, createSessionsMock() as never);
|
||||
const service = new AuthService(users as never, createSessionsMock() as never, createMetricsMock() as never);
|
||||
await expect(loginWithCaptcha(service, 'admin')).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
expect(users.recordLoginFailure).toHaveBeenCalledWith('user-1');
|
||||
});
|
||||
|
||||
it('locks user after five failed password attempts', async () => {
|
||||
const users = createUsersMock('platform_admin');
|
||||
const service = new AuthService(users as never, createSessionsMock() as never);
|
||||
const service = new AuthService(users as never, createSessionsMock() as never, createMetricsMock() as never);
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
await expect(loginWithCaptcha(service, 'admin', 'bad-password')).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
}
|
||||
expect(users.recordLoginFailure).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it('rejects captcha bursts before allocating a captcha', async () => {
|
||||
const sessions = createSessionsMock();
|
||||
sessions.assertCaptchaRequestAllowed.mockResolvedValue(false);
|
||||
const metrics = createMetricsMock();
|
||||
const service = new AuthService(createUsersMock('platform_admin') as never, sessions as never, metrics as never);
|
||||
await expect(service.createCaptcha('203.0.113.10')).rejects.toMatchObject({ status: 429 });
|
||||
expect(sessions.storeCaptcha).not.toHaveBeenCalled();
|
||||
expect(metrics.recordAuthProtectionResult).toHaveBeenCalledWith('captcha_rejected');
|
||||
});
|
||||
|
||||
it('allows a tenant-bound enterprise admin to login to the client portal', async () => {
|
||||
const service = new AuthService(
|
||||
createUsersMock('enterprise_admin') as never,
|
||||
createSessionsMock() as never,
|
||||
createMetricsMock() as never,
|
||||
);
|
||||
await expect(loginWithCaptcha(service, 'client')).resolves.toEqual(expect.objectContaining({ portal: 'client' }));
|
||||
});
|
||||
|
||||
it('records anonymous failures without disclosing whether an account exists', async () => {
|
||||
const users = createUsersMock('platform_admin');
|
||||
users.findByLogin.mockResolvedValue(null);
|
||||
const sessions = createSessionsMock();
|
||||
const service = new AuthService(users as never, sessions as never, createMetricsMock() as never);
|
||||
await expect(loginWithCaptcha(service, 'admin')).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
expect(sessions.recordAnonymousLoginFailure).toHaveBeenCalledWith('user@example.com', '203.0.113.10');
|
||||
});
|
||||
|
||||
it('rejects expired and incorrect one-time captchas', async () => {
|
||||
const sessions = createSessionsMock();
|
||||
const service = new AuthService(
|
||||
createUsersMock('platform_admin') as never,
|
||||
sessions as never,
|
||||
createMetricsMock() as never,
|
||||
);
|
||||
await expect(
|
||||
service.login(
|
||||
{ login: 'user', password: 'secret1', captchaId: 'missing', captchaText: '1' },
|
||||
'admin',
|
||||
'203.0.113.10',
|
||||
),
|
||||
).rejects.toMatchObject({ status: 400 });
|
||||
await sessions.storeCaptcha('captcha-wrong', '7');
|
||||
await expect(
|
||||
service.login(
|
||||
{ login: 'user', password: 'secret1', captchaId: 'captcha-wrong', captchaText: '8' },
|
||||
'admin',
|
||||
'203.0.113.10',
|
||||
),
|
||||
).rejects.toMatchObject({ status: 400 });
|
||||
});
|
||||
|
||||
it('delegates unlock and recent reauthentication to password and session services', async () => {
|
||||
const users = createUsersMock('platform_admin');
|
||||
const sessions = createSessionsMock();
|
||||
const service = new AuthService(users as never, sessions as never, createMetricsMock() as never);
|
||||
await expect(service.unlock('token', 'user-1', 'secret1')).resolves.toEqual({ status: 'active' });
|
||||
await expect(service.reauthenticate('token', 'user-1', 'secret1')).resolves.toEqual({ status: 'active' });
|
||||
expect(users.verifyCurrentPassword).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user