147 lines
5.6 KiB
TypeScript
147 lines
5.6 KiB
TypeScript
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<string, AuthSessionRecord>();
|
|
|
|
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<E2eAuthRepository> {
|
|
return new E2eAuthRepository(await hashPasswordArgon2id(secret, { memoryKiB: 1024, passes: 1 }));
|
|
}
|
|
|
|
async findUserByUsername(username: string): Promise<AuthUserRecord | null> {
|
|
return username === this.user.username ? { ...this.user, roles: [...this.user.roles] } : null;
|
|
}
|
|
|
|
async markLoginSuccess(): Promise<void> {
|
|
this.user.failedLoginCount = 0;
|
|
this.user.lockedUntil = null;
|
|
}
|
|
|
|
async markLoginFailure(_userId: string, failedLoginCount: number, lockedUntil: Date | null): Promise<void> {
|
|
this.user.failedLoginCount = failedLoginCount;
|
|
this.user.lockedUntil = lockedUntil;
|
|
}
|
|
|
|
async createSession(input: CreateSessionInput): Promise<AuthSessionRecord> {
|
|
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<AuthSessionRecord | null> {
|
|
return (
|
|
[...this.sessions.values()].find(
|
|
(session) => session.refreshTokenHash === refreshTokenHash && !session.revokedAt && session.expiresAt > now
|
|
) ?? null
|
|
);
|
|
}
|
|
|
|
async revokeSession(sessionId: string, revokedAt: Date): Promise<void> {
|
|
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<NestFastifyApplication>(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);
|
|
});
|
|
});
|