Files
lehuo/apps/api/test/auth.integration-spec.ts
T

309 lines
13 KiB
TypeScript

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<T>(response: { body: unknown }): T {
return response.body as T;
}
function sessionCookie(response: { headers: Record<string, unknown> }): 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.financialDisclosureStatusHistory.deleteMany();
await prisma.financialDisclosure.deleteMany();
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<ApiErrorResponse>(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<string, unknown>)['set-cookie'] as string[];
expect(cookieHeaders.join(';')).toContain('HttpOnly');
expect(cookieHeaders.join(';')).toContain('SameSite=Strict');
const payload = responseBody<ApiSuccessResponse<AdminLoginResponse>>(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<ApiSuccessResponse<CurrentUserResponse>>(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<ApiErrorResponse>(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<ApiErrorResponse>(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<ApiErrorResponse>(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<ApiErrorResponse>(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<ApiSuccessResponse<WechatLoginResponse>>(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<ApiSuccessResponse<WechatLoginResponse>>(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<ApiSuccessResponse<UpdateUserRolesResponse>>(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<ApiErrorResponse>(response).error.code).toBe('VALIDATION_ERROR');
});
});