import 'reflect-metadata'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import crypto from 'node:crypto'; import { Test, type TestingModule } from '@nestjs/testing'; import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; import request from 'supertest'; import { hashPasswordArgon2id, sha256Token } from '@lisglosips/auth'; import { CaptchaService } from './captcha.service.js'; import { AUTH_REPOSITORY, type AuthRepository, type AuthSessionRecord, type AuthUserRecord, type CreateSessionInput } from './auth.types.js'; class E2eAuthRepository implements AuthRepository { user: AuthUserRecord; sessions = new Map(); private constructor(passwordHash: string) { this.user = { id: 'usr_e2e', username: 'operator', displayName: 'Operator', passwordHash, passwordAlgo: 'argon2id', status: 'ENABLED', failedLoginCount: 0, lockedUntil: null, requirePasswordChange: false, roles: ['admin'] }; } static async create(secret: string): Promise { return new E2eAuthRepository(await hashPasswordArgon2id(secret, { memoryKiB: 1024, passes: 1 })); } async findUserByUsername(username: string): Promise { return username === this.user.username ? { ...this.user, roles: [...this.user.roles] } : null; } async markLoginSuccess(): Promise { this.user.failedLoginCount = 0; this.user.lockedUntil = null; } async markLoginFailure(_userId: string, failedLoginCount: number, lockedUntil: Date | null): Promise { this.user.failedLoginCount = failedLoginCount; this.user.lockedUntil = lockedUntil; } async createSession(input: CreateSessionInput): Promise { const session: AuthSessionRecord = { id: `ses_e2e_${this.sessions.size + 1}`, userId: input.userId, refreshTokenHash: input.refreshTokenHash, expiresAt: input.expiresAt, revokedAt: null, user: { ...this.user, roles: [...this.user.roles] } }; this.sessions.set(session.id, session); return session; } async findActiveSessionByRefreshTokenHash(refreshTokenHash: string, now: Date): Promise { return ( [...this.sessions.values()].find( (session) => session.refreshTokenHash === refreshTokenHash && !session.revokedAt && session.expiresAt > now ) ?? null ); } async revokeSession(sessionId: string, revokedAt: Date): Promise { const session = this.sessions.get(sessionId); if (session) { session.revokedAt = revokedAt; } } } describe('LisgloSIPS Auth API', () => { let app: NestFastifyApplication; let repo: E2eAuthRepository; let captchaService: CaptchaService; let secret: string; beforeAll(async () => { process.env.DATABASE_URL = 'mysql://lisglosips_app@127.0.0.1:3306/lisglosips'; process.env.REDIS_URL = 'redis://127.0.0.1:6379/0'; process.env.LISGLOSIPS_LOG_LEVEL = 'silent'; process.env.AUTH_COOKIE_SECURE = 'false'; process.env.AUTH_ACCESS_TOKEN_SECRET = 'test-only-access-token-secret-min-32-bytes'; secret = crypto.randomUUID(); repo = await E2eAuthRepository.create(secret); const { AppModule } = await import('../app.module.js'); const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule] }) .overrideProvider(AUTH_REPOSITORY) .useValue(repo) .compile(); app = moduleFixture.createNestApplication(new FastifyAdapter({ logger: false })); app.setGlobalPrefix('api/v2'); await app.init(); await app.getHttpAdapter().getInstance().ready(); captchaService = app.get(CaptchaService); }); afterAll(async () => { await app?.close(); }); it('logs in, refreshes with cookie rotation, and logs out', async () => { const publicCaptcha = await request(app.getHttpServer()).get('/api/v2/auth/captcha').expect(200); expect(publicCaptcha.body.captchaId).toBeTypeOf('string'); expect(publicCaptcha.body.imageDataUrl).toMatch(/^data:image\/svg\+xml;base64,/); expect(publicCaptcha.body.answer).toBeUndefined(); await request(app.getHttpServer()) .post('/api/v2/auth/login') .send({ username: 'operator', password: secret }) .expect(401); const captcha = captchaService.createChallenge(); const login = await request(app.getHttpServer()) .post('/api/v2/auth/login') .send({ username: 'operator', password: secret, captchaId: captcha.id, captchaCode: captcha.answer }) .expect(200); const loginCookie = login.headers['set-cookie'][0]; const firstRefreshToken = /lisglosips_refresh=([^;]+)/.exec(loginCookie)?.[1] ?? ''; expect(login.body.accessToken).toBeTypeOf('string'); expect(loginCookie).toContain('HttpOnly'); expect([...repo.sessions.values()][0].refreshTokenHash).toBe(sha256Token(decodeURIComponent(firstRefreshToken))); const refresh = await request(app.getHttpServer()).post('/api/v2/auth/refresh').set('Cookie', loginCookie).expect(200); const refreshCookie = refresh.headers['set-cookie'][0]; expect(refresh.body.accessToken).toBeTypeOf('string'); expect([...repo.sessions.values()][0].revokedAt).toBeInstanceOf(Date); await request(app.getHttpServer()).post('/api/v2/auth/refresh').set('Cookie', loginCookie).expect(401); await request(app.getHttpServer()).post('/api/v2/auth/logout').set('Cookie', refreshCookie).expect(204); await request(app.getHttpServer()).post('/api/v2/auth/refresh').set('Cookie', refreshCookie).expect(401); }); });