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:
@@ -11,3 +11,5 @@ UPLOAD_MAX_BYTES=52428800
|
|||||||
PORT=8080
|
PORT=8080
|
||||||
CORS_ORIGINS=http://localhost:5173
|
CORS_ORIGINS=http://localhost:5173
|
||||||
LOG_LEVEL=info
|
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 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。
|
- [x] **Step 3: 运行** `pnpm --filter @photo/storage test`(完成:6/6 通过,真实 MinIO;规格审查负向实测发现 SDK 对 content-type 不签名——已修正文档为「约定发送」,head() 增加 contentType 返回,8e870de)
|
||||||
- [ ] **Step 4: Commit** `feat(storage): storage port and minio adapter with forbid-overwrite semantics`
|
- [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)。
|
> 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。
|
- [x] **Step 1: 失败测试**:hash/verify(@node-rs/argon2,memoryCost 默认)、错误口令 verify=false。(完成:test/password.spec.ts 5 例,先红后绿)
|
||||||
- [ ] **Step 2: 实现 + seed 脚本**(读 env `ADMIN_ACCOUNT/ADMIN_PASSWORD`,不存在则插入 admin 角色,存在则跳过——幂等)。
|
- [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 依赖)
|
||||||
- [ ] **Step 3: 测试通过;Commit** `feat(api): argon2 password and idempotent admin seed`
|
- [x] **Step 3: 测试通过;Commit** `feat(api): argon2 password and idempotent admin seed`(完成:api 单测 35 通过 / e2e 10 通过,根 lint+typecheck+test 绿)
|
||||||
|
|
||||||
### Task 13: 登录 / 刷新 / 注销
|
### Task 13: 登录 / 刷新 / 注销
|
||||||
|
|
||||||
@@ -566,7 +566,7 @@ export interface StoragePort {
|
|||||||
- **重复 complete(A03)**→200 幂等,不产生第二个 photo/job/outbox;
|
- **重复 complete(A03)**→200 幂等,不产生第二个 photo/job/outbox;
|
||||||
- 被移除成员 complete→403;
|
- 被移除成员 complete→403;
|
||||||
- `GET /uploads` 返回本人会话历史(state 联查 photo.processingState,App 服务端历史的后端前置)。
|
- `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`
|
- [ ] **Step 3: 通过;Commit** `feat(api): upload verify, forbid-overwrite copy, idempotent complete`
|
||||||
|
|
||||||
### Task 20: worker 服务与 sharp 处理流水线
|
### Task 20: worker 服务与 sharp 处理流水线
|
||||||
|
|||||||
Generated
+144
-4
@@ -46,7 +46,7 @@ importers:
|
|||||||
version: 7.13.0(typescript@5.9.3)
|
version: 7.13.0(typescript@5.9.3)
|
||||||
ts-jest:
|
ts-jest:
|
||||||
specifier: ^29.2.0
|
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:
|
typescript:
|
||||||
specifier: ^5.6.0
|
specifier: ^5.6.0
|
||||||
version: 5.9.3
|
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))
|
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:
|
ts-jest:
|
||||||
specifier: ^29.2.5
|
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:
|
typescript:
|
||||||
specifier: ^5.6.0
|
specifier: ^5.6.0
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
@@ -106,6 +106,9 @@ importers:
|
|||||||
'@nestjs/platform-express':
|
'@nestjs/platform-express':
|
||||||
specifier: ^10.4.6
|
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)
|
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':
|
'@photo/contracts':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/contracts
|
version: link:../../packages/contracts
|
||||||
@@ -151,7 +154,7 @@ importers:
|
|||||||
version: 7.2.2
|
version: 7.2.2
|
||||||
ts-jest:
|
ts-jest:
|
||||||
specifier: ^29.2.5
|
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:
|
ts-node:
|
||||||
specifier: ^10.9.2
|
specifier: ^10.9.2
|
||||||
version: 10.9.2(@types/node@22.20.3)(typescript@5.9.3)
|
version: 10.9.2(@types/node@22.20.3)(typescript@5.9.3)
|
||||||
@@ -653,6 +656,88 @@ packages:
|
|||||||
'@nodable/entities@3.0.0':
|
'@nodable/entities@3.0.0':
|
||||||
resolution: {integrity: sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==}
|
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':
|
'@nuxtjs/opencollective@0.3.2':
|
||||||
resolution: {integrity: sha512-um0xL3fO7Mf4fDxcqx9KryrB7zgRM5JSlvGN5AGkP6JLM5XEKyjeAiPbNxdXVXQ16isuAhYpvP88NgL2BGd6aA==}
|
resolution: {integrity: sha512-um0xL3fO7Mf4fDxcqx9KryrB7zgRM5JSlvGN5AGkP6JLM5XEKyjeAiPbNxdXVXQ16isuAhYpvP88NgL2BGd6aA==}
|
||||||
engines: {node: '>=8.0.0', npm: '>=5.0.0'}
|
engines: {node: '>=8.0.0', npm: '>=5.0.0'}
|
||||||
@@ -3764,6 +3849,61 @@ snapshots:
|
|||||||
|
|
||||||
'@nodable/entities@3.0.0': {}
|
'@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':
|
'@nuxtjs/opencollective@0.3.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
chalk: 4.1.2
|
chalk: 4.1.2
|
||||||
@@ -6257,7 +6397,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
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)(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:
|
dependencies:
|
||||||
bs-logger: 0.2.6
|
bs-logger: 0.2.6
|
||||||
fast-json-stable-stringify: 2.1.0
|
fast-json-stable-stringify: 2.1.0
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// services/api/jest.config.ts —— 单测(*.spec.ts,排除 e2e)。
|
// services/api/jest.config.ts —— 单测(src/*.spec.ts + test/*.spec.ts,排除 e2e)。
|
||||||
// ts-jest 保持类型检查模式(非 isolatedModules),@ts-expect-error 负向断言可生效。
|
// ts-jest 保持类型检查模式(非 isolatedModules),@ts-expect-error 负向断言可生效;
|
||||||
|
// 编译选项复用包级 tsconfig.json(Task 7 遗留项:不再内联重复一份 compilerOptions)。
|
||||||
// env 约定:setupFiles 经 test/load-env.ts 载入仓库根 .env(全仓库唯一 env 文件)。
|
// env 约定:setupFiles 经 test/load-env.ts 载入仓库根 .env(全仓库唯一 env 文件)。
|
||||||
// ESM 互操作:@photo/contracts 为 ESM 包(NodeNext + .js 后缀导入),测试经
|
// ESM 互操作:@photo/contracts 为 ESM 包(NodeNext + .js 后缀导入),测试经
|
||||||
// moduleNameMapper 直接指向其 TS 源码,由 ts-jest 以 CJS 编译(同
|
// moduleNameMapper 直接指向其 TS 源码,由 ts-jest 以 CJS 编译(同
|
||||||
@@ -15,28 +16,16 @@ const config: Config = {
|
|||||||
rootDir: '.',
|
rootDir: '.',
|
||||||
setupFiles: ['./test/load-env.ts'],
|
setupFiles: ['./test/load-env.ts'],
|
||||||
transform: {
|
transform: {
|
||||||
'^.+\\.tsx?$': [
|
'^.+\\.tsx?$': ['ts-jest', { tsconfig: 'tsconfig.json' }],
|
||||||
'ts-jest',
|
|
||||||
{
|
|
||||||
tsconfig: {
|
|
||||||
module: 'commonjs',
|
|
||||||
moduleResolution: 'node10',
|
|
||||||
target: 'es2022',
|
|
||||||
lib: ['es2022'],
|
|
||||||
strict: true,
|
|
||||||
esModuleInterop: true,
|
|
||||||
skipLibCheck: true,
|
|
||||||
types: ['node', 'jest'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
'^@photo/contracts$': '<rootDir>/../../packages/contracts/src/index.ts',
|
'^@photo/contracts$': '<rootDir>/../../packages/contracts/src/index.ts',
|
||||||
'^@photo/db-cn$': '<rootDir>/../../packages/db-cn/src/index.ts',
|
'^@photo/db-cn$': '<rootDir>/../../packages/db-cn/src/index.ts',
|
||||||
'^(\\.{1,2}/.*)\\.js$': '$1',
|
'^(\\.{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/'],
|
testPathIgnorePatterns: ['/node_modules/', '/dist/'],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
"description": "CN NestJS API service (P1a skeleton: bootstrap, /v1/health, request-id, error envelope)",
|
"description": "CN NestJS API service (P1a skeleton: bootstrap, /v1/health, request-id, error envelope)",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.build.json",
|
"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",
|
"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",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
@@ -15,6 +17,7 @@
|
|||||||
"@nestjs/common": "^10.4.6",
|
"@nestjs/common": "^10.4.6",
|
||||||
"@nestjs/core": "^10.4.6",
|
"@nestjs/core": "^10.4.6",
|
||||||
"@nestjs/platform-express": "^10.4.6",
|
"@nestjs/platform-express": "^10.4.6",
|
||||||
|
"@node-rs/argon2": "^2.2.1",
|
||||||
"@photo/contracts": "workspace:*",
|
"@photo/contracts": "workspace:*",
|
||||||
"@photo/db-cn": "workspace:*",
|
"@photo/db-cn": "workspace:*",
|
||||||
"nestjs-pino": "^4.6.1",
|
"nestjs-pino": "^4.6.1",
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
// services/api/src/all-exceptions.filter.spec.ts
|
// services/api/src/all-exceptions.filter.spec.ts
|
||||||
// 过滤器信封映射单测:HttpException -> ErrorCodes 映射、未知异常不泄露内部信息。
|
// 过滤器信封映射单测:ApiException 精确错误码透传、HttpException -> ErrorCodes
|
||||||
|
// 状态码表映射(含未映射状态兜底规则)、5xx 文案不泄露内部信息。
|
||||||
import {
|
import {
|
||||||
type ArgumentsHost,
|
type ArgumentsHost,
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
|
HttpException,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
|
InternalServerErrorException,
|
||||||
Logger,
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ErrorCodes } from '@photo/contracts';
|
import { ErrorCodes } from '@photo/contracts';
|
||||||
|
import { ApiException } from './api-exception';
|
||||||
import { AllExceptionsFilter } from './all-exceptions.filter';
|
import { AllExceptionsFilter } from './all-exceptions.filter';
|
||||||
|
|
||||||
// 测试不断言日志内容,静音 Nest 控制台日志以保持输出干净。
|
// 测试不断言日志内容,静音 Nest 控制台日志以保持输出干净。
|
||||||
@@ -17,7 +21,7 @@ Logger.overrideLogger(false);
|
|||||||
|
|
||||||
interface Captured {
|
interface Captured {
|
||||||
status?: number;
|
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 {
|
function runFilter(exception: unknown, requestId: string): Captured {
|
||||||
@@ -74,4 +78,79 @@ describe('AllExceptionsFilter', () => {
|
|||||||
requestId: 'req-3',
|
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://');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
// services/api/src/all-exceptions.filter.ts
|
// services/api/src/all-exceptions.filter.ts
|
||||||
// 全局兜底异常过滤器:所有错误响应统一 {code, message, requestId} 信封。
|
// 全局兜底异常过滤器:所有错误响应统一 {code, message, requestId} 信封。
|
||||||
// code 取自 @photo/contracts ErrorCodes;HttpException 按状态码映射默认错误码
|
// 识别顺序(Task 7 遗留项①②):
|
||||||
// (后续任务可按异常类型精化),未知异常一律 INTERNAL_ERROR + 通用文案,
|
// 1. ApiException —— 业务异常自带契约错误码,精确透传 code/status/message(/details);
|
||||||
// 完整错误仅写服务端日志,绝不向客户端泄露堆栈或内部细节。
|
// 2. 其它 HttpException —— 按状态码表映射默认错误码;未映射状态的兜底规则:
|
||||||
|
// · >=500 -> INTERNAL_ERROR(且响应文案强制通用,杜绝 InternalServerError-
|
||||||
|
// Exception(message) 把内部细节如连接串泄露给客户端,原文案仅进日志);
|
||||||
|
// · <500 -> VALIDATION_FAILED(4xx 语义近似"请求有问题"),保留原状态码,
|
||||||
|
// 并以 warn 日志记录原状态码便于排查(如 409 冲突未显式映射的场景);
|
||||||
|
// 3. 未知异常 —— INTERNAL_ERROR + 通用文案,完整错误仅写服务端日志。
|
||||||
import {
|
import {
|
||||||
type ArgumentsHost,
|
type ArgumentsHost,
|
||||||
Catch,
|
Catch,
|
||||||
@@ -12,15 +17,13 @@ import {
|
|||||||
Logger,
|
Logger,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { type ErrorCode, ErrorCodes } from '@photo/contracts';
|
import { type ErrorCode, ErrorCodes } from '@photo/contracts';
|
||||||
|
import { ApiException } from './api-exception';
|
||||||
|
import type { RequestWithId } from './shared/request-with-id';
|
||||||
|
|
||||||
interface JsonRespondable {
|
interface JsonRespondable {
|
||||||
status(code: number): { json(body: unknown): void };
|
status(code: number): { json(body: unknown): void };
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RequestWithId {
|
|
||||||
id?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATUS_TO_ERROR_CODE: Readonly<Record<number, ErrorCode>> = {
|
const STATUS_TO_ERROR_CODE: Readonly<Record<number, ErrorCode>> = {
|
||||||
[HttpStatus.BAD_REQUEST]: ErrorCodes.VALIDATION_FAILED,
|
[HttpStatus.BAD_REQUEST]: ErrorCodes.VALIDATION_FAILED,
|
||||||
[HttpStatus.UNAUTHORIZED]: ErrorCodes.AUTH_TOKEN_EXPIRED,
|
[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,
|
[HttpStatus.NOT_FOUND]: ErrorCodes.ALBUM_NOT_FOUND,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const GENERIC_INTERNAL_MESSAGE = 'Internal server error';
|
||||||
|
|
||||||
@Catch()
|
@Catch()
|
||||||
export class AllExceptionsFilter implements ExceptionFilter {
|
export class AllExceptionsFilter implements ExceptionFilter {
|
||||||
private readonly logger = new Logger(AllExceptionsFilter.name);
|
private readonly logger = new Logger(AllExceptionsFilter.name);
|
||||||
@@ -37,13 +42,47 @@ export class AllExceptionsFilter implements ExceptionFilter {
|
|||||||
const req = host.switchToHttp().getRequest<RequestWithId>();
|
const req = host.switchToHttp().getRequest<RequestWithId>();
|
||||||
const requestId = req.id ?? 'unknown';
|
const requestId = req.id ?? 'unknown';
|
||||||
|
|
||||||
if (exception instanceof HttpException) {
|
if (exception instanceof ApiException) {
|
||||||
const status = exception.getStatus();
|
const status = exception.getStatus();
|
||||||
const code = STATUS_TO_ERROR_CODE[status] ?? ErrorCodes.INTERNAL_ERROR;
|
const code = exception.apiCode;
|
||||||
const message = extractMessage(exception);
|
const message = extractMessage(exception);
|
||||||
this.logger.warn(
|
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 });
|
res.status(status).json({ code, message, requestId });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -55,7 +94,7 @@ export class AllExceptionsFilter implements ExceptionFilter {
|
|||||||
);
|
);
|
||||||
res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
|
res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
|
||||||
code: ErrorCodes.INTERNAL_ERROR,
|
code: ErrorCodes.INTERNAL_ERROR,
|
||||||
message: 'Internal server error',
|
message: GENERIC_INTERNAL_MESSAGE,
|
||||||
requestId,
|
requestId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ import { AllExceptionsFilter } from './all-exceptions.filter';
|
|||||||
import { HealthController } from './health.controller';
|
import { HealthController } from './health.controller';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
import { RequestIdMiddleware, resolveRequestId } from './request-id.middleware';
|
import { RequestIdMiddleware, resolveRequestId } from './request-id.middleware';
|
||||||
|
import { resolveLogLevel } from './shared/log-level';
|
||||||
|
import type { ServerRequestWithId } from './shared/request-with-id';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -22,12 +24,21 @@ import { RequestIdMiddleware, resolveRequestId } from './request-id.middleware';
|
|||||||
// 与 RequestIdMiddleware 共用同一解析函数:优先透传 x-request-id,
|
// 与 RequestIdMiddleware 共用同一解析函数:优先透传 x-request-id,
|
||||||
// 其次复用已生成的 req.id,否则生成 UUID;并回写响应头。
|
// 其次复用已生成的 req.id,否则生成 UUID;并回写响应头。
|
||||||
genReqId: (req: IncomingMessage, res: ServerResponse) =>
|
genReqId: (req: IncomingMessage, res: ServerResponse) =>
|
||||||
resolveRequestId(req, res),
|
resolveRequestId(req as ServerRequestWithId, res),
|
||||||
redact: {
|
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]',
|
censor: '[REDACTED]',
|
||||||
},
|
},
|
||||||
level: process.env.LOG_LEVEL ?? 'info',
|
// Task 7 遗留项⑤:非法 LOG_LEVEL 在模块装配期(即启动期)快速失败,
|
||||||
|
// 而不是让 pino 首次写日志时才炸或静默吞掉输出。
|
||||||
|
level: resolveLogLevel(process.env.LOG_LEVEL),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -6,13 +6,14 @@ import { type INestApplication } from '@nestjs/common';
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { Logger } from 'nestjs-pino';
|
import { Logger } from 'nestjs-pino';
|
||||||
import { AppCnModule } from './app-cn.module';
|
import { AppCnModule } from './app-cn.module';
|
||||||
|
import { API_PREFIX } from './shared/constants';
|
||||||
|
|
||||||
async function bootstrap(): Promise<void> {
|
async function bootstrap(): Promise<void> {
|
||||||
const app = await NestFactory.create<INestApplication>(AppCnModule, {
|
const app = await NestFactory.create<INestApplication>(AppCnModule, {
|
||||||
bufferLogs: true,
|
bufferLogs: true,
|
||||||
});
|
});
|
||||||
app.useLogger(app.get(Logger));
|
app.useLogger(app.get(Logger));
|
||||||
app.setGlobalPrefix('v1');
|
app.setGlobalPrefix(API_PREFIX);
|
||||||
const corsOrigins = (process.env.CORS_ORIGINS ?? 'http://localhost:5173')
|
const corsOrigins = (process.env.CORS_ORIGINS ?? 'http://localhost:5173')
|
||||||
.split(',')
|
.split(',')
|
||||||
.map((origin) => origin.trim())
|
.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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,36 +1,39 @@
|
|||||||
// services/api/src/request-id.middleware.ts
|
// services/api/src/request-id.middleware.ts
|
||||||
// 请求 ID 约定:优先透传上游 x-request-id(网关/前端可携带),否则生成 UUID v4;
|
// 请求 ID 约定:优先透传上游 x-request-id(网关/前端可携带),否则生成 UUID v4;
|
||||||
// 挂到 req.id(pino-http 日志字段)并写回响应头 x-request-id,便于全链路排障。
|
// 挂到 req.id(pino-http 日志字段)并写回响应头 x-request-id,便于全链路排障。
|
||||||
|
// 长度上限(Task 7 遗留项④):上游值 >128 字符时视为滥用/异常,静默换成新 UUID
|
||||||
|
//(不透传也不告警——日志与响应头由本函数统一写回,不会出现半截 ID)。
|
||||||
// genReqId 与本中间件共用 resolveRequestId,先后顺序不影响结果。
|
// genReqId 与本中间件共用 resolveRequestId,先后顺序不影响结果。
|
||||||
import { randomUUID } from 'node:crypto';
|
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 { NestMiddleware } from '@nestjs/common';
|
||||||
|
import type { ServerRequestWithId } from './shared/request-with-id';
|
||||||
|
|
||||||
interface RequestWithId {
|
/** 透传上游 x-request-id 的长度上限(UUID v4 为 36 字符,128 已十分宽裕)。 */
|
||||||
id?: string;
|
export const MAX_REQUEST_ID_LENGTH = 128;
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveRequestId(
|
export function resolveRequestId(
|
||||||
req: IncomingMessage,
|
req: ServerRequestWithId,
|
||||||
res: ServerResponse,
|
res: ServerResponse,
|
||||||
): string {
|
): string {
|
||||||
const existing = (req as IncomingMessage & RequestWithId).id;
|
if (req.id) {
|
||||||
if (existing) {
|
return req.id;
|
||||||
return existing;
|
|
||||||
}
|
}
|
||||||
const header = req.headers['x-request-id'];
|
const header = req.headers['x-request-id'];
|
||||||
const fromHeader = Array.isArray(header) ? header[0] : header;
|
const fromHeader = Array.isArray(header) ? header[0] : header;
|
||||||
const id =
|
const id =
|
||||||
typeof fromHeader === 'string' && fromHeader.trim() !== ''
|
typeof fromHeader === 'string' &&
|
||||||
|
fromHeader.trim() !== '' &&
|
||||||
|
fromHeader.length <= MAX_REQUEST_ID_LENGTH
|
||||||
? fromHeader
|
? fromHeader
|
||||||
: randomUUID();
|
: randomUUID();
|
||||||
(req as IncomingMessage & RequestWithId).id = id;
|
req.id = id;
|
||||||
res.setHeader('x-request-id', id);
|
res.setHeader('x-request-id', id);
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RequestIdMiddleware implements NestMiddleware {
|
export class RequestIdMiddleware implements NestMiddleware {
|
||||||
use(req: IncomingMessage, res: ServerResponse, next: () => void): void {
|
use(req: ServerRequestWithId, res: ServerResponse, next: () => void): void {
|
||||||
resolveRequestId(req, res);
|
resolveRequestId(req, res);
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// services/api/src/shared/constants.ts —— 跨层共享常量(Task 7 遗留项⑥:
|
||||||
|
// 此前 'v1' 前缀在 main-cn 与 e2e spec 各写一份,漂移风险集中到此处消除)。
|
||||||
|
export const API_PREFIX = 'v1';
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
// services/api/test/health.e2e-spec.ts
|
// services/api/test/health.e2e-spec.ts
|
||||||
// e2e 冒烟:验证 e2e 工装(Nest testing module + supertest + 根 .env 加载)可用。
|
// e2e 冒烟:验证 e2e 工装(Nest testing module + supertest + 根 .env 加载)可用。
|
||||||
// 不监听端口:app.init() 后直接对 app.getHttpServer() 发请求。
|
// 不监听端口: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 { type INestApplication } from '@nestjs/common';
|
||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import { AppCnModule } from '../src/app-cn.module';
|
import { AppCnModule } from '../src/app-cn.module';
|
||||||
|
import { API_PREFIX } from '../src/shared/constants';
|
||||||
import { resetDb } from './reset';
|
import { resetDb } from './reset';
|
||||||
|
|
||||||
describe('GET /v1/health (e2e)', () => {
|
describe(`GET /${API_PREFIX}/health (e2e)`, () => {
|
||||||
let app: INestApplication;
|
let app: INestApplication;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
@@ -17,7 +18,7 @@ describe('GET /v1/health (e2e)', () => {
|
|||||||
imports: [AppCnModule],
|
imports: [AppCnModule],
|
||||||
}).compile();
|
}).compile();
|
||||||
app = moduleRef.createNestApplication();
|
app = moduleRef.createNestApplication();
|
||||||
app.setGlobalPrefix('v1');
|
app.setGlobalPrefix(API_PREFIX);
|
||||||
await app.init();
|
await app.init();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -26,13 +27,13 @@ describe('GET /v1/health (e2e)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('responds 200 {"status":"ok"}', async () => {
|
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.status).toBe(200);
|
||||||
expect(res.body).toEqual({ status: 'ok' });
|
expect(res.body).toEqual({ status: 'ok' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sets x-request-id response header (generated when absent)', async () => {
|
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'];
|
const requestId = res.headers['x-request-id'];
|
||||||
expect(typeof requestId).toBe('string');
|
expect(typeof requestId).toBe('string');
|
||||||
expect(requestId.length ?? 0).toBeGreaterThan(0);
|
expect(requestId.length ?? 0).toBeGreaterThan(0);
|
||||||
@@ -40,8 +41,19 @@ describe('GET /v1/health (e2e)', () => {
|
|||||||
|
|
||||||
it('echoes incoming x-request-id header', async () => {
|
it('echoes incoming x-request-id header', async () => {
|
||||||
const res = await request(app.getHttpServer())
|
const res = await request(app.getHttpServer())
|
||||||
.get('/v1/health')
|
.get(`/${API_PREFIX}/health`)
|
||||||
.set('x-request-id', 'e2e-fixed-request-id');
|
.set('x-request-id', 'e2e-fixed-request-id');
|
||||||
expect(res.headers['x-request-id']).toBe('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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user