From 31ba03de11373ddccfef54c2b64d467b2cadd770 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Sat, 19 Sep 2026 00:59:39 +0800 Subject: [PATCH] feat(api): password change endpoint and source device id on uploads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P3 Task 3 契约增补批次(单一事实来源 openapi.yaml + cn 实现 + e2e): - POST /v1/auth/password-change(operationId changePassword,需求 4.1 的 P1 契约缺口补齐):Bearer + {oldPassword 1-128, newPassword 8-128} → 204; 旧密码错误 401 AUTH_INVALID_CREDENTIALS;成功后事务内更新 argon2 哈希、 撤销该用户除当前会话外的全部其他会话(当前会话保留,本人不下线)、写 审计 auth.password_change。 - sourceDeviceId(A19 设备来源语义):UploadInitRequest 可选 8-64 位设备 标识;upload_sessions 落列(VARCHAR(64) utf8mb4_0900_bin,可空,存量 行 NULL 兼容);GET /uploads 条目回显;幂等重放不比对(first-writer, 首次落库值不随重放漂移)。 - e2e:auth 增 4 例(未认证 401 / 新密码过短 400 / 旧密码错误 401 且会话 无副作用 / 成功后他端会话撤销当前保留 + 新旧密码登录裁决 + 审计); uploads 增 1 例(携带落库回显、换设备重放保留首值、缺省 null、长度 越界 400)。api e2e 179 → 184;contracts gen/redocly/单测、worker、 sync 门全绿。 Co-Authored-By: Claude Code --- packages/contracts/openapi.yaml | 63 ++++++++++++ packages/contracts/src/generated/openapi.d.ts | 80 +++++++++++++++ .../migration.sql | 7 ++ packages/db-cn/prisma/schema.prisma | 25 ++--- services/api/src/auth/auth.controller.ts | 29 +++++- services/api/src/auth/auth.service.ts | 63 +++++++++++- services/api/src/auth/dto.ts | 33 +++++-- services/api/src/uploads/dto.ts | 14 ++- services/api/src/uploads/uploads.service.ts | 5 + services/api/test/auth.e2e-spec.ts | 98 +++++++++++++++++++ services/api/test/uploads.e2e-spec.ts | 49 ++++++++++ 11 files changed, 437 insertions(+), 29 deletions(-) create mode 100644 packages/db-cn/prisma/migrations/20260918164834_upload_sessions_source_device_id/migration.sql diff --git a/packages/contracts/openapi.yaml b/packages/contracts/openapi.yaml index 863edfd..488a7a4 100644 --- a/packages/contracts/openapi.yaml +++ b/packages/contracts/openapi.yaml @@ -152,6 +152,41 @@ paths: '500': $ref: '#/components/responses/InternalError' + /auth/password-change: + post: + operationId: changePassword + tags: [auth] + summary: 修改密码 + description: | + 已认证用户修改自身密码:校验旧密码,成功后更新密码哈希并撤销该用户 + 除当前会话外的全部其他会话(当前会话保持有效,无需重新登录)。 + P3 Task 3 契约增补(需求 4.1「支持修改密码」,P1 契约缺口补齐)。 + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PasswordChangeRequest' + responses: + '204': + description: 修改成功,无响应体;当前会话保留,其他会话已撤销。 + '400': + description: 参数校验失败(VALIDATION_FAILED,经全局校验管道)。 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + description: 旧密码错误(AUTH_INVALID_CREDENTIALS)。 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + $ref: '#/components/responses/InternalError' + # ─────────────────────────── albums ─────────────────────────── /albums: @@ -1674,6 +1709,21 @@ components: format: date-time readOnly: true description: 续期后的过期时间。 + PasswordChangeRequest: + type: object + description: 修改密码请求(P3 Task 3 契约增补)。 + required: [oldPassword, newPassword] + properties: + oldPassword: + type: string + minLength: 1 + maxLength: 128 + description: 当前密码。 + newPassword: + type: string + minLength: 8 + maxLength: 128 + description: 新密码(至少 8 位)。 # ── 相册 ── @@ -2138,6 +2188,13 @@ components: type: string enum: [image/jpeg, image/png, image/heic] description: MIME 类型白名单;image/heic 为暂定项,解码能力待 G2 真机验证后确认。 + sourceDeviceId: + type: string + minLength: 8 + maxLength: 64 + description: | + 客户端首次生成并持久化的设备标识;服务端历史返回用于区分上传来源 + (A19)。可选;同一 clientTaskId 幂等重放时保留首次落库的值。 UploadCredential: type: object @@ -2257,6 +2314,12 @@ components: format: date-time readOnly: true description: 会话创建时间。 + sourceDeviceId: + type: [string, 'null'] + readOnly: true + description: | + 发起该上传的设备标识(客户端首次生成并持久化;init 时未携带为 null)。 + P3 Task 3 契约增补(A19 设备来源语义)。 photo: $ref: '#/components/schemas/UploadSessionPhoto' diff --git a/packages/contracts/src/generated/openapi.d.ts b/packages/contracts/src/generated/openapi.d.ts index 7fa7d3d..d1676c5 100644 --- a/packages/contracts/src/generated/openapi.d.ts +++ b/packages/contracts/src/generated/openapi.d.ts @@ -64,6 +64,28 @@ export interface paths { patch?: never; trace?: never; }; + "/auth/password-change": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 修改密码 + * @description 已认证用户修改自身密码:校验旧密码,成功后更新密码哈希并撤销该用户 + * 除当前会话外的全部其他会话(当前会话保持有效,无需重新登录)。 + * P3 Task 3 契约增补(需求 4.1「支持修改密码」,P1 契约缺口补齐)。 + */ + post: operations["changePassword"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/albums": { parameters: { query?: never; @@ -787,6 +809,13 @@ export interface components { */ readonly expiresAt: string; }; + /** @description 修改密码请求(P3 Task 3 契约增补)。 */ + PasswordChangeRequest: { + /** @description 当前密码。 */ + oldPassword: string; + /** @description 新密码(至少 8 位)。 */ + newPassword: string; + }; /** @description 相册多语言条目(创建/更新与响应共用;未填的可选字段存储为 null)。 */ AlbumTranslation: { locale: components["schemas"]["Locale"]; @@ -1061,6 +1090,11 @@ export interface components { * @enum {string} */ contentType: "image/jpeg" | "image/png" | "image/heic"; + /** + * @description 客户端首次生成并持久化的设备标识;服务端历史返回用于区分上传来源 + * (A19)。可选;同一 clientTaskId 幂等重放时保留首次落库的值。 + */ + sourceDeviceId?: string; }; /** @description staging 对象的临时直传凭证;仅可写入指定 objectKey,不可访问 originals/variants。 */ UploadCredential: { @@ -1128,6 +1162,11 @@ export interface components { * @description 会话创建时间。 */ readonly createdAt: string; + /** + * @description 发起该上传的设备标识(客户端首次生成并持久化;init 时未携带为 null)。 + * P3 Task 3 契约增补(A19 设备来源语义)。 + */ + readonly sourceDeviceId?: string | null; photo: components["schemas"]["UploadSessionPhoto"]; }; UploadSessionList: components["schemas"]["CursorPageMeta"] & { @@ -1702,6 +1741,47 @@ export interface operations { 500: components["responses"]["InternalError"]; }; }; + changePassword: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PasswordChangeRequest"]; + }; + }; + responses: { + /** @description 修改成功,无响应体;当前会话保留,其他会话已撤销。 */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description 参数校验失败(VALIDATION_FAILED,经全局校验管道)。 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description 旧密码错误(AUTH_INVALID_CREDENTIALS)。 */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 500: components["responses"]["InternalError"]; + }; + }; listAlbums: { parameters: { query?: { diff --git a/packages/db-cn/prisma/migrations/20260918164834_upload_sessions_source_device_id/migration.sql b/packages/db-cn/prisma/migrations/20260918164834_upload_sessions_source_device_id/migration.sql new file mode 100644 index 0000000..7808a97 --- /dev/null +++ b/packages/db-cn/prisma/migrations/20260918164834_upload_sessions_source_device_id/migration.sql @@ -0,0 +1,7 @@ +-- P3 Task 3:upload_sessions 增 sourceDeviceId(A19 设备来源语义)——客户端 +-- 首次生成并持久化的设备标识,init 时可选携带、会话历史回显;幂等重放保留 +-- 首次落库值(first-writer,见 uploads.service paramsMatch 不比对该项)。 +-- VARCHAR 列显式 utf8mb4_0900_bin(沿用库级约定:--create-only 后手工定校对, +-- Prisma 生成 DDL 固定 unicode_ci)。可空新列,存量行(NULL=未上报设备)与 +-- 既有流程不受影响。 +ALTER TABLE `upload_sessions` ADD COLUMN `sourceDeviceId` VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin NULL; diff --git a/packages/db-cn/prisma/schema.prisma b/packages/db-cn/prisma/schema.prisma index 657c348..6e0ee61 100644 --- a/packages/db-cn/prisma/schema.prisma +++ b/packages/db-cn/prisma/schema.prisma @@ -178,18 +178,19 @@ model photo_variants { @@unique([photoId, variant]) } model upload_sessions { - id String @id @default(cuid()) - userId String @db.VarChar(64) - albumId String @db.VarChar(64) - clientTaskId String @db.VarChar(128) - photoId String @unique @db.VarChar(64) - objectKey String @unique @db.VarChar(255) // staging 键 - expectedBytes Int - contentType String @db.VarChar(128) - multipartId String? @db.VarChar(255) - state String @default("pending") // pending|confirmed|cancelled|expired(对齐契约 UploadSessionState;核验失败是请求级错误不改会话态,expired 由 expiresAt 判定或 sweep 置位) - expiresAt DateTime @db.DateTime(6) - createdAt DateTime @default(now()) @db.DateTime(6) + id String @id @default(cuid()) + userId String @db.VarChar(64) + albumId String @db.VarChar(64) + clientTaskId String @db.VarChar(128) + photoId String @unique @db.VarChar(64) + objectKey String @unique @db.VarChar(255) // staging 键 + expectedBytes Int + contentType String @db.VarChar(128) + sourceDeviceId String? @db.VarChar(64) // 客户端设备标识(A19 来源语义;幂等重放保留首值) + multipartId String? @db.VarChar(255) + state String @default("pending") // pending|confirmed|cancelled|expired(对齐契约 UploadSessionState;核验失败是请求级错误不改会话态,expired 由 expiresAt 判定或 sweep 置位) + expiresAt DateTime @db.DateTime(6) + createdAt DateTime @default(now()) @db.DateTime(6) @@unique([userId, clientTaskId]) // 幂等锚点(A03) } model photo_jobs { diff --git a/services/api/src/auth/auth.controller.ts b/services/api/src/auth/auth.controller.ts index 19d807f..49445a5 100644 --- a/services/api/src/auth/auth.controller.ts +++ b/services/api/src/auth/auth.controller.ts @@ -1,7 +1,8 @@ -// services/api/src/auth/auth.controller.ts —— /auth/* 端点(Task 13)。 -// 方法名与契约 operationId 一一对应(loginWithPassword / refreshSession / logout), -// 响应形状严格契约:camelCase sessionToken/expiresAt,logout 204 无响应体。 -// /auth/password 无守卫(security: []);refresh/logout 经 SessionGuard(Bearer)。 +// services/api/src/auth/auth.controller.ts —— /auth/* 端点(Task 13; +// changePassword 为 P3 Task 3 契约增补)。方法名与契约 operationId 一一对应 +// (loginWithPassword / refreshSession / logout / changePassword),响应形状严格 +// 契约:camelCase sessionToken/expiresAt,logout 与 password-change 204 无响应体。 +// /auth/password 无守卫(security: []);其余端点经 SessionGuard(Bearer)。 import { Body, Controller, @@ -14,7 +15,7 @@ import { import { ErrorCodes } from '@photo/contracts'; import { ApiException } from '../api-exception'; import { AuthService } from './auth.service'; -import { LoginRequestDto } from './dto'; +import { LoginRequestDto, PasswordChangeRequestDto } from './dto'; import { AuthenticatedRequest, SessionGuard } from './session.guard'; /** 登录请求侧只需客户端来源 IP(限速键的一半)。 */ @@ -67,4 +68,22 @@ export class AuthController { async logout(@Req() req: AuthenticatedRequest): Promise { await this.authService.logout(sessionOf(req)); } + + /** 修改密码(P3 Task 3):204 无响应体;当前会话保留、其他会话撤销。 */ + @Post('password-change') + @UseGuards(SessionGuard) + @HttpCode(HttpStatus.NO_CONTENT) + async changePassword( + @Body() dto: PasswordChangeRequestDto, + @Req() req: AuthenticatedRequest, + ): Promise { + const sessionId = sessionOf(req); + // sessionOf 已防御性保证 user 挂载(同 logout 的装配错误兜底语义)。 + await this.authService.changePassword( + req.user!.id, + sessionId, + dto.oldPassword, + dto.newPassword, + ); + } } diff --git a/services/api/src/auth/auth.service.ts b/services/api/src/auth/auth.service.ts index ff6cf2b..8de55da 100644 --- a/services/api/src/auth/auth.service.ts +++ b/services/api/src/auth/auth.service.ts @@ -1,4 +1,5 @@ -// services/api/src/auth/auth.service.ts —— 登录 / 滑动续期 / 注销核心逻辑(Task 13)。 +// services/api/src/auth/auth.service.ts —— 登录 / 滑动续期 / 注销 / 修改密码核心逻辑 +// (Task 13;changePassword 为 P3 Task 3 契约增补)。 // 会话模型:长效单令牌 + 滑动续期(用户决策 2026-09-17,不做 access/refresh 双层)。 // 令牌 = 32B randomBytes hex(仅登录响应出现一次);库内只存 sha256(token)。 // 安全要点: @@ -9,7 +10,8 @@ // 成功登录清零。进程内存实现:P1a 单实例足够;P5 多实例需换 Redis 后端限速器 // (本类私有接口不变,替换实现即可)。 // · 审计——登录成功写 audit_logs(action='auth.login', actor=用户); -// 注销不写审计(会话行 revokedAt 本身即持久的注销凭据)。 +// 注销不写审计(会话行 revokedAt 本身即持久的注销凭据); +// 修改密码写 audit_logs(action='auth.password_change')(敏感凭据变更)。 import { randomBytes } from 'node:crypto'; import { HttpStatus, Injectable } from '@nestjs/common'; import { ErrorCodes } from '@photo/contracts'; @@ -206,4 +208,61 @@ export class AuthService { data: { revokedAt: new Date() }, }); } + + /** + * 修改密码(P3 Task 3 契约增补,需求 4.1): + * ① 校验旧密码——错误时 401 AUTH_INVALID_CREDENTIALS(契约该端点 401 语义 + * 即「旧密码错误」)。调用方已认证(SessionGuard),无需 dummy-verify + * 拉平时延——用户身份已知,不存在账号枚举面。 + * ② 事务三写:更新 passwordHash + 撤销该用户除当前会话外的全部其他会话 + * (改密不应把本人踢下线,但旧凭据泄露面要即刻收敛)+ 审计 + * auth.password_change。哈希在事务外先算好(argon2 耗时,不占连接)。 + */ + async changePassword( + userId: string, + sessionId: string, + oldPassword: string, + newPassword: string, + ): Promise { + const user = await this.prisma.users.findUnique({ + where: { id: userId }, + select: { passwordHash: true }, + }); + if (user === null) { + // 守卫已确保用户存在且资格合格;不可达分支按同码兜底(不泄露差异)。 + throw new ApiException( + ErrorCodes.AUTH_INVALID_CREDENTIALS, + 'Old password is incorrect', + HttpStatus.UNAUTHORIZED, + ); + } + if (!(await verifyPassword(user.passwordHash, oldPassword))) { + throw new ApiException( + ErrorCodes.AUTH_INVALID_CREDENTIALS, + 'Old password is incorrect', + HttpStatus.UNAUTHORIZED, + ); + } + + const passwordHash = await hashPassword(newPassword); + const revokedAt = new Date(); + await this.prisma.$transaction([ + this.prisma.users.update({ + where: { id: userId }, + data: { passwordHash }, + }), + // 只撤销「其他」会话且只动尚未撤销的行(幂等;当前会话保留)。 + this.prisma.sessions.updateMany({ + where: { userId, id: { not: sessionId }, revokedAt: null }, + data: { revokedAt }, + }), + this.prisma.audit_logs.create({ + data: { + actorId: userId, + action: 'auth.password_change', + resourceId: userId, + }, + }), + ]); + } } diff --git a/services/api/src/auth/dto.ts b/services/api/src/auth/dto.ts index 1c32b58..d872f97 100644 --- a/services/api/src/auth/dto.ts +++ b/services/api/src/auth/dto.ts @@ -1,10 +1,17 @@ -// services/api/src/auth/dto.ts —— auth 端点入参 DTO(Task 13)。 -// Nest 约定用 class-validator + class-transformer(非 zod):装饰器元数据供全局 -// ValidationPipe(app-cn.module 经 APP_PIPE 注册)校验,失败抛 BadRequestException, -// 由全局过滤器映射 400 VALIDATION_FAILED(契约各 auth 端点的 '400' 响应)。 -// 字段约束对齐契约 LoginRequest:account/password 均为非空字符串;契约未定义 -// 长度上限(users.account 列宽 64 由入库侧保证,超长账号查无此人不构成枚举面)。 -import { IsNotEmpty, IsString } from 'class-validator'; +// services/api/src/auth/dto.ts —— auth 端点入参 DTO(Task 13;Task 3/P3 增补 +// password-change)。Nest 约定用 class-validator + class-transformer(非 zod): +// 装饰器元数据供全局 ValidationPipe(app-cn.module 经 APP_PIPE 注册)校验, +// 失败抛 BadRequestException,由全局过滤器映射 400 VALIDATION_FAILED(契约各 +// auth 端点的 '400' 响应)。字段约束对齐契约 LoginRequest:account/password +// 均为非空字符串;契约未定义长度上限(users.account 列宽 64 由入库侧保证, +// 超长账号查无此人不构成枚举面)。PasswordChangeRequest(P3 Task 3 契约增补): +// oldPassword 1-128、newPassword 8-128。 +import { + IsNotEmpty, + IsString, + MaxLength, + MinLength, +} from 'class-validator'; export class LoginRequestDto { @IsString() @@ -15,3 +22,15 @@ export class LoginRequestDto { @IsNotEmpty() password!: string; } + +export class PasswordChangeRequestDto { + @IsString() + @IsNotEmpty() + @MaxLength(128) + oldPassword!: string; + + @IsString() + @MinLength(8) + @MaxLength(128) + newPassword!: string; +} diff --git a/services/api/src/uploads/dto.ts b/services/api/src/uploads/dto.ts index 7b49e29..49a11a1 100644 --- a/services/api/src/uploads/dto.ts +++ b/services/api/src/uploads/dto.ts @@ -1,10 +1,10 @@ -// services/api/src/uploads/dto.ts —— 上传端点入参 DTO(Task 18)。校验约束对齐 -// 契约 UploadInitRequest: +// services/api/src/uploads/dto.ts —— 上传端点入参 DTO(Task 18;sourceDeviceId +// 为 P3 Task 3 契约增补)。校验约束对齐契约 UploadInitRequest: // · contentType 限白名单枚举(image/heic 暂定,G2 真机验证); // · bytes ≥ 1(契约 minimum);上限 UPLOAD_MAX_BYTES 是环境相关量,逐请求 // 惰性读取,故在服务层校验(同 filename 扩展名白名单与 categoryId 属主校验); // · clientTaskId/filename/albumId/categoryId 列宽对齐 db-cn schema -// (VarChar 128/255/64/64)。 +// (VarChar 128/255/64/64);sourceDeviceId 8-64(A19 设备来源语义)。 import { IsIn, IsInt, @@ -13,6 +13,7 @@ import { IsString, MaxLength, Min, + MinLength, } from 'class-validator'; import { UPLOAD_CONTENT_TYPES, type UploadContentType } from './support'; @@ -47,4 +48,11 @@ export class UploadInitRequestDto { @IsIn(UPLOAD_CONTENT_TYPES) contentType!: UploadContentType; + + /** 客户端设备标识(A19,P3 Task 3 增补):可选,8-64 字符。 */ + @IsOptional() + @IsString() + @MinLength(8) + @MaxLength(64) + sourceDeviceId?: string; } diff --git a/services/api/src/uploads/uploads.service.ts b/services/api/src/uploads/uploads.service.ts index 2a10f37..7f0fb53 100644 --- a/services/api/src/uploads/uploads.service.ts +++ b/services/api/src/uploads/uploads.service.ts @@ -180,6 +180,8 @@ export class UploadsService { objectKey: stagingObjectKey(uploadId, photoId, ext), expectedBytes: dto.bytes, contentType: dto.contentType, + // A19 设备来源(P3 Task 3):未携带落 null;幂等重放不比对(首值保留)。 + sourceDeviceId: dto.sourceDeviceId ?? null, expiresAt: new Date(Date.now() + signedUrlTtlSeconds() * 1000), }, }); @@ -226,6 +228,8 @@ export class UploadsService { * 幂等参数一致性:对「会话行可持久化的事实」逐项比对——albumId/expectedBytes/ * contentType,外加 staging 键扩展名 vs 新 filename 扩展名(filename 本身不落库, * schema 无该列;同扩展名改名视为同一任务意图,返回原会话)。 + * sourceDeviceId(P3 Task 3)刻意不比对:幂等重放换设备时保留首次落库值 + * (first-writer)——任务归属以创建时刻的设备为准,历史不随重放漂移。 */ private paramsMatch( session: SessionRow, @@ -499,6 +503,7 @@ export class UploadsService { expectedBytes: row.expectedBytes, contentType: row.contentType, createdAt: row.createdAt.toISOString(), + sourceDeviceId: row.sourceDeviceId, photo: { photoId: row.photoId, processingState: diff --git a/services/api/test/auth.e2e-spec.ts b/services/api/test/auth.e2e-spec.ts index 1cfc93c..b19e097 100644 --- a/services/api/test/auth.e2e-spec.ts +++ b/services/api/test/auth.e2e-spec.ts @@ -91,6 +91,10 @@ describe('auth (e2e)', () => { await prisma.users.create({ data: { account: 'gina', passwordHash: hash, displayName: 'Gina' }, }); + // password-change 专用账号(P3 Task 3;改密用例放在最后,成功后旧密码失效)。 + await prisma.users.create({ + data: { account: 'hank', passwordHash: hash, displayName: 'Hank' }, + }); }); afterAll(async () => { @@ -280,4 +284,98 @@ describe('auth (e2e)', () => { expect(res.status).toBe(401); expect(res.body.code).toBe(ErrorCodes.AUTH_INVALID_CREDENTIALS); }); + + // ── 修改密码(P3 Task 3 契约增补)── + + const changePassword = (token: string, oldPassword: string, newPassword: string) => + request(app.getHttpServer()) + .post(`/${API_PREFIX}/auth/password-change`) + .set('Authorization', `Bearer ${token}`) + .send({ oldPassword, newPassword }); + + it('修改密码:未认证 → 401 AUTH_TOKEN_EXPIRED', async () => { + const res = await request(app.getHttpServer()) + .post(`/${API_PREFIX}/auth/password-change`) + .send({ oldPassword: PASSWORD, newPassword: 'brand-new-pass-9' }); + expect(res.status).toBe(401); + expect(res.body.code).toBe(ErrorCodes.AUTH_TOKEN_EXPIRED); + }); + + it('修改密码:newPassword 少于 8 位 → 400 VALIDATION_FAILED', async () => { + const loginRes = await login('hank', PASSWORD); + const token: string = loginRes.body.sessionToken; + + const res = await changePassword(token, PASSWORD, 'short7'); + expect(res.status).toBe(400); + expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED); + + // 密码未变:旧密码仍可登录(新密码不可)。 + const relogin = await login('hank', PASSWORD); + expect(relogin.status).toBe(200); + }); + + it('修改密码:旧密码错误 → 401 AUTH_INVALID_CREDENTIALS,当前会话不受影响', async () => { + const loginRes = await login('hank', PASSWORD); + const token: string = loginRes.body.sessionToken; + + const res = await changePassword(token, 'wrong-old-password', 'brand-new-pass-9'); + expect(res.status).toBe(401); + expect(res.body.code).toBe(ErrorCodes.AUTH_INVALID_CREDENTIALS); + + // 会话未被撤销(错误旧密码不产生任何副作用)。 + const refresh = await request(app.getHttpServer()) + .post(`/${API_PREFIX}/auth/refresh`) + .set('Authorization', `Bearer ${token}`); + expect(refresh.status).toBe(200); + }); + + it('修改密码:成功 → 204;其他会话撤销、当前会话保留;新密码可登录、旧密码拒绝;写审计', async () => { + // 双会话:tokenA 为当前会话(发起改密),tokenB 为应被撤销的其他会话。 + const loginA = await login('hank', PASSWORD); + const tokenA: string = loginA.body.sessionToken; + const loginB = await login('hank', PASSWORD); + const tokenB: string = loginB.body.sessionToken; + const NEW_PASSWORD = 'brand-new-pass-9'; + + const res = await changePassword(tokenA, PASSWORD, NEW_PASSWORD); + expect(res.status).toBe(204); + expect(res.body).toEqual({}); + + // 其他会话:即刻失效(revokedAt 落库)。 + const refreshB = await request(app.getHttpServer()) + .post(`/${API_PREFIX}/auth/refresh`) + .set('Authorization', `Bearer ${tokenB}`); + expect(refreshB.status).toBe(401); + expect(refreshB.body.code).toBe(ErrorCodes.AUTH_TOKEN_EXPIRED); + const sessionB = await prisma.sessions.findUnique({ + where: { tokenHash: sha256Hex(tokenB) }, + select: { revokedAt: true }, + }); + expect(sessionB?.revokedAt).not.toBeNull(); + + // 当前会话:保留(改密不踢本人下线)。 + const refreshA = await request(app.getHttpServer()) + .post(`/${API_PREFIX}/auth/refresh`) + .set('Authorization', `Bearer ${tokenA}`); + expect(refreshA.status).toBe(200); + + // 新旧密码登录裁决。 + const reloginNew = await login('hank', NEW_PASSWORD); + expect(reloginNew.status).toBe(200); + const reloginOld = await login('hank', PASSWORD); + expect(reloginOld.status).toBe(401); + expect(reloginOld.body.code).toBe(ErrorCodes.AUTH_INVALID_CREDENTIALS); + + // 审计:auth.password_change(actor=本人)。 + const hankId = ( + await prisma.users.findUnique({ + where: { account: 'hank' }, + select: { id: true }, + }) + )?.id; + const audit = await prisma.audit_logs.findFirst({ + where: { action: 'auth.password_change', actorId: hankId }, + }); + expect(audit).not.toBeNull(); + }); }); diff --git a/services/api/test/uploads.e2e-spec.ts b/services/api/test/uploads.e2e-spec.ts index b1a16fa..c1c1399 100644 --- a/services/api/test/uploads.e2e-spec.ts +++ b/services/api/test/uploads.e2e-spec.ts @@ -810,6 +810,7 @@ describe('uploads renew & complete & history (e2e)', () => { 'createdAt', 'expectedBytes', 'photo', + 'sourceDeviceId', 'state', 'uploadId', ]); @@ -892,4 +893,52 @@ describe('uploads renew & complete & history (e2e)', () => { // 存在其他 member 照片之外的 job。 expect(byPhoto.size).toBe(2); }); + + it('sourceDeviceId:init 携带 → 落库并回显;幂等重放换设备保留首值(first-writer)', async () => { + // 设备 A 初始化(P3 Task 3 契约增补,A19 设备来源语义)。 + const first = await initAs(memberToken, 'DEV-1', { + sourceDeviceId: 'device-aaaa-1111', + }); + + const findByTask = async (clientTaskId: string) => { + const list = await listUploads(memberToken, { limit: 100 }); + expect(list.status).toBe(200); + return list.body.items.find( + (i: { clientTaskId: string }) => i.clientTaskId === clientTaskId, + ); + }; + + expect((await findByTask('DEV-1'))?.sourceDeviceId).toBe('device-aaaa-1111'); + + // 同 clientTaskId 换设备 B 重放:复用原会话,sourceDeviceId 保留首值。 + const replay = await initAs(memberToken, 'DEV-1', { + sourceDeviceId: 'device-bbbb-2222', + }); + expect(replay.uploadId).toBe(first.uploadId); + expect((await findByTask('DEV-1'))?.sourceDeviceId).toBe('device-aaaa-1111'); + + // 未携带 → null(老客户端兼容)。 + await initAs(memberToken, 'DEV-2'); + expect((await findByTask('DEV-2'))?.sourceDeviceId).toBeNull(); + + // 长度越界(<8 / >64)→ 400 VALIDATION_FAILED。 + const rawInit = (sourceDeviceId: string) => + request(app.getHttpServer()) + .post(`/${API_PREFIX}/uploads`) + .set('Authorization', `Bearer ${memberToken}`) + .send({ + albumId, + clientTaskId: 'DEV-bad', + filename: 'a.jpg', + bytes: 1000, + contentType: 'image/jpeg', + sourceDeviceId, + }); + const tooShort = await rawInit('short7'); + expect(tooShort.status).toBe(400); + expect(tooShort.body.code).toBe(ErrorCodes.VALIDATION_FAILED); + const tooLong = await rawInit('x'.repeat(65)); + expect(tooLong.status).toBe(400); + expect(tooLong.body.code).toBe(ErrorCodes.VALIDATION_FAILED); + }); });