Files
photo-live/services/api/test/auth.e2e-spec.ts
T
浅念 31ba03de11 feat(api): password change endpoint and source device id on uploads
P3 Task 3 契约增补批次(单一事实来源 openapi.yaml + cn 实现 + e2e):

- POST /v1/auth/password-change(operationId changePassword,需求 4.1 的
  P1 契约缺口补齐):Bearer + {oldPassword 1-128, newPassword 8-128} → 204;
  旧密码错误 401 AUTH_INVALID_CREDENTIALS;成功后事务内更新 argon2 哈希、
  撤销该用户除当前会话外的全部其他会话(当前会话保留,本人不下线)、写
  审计 auth.password_change。
- sourceDeviceId(A19 设备来源语义):UploadInitRequest 可选 8-64 位设备
  标识;upload_sessions 落列(VARCHAR(64) utf8mb4_0900_bin,可空,存量
  行 NULL 兼容);GET /uploads 条目回显;幂等重放不比对(first-writer,
  首次落库值不随重放漂移)。
- e2e:auth 增 4 例(未认证 401 / 新密码过短 400 / 旧密码错误 401 且会话
  无副作用 / 成功后他端会话撤销当前保留 + 新旧密码登录裁决 + 审计);
  uploads 增 1 例(携带落库回显、换设备重放保留首值、缺省 null、长度
  越界 400)。api e2e 179 → 184;contracts gen/redocly/单测、worker、
  sync 门全绿。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-19 00:59:39 +08:00

382 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// services/api/test/auth.e2e-spec.ts —— Task 13 登录/刷新/注销 e2e(先写后实现)。
// 真实 app + MySQL(reset.ts 隔离,用户经 Prisma 直种)。覆盖:
// 成功登录(令牌形状 / TTL / audit_logs / sessions.tokenHash)、
// 凭据错误的三种 401 归并(错误密码 / 账号不存在 / 状态不合格——同码防枚举)、
// 滑动续期(同令牌不轮换、过期时间前移)、垃圾/缺失令牌 401、
// 注销 204 + 令牌即刻失效 + sessions.revokedAt、
// 账号被停用后既有会话被守卫撤销、
// 登录失败限速(第 6 次 429 AUTH_RATE_LIMITED;成功登录清零计数)、
// DTO 校验 400 VALIDATION_FAILED(全局校验管道)。
// 限速计数在 app 实例内存中跨用例存留——各限速用例使用独立账号,避免相互污染;
// alice 在首个用例失败 1 次后由成功登录清零,后续用例不受影响。
import { createHash } from 'node:crypto';
import { type INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { PrismaClient } from '@photo/db-cn';
import { ErrorCodes } from '@photo/contracts';
import { AppCnModule } from '../src/app-cn.module';
import { hashPassword } from '../src/auth/password';
import { API_PREFIX } from '../src/shared/constants';
import { resetDb } from './reset';
const TTL_SECONDS = Number.parseInt(
process.env.AUTH_SESSION_TTL_SECONDS ?? '2592000',
10,
);
/** 断言容差:登录/续期到响应到达之间的真实耗时(本机 e2e 足够)。 */
const TTL_TOLERANCE_MS = 120_000;
function sha256Hex(value: string): string {
return createHash('sha256').update(value).digest('hex');
}
describe('auth (e2e)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let aliceId: string;
const PASSWORD = 'correct-pass-1';
beforeAll(async () => {
await resetDb();
const moduleRef = await Test.createTestingModule({
imports: [AppCnModule],
}).compile();
app = moduleRef.createNestApplication();
app.setGlobalPrefix(API_PREFIX);
await app.init();
prisma = new PrismaClient();
const hash = await hashPassword(PASSWORD);
aliceId = (
await prisma.users.create({
data: { account: 'alice', passwordHash: hash, displayName: 'Alice' },
select: { id: true },
})
).id;
// 状态不合格三态:停用 / 本地锁定 / 凭据待激活(A01 未激活无法进入)。
await prisma.users.create({
data: {
account: 'bob',
passwordHash: hash,
displayName: 'Bob',
sourceStatus: 'disabled',
},
});
await prisma.users.create({
data: {
account: 'carol',
passwordHash: hash,
displayName: 'Carol',
localLocked: true,
},
});
await prisma.users.create({
data: {
account: 'dave',
passwordHash: hash,
displayName: 'Dave',
credentialStatus: 'pending',
},
});
// 限速专用账号(与其它用例隔离,避免内存计数交叉污染)。
await prisma.users.create({
data: { account: 'erin', passwordHash: hash, displayName: 'Erin' },
});
await prisma.users.create({
data: { account: 'frank', passwordHash: hash, displayName: 'Frank' },
});
// 会话撤销语义专用账号(测试内会被置为 disabled,不复用其它账号)。
await prisma.users.create({
data: { account: 'gina', passwordHash: hash, displayName: 'Gina' },
});
// password-change 专用账号(P3 Task 3;改密用例放在最后,成功后旧密码失效)。
await prisma.users.create({
data: { account: 'hank', passwordHash: hash, displayName: 'Hank' },
});
});
afterAll(async () => {
await app.close();
await prisma.$disconnect();
});
function login(account: string, password: string) {
return request(app.getHttpServer())
.post(`/${API_PREFIX}/auth/password`)
.send({ account, password });
}
it('正确账号密码 → 200 {sessionToken, expiresAt≈now+TTL},tokenHash=sha256(token),且写 audit_logs(auth.login)', async () => {
const before = Date.now();
const res = await login('alice', PASSWORD);
expect(res.status).toBe(200);
expect(Object.keys(res.body).sort()).toEqual(['expiresAt', 'sessionToken']);
const { sessionToken, expiresAt } = res.body as {
sessionToken: string;
expiresAt: string;
};
// 32B 随机数的 hex 编码。
expect(sessionToken).toMatch(/^[0-9a-f]{64}$/);
const expiresMs = Date.parse(expiresAt);
const lower = before + TTL_SECONDS * 1000 - TTL_TOLERANCE_MS;
const upper = before + TTL_SECONDS * 1000 + TTL_TOLERANCE_MS;
expect(expiresMs).toBeGreaterThanOrEqual(lower);
expect(expiresMs).toBeLessThanOrEqual(upper);
// 库内只存哈希;明文令牌不落库。
const session = await prisma.sessions.findUnique({
where: { tokenHash: sha256Hex(sessionToken) },
});
expect(session).not.toBeNull();
expect(session?.userId).toBe(aliceId);
expect(session?.revokedAt).toBeNull();
const audit = await prisma.audit_logs.findFirst({
where: { action: 'auth.login', actorId: aliceId },
});
expect(audit).not.toBeNull();
});
it('错误密码 → 401 AUTH_INVALID_CREDENTIALS', async () => {
const res = await login('alice', 'wrong-password');
expect(res.status).toBe(401);
expect(res.body.code).toBe(ErrorCodes.AUTH_INVALID_CREDENTIALS);
expect(typeof res.body.requestId).toBe('string');
});
it('不存在的账号 → 401 同一错误码(防账号枚举)', async () => {
const res = await login('ghost-account', 'whatever');
expect(res.status).toBe(401);
expect(res.body.code).toBe(ErrorCodes.AUTH_INVALID_CREDENTIALS);
});
it.each([
['sourceStatus=disabled', 'bob'],
['localLocked=true', 'carol'],
['credentialStatus=pending(A01 未激活)', 'dave'],
])('%s → 401 同一错误码(正确密码也不放行)', async (_label, account) => {
const res = await login(account as string, PASSWORD);
expect(res.status).toBe(401);
expect(res.body.code).toBe(ErrorCodes.AUTH_INVALID_CREDENTIALS);
});
it('请求体缺 password → 400 VALIDATION_FAILED(全局校验管道)', async () => {
const res = await request(app.getHttpServer())
.post(`/${API_PREFIX}/auth/password`)
.send({ account: 'alice' });
expect(res.status).toBe(400);
expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
});
it('滑动续期:有效令牌 → 200 {expiresAt} 前移,且旧令牌仍可用(不轮换)', async () => {
const loginRes = await login('alice', PASSWORD);
const token: string = loginRes.body.sessionToken;
const originalExpires: number = Date.parse(loginRes.body.expiresAt);
// 把会话过期时间手工缩短到即将过期,续期后应回到 ~now+TTL(前移可观测)。
await prisma.sessions.update({
where: { tokenHash: sha256Hex(token) },
data: { expiresAt: new Date(Date.now() + 60_000) },
});
const before = Date.now();
const res = await request(app.getHttpServer())
.post(`/${API_PREFIX}/auth/refresh`)
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(Object.keys(res.body)).toEqual(['expiresAt']);
const renewedMs = Date.parse(res.body.expiresAt);
expect(renewedMs).toBeGreaterThan(originalExpires);
expect(renewedMs).toBeGreaterThanOrEqual(
before + TTL_SECONDS * 1000 - TTL_TOLERANCE_MS,
);
expect(renewedMs).toBeLessThanOrEqual(
before + TTL_SECONDS * 1000 + TTL_TOLERANCE_MS,
);
// 不轮换:同一令牌再次续期仍 200(若实现为轮换,旧令牌应已失效)。
const again = await request(app.getHttpServer())
.post(`/${API_PREFIX}/auth/refresh`)
.set('Authorization', `Bearer ${token}`);
expect(again.status).toBe(200);
});
it('续期携带垃圾令牌 → 401 AUTH_TOKEN_EXPIRED', async () => {
const res = await request(app.getHttpServer())
.post(`/${API_PREFIX}/auth/refresh`)
.set('Authorization', 'Bearer not-a-real-token');
expect(res.status).toBe(401);
expect(res.body.code).toBe(ErrorCodes.AUTH_TOKEN_EXPIRED);
});
it('续期缺失 Authorization 头 → 401 AUTH_TOKEN_EXPIRED', async () => {
const res = await request(app.getHttpServer())
.post(`/${API_PREFIX}/auth/refresh`);
expect(res.status).toBe(401);
expect(res.body.code).toBe(ErrorCodes.AUTH_TOKEN_EXPIRED);
});
it('注销 → 204 无响应体;同令牌后续受保护请求 401;sessions.revokedAt 落库', async () => {
const loginRes = await login('alice', PASSWORD);
const token: string = loginRes.body.sessionToken;
const logout = await request(app.getHttpServer())
.post(`/${API_PREFIX}/auth/logout`)
.set('Authorization', `Bearer ${token}`);
expect(logout.status).toBe(204);
expect(logout.body).toEqual({});
const after = await request(app.getHttpServer())
.post(`/${API_PREFIX}/auth/refresh`)
.set('Authorization', `Bearer ${token}`);
expect(after.status).toBe(401);
expect(after.body.code).toBe(ErrorCodes.AUTH_TOKEN_EXPIRED);
const session = await prisma.sessions.findUnique({
where: { tokenHash: sha256Hex(token) },
select: { revokedAt: true },
});
expect(session?.revokedAt).not.toBeNull();
});
it('账号被停用后,既有会话被守卫拒绝(撤销语义)', async () => {
const res = await login('gina', PASSWORD);
expect(res.status).toBe(200);
const token: string = res.body.sessionToken;
await prisma.users.update({
where: { account: 'gina' },
data: { sourceStatus: 'disabled' },
});
const refresh = await request(app.getHttpServer())
.post(`/${API_PREFIX}/auth/refresh`)
.set('Authorization', `Bearer ${token}`);
expect(refresh.status).toBe(401);
expect(refresh.body.code).toBe(ErrorCodes.AUTH_TOKEN_EXPIRED);
});
it('限速:同账号连续 5 次失败后,第 6 次(即使密码正确)→ 429 AUTH_RATE_LIMITED', async () => {
for (let i = 0; i < 5; i++) {
const res = await login('erin', 'wrong-password');
expect(res.status).toBe(401);
expect(res.body.code).toBe(ErrorCodes.AUTH_INVALID_CREDENTIALS);
}
const locked = await login('erin', PASSWORD);
expect(locked.status).toBe(429);
expect(locked.body.code).toBe(ErrorCodes.AUTH_RATE_LIMITED);
});
it('限速:成功登录清零失败计数(4 次失败 + 成功 + 再失败仍是 401 而非 429)', async () => {
for (let i = 0; i < 4; i++) {
const res = await login('frank', 'wrong-password');
expect(res.status).toBe(401);
}
const ok = await login('frank', PASSWORD);
expect(ok.status).toBe(200);
const res = await login('frank', 'wrong-password');
expect(res.status).toBe(401);
expect(res.body.code).toBe(ErrorCodes.AUTH_INVALID_CREDENTIALS);
});
// ── 修改密码(P3 Task 3 契约增补)──
const changePassword = (token: string, oldPassword: string, newPassword: string) =>
request(app.getHttpServer())
.post(`/${API_PREFIX}/auth/password-change`)
.set('Authorization', `Bearer ${token}`)
.send({ oldPassword, newPassword });
it('修改密码:未认证 → 401 AUTH_TOKEN_EXPIRED', async () => {
const res = await request(app.getHttpServer())
.post(`/${API_PREFIX}/auth/password-change`)
.send({ oldPassword: PASSWORD, newPassword: 'brand-new-pass-9' });
expect(res.status).toBe(401);
expect(res.body.code).toBe(ErrorCodes.AUTH_TOKEN_EXPIRED);
});
it('修改密码:newPassword 少于 8 位 → 400 VALIDATION_FAILED', async () => {
const loginRes = await login('hank', PASSWORD);
const token: string = loginRes.body.sessionToken;
const res = await changePassword(token, PASSWORD, 'short7');
expect(res.status).toBe(400);
expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
// 密码未变:旧密码仍可登录(新密码不可)。
const relogin = await login('hank', PASSWORD);
expect(relogin.status).toBe(200);
});
it('修改密码:旧密码错误 → 401 AUTH_INVALID_CREDENTIALS,当前会话不受影响', async () => {
const loginRes = await login('hank', PASSWORD);
const token: string = loginRes.body.sessionToken;
const res = await changePassword(token, 'wrong-old-password', 'brand-new-pass-9');
expect(res.status).toBe(401);
expect(res.body.code).toBe(ErrorCodes.AUTH_INVALID_CREDENTIALS);
// 会话未被撤销(错误旧密码不产生任何副作用)。
const refresh = await request(app.getHttpServer())
.post(`/${API_PREFIX}/auth/refresh`)
.set('Authorization', `Bearer ${token}`);
expect(refresh.status).toBe(200);
});
it('修改密码:成功 → 204;其他会话撤销、当前会话保留;新密码可登录、旧密码拒绝;写审计', async () => {
// 双会话:tokenA 为当前会话(发起改密),tokenB 为应被撤销的其他会话。
const loginA = await login('hank', PASSWORD);
const tokenA: string = loginA.body.sessionToken;
const loginB = await login('hank', PASSWORD);
const tokenB: string = loginB.body.sessionToken;
const NEW_PASSWORD = 'brand-new-pass-9';
const res = await changePassword(tokenA, PASSWORD, NEW_PASSWORD);
expect(res.status).toBe(204);
expect(res.body).toEqual({});
// 其他会话:即刻失效(revokedAt 落库)。
const refreshB = await request(app.getHttpServer())
.post(`/${API_PREFIX}/auth/refresh`)
.set('Authorization', `Bearer ${tokenB}`);
expect(refreshB.status).toBe(401);
expect(refreshB.body.code).toBe(ErrorCodes.AUTH_TOKEN_EXPIRED);
const sessionB = await prisma.sessions.findUnique({
where: { tokenHash: sha256Hex(tokenB) },
select: { revokedAt: true },
});
expect(sessionB?.revokedAt).not.toBeNull();
// 当前会话:保留(改密不踢本人下线)。
const refreshA = await request(app.getHttpServer())
.post(`/${API_PREFIX}/auth/refresh`)
.set('Authorization', `Bearer ${tokenA}`);
expect(refreshA.status).toBe(200);
// 新旧密码登录裁决。
const reloginNew = await login('hank', NEW_PASSWORD);
expect(reloginNew.status).toBe(200);
const reloginOld = await login('hank', PASSWORD);
expect(reloginOld.status).toBe(401);
expect(reloginOld.body.code).toBe(ErrorCodes.AUTH_INVALID_CREDENTIALS);
// 审计:auth.password_change(actor=本人)。
const hankId = (
await prisma.users.findUnique({
where: { account: 'hank' },
select: { id: true },
})
)?.id;
const audit = await prisma.audit_logs.findFirst({
where: { action: 'auth.password_change', actorId: hankId },
});
expect(audit).not.toBeNull();
});
});