Initial LisgloSIPS V2 implementation

This commit is contained in:
hectorzhao
2026-06-22 10:56:38 +08:00
commit 5fa1bd35e9
303 changed files with 35644 additions and 0 deletions
+132
View File
@@ -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);
});
});