diff --git a/.env.example b/.env.example index 6d83168..e81f48d 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/docs/plans/2026-09-17-P1a-后端核心链路.md b/docs/plans/2026-09-17-P1a-后端核心链路.md index 058bb32..4abf00a 100644 --- a/docs/plans/2026-09-17-P1a-后端核心链路.md +++ b/docs/plans/2026-09-17-P1a-后端核心链路.md @@ -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 处理流水线 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7fb760f..91bc1c4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/services/api/jest.config.ts b/services/api/jest.config.ts index 9bfe47f..52ced56 100644 --- a/services/api/jest.config.ts +++ b/services/api/jest.config.ts @@ -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$': '/../../packages/contracts/src/index.ts', '^@photo/db-cn$': '/../../packages/db-cn/src/index.ts', '^(\\.{1,2}/.*)\\.js$': '$1', }, - testMatch: ['/src/**/*.spec.ts'], + // test/ 下的 *.spec.ts 为直连真实 DB 的集成型单测(seed.spec.ts,经 reset.ts + // 隔离);纯 HTTP e2e 仍由 jest.e2e.config.ts 的 testMatch 接管。 + testMatch: ['/src/**/*.spec.ts', '/test/**/*.spec.ts'], testPathIgnorePatterns: ['/node_modules/', '/dist/'], }; diff --git a/services/api/package.json b/services/api/package.json index cd4227f..79e0a7f 100644 --- a/services/api/package.json +++ b/services/api/package.json @@ -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", diff --git a/services/api/src/all-exceptions.filter.spec.ts b/services/api/src/all-exceptions.filter.spec.ts index f7ce8a1..06d4395 100644 --- a/services/api/src/all-exceptions.filter.spec.ts +++ b/services/api/src/all-exceptions.filter.spec.ts @@ -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://'); + }); + }); }); diff --git a/services/api/src/all-exceptions.filter.ts b/services/api/src/all-exceptions.filter.ts index 739c25c..0928018 100644 --- a/services/api/src/all-exceptions.filter.ts +++ b/services/api/src/all-exceptions.filter.ts @@ -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> = { [HttpStatus.BAD_REQUEST]: ErrorCodes.VALIDATION_FAILED, [HttpStatus.UNAUTHORIZED]: ErrorCodes.AUTH_TOKEN_EXPIRED, @@ -28,6 +31,8 @@ const STATUS_TO_ERROR_CODE: Readonly> = { [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(); 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, }); } diff --git a/services/api/src/api-exception.ts b/services/api/src/api-exception.ts new file mode 100644 index 0000000..1836273 --- /dev/null +++ b/services/api/src/api-exception.ts @@ -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; + } +} diff --git a/services/api/src/app-cn.module.ts b/services/api/src/app-cn.module.ts index a9cae4c..8fa372d 100644 --- a/services/api/src/app-cn.module.ts +++ b/services/api/src/app-cn.module.ts @@ -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), }, }), ], diff --git a/services/api/src/auth/password.ts b/services/api/src/auth/password.ts new file mode 100644 index 0000000..c943d17 --- /dev/null +++ b/services/api/src/auth/password.ts @@ -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 { + 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 { + return verify(passwordHash, plain); +} diff --git a/services/api/src/main-cn.ts b/services/api/src/main-cn.ts index f1a703d..e29b180 100644 --- a/services/api/src/main-cn.ts +++ b/services/api/src/main-cn.ts @@ -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 { const app = await NestFactory.create(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()) diff --git a/services/api/src/request-id.middleware.spec.ts b/services/api/src/request-id.middleware.spec.ts new file mode 100644 index 0000000..ae2b8c0 --- /dev/null +++ b/services/api/src/request-id.middleware.spec.ts @@ -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; 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'); + }); +}); diff --git a/services/api/src/request-id.middleware.ts b/services/api/src/request-id.middleware.ts index ac07eb9..faf6318 100644 --- a/services/api/src/request-id.middleware.ts +++ b/services/api/src/request-id.middleware.ts @@ -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(); } diff --git a/services/api/src/scripts/seed-admin.ts b/services/api/src/scripts/seed-admin.ts new file mode 100644 index 0000000..388d060 --- /dev/null +++ b/services/api/src/scripts/seed-admin.ts @@ -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 { + 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 { + 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; + }); +} diff --git a/services/api/src/shared/constants.ts b/services/api/src/shared/constants.ts new file mode 100644 index 0000000..be9aaec --- /dev/null +++ b/services/api/src/shared/constants.ts @@ -0,0 +1,3 @@ +// services/api/src/shared/constants.ts —— 跨层共享常量(Task 7 遗留项⑥: +// 此前 'v1' 前缀在 main-cn 与 e2e spec 各写一份,漂移风险集中到此处消除)。 +export const API_PREFIX = 'v1'; diff --git a/services/api/src/shared/log-level.spec.ts b/services/api/src/shared/log-level.spec.ts new file mode 100644 index 0000000..0e7658a --- /dev/null +++ b/services/api/src/shared/log-level.spec.ts @@ -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/); + }); +}); diff --git a/services/api/src/shared/log-level.ts b/services/api/src/shared/log-level.ts new file mode 100644 index 0000000..87bda12 --- /dev/null +++ b/services/api/src/shared/log-level.ts @@ -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; +} diff --git a/services/api/src/shared/request-with-id.ts b/services/api/src/shared/request-with-id.ts new file mode 100644 index 0000000..b5383ef --- /dev/null +++ b/services/api/src/shared/request-with-id.ts @@ -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; diff --git a/services/api/test/health.e2e-spec.ts b/services/api/test/health.e2e-spec.ts index 5a0dfac..9670f63 100644 --- a/services/api/test/health.e2e-spec.ts +++ b/services/api/test/health.e2e-spec.ts @@ -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); + }); }); diff --git a/services/api/test/password.spec.ts b/services/api/test/password.spec.ts new file mode 100644 index 0000000..c75a1d5 --- /dev/null +++ b/services/api/test/password.spec.ts @@ -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); + }); +}); diff --git a/services/api/test/seed.spec.ts b/services/api/test/seed.spec.ts new file mode 100644 index 0000000..fc96c1d --- /dev/null +++ b/services/api/test/seed.spec.ts @@ -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); + }); +});