Files
lislgosms/api/src/auth/auth.service.spec.ts
T

185 lines
7.3 KiB
TypeScript

import { UnauthorizedException } from '@nestjs/common';
import { AuthService } from './auth.service';
import { legacyHashPassword } from './password-hasher';
function createUsersMock(roleCode: string, overrides: Record<string, unknown> = {}) {
const user = {
id: 'user-1',
tenantId: roleCode === 'enterprise_admin' ? 'tenant-1' : null,
username: 'user',
email: 'user@example.com',
phone: '13800000000',
displayName: '用户',
passwordHash: legacyHashPassword('secret1'),
status: 'active',
deletedAt: null,
lockedUntil: null,
tenant: { id: 'tenant-1', name: '企业A' },
roles: [{ role: { code: roleCode } }],
...overrides,
};
return {
findByLogin: jest.fn().mockResolvedValue(user),
verifyLoginPassword: jest.fn(async (_id: string, password: string) => password === 'secret1'),
recordLoginSuccess: jest.fn(),
recordLoginFailure: jest.fn(),
verifyCurrentPassword: jest.fn(),
};
}
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,
};
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;
}),
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(),
}),
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('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, 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, 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, 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);
});
});