Closes the two gaps 010-identity-auth explicitly deferred (password reset, login rate-limiting), plus a shared password-strength validator both the reset-consume endpoint and admin account creation now depend on. - Password reset: single-use, paired-Redis-key tokens (never in Postgres), identical response regardless of account existence, stubbed delivery via a structured log line (no email infrastructure exists yet). - Password strength: one validatePasswordStrength() call site, wired into both POST /admin/users and the reset-consume flow. - Login rate-limiting: checkRateLimit keyed by submitted email, checked before any credential verification. Also fixes tests/helpers/auth.ts's shared loginAs() helper, which reused two fixed accounts across the whole integration suite via upsert — now rate-limited per email, that collided across ~30 files sharing one budget. Each call now gets a unique email; no call sites needed to change. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
68 lines
2.2 KiB
TypeScript
68 lines
2.2 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import bcrypt from 'bcryptjs';
|
|
import { AuthService } from '@/modules/identity/auth/service/auth.service';
|
|
import * as cache from '@/infrastructure/cache';
|
|
|
|
const REAL_PASSWORD_HASH = bcrypt.hashSync('the-real-password', 10);
|
|
|
|
function fakeUser(overrides: Partial<Record<string, unknown>> = {}) {
|
|
return {
|
|
id: 'u1',
|
|
email: 'agent@example.com',
|
|
name: 'Agent',
|
|
role: 'AGENT',
|
|
passwordHash: REAL_PASSWORD_HASH,
|
|
active: true,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe('AuthService.login failure parity', () => {
|
|
beforeEach(() => {
|
|
vi.spyOn(cache, 'checkRateLimit').mockResolvedValue({ allowed: true, count: 1 });
|
|
});
|
|
|
|
it('throws the identical error for a nonexistent email and a wrong password', async () => {
|
|
const repoFoundUser = { findByEmail: vi.fn().mockResolvedValue(fakeUser()) } as never;
|
|
const repoNoUser = { findByEmail: vi.fn().mockResolvedValue(null) } as never;
|
|
|
|
const serviceWithUser = new AuthService(repoFoundUser);
|
|
const serviceWithoutUser = new AuthService(repoNoUser);
|
|
|
|
let errorWithUser: Error | undefined;
|
|
let errorWithoutUser: Error | undefined;
|
|
|
|
try {
|
|
await serviceWithUser.login({ email: 'agent@example.com', password: 'definitely-wrong' });
|
|
} catch (e) {
|
|
errorWithUser = e as Error;
|
|
}
|
|
|
|
try {
|
|
await serviceWithoutUser.login({ email: 'nobody@example.com', password: 'anything' });
|
|
} catch (e) {
|
|
errorWithoutUser = e as Error;
|
|
}
|
|
|
|
expect(errorWithUser).toBeDefined();
|
|
expect(errorWithoutUser).toBeDefined();
|
|
expect(errorWithUser?.message).toBe(errorWithoutUser?.message);
|
|
expect((errorWithUser as { statusCode?: number })?.statusCode).toBe(
|
|
(errorWithoutUser as { statusCode?: number })?.statusCode,
|
|
);
|
|
});
|
|
|
|
it('rejects a deactivated account with the same error, never a distinguishable one', async () => {
|
|
const repo = {
|
|
findByEmail: vi.fn().mockResolvedValue(fakeUser({ active: false })),
|
|
} as never;
|
|
const service = new AuthService(repo);
|
|
|
|
await expect(
|
|
service.login({ email: 'agent@example.com', password: 'anything' }),
|
|
).rejects.toMatchObject({ statusCode: 401 });
|
|
});
|
|
});
|