Initial LisgloSIPS V2 implementation
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
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 { 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 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();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
});
|
||||
|
||||
it('logs in, refreshes with cookie rotation, and logs out', async () => {
|
||||
const login = await request(app.getHttpServer())
|
||||
.post('/api/v2/auth/login')
|
||||
.send({ username: 'operator', password: secret })
|
||||
.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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user