feat(api): argon2 password, idempotent admin seed, api-exception infra

Task 12 + Task 7 quality-review carryovers:

Part A - password hashing & controlled initial admin:
- src/auth/password.ts: hashPassword/verifyPassword over @node-rs/argon2
  (napi prebuilt, argon2id default); empty plain rejected at the door
- src/scripts/seed-admin.ts: idempotent admin seed (ADMIN_ACCOUNT/
  ADMIN_PASSWORD env, walk-up root .env load); existing account -> skip,
  never overwrites the password; no audit_logs (system op, no actor)
- pnpm seed script (build + node dist/scripts/seed-admin.js);
  .env.example dev defaults admin/admin-secret-change-me
- tests: test/password.spec.ts (5), test/seed.spec.ts real-DB idempotency (4)

Part B - Task 7 carryovers (all 8):
- ApiException(apiCode, message, httpStatus, details?) recognized first by
  the filter; unmapped-status rule: >=500 -> INTERNAL_ERROR + generic
  message, <500 -> VALIDATION_FAILED keeping original status (warn-logged)
- 5xx/INTERNAL_ERROR responses forced to generic message (leak fix)
- pino redact adds req.headers.cookie / res.headers.set-cookie
- x-request-id echo capped at 128 chars (fresh UUID beyond, no log)
- LOG_LEVEL validated against pino levels at module wiring -> fail fast
- shared/ constants (API_PREFIX), request-with-id, log-level (+spec)
- jest ts-jest reuses tsconfig.json; unit testMatch includes test/*.spec.ts
- build:watch script (no concurrently dep; dev = build:watch + node --watch)

api: 35 unit + 10 e2e tests green; root lint/typecheck/test green.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-17 20:54:30 +08:00
parent 8e870de9d7
commit 368b1d5b47
21 changed files with 724 additions and 62 deletions
+2
View File
@@ -11,3 +11,5 @@ UPLOAD_MAX_BYTES=52428800
PORT=8080
CORS_ORIGINS=http://localhost:5173
LOG_LEVEL=info
ADMIN_ACCOUNT=admin
ADMIN_PASSWORD=admin-secret-change-me
@@ -480,8 +480,8 @@ export interface StoragePort {
```
- [ ] **Step 2: MinIO 实现**:`@aws-sdk/client-s3` + `s3-request-presigner`;`copyIfAbsent` = head(dest) 存在→比对 bytes 一致返回 `{created:false}`、不一致抛 `UPLOAD_VERIFY_FAILED`;不存在→`CopyObject` 后复核。测试覆盖:presign PUT 可写入、head 字节数、copyIfAbsent 幂等(二次调用 created:false)、目标不一致抛错。
- [ ] **Step 3: 运行** `pnpm --filter @photo/storage test`(compose 已起 MinIO)。Expected: PASS。
- [ ] **Step 4: Commit** `feat(storage): storage port and minio adapter with forbid-overwrite semantics`
- [x] **Step 3: 运行** `pnpm --filter @photo/storage test`(完成:6/6 通过,真实 MinIO;规格审查负向实测发现 SDK 对 content-type 不签名——已修正文档为「约定发送」,head() 增加 contentType 返回,8e870de)
- [x] **Step 4: Commit** `feat(storage): storage port and minio adapter with forbid-overwrite semantics`(完成:37f4b3e + 8e870de)
---
@@ -495,9 +495,9 @@ export interface StoragePort {
> Task 7 质量审查遗留(本任务一并落地):① `ApiException extends HttpException` 携带 `{code: ErrorCode, message, httpStatus}`,异常过滤器优先识别(状态码表无法表达同状态多错误码的契约空间)+ 未映射状态分支单测;② 5xx/INTERNAL_ERROR 强制通用文案(堵住 InternalServerErrorException(message) 泄露);③ pino redact 增 cookie/set-cookie;④ x-request-id 回显加长度上限(128);⑤ LOG_LEVEL 非法值启动期明确报错;⑥ 抽出 `API_PREFIX='v1'` 与 `RequestWithId` 共享类型;⑦ jest ts-jest 改 `tsconfig: 'tsconfig.json'` 复用;⑧ 增加 `start:dev`(tsc --watch + node --watch 或 nodemon 任一)。计划文本笔误修正:request-id 生成为 UUID v4(非 cuid)。
- [ ] **Step 1: 失败测试**:hash/verify(@node-rs/argon2,memoryCost 默认)、错误口令 verify=false。
- [ ] **Step 2: 实现 + seed 脚本**(读 env `ADMIN_ACCOUNT/ADMIN_PASSWORD`,不存在则插入 admin 角色,存在则跳过——幂等)。
- [ ] **Step 3: 测试通过;Commit** `feat(api): argon2 password and idempotent admin seed`
- [x] **Step 1: 失败测试**:hash/verify(@node-rs/argon2,memoryCost 默认)、错误口令 verify=false。(完成:test/password.spec.ts 5 例,先红后绿)
- [x] **Step 2: 实现 + seed 脚本**(读 env `ADMIN_ACCOUNT/ADMIN_PASSWORD`,不存在则插入 admin 角色,存在则跳过——幂等)。(完成:src/auth/password.ts + src/scripts/seed-admin.ts,`pnpm --filter @photo/api-cn seed`;幂等性真实 DB 集成测试 test/seed.spec.ts,不写 audit_logs;⑧ 决策为 `build:watch` 脚本,无 concurrently 依赖)
- [x] **Step 3: 测试通过;Commit** `feat(api): argon2 password and idempotent admin seed`(完成:api 单测 35 通过 / e2e 10 通过,根 lint+typecheck+test 绿)
### Task 13: 登录 / 刷新 / 注销
@@ -566,7 +566,7 @@ export interface StoragePort {
- **重复 complete(A03)**→200 幂等,不产生第二个 photo/job/outbox;
- 被移除成员 complete→403;
- `GET /uploads` 返回本人会话历史(state 联查 photo.processingState,App 服务端历史的后端前置)。
- [ ] **Step 2: 实现**:head(staging) 校验 bytes → `copyIfAbsent(staging→originals:{albumId}/originals/{photoId}.ext)` → 事务更新三表 → enqueue BullMQ(队列名常量 `PHOTO_PROCESS` 由 `@photo/contracts` 导出,api 与 worker 共享;提交后入队,队列不可用不回滚,靠 Task 21 sweep 补偿)。
- [ ] **Step 2: 实现**:head(staging) 校验 bytes **与 contentType(比对会话申明值——presign 对 content-type 不签名,服务端兜底,Task 11 审查发现)** → `copyIfAbsent(staging→originals:{albumId}/originals/{photoId}.ext)` → 事务更新三表 → enqueue BullMQ(队列名常量 `PHOTO_PROCESS` 由 `@photo/contracts` 导出,api 与 worker 共享;提交后入队,队列不可用不回滚,靠 Task 21 sweep 补偿)。
- [ ] **Step 3: 通过;Commit** `feat(api): upload verify, forbid-overwrite copy, idempotent complete`
### Task 20: worker 服务与 sharp 处理流水线
+144 -4
View File
@@ -46,7 +46,7 @@ importers:
version: 7.13.0(typescript@5.9.3)
ts-jest:
specifier: ^29.2.0
version: 29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.9.3)))(typescript@5.9.3)
version: 29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.20.3))(typescript@5.9.3)
typescript:
specifier: ^5.6.0
version: 5.9.3
@@ -90,7 +90,7 @@ importers:
version: 29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.9.3))
ts-jest:
specifier: ^29.2.5
version: 29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.9.3)))(typescript@5.9.3)
version: 29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.20.3))(typescript@5.9.3)
typescript:
specifier: ^5.6.0
version: 5.9.3
@@ -106,6 +106,9 @@ importers:
'@nestjs/platform-express':
specifier: ^10.4.6
version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)
'@node-rs/argon2':
specifier: ^2.2.1
version: 2.2.1
'@photo/contracts':
specifier: workspace:*
version: link:../../packages/contracts
@@ -151,7 +154,7 @@ importers:
version: 7.2.2
ts-jest:
specifier: ^29.2.5
version: 29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.9.3)))(typescript@5.9.3)
version: 29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.20.3))(typescript@5.9.3)
ts-node:
specifier: ^10.9.2
version: 10.9.2(@types/node@22.20.3)(typescript@5.9.3)
@@ -653,6 +656,88 @@ packages:
'@nodable/entities@3.0.0':
resolution: {integrity: sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==}
'@node-rs/argon2-android-arm-eabi@2.2.1':
resolution: {integrity: sha512-tvHjNf7XkLsuDKb6VhNZekA/Wq2Qf0yxygxQYWYcUUCawgg3QRDeXMBYa6QVQPHp1fpDXk42euRjVpHmQ5CwFQ==}
engines: {node: '>= 10'}
cpu: [arm]
os: [android]
'@node-rs/argon2-android-arm64@2.2.1':
resolution: {integrity: sha512-hbPJZIMNIBPysg6CFe2OcXyB3pv6hFED0bOPamFRhUon3T3PT7OBfQfbXxsBK2dklELbfQ0R/2497LkTTrZRLw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [android]
'@node-rs/argon2-darwin-arm64@2.2.1':
resolution: {integrity: sha512-aJWtws4QUJX47NPnOr7j8KnxUtBESV3qPXijqw/vV7cZdMbcI4sKeHmH23ER2vpgALzXLhO53Lno3wkP7Xgl6g==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@node-rs/argon2-darwin-x64@2.2.1':
resolution: {integrity: sha512-RxXqM4SkH8VYNEnR82Pi1ADoWwc5HbBd0X8HFAHHLpr/iBpzvhwyl1ge6KaB4Ik7yUpzg/d2O8gYBxKGtZbmQg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@node-rs/argon2-freebsd-x64@2.2.1':
resolution: {integrity: sha512-YxFKQJogkwhWSZbwAHhZIANOj8FytJgxaNzcTOAgWeEw2d6aobAoDWqsO1jw7QZfxLFb+q/uFAmd84TgCsyioQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [freebsd]
'@node-rs/argon2-linux-arm-gnueabihf@2.2.1':
resolution: {integrity: sha512-gSORtjKCdokQ8bcDupKWw0ZpitKMkGnvxxOO9Y0sf6Nt7WDWF47YglZ87D4B35mSYTxiyer+BbZsoa2gGJr6zg==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@node-rs/argon2-linux-arm64-gnu@2.2.1':
resolution: {integrity: sha512-1R1K1+9LC60sE24VFfu0+DvurMzZMgPMHODCr2b4aUSeB4ST5DgMHqy6nKMyxh3FDjy/xib6g/Ew0+B5O7nPHg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@node-rs/argon2-linux-arm64-musl@2.2.1':
resolution: {integrity: sha512-OgSW61LB/xy+uLeSIWWZ817x7xg/P0UfmzS8KpiN73l5lD4Fx13aqQ3fv7pVirorL2vH9PYsX2fKEXlg66STlQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@node-rs/argon2-linux-x64-gnu@2.2.1':
resolution: {integrity: sha512-PIbG2WrqB84k2sdirnSXigFJvAemw0JkzZ/hOg6I9tO2GvhlY4P4z0NgmoRm7iKH+XTMo199src1Upm7RZwRAw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@node-rs/argon2-linux-x64-musl@2.2.1':
resolution: {integrity: sha512-atVrlz9vrkf6NvxoTgRTm6KeEBIooUGE4njBTVtJ3DAngOi+QKwOHL/VIhV5y2jE2d7lmuoOFnVSwHHKBzwTuQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@node-rs/argon2-win32-arm64-msvc@2.2.1':
resolution: {integrity: sha512-vbPiOBa/mjSJ0md+bummYScLPmGOxY46A77pvm+sDxsKH7a04oWqfuRK+EXBc4iEXQh6NoA4MlFwQW79CM7+kA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@node-rs/argon2-win32-ia32-msvc@2.2.1':
resolution: {integrity: sha512-e9ApHBN3MLmGwTFtKYJzm1VTra8bYkAZxwaK5J5y9ka4LDSpeWoyvMkfpVhRIg13k0Zr4XqGkl9XnALyxC2NtQ==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
'@node-rs/argon2-win32-x64-msvc@2.2.1':
resolution: {integrity: sha512-1iOOVP0KHp9pRyq7Qcg5cVFjSGGAsff6rYMgB6v98PM282v7OzuxgMpGPy7U3iPfFZb6IRh881SpoaKs+E1czQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@node-rs/argon2@2.2.1':
resolution: {integrity: sha512-miqgmDcvOoAxCf0H3iW2nSd09FFO15geUU30yRNEr2XVXYreErwxAOm1rAKHxNDdjcySA+UTDGMXOWtvFJ3hXQ==}
engines: {node: '>= 10'}
'@nuxtjs/opencollective@0.3.2':
resolution: {integrity: sha512-um0xL3fO7Mf4fDxcqx9KryrB7zgRM5JSlvGN5AGkP6JLM5XEKyjeAiPbNxdXVXQ16isuAhYpvP88NgL2BGd6aA==}
engines: {node: '>=8.0.0', npm: '>=5.0.0'}
@@ -3764,6 +3849,61 @@ snapshots:
'@nodable/entities@3.0.0': {}
'@node-rs/argon2-android-arm-eabi@2.2.1':
optional: true
'@node-rs/argon2-android-arm64@2.2.1':
optional: true
'@node-rs/argon2-darwin-arm64@2.2.1':
optional: true
'@node-rs/argon2-darwin-x64@2.2.1':
optional: true
'@node-rs/argon2-freebsd-x64@2.2.1':
optional: true
'@node-rs/argon2-linux-arm-gnueabihf@2.2.1':
optional: true
'@node-rs/argon2-linux-arm64-gnu@2.2.1':
optional: true
'@node-rs/argon2-linux-arm64-musl@2.2.1':
optional: true
'@node-rs/argon2-linux-x64-gnu@2.2.1':
optional: true
'@node-rs/argon2-linux-x64-musl@2.2.1':
optional: true
'@node-rs/argon2-win32-arm64-msvc@2.2.1':
optional: true
'@node-rs/argon2-win32-ia32-msvc@2.2.1':
optional: true
'@node-rs/argon2-win32-x64-msvc@2.2.1':
optional: true
'@node-rs/argon2@2.2.1':
optionalDependencies:
'@node-rs/argon2-android-arm-eabi': 2.2.1
'@node-rs/argon2-android-arm64': 2.2.1
'@node-rs/argon2-darwin-arm64': 2.2.1
'@node-rs/argon2-darwin-x64': 2.2.1
'@node-rs/argon2-freebsd-x64': 2.2.1
'@node-rs/argon2-linux-arm-gnueabihf': 2.2.1
'@node-rs/argon2-linux-arm64-gnu': 2.2.1
'@node-rs/argon2-linux-arm64-musl': 2.2.1
'@node-rs/argon2-linux-x64-gnu': 2.2.1
'@node-rs/argon2-linux-x64-musl': 2.2.1
'@node-rs/argon2-win32-arm64-msvc': 2.2.1
'@node-rs/argon2-win32-ia32-msvc': 2.2.1
'@node-rs/argon2-win32-x64-msvc': 2.2.1
'@nuxtjs/opencollective@0.3.2':
dependencies:
chalk: 4.1.2
@@ -6257,7 +6397,7 @@ snapshots:
dependencies:
typescript: 5.9.3
ts-jest@29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.9.3)))(typescript@5.9.3):
ts-jest@29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.20.3))(typescript@5.9.3):
dependencies:
bs-logger: 0.2.6
fast-json-stable-stringify: 2.1.0
+7 -18
View File
@@ -1,5 +1,6 @@
// services/api/jest.config.ts —— 单测(*.spec.ts,排除 e2e)。
// ts-jest 保持类型检查模式(非 isolatedModules),@ts-expect-error 负向断言可生效。
// services/api/jest.config.ts —— 单测(src/*.spec.ts + test/*.spec.ts,排除 e2e)。
// ts-jest 保持类型检查模式(非 isolatedModules),@ts-expect-error 负向断言可生效;
// 编译选项复用包级 tsconfig.json(Task 7 遗留项:不再内联重复一份 compilerOptions)。
// env 约定:setupFiles 经 test/load-env.ts 载入仓库根 .env(全仓库唯一 env 文件)。
// ESM 互操作:@photo/contracts 为 ESM 包(NodeNext + .js 后缀导入),测试经
// moduleNameMapper 直接指向其 TS 源码,由 ts-jest 以 CJS 编译(同
@@ -15,28 +16,16 @@ const config: Config = {
rootDir: '.',
setupFiles: ['./test/load-env.ts'],
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{
tsconfig: {
module: 'commonjs',
moduleResolution: 'node10',
target: 'es2022',
lib: ['es2022'],
strict: true,
esModuleInterop: true,
skipLibCheck: true,
types: ['node', 'jest'],
},
},
],
'^.+\\.tsx?$': ['ts-jest', { tsconfig: 'tsconfig.json' }],
},
moduleNameMapper: {
'^@photo/contracts$': '<rootDir>/../../packages/contracts/src/index.ts',
'^@photo/db-cn$': '<rootDir>/../../packages/db-cn/src/index.ts',
'^(\\.{1,2}/.*)\\.js$': '$1',
},
testMatch: ['<rootDir>/src/**/*.spec.ts'],
// test/ 下的 *.spec.ts 为直连真实 DB 的集成型单测(seed.spec.ts,经 reset.ts
// 隔离);纯 HTTP e2e 仍由 jest.e2e.config.ts 的 testMatch 接管。
testMatch: ['<rootDir>/src/**/*.spec.ts', '<rootDir>/test/**/*.spec.ts'],
testPathIgnorePatterns: ['/node_modules/', '/dist/'],
};
+3
View File
@@ -5,7 +5,9 @@
"description": "CN NestJS API service (P1a skeleton: bootstrap, /v1/health, request-id, error envelope)",
"scripts": {
"build": "tsc -p tsconfig.build.json",
"build:watch": "tsc -p tsconfig.build.json --watch",
"start": "tsc -p tsconfig.build.json && node dist/main-cn.js",
"seed": "tsc -p tsconfig.build.json && node dist/scripts/seed-admin.js",
"typecheck": "tsc --noEmit",
"lint": "eslint .",
"test": "jest",
@@ -15,6 +17,7 @@
"@nestjs/common": "^10.4.6",
"@nestjs/core": "^10.4.6",
"@nestjs/platform-express": "^10.4.6",
"@node-rs/argon2": "^2.2.1",
"@photo/contracts": "workspace:*",
"@photo/db-cn": "workspace:*",
"nestjs-pino": "^4.6.1",
+81 -2
View File
@@ -1,15 +1,19 @@
// services/api/src/all-exceptions.filter.spec.ts
// 过滤器信封映射单测:HttpException -> ErrorCodes 映射、未知异常不泄露内部信息。
// 过滤器信封映射单测:ApiException 精确错误码透传、HttpException -> ErrorCodes
// 状态码表映射(含未映射状态兜底规则)、5xx 文案不泄露内部信息。
import {
type ArgumentsHost,
BadRequestException,
ForbiddenException,
HttpException,
HttpStatus,
InternalServerErrorException,
Logger,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { ErrorCodes } from '@photo/contracts';
import { ApiException } from './api-exception';
import { AllExceptionsFilter } from './all-exceptions.filter';
// 测试不断言日志内容,静音 Nest 控制台日志以保持输出干净。
@@ -17,7 +21,7 @@ Logger.overrideLogger(false);
interface Captured {
status?: number;
body?: { code?: string; message?: string; requestId?: string };
body?: { code?: string; message?: string; requestId?: string; details?: unknown };
}
function runFilter(exception: unknown, requestId: string): Captured {
@@ -74,4 +78,79 @@ describe('AllExceptionsFilter', () => {
requestId: 'req-3',
});
});
describe('ApiException (Task 7 遗留项①)', () => {
it('passes through its exact contract code, status and message', () => {
const exception = new ApiException(
ErrorCodes.AUTH_INVALID_CREDENTIALS,
'account or password is incorrect',
HttpStatus.UNAUTHORIZED,
);
const captured = runFilter(exception, 'req-4');
expect(captured.status).toBe(HttpStatus.UNAUTHORIZED);
expect(captured.body).toEqual({
code: ErrorCodes.AUTH_INVALID_CREDENTIALS,
message: 'account or password is incorrect',
requestId: 'req-4',
});
});
it('includes optional details in the envelope when provided', () => {
const exception = new ApiException(
ErrorCodes.MEMBER_CONFLICT,
'already a member',
HttpStatus.CONFLICT,
{ memberRole: 'collaborator' },
);
const captured = runFilter(exception, 'req-5');
expect(captured.status).toBe(HttpStatus.CONFLICT);
expect(captured.body).toEqual({
code: ErrorCodes.MEMBER_CONFLICT,
message: 'already a member',
requestId: 'req-5',
details: { memberRole: 'collaborator' },
});
});
it('wins over the status table for same-status collisions (401)', () => {
// 状态表将 401 默认映射为 AUTH_TOKEN_EXPIRED;ApiException 必须优先。
const exception = new ApiException(
ErrorCodes.AUTH_INVALID_CREDENTIALS,
'nope',
HttpStatus.UNAUTHORIZED,
);
const captured = runFilter(exception, 'req-6');
expect(captured.body?.code).toBe(ErrorCodes.AUTH_INVALID_CREDENTIALS);
});
});
describe('unmapped-status fallback (Task 7 遗留项①)', () => {
it('maps unmapped 4xx (409) to VALIDATION_FAILED, keeping the 409 status and message', () => {
const captured = runFilter(new HttpException('conflicting state', 409), 'req-7');
expect(captured.status).toBe(HttpStatus.CONFLICT);
expect(captured.body?.code).toBe(ErrorCodes.VALIDATION_FAILED);
expect(captured.body?.message).toBe('conflicting state');
expect(captured.body?.requestId).toBe('req-7');
});
it('maps unmapped 5xx to INTERNAL_ERROR with generic message', () => {
const captured = runFilter(new HttpException('boom', 503), 'req-8');
expect(captured.status).toBe(503);
expect(captured.body?.code).toBe(ErrorCodes.INTERNAL_ERROR);
expect(captured.body?.message).toBe('Internal server error');
});
});
describe('5xx message leak fix (Task 7 遗留项②)', () => {
it('forces a generic message for InternalServerErrorException with sensitive detail', () => {
const captured = runFilter(
new InternalServerErrorException('DB exploded: mysql://root:hunter2@db:3306/photo'),
'req-9',
);
expect(captured.status).toBe(HttpStatus.INTERNAL_SERVER_ERROR);
expect(captured.body?.code).toBe(ErrorCodes.INTERNAL_ERROR);
expect(captured.body?.message).toBe('Internal server error');
expect(captured.body?.message).not.toContain('mysql://');
});
});
});
+50 -11
View File
@@ -1,8 +1,13 @@
// services/api/src/all-exceptions.filter.ts
// 全局兜底异常过滤器:所有错误响应统一 {code, message, requestId} 信封。
// code 取自 @photo/contracts ErrorCodes;HttpException 按状态码映射默认错误码
// (后续任务可按异常类型精化),未知异常一律 INTERNAL_ERROR + 通用文案,
// 完整错误仅写服务端日志,绝不向客户端泄露堆栈或内部细节。
// 识别顺序(Task 7 遗留项①②):
// 1. ApiException —— 业务异常自带契约错误码,精确透传 code/status/message(/details);
// 2. 其它 HttpException —— 按状态码表映射默认错误码;未映射状态的兜底规则:
// · >=500 -> INTERNAL_ERROR(且响应文案强制通用,杜绝 InternalServerError-
// Exception(message) 把内部细节如连接串泄露给客户端,原文案仅进日志);
// · <500 -> VALIDATION_FAILED(4xx 语义近似"请求有问题"),保留原状态码,
// 并以 warn 日志记录原状态码便于排查(如 409 冲突未显式映射的场景);
// 3. 未知异常 —— INTERNAL_ERROR + 通用文案,完整错误仅写服务端日志。
import {
type ArgumentsHost,
Catch,
@@ -12,15 +17,13 @@ import {
Logger,
} from '@nestjs/common';
import { type ErrorCode, ErrorCodes } from '@photo/contracts';
import { ApiException } from './api-exception';
import type { RequestWithId } from './shared/request-with-id';
interface JsonRespondable {
status(code: number): { json(body: unknown): void };
}
interface RequestWithId {
id?: string;
}
const STATUS_TO_ERROR_CODE: Readonly<Record<number, ErrorCode>> = {
[HttpStatus.BAD_REQUEST]: ErrorCodes.VALIDATION_FAILED,
[HttpStatus.UNAUTHORIZED]: ErrorCodes.AUTH_TOKEN_EXPIRED,
@@ -28,6 +31,8 @@ const STATUS_TO_ERROR_CODE: Readonly<Record<number, ErrorCode>> = {
[HttpStatus.NOT_FOUND]: ErrorCodes.ALBUM_NOT_FOUND,
};
const GENERIC_INTERNAL_MESSAGE = 'Internal server error';
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
private readonly logger = new Logger(AllExceptionsFilter.name);
@@ -37,13 +42,47 @@ export class AllExceptionsFilter implements ExceptionFilter {
const req = host.switchToHttp().getRequest<RequestWithId>();
const requestId = req.id ?? 'unknown';
if (exception instanceof HttpException) {
if (exception instanceof ApiException) {
const status = exception.getStatus();
const code = STATUS_TO_ERROR_CODE[status] ?? ErrorCodes.INTERNAL_ERROR;
const code = exception.apiCode;
const message = extractMessage(exception);
this.logger.warn(
`HttpException ${status} -> ${code} (requestId=${requestId}): ${message}`,
`ApiException ${status} -> ${code} (requestId=${requestId}): ${message}`,
);
const body = exception.details === undefined
? { code, message, requestId }
: { code, message, requestId, details: exception.details };
res.status(status).json(body);
return;
}
if (exception instanceof HttpException) {
const status = exception.getStatus();
const isServerError = status >= HttpStatus.INTERNAL_SERVER_ERROR;
const code =
STATUS_TO_ERROR_CODE[status] ??
(isServerError
? ErrorCodes.INTERNAL_ERROR
: ErrorCodes.VALIDATION_FAILED);
if (!(status in STATUS_TO_ERROR_CODE)) {
this.logger.warn(
`HttpException status ${status} has no explicit error-code mapping, ` +
`falling back to ${code}`,
);
}
const original = extractMessage(exception);
// ② 5xx / INTERNAL_ERROR:客户端只见通用文案,原文案可能含内部细节
//(连接串、堆栈片段),仅写服务端日志。
const message =
isServerError || code === ErrorCodes.INTERNAL_ERROR
? GENERIC_INTERNAL_MESSAGE
: original;
const log = `HttpException ${status} -> ${code} (requestId=${requestId}): ${original}`;
if (isServerError) {
this.logger.error(log, exception.stack);
} else {
this.logger.warn(log);
}
res.status(status).json({ code, message, requestId });
return;
}
@@ -55,7 +94,7 @@ export class AllExceptionsFilter implements ExceptionFilter {
);
res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
code: ErrorCodes.INTERNAL_ERROR,
message: 'Internal server error',
message: GENERIC_INTERNAL_MESSAGE,
requestId,
});
}
+28
View File
@@ -0,0 +1,28 @@
// services/api/src/api-exception.ts —— 携带契约错误码的业务异常(Task 7 遗留项①)。
// 状态码表(过滤器内 STATUS_TO_ERROR_CODE)只能表达"一个 HTTP 状态 = 一个错误码",
// 无法表达同状态多错误码的契约空间(如 401 既可能是 AUTH_TOKEN_EXPIRED 也可能是
// AUTH_INVALID_CREDENTIALS,409 既可能是 MEMBER_CONFLICT 也可能是
// UPLOAD_SESSION_CONFLICT)。业务代码抛 ApiException 精确指定 ErrorCode;
// 过滤器优先识别它,状态码表仅作普通 HttpException 的兜底映射。
import { HttpException } from '@nestjs/common';
import type { ErrorCode } from '@photo/contracts';
export class ApiException extends HttpException {
constructor(
readonly apiCode: ErrorCode,
message: string,
httpStatus: number,
readonly details?: unknown,
) {
// 响应体形状 {code, message, details?}:details 缺省时不占位(保持信封干净)。
// 注意 message 须从 getResponse().message 提取(见过滤器 extractMessage),
// HttpException.message 属性在对象响应下不含业务文案。
super(
details === undefined
? { code: apiCode, message }
: { code: apiCode, message, details },
httpStatus,
);
this.name = ApiException.name;
}
}
+14 -3
View File
@@ -12,6 +12,8 @@ import { AllExceptionsFilter } from './all-exceptions.filter';
import { HealthController } from './health.controller';
import { PrismaModule } from './prisma/prisma.module';
import { RequestIdMiddleware, resolveRequestId } from './request-id.middleware';
import { resolveLogLevel } from './shared/log-level';
import type { ServerRequestWithId } from './shared/request-with-id';
@Module({
imports: [
@@ -22,12 +24,21 @@ import { RequestIdMiddleware, resolveRequestId } from './request-id.middleware';
// 与 RequestIdMiddleware 共用同一解析函数:优先透传 x-request-id,
// 其次复用已生成的 req.id,否则生成 UUID;并回写响应头。
genReqId: (req: IncomingMessage, res: ServerResponse) =>
resolveRequestId(req, res),
resolveRequestId(req as ServerRequestWithId, res),
redact: {
paths: ['req.headers.authorization', 'res.headers.authorization'],
// 授权头 + Cookie(会话凭据,Task 7 遗留项③):请求/响应两侧都打码,
// 防止日志聚合层留存可重放的凭据。
paths: [
'req.headers.authorization',
'req.headers.cookie',
'res.headers.authorization',
'res.headers.set-cookie',
],
censor: '[REDACTED]',
},
level: process.env.LOG_LEVEL ?? 'info',
// Task 7 遗留项⑤:非法 LOG_LEVEL 在模块装配期(即启动期)快速失败,
// 而不是让 pino 首次写日志时才炸或静默吞掉输出。
level: resolveLogLevel(process.env.LOG_LEVEL),
},
}),
],
+28
View File
@@ -0,0 +1,28 @@
// services/api/src/auth/password.ts —— 密码哈希工具(Task 12)。
// @node-rs/argon2 为 napi 预编译绑定(无原生构建脚本),默认算法即 argon2id
// (抗 GPU 侧信道,口令哈希的现代默认)。哈希自带随机盐,同口令两次结果不同,
// 调用方不得做哈希相等比较——一律走 verifyPassword。
import { hash, verify } from '@node-rs/argon2';
/**
* 哈希明文口令(argon2id,默认参数 m=19456/t=2/p=1,约 19MiB 内存,服务端
* 登录路径的合理默认;如需调参在登录压测后集中调整)。
*
* 空口令直接抛错而不是静默哈希:空口令只会来自漏校验的调用方,属程序错误。
* 调用方(如 auth.service)应先做入参校验(DTO 非空约束),若仍逸出则由
* 全局过滤器按 400 -> VALIDATION_FAILED 兜底。
*/
export function hashPassword(plain: string): Promise<string> {
if (plain === '') {
return Promise.reject(new Error('plain password must not be empty'));
}
return hash(plain);
}
/** 校验明文口令是否与存储的 argon2 哈希匹配(常量时间比较由 argon2 内部保证)。 */
export function verifyPassword(
passwordHash: string,
plain: string,
): Promise<boolean> {
return verify(passwordHash, plain);
}
+2 -1
View File
@@ -6,13 +6,14 @@ import { type INestApplication } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { Logger } from 'nestjs-pino';
import { AppCnModule } from './app-cn.module';
import { API_PREFIX } from './shared/constants';
async function bootstrap(): Promise<void> {
const app = await NestFactory.create<INestApplication>(AppCnModule, {
bufferLogs: true,
});
app.useLogger(app.get(Logger));
app.setGlobalPrefix('v1');
app.setGlobalPrefix(API_PREFIX);
const corsOrigins = (process.env.CORS_ORIGINS ?? 'http://localhost:5173')
.split(',')
.map((origin) => origin.trim())
@@ -0,0 +1,66 @@
// services/api/src/request-id.middleware.spec.ts —— 请求 ID 解析单测:
// 透传上游值 / 缺省生成 UUID / 超长值拒收换新(Task 7 遗留项④)。
import type { ServerResponse } from 'node:http';
import {
MAX_REQUEST_ID_LENGTH,
resolveRequestId,
} from './request-id.middleware';
function fakeReq(header?: string): { headers: Record<string, string>; id?: string } {
return header === undefined ? { headers: {} } : { headers: { 'x-request-id': header } };
}
function fakeRes(): ServerResponse & { captured?: string } {
const res = { headers: {} } as unknown as ServerResponse & { captured?: string };
Object.defineProperty(res, 'setHeader', {
value: (name: string, value: string) => {
res.captured = value;
return res;
},
});
return res;
}
describe('resolveRequestId', () => {
it('echoes a reasonable upstream x-request-id', () => {
const req = fakeReq('upstream-id-1');
const res = fakeRes();
expect(resolveRequestId(req as never, res)).toBe('upstream-id-1');
expect(req.id).toBe('upstream-id-1');
expect(res.captured).toBe('upstream-id-1');
});
it('generates a UUID v4 when the header is absent', () => {
const req = fakeReq();
const id = resolveRequestId(req as never, fakeRes());
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
expect(req.id).toBe(id);
});
it('treats a blank header as absent', () => {
const id = resolveRequestId(fakeReq(' ') as never, fakeRes());
expect(id).not.toBe(' ');
});
it('replaces an over-long upstream id (>128 chars) with a fresh UUID', () => {
const overlong = 'x'.repeat(MAX_REQUEST_ID_LENGTH + 1);
const req = fakeReq(overlong);
const res = fakeRes();
const id = resolveRequestId(req as never, res);
expect(id).not.toBe(overlong);
expect(id.length).toBeLessThanOrEqual(MAX_REQUEST_ID_LENGTH);
expect(req.id).toBe(id);
expect(res.captured).toBe(id);
});
it('still echoes an id of exactly the cap (128 chars)', () => {
const exact = 'y'.repeat(MAX_REQUEST_ID_LENGTH);
const id = resolveRequestId(fakeReq(exact) as never, fakeRes());
expect(id).toBe(exact);
});
it('reuses req.id when already resolved (pino-http genReqId runs first)', () => {
const req = { ...fakeReq('ignored'), id: 'already-set' };
expect(resolveRequestId(req as never, fakeRes())).toBe('already-set');
});
});
+14 -11
View File
@@ -1,36 +1,39 @@
// services/api/src/request-id.middleware.ts
// 请求 ID 约定:优先透传上游 x-request-id(网关/前端可携带),否则生成 UUID v4;
// 挂到 req.id(pino-http 日志字段)并写回响应头 x-request-id,便于全链路排障。
// 长度上限(Task 7 遗留项④):上游值 >128 字符时视为滥用/异常,静默换成新 UUID
//(不透传也不告警——日志与响应头由本函数统一写回,不会出现半截 ID)。
// genReqId 与本中间件共用 resolveRequestId,先后顺序不影响结果。
import { randomUUID } from 'node:crypto';
import type { IncomingMessage, ServerResponse } from 'node:http';
import type { ServerResponse } from 'node:http';
import type { NestMiddleware } from '@nestjs/common';
import type { ServerRequestWithId } from './shared/request-with-id';
interface RequestWithId {
id?: string;
}
/** 透传上游 x-request-id 的长度上限(UUID v4 为 36 字符,128 已十分宽裕)。 */
export const MAX_REQUEST_ID_LENGTH = 128;
export function resolveRequestId(
req: IncomingMessage,
req: ServerRequestWithId,
res: ServerResponse,
): string {
const existing = (req as IncomingMessage & RequestWithId).id;
if (existing) {
return existing;
if (req.id) {
return req.id;
}
const header = req.headers['x-request-id'];
const fromHeader = Array.isArray(header) ? header[0] : header;
const id =
typeof fromHeader === 'string' && fromHeader.trim() !== ''
typeof fromHeader === 'string' &&
fromHeader.trim() !== '' &&
fromHeader.length <= MAX_REQUEST_ID_LENGTH
? fromHeader
: randomUUID();
(req as IncomingMessage & RequestWithId).id = id;
req.id = id;
res.setHeader('x-request-id', id);
return id;
}
export class RequestIdMiddleware implements NestMiddleware {
use(req: IncomingMessage, res: ServerResponse, next: () => void): void {
use(req: ServerRequestWithId, res: ServerResponse, next: () => void): void {
resolveRequestId(req, res);
next();
}
+88
View File
@@ -0,0 +1,88 @@
// services/api/src/scripts/seed-admin.ts —— 受控初始管理员种子脚本(Task 12)。
// 运行方式:`pnpm --filter @photo/api-cn seed`(= tsc 构建后 node dist/scripts/
// seed-admin.js)。环境变量 ADMIN_ACCOUNT / ADMIN_PASSWORD(见 .env.example,
// 开发默认 admin / admin-secret-change-me)。
// 幂等语义:账号已存在则跳过且【绝不改写密码】——生产上该账号的密码应由首个
// 登录流程/管理员自行轮换,重跑脚本不得悄悄重置。不写 audit_logs:种子是系统
// 运维操作而非用户动作,actor 不存在,审计语义不成立(计划 Task 12 明确排除)。
import { existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { config } from 'dotenv';
import { PrismaClient } from '@photo/db-cn';
import { hashPassword } from '../auth/password';
export interface SeedAdminResult {
/** true=本次新建;false=账号已存在,跳过(未改写任何字段)。 */
created: boolean;
}
/**
* 种子核心逻辑(可测):存在即跳过,不存在则建 admin 用户。
* displayName 缺省取账号名(users.displayName 非空约束)。
*/
export async function seedAdmin(
prisma: PrismaClient,
account: string,
password: string,
): Promise<SeedAdminResult> {
if (account.trim() === '') {
throw new Error('seed-admin: ADMIN_ACCOUNT must not be empty');
}
const existing = await prisma.users.findUnique({ where: { account } });
if (existing !== null) {
return { created: false };
}
await prisma.users.create({
data: {
account,
passwordHash: await hashPassword(password),
displayName: account,
role: 'admin',
credentialStatus: 'active',
},
});
return { created: true };
}
/** 从脚本所在目录向上找 pnpm-workspace.yaml 定位仓库根并载入根 .env(同 packages/storage/test/load-env.ts 约定;不覆盖已有进程环境变量)。 */
function loadRootEnv(): void {
let dir = __dirname;
while (!existsSync(join(dir, 'pnpm-workspace.yaml'))) {
const parent = dirname(dir);
if (parent === dir) {
throw new Error('seed-admin: pnpm-workspace.yaml not found, cannot locate repo root (.env)');
}
dir = parent;
}
config({ path: join(dir, '.env') });
}
async function main(): Promise<void> {
loadRootEnv();
const account = process.env.ADMIN_ACCOUNT;
const password = process.env.ADMIN_PASSWORD;
if (!account || !password) {
throw new Error(
'seed-admin: ADMIN_ACCOUNT and ADMIN_PASSWORD are required (see .env.example)',
);
}
const prisma = new PrismaClient();
try {
const { created } = await seedAdmin(prisma, account, password);
if (created) {
console.log(`seed-admin: created admin "${account}" (role=admin, credentialStatus=active)`);
} else {
console.log(`seed-admin: admin "${account}" already exists, skipping (password untouched)`);
}
} finally {
await prisma.$disconnect();
}
}
// 直接以 CLI 运行(node dist/scripts/seed-admin.js)时执行 main;被 import(测试)时不执行。
if (require.main === module) {
void main().catch((err: unknown) => {
console.error(err instanceof Error ? err.message : err);
process.exitCode = 1;
});
}
+3
View File
@@ -0,0 +1,3 @@
// services/api/src/shared/constants.ts —— 跨层共享常量(Task 7 遗留项⑥:
// 此前 'v1' 前缀在 main-cn 与 e2e spec 各写一份,漂移风险集中到此处消除)。
export const API_PREFIX = 'v1';
+17
View File
@@ -0,0 +1,17 @@
// services/api/src/shared/log-level.spec.ts —— LOG_LEVEL 校验单测。
import { resolveLogLevel, VALID_LOG_LEVELS } from './log-level';
describe('resolveLogLevel', () => {
it('defaults to info when unset', () => {
expect(resolveLogLevel(undefined)).toBe('info');
});
it.each([...VALID_LOG_LEVELS])('accepts built-in pino level %s', (level) => {
expect(resolveLogLevel(level)).toBe(level);
});
it('rejects an invalid level with a message listing valid values', () => {
expect(() => resolveLogLevel('verbose')).toThrow(/LOG_LEVEL.*verbose/);
expect(() => resolveLogLevel('')).toThrow(/fatal/);
});
});
+29
View File
@@ -0,0 +1,29 @@
// services/api/src/shared/log-level.ts —— LOG_LEVEL 启动期校验(Task 7 遗留项⑤)。
// 此前非法 LOG_LEVEL 由 pino 在首次写日志时才隐式抛错(或更糟:被当成自定义
// level 静默吞掉全部输出)。改为模块装配(LoggerModule.forRoot 前)即快速失败,
// 错误信息列出全部合法值。合法值即 pino 内建 level 集合(pino.Level 类型)。
import type { Level } from 'pino';
export const VALID_LOG_LEVELS: readonly Level[] = [
'fatal',
'error',
'warn',
'info',
'debug',
'trace',
];
/**
* 解析进程环境 LOG_LEVEL:缺省 'info';非法值抛错(调用点在模块装配期,
* 即应用启动即失败,不带半残日志配置上线)。
*/
export function resolveLogLevel(raw: string | undefined): Level {
const level = raw ?? 'info';
if (!VALID_LOG_LEVELS.includes(level as Level)) {
throw new Error(
`Invalid LOG_LEVEL "${level}". Valid levels: ${VALID_LOG_LEVELS.join(', ')} ` +
'(see .env.example).',
);
}
return level as Level;
}
@@ -0,0 +1,14 @@
// services/api/src/shared/request-with-id.ts —— 请求对象带 req.id 的共享类型
// (Task 7 遗留项⑥):request-id 中间件 / pino-http genReqId 写入,异常过滤器、
// 后续 auth guard(req.user)等读取。此前在过滤器与中间件各声明一份局部接口。
// 注意不继承 IncomingMessage:pino-http 经 module augmentation 给后者挂了
// 非可选的 id: ReqId,扩展会与其冲突;组合用 ServerRequestWithId。
import type { IncomingMessage } from 'node:http';
/** 请求 ID 字段约定:上游 x-request-id 透传或 UUID v4(见 request-id.middleware.ts)。 */
export interface RequestWithId {
id?: string;
}
/** 中间件/genReqId 实际操作的请求对象(node 原始请求 + id 字段)。 */
export type ServerRequestWithId = IncomingMessage & RequestWithId;
+18 -6
View File
@@ -1,14 +1,15 @@
// services/api/test/health.e2e-spec.ts
// e2e 冒烟:验证 e2e 工装(Nest testing module + supertest + 根 .env 加载)可用。
// 不监听端口:app.init() 后直接对 app.getHttpServer() 发请求。
// 全局前缀与 main-cn.ts 保持一致('v1'),由 spec 显式设置。
// 全局前缀与 main-cn.ts 共用 src/shared/constants 的 API_PREFIX(Task 7 遗留项⑥)。
import { type INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { AppCnModule } from '../src/app-cn.module';
import { API_PREFIX } from '../src/shared/constants';
import { resetDb } from './reset';
describe('GET /v1/health (e2e)', () => {
describe(`GET /${API_PREFIX}/health (e2e)`, () => {
let app: INestApplication;
beforeAll(async () => {
@@ -17,7 +18,7 @@ describe('GET /v1/health (e2e)', () => {
imports: [AppCnModule],
}).compile();
app = moduleRef.createNestApplication();
app.setGlobalPrefix('v1');
app.setGlobalPrefix(API_PREFIX);
await app.init();
});
@@ -26,13 +27,13 @@ describe('GET /v1/health (e2e)', () => {
});
it('responds 200 {"status":"ok"}', async () => {
const res = await request(app.getHttpServer()).get('/v1/health');
const res = await request(app.getHttpServer()).get(`/${API_PREFIX}/health`);
expect(res.status).toBe(200);
expect(res.body).toEqual({ status: 'ok' });
});
it('sets x-request-id response header (generated when absent)', async () => {
const res = await request(app.getHttpServer()).get('/v1/health');
const res = await request(app.getHttpServer()).get(`/${API_PREFIX}/health`);
const requestId = res.headers['x-request-id'];
expect(typeof requestId).toBe('string');
expect(requestId.length ?? 0).toBeGreaterThan(0);
@@ -40,8 +41,19 @@ describe('GET /v1/health (e2e)', () => {
it('echoes incoming x-request-id header', async () => {
const res = await request(app.getHttpServer())
.get('/v1/health')
.get(`/${API_PREFIX}/health`)
.set('x-request-id', 'e2e-fixed-request-id');
expect(res.headers['x-request-id']).toBe('e2e-fixed-request-id');
});
it('replaces an over-long incoming x-request-id (>128 chars) with a fresh one', async () => {
const overlong = 'z'.repeat(200);
const res = await request(app.getHttpServer())
.get(`/${API_PREFIX}/health`)
.set('x-request-id', overlong);
const requestId = res.headers['x-request-id'];
expect(typeof requestId).toBe('string');
expect(requestId).not.toBe(overlong);
expect((requestId as string).length).toBeLessThanOrEqual(128);
});
});
+41
View File
@@ -0,0 +1,41 @@
// services/api/test/password.spec.ts —— 密码哈希纯函数单测(Task 12 Part A)。
// 断言 @node-rs/argon2 语义:argon2id 编码格式($argon2... 开头)、正确口令验证
// 通过、错误口令拒绝、同口令两次哈希不同(随机盐)、空口令直接拒绝。
import {
hashPassword,
verifyPassword,
} from '../src/auth/password';
describe('hashPassword / verifyPassword', () => {
const plain = 'correct-horse-battery-staple';
it('returns an argon2 hash distinct from the plain text', async () => {
const hashed = await hashPassword(plain);
expect(typeof hashed).toBe('string');
expect(hashed.length).toBeGreaterThan(0);
expect(hashed).not.toBe(plain);
expect(hashed.startsWith('$argon2')).toBe(true);
});
it('verifies the correct password', async () => {
const hashed = await hashPassword(plain);
await expect(verifyPassword(hashed, plain)).resolves.toBe(true);
});
it('rejects a wrong password', async () => {
const hashed = await hashPassword(plain);
await expect(verifyPassword(hashed, 'wrong-password')).resolves.toBe(false);
});
it('produces different hashes for the same password (random salt)', async () => {
const [a, b] = await Promise.all([hashPassword(plain), hashPassword(plain)]);
expect(a).not.toBe(b);
// 且两条哈希都能通过校验(各自携带自己的盐)。
await expect(verifyPassword(a, plain)).resolves.toBe(true);
await expect(verifyPassword(b, plain)).resolves.toBe(true);
});
it('rejects an empty password (never hash empty strings)', async () => {
await expect(hashPassword('')).rejects.toThrow(/empty/i);
});
});
+69
View File
@@ -0,0 +1,69 @@
// services/api/test/seed.spec.ts —— 初始管理员种子幂等性集成测试(真实 DB)。
// 直接调用导出的 seedAdmin 函数(非 CLI):第一次创建、第二次跳过且不改写密码;
// 全程不写 audit_logs(系统运维操作,无 actor,计划 Task 12 明确排除审计)。
import { PrismaClient } from '@photo/db-cn';
import { seedAdmin } from '../src/scripts/seed-admin';
import { verifyPassword } from '../src/auth/password';
import { resetDb } from './reset';
describe('seedAdmin (real DB, idempotent)', () => {
let prisma: PrismaClient;
beforeAll(async () => {
await resetDb();
prisma = new PrismaClient();
await prisma.$connect();
});
afterAll(async () => {
await prisma.$disconnect();
});
it('creates the admin on first run', async () => {
const result = await seedAdmin(prisma, 'admin', 'admin-secret-change-me');
expect(result.created).toBe(true);
const rows = await prisma.users.findMany({ where: { account: 'admin' } });
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
account: 'admin',
role: 'admin',
credentialStatus: 'active',
sourceStatus: 'local',
localLocked: false,
displayName: 'admin',
});
// 存的是 argon2 哈希而非明文。
expect(rows[0].passwordHash.startsWith('$argon2')).toBe(true);
expect(rows[0].passwordHash).not.toBe('admin-secret-change-me');
});
it('skips on second run without touching the stored password', async () => {
const before = await prisma.users.findUniqueOrThrow({
where: { account: 'admin' },
});
const result = await seedAdmin(prisma, 'admin', 'a-different-password');
expect(result.created).toBe(false);
const rows = await prisma.users.findMany();
expect(rows).toHaveLength(1);
const after = rows[0];
expect(after.passwordHash).toBe(before.passwordHash);
// 原密码仍然有效(未被第二个口令替换)。
await expect(
verifyPassword(after.passwordHash, 'admin-secret-change-me'),
).resolves.toBe(true);
await expect(
verifyPassword(after.passwordHash, 'a-different-password'),
).resolves.toBe(false);
});
it('writes no audit_logs rows (system op, not a user action)', async () => {
expect(await prisma.audit_logs.count()).toBe(0);
});
it('rejects an empty account before touching the DB', async () => {
await expect(seedAdmin(prisma, ' ', 'whatever')).rejects.toThrow(
/ADMIN_ACCOUNT/,
);
expect(await prisma.users.count()).toBe(1);
});
});