import type { INestApplication } from '@nestjs/common'; import { ValidationPipe } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import cookieParser from 'cookie-parser'; import type { Server } from 'node:http'; import request from 'supertest'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import type { AdminLoginResponse, ApiErrorResponse, ApiSuccessResponse, CurrentUserResponse, RoleCode, UpdateUserRolesResponse, WechatLoginResponse, } from '@lehuo/contracts'; import { AppModule } from '../src/app.module'; import { PasswordService } from '../src/auth/password.service'; import { ADMIN_SESSION_COOKIE } from '../src/auth/session.constants'; import { WECHAT_AUTH_ADAPTER, type WechatAuthAdapter } from '../src/auth/wechat-auth.adapter'; import { PrismaService } from '../src/database/prisma.service'; const ADMIN_ORIGIN = 'http://localhost:5173'; class TestWechatAuthAdapter implements WechatAuthAdapter { exchangeCode(code: string): Promise<{ openid: string; unionid: string }> { return Promise.resolve({ openid: `generated-openid-${code}`, unionid: `generated-unionid-${code}`, }); } } function responseBody(response: { body: unknown }): T { return response.body as T; } function sessionCookie(response: { headers: Record }): string { const value = response.headers['set-cookie']; const cookies = Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []; const cookie = cookies.find((item) => item.startsWith(`${ADMIN_SESSION_COOKIE}=`)); if (!cookie) throw new Error('Admin session cookie was not set'); return cookie.split(';')[0] ?? ''; } describe('Phase 1 authentication and authorization', () => { let app: INestApplication; let prisma: PrismaService; let passwords: PasswordService; beforeAll(async () => { const moduleRef = await Test.createTestingModule({ imports: [AppModule] }) .overrideProvider(WECHAT_AUTH_ADAPTER) .useValue(new TestWechatAuthAdapter()) .compile(); app = moduleRef.createNestApplication(); app.use(cookieParser()); app.useGlobalPipes( new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }), ); await app.init(); prisma = app.get(PrismaService); passwords = app.get(PasswordService); }); beforeEach(async () => { await prisma.auditLog.deleteMany(); await prisma.notificationDelivery.deleteMany(); await prisma.notification.deleteMany(); await prisma.notificationPreference.deleteMany(); await prisma.contentPostStatusHistory.deleteMany(); await prisma.contentPost.deleteMany(); await prisma.activityRegistrationHistory.deleteMany(); await prisma.activityRegistration.deleteMany(); await prisma.activityStatusHistory.deleteMany(); await prisma.activity.deleteMany(); await prisma.serviceCase.updateMany({ data: { projectId: null } }); await prisma.projectStatusHistory.deleteMany(); await prisma.project.deleteMany(); await prisma.serviceApplicationStatusHistory.deleteMany(); await prisma.serviceApplication.deleteMany({ where: { sourceFollowUpTaskId: { not: null } } }); await prisma.followUpStatusHistory.deleteMany(); await prisma.followUpTask.deleteMany(); await prisma.serviceRecordFile.deleteMany(); await prisma.serviceRecordRevision.deleteMany(); await prisma.serviceRecord.deleteMany(); await prisma.file.deleteMany(); await prisma.serviceCaseStatusHistory.deleteMany(); await prisma.caseAssignee.deleteMany(); await prisma.serviceCase.deleteMany(); await prisma.serviceApplicationStatusHistory.deleteMany(); await prisma.serviceApplication.deleteMany(); await prisma.beneficiaryTag.deleteMany(); await prisma.beneficiaryContact.deleteMany(); await prisma.volunteerStatusHistory.deleteMany(); await prisma.volunteerSkill.deleteMany(); await prisma.volunteerInterest.deleteMany(); await prisma.volunteer.deleteMany(); await prisma.consent.deleteMany(); await prisma.beneficiaryProfile.deleteMany(); await prisma.authSession.deleteMany(); await prisma.userRole.deleteMany(); await prisma.adminCredential.deleteMany(); await prisma.staffProfile.deleteMany(); await prisma.wechatIdentity.deleteMany(); await prisma.user.deleteMany(); }); afterAll(async () => { await app.close(); }); async function createStaff( username: string, password: string, roleCode: RoleCode, ): Promise<{ publicId: string }> { const passwordHash = await passwords.hash(password); const role = await prisma.role.findUniqueOrThrow({ where: { code: roleCode } }); const user = await prisma.user.create({ data: { publicId: crypto.randomUUID(), userType: 'STAFF', staffProfile: { create: { displayName: '测试工作人员' } }, adminCredential: { create: { username, passwordHash } }, }, }); await prisma.userRole.create({ data: { userId: user.id, roleId: role.id } }); return { publicId: user.publicId }; } async function adminLogin(username: string, password: string, fixedCookie?: string) { let call = request(app.getHttpServer() as Server) .post('/api/v1/admin/auth/login') .set('origin', ADMIN_ORIGIN); if (fixedCookie) call = call.set('cookie', fixedCookie); return call.send({ username, password }); } it('keeps health public while protecting current-user endpoints', async () => { const health = await request(app.getHttpServer() as Server) .get('/health') .expect(200); expect(responseBody<{ status: string }>(health).status).toBe('ok'); const unauthorized = await request(app.getHttpServer() as Server) .get('/api/v1/me') .expect(401); const error = responseBody(unauthorized); expect(error.error.code).toBe('AUTH_REQUIRED'); expect(error.requestId).toBeTruthy(); }); it('uses an HttpOnly cookie, rejects session fixation, and returns no admin token in JSON', async () => { const password = 'correct horse battery staple'; await createStaff('admin.one', password, 'ADMIN'); const login = await adminLogin( 'admin.one', password, `${ADMIN_SESSION_COOKIE}=attacker-fixed`, ).then((response) => { expect(response.status).toBe(201); return response; }); const cookie = sessionCookie(login); expect(cookie).not.toContain('attacker-fixed'); const cookieHeaders = (login.headers as Record)['set-cookie'] as string[]; expect(cookieHeaders.join(';')).toContain('HttpOnly'); expect(cookieHeaders.join(';')).toContain('SameSite=Strict'); const payload = responseBody>(login); expect(payload.data.user.roles).toContain('ADMIN'); expect(JSON.stringify(payload)).not.toContain('accessToken'); const me = await request(app.getHttpServer() as Server) .get('/api/v1/admin/auth/me') .set('cookie', cookie) .expect(200); expect( responseBody>(me).data.user.permissions, ).toContain('admin:access'); }); it('rejects cross-origin admin login requests', async () => { await createStaff('admin.origin', 'correct horse battery staple', 'ADMIN'); const response = await request(app.getHttpServer() as Server) .post('/api/v1/admin/auth/login') .set('origin', 'https://malicious.example') .send({ username: 'admin.origin', password: 'correct horse battery staple' }) .expect(403); expect(responseBody(response).error.code).toBe('FORBIDDEN'); }); it('records failed login without password, username, or session data in audit metadata', async () => { await createStaff('admin.audit', 'correct horse battery staple', 'ADMIN'); const attemptedPassword = 'incorrect password value'; const response = await adminLogin('admin.audit', attemptedPassword); expect(response.status).toBe(401); expect(responseBody(response).error.code).toBe('INVALID_CREDENTIALS'); const logs = await prisma.auditLog.findMany({ select: { action: true, metadataJson: true } }); const serialized = JSON.stringify(logs); expect(serialized).not.toContain(attemptedPassword); expect(serialized).not.toContain('admin.audit'); expect(serialized).toContain('INVALID_CREDENTIALS'); }); it('rejects a temporarily locked administrator account', async () => { const password = 'correct horse battery staple'; await createStaff('admin.locked', password, 'ADMIN'); await prisma.adminCredential.update({ where: { username: 'admin.locked' }, data: { failedLoginCount: 5, lockedUntil: new Date(Date.now() + 60_000) }, }); const response = await adminLogin('admin.locked', password); expect(response.status).toBe(429); expect(responseBody(response).error.code).toBe('ACCOUNT_LOCKED'); }); it('enforces RBAC after a valid staff credential login', async () => { const password = 'correct horse battery staple'; await createStaff('volunteer.login', password, 'VOLUNTEER'); const login = await adminLogin('volunteer.login', password); expect(login.status).toBe(201); const cookie = sessionCookie(login); const response = await request(app.getHttpServer() as Server) .get('/api/v1/admin/auth/me') .set('cookie', cookie) .expect(403); expect(responseBody(response).error.code).toBe('FORBIDDEN'); }); it('encrypts WeChat identity values, hashes tokens, and rotates previous sessions', async () => { const first = await request(app.getHttpServer() as Server) .post('/api/v1/auth/wechat/login') .send({ code: 'same-code' }) .expect(201); const firstPayload = responseBody>(first); const identity = await prisma.wechatIdentity.findFirstOrThrow(); expect(identity.openidCiphertext).not.toContain('generated-openid-same-code'); expect(identity.openidHash).not.toContain('generated-openid-same-code'); const storedSession = await prisma.authSession.findFirstOrThrow(); expect(storedSession.tokenHash).not.toBe(firstPayload.data.accessToken); const second = await request(app.getHttpServer() as Server) .post('/api/v1/auth/wechat/login') .send({ code: 'same-code' }) .expect(201); const secondPayload = responseBody>(second); expect(secondPayload.data.accessToken).not.toBe(firstPayload.data.accessToken); await request(app.getHttpServer() as Server) .get('/api/v1/me') .set('authorization', `Bearer ${firstPayload.data.accessToken}`) .expect(401); await request(app.getHttpServer() as Server) .get('/api/v1/me') .set('authorization', `Bearer ${secondPayload.data.accessToken}`) .expect(200); const activeSession = await prisma.authSession.findFirstOrThrow({ where: { revokedAt: null } }); await prisma.authSession.update({ where: { id: activeSession.id }, data: { expiresAt: new Date(Date.now() - 1_000) }, }); await request(app.getHttpServer() as Server) .get('/api/v1/me') .set('authorization', `Bearer ${secondPayload.data.accessToken}`) .expect(401); }); it('allows an authorized admin to replace roles and audits the change', async () => { const password = 'correct horse battery staple'; await createStaff('role.admin', password, 'ADMIN'); const target = await createStaff('role.target', password, 'STAFF'); const login = await adminLogin('role.admin', password); const cookie = sessionCookie(login); const response = await request(app.getHttpServer() as Server) .put(`/api/v1/admin/users/${target.publicId}/roles`) .set('origin', ADMIN_ORIGIN) .set('cookie', cookie) .send({ roles: ['PROJECT_MANAGER'] }) .expect(200); expect(responseBody>(response).data.roles).toEqual([ 'PROJECT_MANAGER', ]); const audit = await prisma.auditLog.findFirstOrThrow({ where: { action: 'RBAC_USER_ROLES_REPLACED' }, }); expect(audit.resourcePublicId).toBe(target.publicId); }); it('does not allow removal of the last active super administrator', async () => { const password = 'correct horse battery staple'; const superAdmin = await createStaff('last.super', password, 'SUPER_ADMIN'); const login = await adminLogin('last.super', password); const cookie = sessionCookie(login); const response = await request(app.getHttpServer() as Server) .put(`/api/v1/admin/users/${superAdmin.publicId}/roles`) .set('origin', ADMIN_ORIGIN) .set('cookie', cookie) .send({ roles: ['ADMIN'] }) .expect(409); expect(responseBody(response).error.code).toBe('VALIDATION_ERROR'); }); });