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
|
||||
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 处理流水线
|
||||
|
||||
Generated
+144
-4
@@ -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
|
||||
|
||||
@@ -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/'],
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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://');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 { 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),
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -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 { 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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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