diff --git a/services/api/src/admin/admin-users.controller.ts b/services/api/src/admin/admin-users.controller.ts new file mode 100644 index 0000000..aec6810 --- /dev/null +++ b/services/api/src/admin/admin-users.controller.ts @@ -0,0 +1,76 @@ +// services/api/src/admin/admin-users.controller.ts —— 账号治理端点(P4a +// Task 6)。方法名与契约 operationId 一一对应(updateAdminUser / +// resetAdminUserPassword / activateAdminUser / deactivateAdminUser,tag +// [admin])。控制器级 SessionGuard:全端点需认证;admin 判定在服务层 +//(AuthUser.role,403 AUTH_FORBIDDEN)。控制器只做路径参数与 DTO 透传; +// POST 三端点契约响应均为 200(非 201),显式 @HttpCode。 +// GET /admin/users(listAdminUsers 双模式)仍在 users-lookup.controller。 +import { + Body, + Controller, + HttpCode, + HttpStatus, + Param, + Patch, + Post, + Req, + UseGuards, +} from '@nestjs/common'; +import { userOf } from '../albums/albums.controller'; +import { AuthenticatedRequest, SessionGuard } from '../auth/session.guard'; +import { + AdminPasswordResetDto, + AdminUserUpdateDto, +} from './admin-users.dto'; +import { + AdminUsersService, + type AdminPasswordResetResponse, + type AdminUserResponse, +} from './admin-users.service'; + +@Controller('admin/users') +@UseGuards(SessionGuard) +export class AdminUsersController { + constructor(private readonly adminUsersService: AdminUsersService) {} + + /** 更新用户治理属性(role/localLocked/displayName;sourceStatus 只读)。 */ + @Patch(':userId') + updateAdminUser( + @Param('userId') userId: string, + @Body() dto: AdminUserUpdateDto, + @Req() req: AuthenticatedRequest, + ): Promise { + return this.adminUsersService.update(userOf(req), userId, dto); + } + + /** 管理员重置用户密码(指定或服务端生成;撤销目标全部会话)。 */ + @Post(':userId/reset-password') + @HttpCode(HttpStatus.OK) + resetAdminUserPassword( + @Param('userId') userId: string, + @Body() dto: AdminPasswordResetDto, + @Req() req: AuthenticatedRequest, + ): Promise { + return this.adminUsersService.resetPassword(userOf(req), userId, dto); + } + + /** 激活待激活用户(credentialStatus pending→active,幂等)。 */ + @Post(':userId/activate') + @HttpCode(HttpStatus.OK) + activateAdminUser( + @Param('userId') userId: string, + @Req() req: AuthenticatedRequest, + ): Promise { + return this.adminUsersService.activate(userOf(req), userId); + } + + /** 停用用户(localLocked=true + 撤销全部会话,幂等)。 */ + @Post(':userId/deactivate') + @HttpCode(HttpStatus.OK) + deactivateAdminUser( + @Param('userId') userId: string, + @Req() req: AuthenticatedRequest, + ): Promise { + return this.adminUsersService.deactivate(userOf(req), userId); + } +} diff --git a/services/api/src/admin/admin-users.dto.ts b/services/api/src/admin/admin-users.dto.ts new file mode 100644 index 0000000..cd19164 --- /dev/null +++ b/services/api/src/admin/admin-users.dto.ts @@ -0,0 +1,65 @@ +// services/api/src/admin/admin-users.dto.ts —— 账号治理端点入参 DTO(P4a +// Task 6)。契约 AdminUserUpdateRequest / AdminPasswordResetRequest。 +// 校验语义要点: +// · 可选字段一律 @ValidateIf((_, value) => value !== undefined) 而非 +// @IsOptional:后者连 null 一并跳过,显式 null 会以 null 值渗入服务层 +// (Prisma 拒绝、错误形态不契约化);ValidateIf 只放行 undefined(真缺省), +// null 落到类型校验上 → 400 VALIDATION_FAILED,与「提供了非法值」同出口。 +// · sourceStatus 只读(技术方案 3.1A 红线:上游状态仅由用户同步任务维护, +// 本地不越权改写):DTO 显式声明该字段 + 只读约束,出现(含 null)即 +// 400。全局 ValidationPipe 是 whitelist(剥离而非拒绝)——若不声明, +// sourceStatus 会被静默剥离成「忽略」,与只读拒绝语义不符。 +import { + IsBoolean, + IsIn, + IsString, + MaxLength, + MinLength, + Validate, + ValidateIf, + ValidationArguments, + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator'; + +/** 只读字段约束:仅 undefined(真缺省)放行,任何显式值(含 null)拒绝。 */ +@ValidatorConstraint({ name: 'adminUserReadOnlyField', async: false }) +class ReadOnlyFieldConstraint implements ValidatorConstraintInterface { + validate(value: unknown): boolean { + return value === undefined; + } + + defaultMessage(args: ValidationArguments): string { + return `${args.property} 为只读字段,不能经本端点修改`; + } +} + +export class AdminUserUpdateDto { + @ValidateIf((_, value) => value !== undefined) + @IsIn(['staff', 'admin']) + role?: 'staff' | 'admin'; + + @ValidateIf((_, value) => value !== undefined) + @IsBoolean() + localLocked?: boolean; + + @ValidateIf((_, value) => value !== undefined) + @IsString() + @MinLength(1) + @MaxLength(128) + displayName?: string; + + /** sourceStatus 不在可更新集(契约 AdminUserUpdateRequest 无此字段)。 */ + @ValidateIf(() => true) + @Validate(ReadOnlyFieldConstraint) + declare sourceStatus?: unknown; +} + +export class AdminPasswordResetDto { + /** 契约 bounds:8-128;缺省由服务端生成随机初始密码。 */ + @ValidateIf((_, value) => value !== undefined) + @IsString() + @MinLength(8) + @MaxLength(128) + newPassword?: string; +} diff --git a/services/api/src/admin/admin-users.service.ts b/services/api/src/admin/admin-users.service.ts new file mode 100644 index 0000000..bbef705 --- /dev/null +++ b/services/api/src/admin/admin-users.service.ts @@ -0,0 +1,360 @@ +// services/api/src/admin/admin-users.service.ts —— 账号治理服务(P4a Task 6)。 +// 契约权威:openapi listAdminUsers 管理列表模式 / updateAdminUser / +// resetAdminUserPassword / activateAdminUser / deactivateAdminUser。全部仅 +// admin(AuthUser.role,403 AUTH_FORBIDDEN)。核心语义: +// · 管理列表:全量用户(与前缀模式「仅激活资格」互补——治理列表必须看见 +// pending/disabled/locked);createdAt+ID 稳定倒序键集游标(shared/cursor); +// query 过滤 = account 或 displayName 二进制前缀(同前缀模式的 ESCAPE '!' +// 转义 + bin 校对,大小写敏感,不做全文搜索);status 过滤 = 单值归类, +// 优先级 pending > disabled > locked > active(契约 AdminUserStatusFilter, +// SQL CASE 一处表达,多态用户只落一个桶)。 +// · PATCH:role/localLocked/displayName 部分更新;sourceStatus 只读(DTO 层 +// 拒绝);localLocked 置 true 等价停用(撤销该用户全部会话)、置 false 仅 +// 解除本地锁定(上游停用/密码维度不动);自我保护:admin 不许自改角色/ +// 自锁(唯一管理员自锁门外、自我降权均为事故面),displayName 自改放行。 +// 幂等 no-op(提交值与现值全同)不落审计——同相册域先例。 +// · reset-password:指定或服务端生成(16 位 62 字符集,~95 bit 熵,仅响应 +// 返回一次);撤销目标全部既有会话;argon2 哈希事务外先算(不占连接, +// 同 auth.changePassword 先例);审计只记 generated 布尔,绝不含密码材料。 +// · activate:credentialStatus pending→active(§10:同步创建的待激活账号 +// 闭环;激活本身不出凭据,初始密码经 reset-password 出具);对 active +// 幂等返回当前条目,不重复落审计。 +// · deactivate:localLocked=true + 撤销该目标全部会话(既有令牌即刻 401); +// 上游 sourceStatus 不动(本地锁定独立,技术方案 3.1A);重复停用幂等。 +// 重新启用 = PATCH localLocked=false(activate 只管 credentialStatus 维度)。 +import { randomInt } from 'node:crypto'; +import { HttpStatus, Injectable } from '@nestjs/common'; +import { ErrorCodes, type components } from '@photo/contracts'; +import { ApiException } from '../api-exception'; +import { hashPassword } from '../auth/password'; +import type { AuthUser } from '../auth/session.guard'; +import { PrismaService } from '../prisma/prisma.service'; +import { + decodeKeysetCursor, + encodeKeysetCursor, + type KeysetCursor, +} from '../shared/cursor'; +import { validationFailed } from '../albums/support'; +import type { AdminPasswordResetDto, AdminUserUpdateDto } from './admin-users.dto'; +import { + escapeLikePrefix, + toAdminUser, + type AdminUserListResponse, + type AdminUserRow, +} from './users-lookup.service'; + +export type AdminUserResponse = components['schemas']['AdminUser']; +export type AdminPasswordResetResponse = + components['schemas']['AdminPasswordResetResponse']; +export type AdminUserStatusFilter = + components['schemas']['AdminUserStatusFilter']; + +/** 契约 AdminUserStatusFilter 取值集(控制器 parseStatusFilter 校验共用)。 */ +export const STATUS_FILTER_VALUES: readonly AdminUserStatusFilter[] = [ + 'active', + 'pending', + 'disabled', + 'locked', +]; + +/** 服务端生成初始密码:16 位、62 字符集(均匀随机,~95 bit 熵)。 */ +const GENERATED_PASSWORD_LENGTH = 16; +const GENERATED_PASSWORD_CHARSET = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + +function generateInitialPassword(): string { + let out = ''; + for (let i = 0; i < GENERATED_PASSWORD_LENGTH; i++) { + out += GENERATED_PASSWORD_CHARSET[ + randomInt(GENERATED_PASSWORD_CHARSET.length) + ]; + } + return out; +} + +function requireAdmin(user: AuthUser): void { + if (user.role !== 'admin') { + throw new ApiException( + ErrorCodes.AUTH_FORBIDDEN, + '仅管理员可进行账号治理操作', + HttpStatus.FORBIDDEN, + ); + } +} + +function userNotFound(): ApiException { + return new ApiException( + ErrorCodes.NOT_FOUND, + '目标用户不存在', + HttpStatus.NOT_FOUND, + ); +} + +/** + * PATCH 有效变更集(幂等 no-op 检测 + 审计 metadata 双用途)。值收敛为 + * string|boolean 联合以兼容 Prisma InputJsonValue 的索引签名要求。 + */ +type UserChanges = Record< + string, + { from: string | boolean; to: string | boolean } +>; + +@Injectable() +export class AdminUsersService { + constructor(private readonly prisma: PrismaService) {} + + /** 管理列表模式:全量用户 + query/status 过滤 + createdAt+ID 倒序游标分页。 */ + async list( + user: AuthUser, + query: string | undefined, + status: AdminUserStatusFilter | undefined, + cursor: string | undefined, + limit: number, + ): Promise { + requireAdmin(user); + + let decoded: KeysetCursor | null = null; + if (cursor !== undefined && cursor !== '') { + decoded = decodeKeysetCursor(cursor); + if (decoded === null) { + throw validationFailed('cursor 不合法'); + } + } + + // 可选条件全部经参数化门控合入单条静态 SQL(模板绑定,无拼接注入面): + // 缺省过滤以 ${null} IS NULL / ${0} = 0 短路为恒真;query 前缀沿用 + // ESCAPE '!' 显式转义(%/_/!/\\ 按字面匹配)。 + const hasQuery = query !== undefined; + const pattern = hasQuery ? `${escapeLikePrefix(query)}%` : ''; + const statusFilter: AdminUserStatusFilter | null = status ?? null; + const cursorSort = decoded === null ? null : new Date(decoded.sort); + const cursorId = decoded === null ? '' : decoded.id; + + const rows = await this.prisma.$queryRaw` + SELECT id, account, displayName, role, sourceStatus, localLocked, + credentialStatus, locale, createdAt + FROM users + WHERE (${hasQuery ? 1 : 0} = 0 + OR account LIKE ${pattern} ESCAPE '!' + OR displayName LIKE ${pattern} ESCAPE '!') + AND (${statusFilter} IS NULL + OR (CASE + WHEN credentialStatus = 'pending' THEN 'pending' + WHEN sourceStatus = 'disabled' THEN 'disabled' + WHEN localLocked = TRUE THEN 'locked' + ELSE 'active' + END) = ${statusFilter}) + AND (${cursorSort} IS NULL + OR createdAt < ${cursorSort} + OR (createdAt = ${cursorSort} AND id < ${cursorId})) + ORDER BY createdAt DESC, id DESC + LIMIT ${limit + 1} + `; + const hasMore = rows.length > limit; + const page = hasMore ? rows.slice(0, limit) : rows; + const last = page.at(-1); + // CursorPageMeta 不变式:hasMore ⟺ nextCursor≠null(同 albums.list 推导)。 + const nextCursor = + hasMore && last !== undefined + ? encodeKeysetCursor({ + sort: last.createdAt.toISOString(), + id: last.id, + }) + : null; + return { + items: page.map((row) => toAdminUser(row)), + nextCursor, + hasMore: nextCursor !== null, + }; + } + + /** 用户治理属性部分更新(updateAdminUser)。 */ + async update( + user: AuthUser, + userId: string, + dto: AdminUserUpdateDto, + ): Promise { + requireAdmin(user); + if ( + dto.role === undefined && + dto.localLocked === undefined && + dto.displayName === undefined + ) { + throw validationFailed( + '请求体为空:至少提供 role/localLocked/displayName 之一', + ); + } + + const target = await this.prisma.users.findUnique({ + where: { id: userId }, + }); + if (target === null) { + throw userNotFound(); + } + + // 自我保护(Task 6 决策):自改角色(提权/降权语义混乱)与自锁(唯一 + // 管理员把自己锁在门外)都是事故面,一律 400;displayName 自改无风险。 + if (userId === user.id) { + if (dto.role !== undefined) { + throw validationFailed('不能修改自己的角色'); + } + if (dto.localLocked === true) { + throw validationFailed('不能锁定自己(localLocked 自锁被拒绝)'); + } + } + + // 变化检测:提交值与现值全同 → 幂等返回,不落审计(同相册域 no-op 先例)。 + const changes: UserChanges = {}; + const data: { role?: string; localLocked?: boolean; displayName?: string } = + {}; + if (dto.role !== undefined && dto.role !== target.role) { + changes.role = { from: target.role, to: dto.role }; + data.role = dto.role; + } + if (dto.localLocked !== undefined && dto.localLocked !== target.localLocked) { + changes.localLocked = { from: target.localLocked, to: dto.localLocked }; + data.localLocked = dto.localLocked; + } + if ( + dto.displayName !== undefined && + dto.displayName !== target.displayName + ) { + changes.displayName = { + from: target.displayName, + to: dto.displayName, + }; + data.displayName = dto.displayName; + } + if (Object.keys(data).length === 0) { + return toAdminUser(target); + } + + const updated = await this.prisma.$transaction(async (tx) => { + const row = await tx.users.update({ where: { id: userId }, data }); + // localLocked false→true 等价停用:撤销该用户全部会话(契约语义)。 + if (data.localLocked === true) { + await tx.sessions.updateMany({ + where: { userId, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + } + await tx.audit_logs.create({ + data: { + actorId: user.id, + action: 'user.update', + resourceId: userId, + metadata: { changes }, + }, + }); + return row; + }); + return toAdminUser(updated); + } + + /** 管理员重置用户密码(resetAdminUserPassword):指定或生成 + 撤全部会话。 */ + async resetPassword( + user: AuthUser, + userId: string, + dto: AdminPasswordResetDto, + ): Promise { + requireAdmin(user); + const target = await this.prisma.users.findUnique({ + where: { id: userId }, + select: { id: true }, + }); + if (target === null) { + throw userNotFound(); + } + + const generated = dto.newPassword === undefined; + const newPassword = dto.newPassword ?? generateInitialPassword(); + // argon2 耗时,事务外先算好(同 auth.changePassword 先例,不占连接)。 + const passwordHash = await hashPassword(newPassword); + await this.prisma.$transaction([ + this.prisma.users.update({ + where: { id: userId }, + data: { passwordHash }, + }), + // 撤销目标的**全部**既有会话(与自助改密保留当前会话不同:管理员重置 + // 意味着凭据交接,旧会话一律作废;执行方 admin 自身会话不在目标域内)。 + this.prisma.sessions.updateMany({ + where: { userId, revokedAt: null }, + data: { revokedAt: new Date() }, + }), + this.prisma.audit_logs.create({ + data: { + actorId: user.id, + action: 'user.password_reset', + resourceId: userId, + // 凭据敏感:审计只记「生成/指定」,绝不含密码明文/哈希/长度等材料。 + metadata: { generated }, + }, + }), + ]); + return { newPassword }; + } + + /** 激活待激活用户(activateAdminUser):pending→active,幂等。 */ + async activate(user: AuthUser, userId: string): Promise { + requireAdmin(user); + const target = await this.prisma.users.findUnique({ + where: { id: userId }, + }); + if (target === null) { + throw userNotFound(); + } + if (target.credentialStatus === 'active') { + // 幂等:已是 active 返回当前条目,不重复落审计。 + return toAdminUser(target); + } + const updated = await this.prisma.$transaction(async (tx) => { + const row = await tx.users.update({ + where: { id: userId }, + data: { credentialStatus: 'active' }, + }); + await tx.audit_logs.create({ + data: { + actorId: user.id, + action: 'user.activate', + resourceId: userId, + }, + }); + return row; + }); + return toAdminUser(updated); + } + + /** 停用用户(deactivateAdminUser):localLocked=true + 撤全部会话,幂等。 */ + async deactivate(user: AuthUser, userId: string): Promise { + requireAdmin(user); + const target = await this.prisma.users.findUnique({ + where: { id: userId }, + }); + if (target === null) { + throw userNotFound(); + } + if (target.localLocked) { + // 幂等:已锁定直接返回(锁定用户无法登录,不存在待撤的新会话)。 + return toAdminUser(target); + } + const updated = await this.prisma.$transaction(async (tx) => { + const row = await tx.users.update({ + where: { id: userId }, + data: { localLocked: true }, + }); + await tx.sessions.updateMany({ + where: { userId, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + await tx.audit_logs.create({ + data: { + actorId: user.id, + action: 'user.deactivate', + resourceId: userId, + }, + }); + return row; + }); + return toAdminUser(updated); + } +} diff --git a/services/api/src/admin/admin.module.ts b/services/api/src/admin/admin.module.ts index 92e3eed..60237d6 100644 --- a/services/api/src/admin/admin.module.ts +++ b/services/api/src/admin/admin.module.ts @@ -1,15 +1,20 @@ // services/api/src/admin/admin.module.ts —— 管理辅助域模块(P1b Task 4 起步; -// P4a Task 5 增补用量统计/阈值告警端点)。装配 UsersLookupController/ -// UsersLookupService 与 UsageController/UsageService;均仅依赖 @Global 的 -// PrismaService(SessionGuard 同样只依赖 Prisma),无需导入其他功能模块。 +// P4a Task 5 增补用量统计/阈值告警端点;Task 6 增补账号治理端点)。装配 +// UsersLookupController/UsersLookupService(GET /admin/users 双模式)、 +// AdminUsersController/AdminUsersService(PATCH/reset-password/activate/ +// deactivate)与 UsageController/UsageService;均仅依赖 @Global 的 +// PrismaService(SessionGuard 同样只依赖 Prisma;hashPassword 为纯函数), +// 无需导入其他功能模块。 import { Module } from '@nestjs/common'; +import { AdminUsersController } from './admin-users.controller'; +import { AdminUsersService } from './admin-users.service'; import { UsageController } from './usage.controller'; import { UsageService } from './usage.service'; import { UsersLookupController } from './users-lookup.controller'; import { UsersLookupService } from './users-lookup.service'; @Module({ - controllers: [UsersLookupController, UsageController], - providers: [UsersLookupService, UsageService], + controllers: [UsersLookupController, AdminUsersController, UsageController], + providers: [UsersLookupService, AdminUsersService, UsageService], }) export class AdminModule {} diff --git a/services/api/src/admin/users-lookup.controller.ts b/services/api/src/admin/users-lookup.controller.ts index a47a93a..5b46d30 100644 --- a/services/api/src/admin/users-lookup.controller.ts +++ b/services/api/src/admin/users-lookup.controller.ts @@ -1,25 +1,36 @@ -// services/api/src/admin/users-lookup.controller.ts —— /admin/users 端点 -// (P1b Task 4 建立;P4a Task 1 契约统一为 listAdminUsers 双模式)。 -// 方法名与契约 operationId 一一对应(listAdminUsers)。 +// services/api/src/admin/users-lookup.controller.ts —— GET /admin/users 端点 +// (P1b Task 4 建立;P4a Task 1 契约统一为 listAdminUsers 双模式;Task 6 +// 收口双模式分流)。方法名与契约 operationId 一一对应(listAdminUsers)。 // 控制器级 SessionGuard:访问策略=任何已认证工作人员(P1b 决策,见服务文件 -// 头——管理列表模式的仅 admin 判定由 Task 6 补齐)。控制器只做查询参数规整, -// 语义在服务层。 -// 过渡期边界(Task 6 收口):契约 P4a 起 accountPrefix 可选(缺省进入管理 -// 列表模式:query/status 过滤 + 游标分页 + 仅 admin);当前实现仅前缀模式, -// 缺 accountPrefix 仍 400——管理列表模式随 Task 6 账号管理落地。 -// limit 边界已对齐统一契约:缺省 20、上下界 1..100(P1b 时为 10/1..20; -// 邀请搜索调用方显式传 limit=10,行为不变)。 +// 头——管理列表模式的仅 admin 判定在 AdminUsersService.list)。 +// 双模式分流(P4a 契约): +// · 提供 accountPrefix → 前缀模式(P1b 语义保留:邀请协作者前置查询, +// 仅激活资格用户、account 升序、任何已认证工作人员可用); +// · 未提供 → 管理列表模式(全量用户、query/status 过滤、createdAt+ID 倒序 +// 游标分页、仅 admin)。 +// 控制器只做查询参数规整(limit/cursor 经 shared/pagination 同一 bounds), +// 语义在服务层。limit 边界已对齐统一契约:缺省 20、上下界 1..100。 +// 显式空串 accountPrefix=''/query='' 是「提供了非法值」而非「未提供」→ 400 +//(前者长度 <2,后者长度 <1,均契约 minLength 违例)。 import { Controller, Get, HttpStatus, Query, + Req, UseGuards, } from '@nestjs/common'; import { ErrorCodes } from '@photo/contracts'; import { ApiException } from '../api-exception'; -import { SessionGuard } from '../auth/session.guard'; +import { AuthenticatedRequest, SessionGuard } from '../auth/session.guard'; +import { userOf } from '../albums/albums.controller'; +import { parseCursor } from '../shared/pagination'; import { validationFailed } from '../albums/support'; +import { + AdminUsersService, + STATUS_FILTER_VALUES, + type AdminUserStatusFilter, +} from './admin-users.service'; import { UsersLookupService, type AdminUserListResponse } from './users-lookup.service'; /** 契约 listAdminUsers.limit:缺省 20,上下界 1..100(P4a 统一分页 bounds)。 */ @@ -28,16 +39,38 @@ export const MAX_LIST_LIMIT = 100; /** accountPrefix 查询参数(前缀模式):2..64(契约 bounds)。 */ export function parseAccountPrefix(raw: string | undefined): string { - if (raw === undefined || raw === '') { - // Task 6 起此分支消失(缺省进入管理列表模式);当前实现仅前缀模式。 - throw validationFailed('accountPrefix 必填(2-64 字符)'); - } - if (raw.length < 2 || raw.length > 64) { + // 双模式收口后 undefined 不再到达此函数(分流在 listAdminUsers);防御性 + // 保留同一校验出口。空串/1 字符/超长 → 400。 + if (raw === undefined || raw.length < 2 || raw.length > 64) { throw validationFailed('accountPrefix 须为 2-64 字符'); } return raw; } +/** query 查询参数(管理列表模式):1..64(契约 bounds);未提供 → undefined。 */ +export function parseAdminQuery(raw: string | undefined): string | undefined { + if (raw === undefined) { + return undefined; + } + if (raw.length < 1 || raw.length > 64) { + throw validationFailed('query 须为 1-64 字符'); + } + return raw; +} + +/** status 查询参数(管理列表模式):契约枚举单值;缺省/空串 → 不过滤。 */ +export function parseStatusFilter( + raw: string | undefined, +): AdminUserStatusFilter | undefined { + if (raw === undefined || raw === '') { + return undefined; + } + if (!STATUS_FILTER_VALUES.includes(raw as AdminUserStatusFilter)) { + throw validationFailed('status 须为 active/pending/disabled/locked 之一'); + } + return raw as AdminUserStatusFilter; +} + /** limit 查询参数:缺省 20,须为 1..100 的十进制整数(契约 bounds)。 */ export function parseListLimit(raw: string | undefined): number { if (raw === undefined || raw === '') { @@ -64,17 +97,36 @@ export function parseListLimit(raw: string | undefined): number { @Controller('admin/users') @UseGuards(SessionGuard) export class UsersLookupController { - constructor(private readonly usersLookupService: UsersLookupService) {} + constructor( + private readonly usersLookupService: UsersLookupService, + private readonly adminUsersService: AdminUsersService, + ) {} - /** 用户查询/管理列表(统一响应 AdminUserList;当前仅前缀模式,见文件头)。 */ + /** 用户查询/管理列表(双模式统一响应 AdminUserList,分流见文件头)。 */ @Get() listAdminUsers( + @Req() req: AuthenticatedRequest, @Query('accountPrefix') accountPrefix: string | undefined, + @Query('query') query: string | undefined, + @Query('status') status: string | undefined, + @Query('cursor') cursor: string | undefined, @Query('limit') limit: string | undefined, ): Promise { - return this.usersLookupService.lookup( - parseAccountPrefix(accountPrefix), - parseListLimit(limit), + const parsedLimit = parseListLimit(limit); + if (accountPrefix !== undefined) { + // 前缀模式(P1b 语义保留):任何已认证工作人员。 + return this.usersLookupService.lookup( + parseAccountPrefix(accountPrefix), + parsedLimit, + ); + } + // 管理列表模式(P4a Task 6):仅 admin(服务层 403)。 + return this.adminUsersService.list( + userOf(req), + parseAdminQuery(query), + parseStatusFilter(status), + parseCursor(cursor), + parsedLimit, ); } } diff --git a/services/api/src/admin/users-lookup.service.ts b/services/api/src/admin/users-lookup.service.ts index 72fdd10..dee4c28 100644 --- a/services/api/src/admin/users-lookup.service.ts +++ b/services/api/src/admin/users-lookup.service.ts @@ -1,9 +1,8 @@ // services/api/src/admin/users-lookup.service.ts —— 用户查询/管理列表(P1b // Task 4 前缀查询;P4a Task 1 契约统一为 listAdminUsers 双模式端点)。 -// P4a 契约决策:/admin/users 响应统一 AdminUserList(AdminUser 字段超集, -// 管理端 UI 按场景裁剪展示);本文件为**过渡实现**——仅落地前缀模式 -// (P1b 语义原样:邀请协作者前置查询),管理列表模式(query/status 过滤 + -// 游标分页 + 仅 admin 访问)由 P4a Task 6 账号管理实现补齐。 +// P4a Task 6 收口后本文件承载**前缀模式**(P1b 语义原样:邀请协作者前置查询, +// 任何已认证工作人员可用);管理列表模式(query/status 过滤 + 游标分页 + 仅 +// admin)与治理端点(PATCH/reset/activate/deactivate)在 admin-users.service。 // 实现要点: // · 前缀匹配大小写敏感:库级 collation utf8mb4_0900_bin(schema.prisma 头注 // 约定),LIKE 按二进制逐字节比较——'AL' 不命中 'alice'(契约已注明)。 @@ -14,7 +13,7 @@ // sourceStatus≠disabled 且 !localLocked):被停用/待激活/锁定账号不可被 // 邀请,也就不应出现在候选列表(与 members.add 的目标校验一致)。 // · 前缀模式即取即用(limit 上限内一次取回):nextCursor=null、 -// hasMore=false;游标翻页随 Task 6 管理列表模式落地。 +// hasMore=false;游标翻页由管理列表模式(admin-users.service)承载。 import { Injectable } from '@nestjs/common'; import type { components } from '@photo/contracts'; import { PrismaService } from '../prisma/prisma.service'; @@ -22,21 +21,41 @@ import { PrismaService } from '../prisma/prisma.service'; export type AdminUserListResponse = components['schemas']['AdminUserList']; /** AdminUser 契约条目类型(枚举字段的窄化转换目标)。 */ -type AdminUserItem = AdminUserListResponse['items'][number]; +export type AdminUserItem = AdminUserListResponse['items'][number]; -/** $queryRaw 行形状(SELECT 列即 AdminUser 契约字段——不含任何凭据列)。 */ -interface AdminUserRow { +/** + * users 行形状(SELECT 列即 AdminUser 契约字段——不含任何凭据列)。 + * localLocked:$queryRaw 返回 0/1、Prisma 客户端返回 boolean,两种来源共用 + * (toAdminUser 以 Boolean 归一)。 + */ +export interface AdminUserRow { id: string; account: string; displayName: string; role: string; sourceStatus: string; - localLocked: number; // MySQL BOOLEAN 经 $queryRaw 返回 0/1 + localLocked: boolean | number; credentialStatus: string; locale: string | null; createdAt: Date; } +/** users 行 → AdminUser 契约条目(双模式列表与治理端点回显共用)。 */ +export function toAdminUser(row: AdminUserRow): AdminUserItem { + return { + id: row.id, + account: row.account, + displayName: row.displayName, + // 库列为自由字符串(schema 约定取值集),窄化到契约枚举联合。 + role: row.role as AdminUserItem['role'], + sourceStatus: row.sourceStatus as AdminUserItem['sourceStatus'], + localLocked: Boolean(row.localLocked), + credentialStatus: row.credentialStatus as AdminUserItem['credentialStatus'], + locale: row.locale, + createdAt: row.createdAt.toISOString(), + }; +} + /** * LIKE 前缀转义(ESCAPE '!'):! 自身、% 与 _(MySQL 通配符)及 \(历史转义 * 字符,防御不同 SQL_MODE 下的语义漂移)前置 ! 后按字面匹配。 @@ -71,18 +90,7 @@ export class UsersLookupService { LIMIT ${limit} `; return { - items: rows.map((row) => ({ - id: row.id, - account: row.account, - displayName: row.displayName, - // 库列为自由字符串(schema 约定取值集),窄化到契约枚举联合。 - role: row.role as AdminUserItem['role'], - sourceStatus: row.sourceStatus as AdminUserItem['sourceStatus'], - localLocked: row.localLocked !== 0, - credentialStatus: row.credentialStatus as AdminUserItem['credentialStatus'], - locale: row.locale, - createdAt: row.createdAt.toISOString(), - })), + items: rows.map((row) => toAdminUser(row)), nextCursor: null, hasMore: false, }; diff --git a/services/api/test/admin-users.e2e-spec.ts b/services/api/test/admin-users.e2e-spec.ts new file mode 100644 index 0000000..534219b --- /dev/null +++ b/services/api/test/admin-users.e2e-spec.ts @@ -0,0 +1,665 @@ +// services/api/test/admin-users.e2e-spec.ts —— P4a Task 6 账号管理 e2e(先写后 +// 实现)。真实 app + MySQL。覆盖 GET /admin/users 管理列表模式(双模式另一半, +// 前缀模式语义由 users-lookup.e2e-spec 守护)与 PATCH / reset-password / +// activate / deactivate 四个治理端点: +// ① 管理列表:无参数 admin → 全量用户(含 pending/disabled/locked——与前缀 +// 模式「仅激活资格」相反,治理列表要看见所有人);createdAt+ID 稳定倒序 + +// 游标分页(limit=2 走完 5 条,hasMore/nextCursor 成对不变式); +// ② query 过滤:account 或 displayName 二进制前缀(大小写敏感;'AGOV' 空、 +// '岳飞' 命中 displayName);空串/超长 → 400; +// ③ status 过滤矩阵:归类优先级 pending > disabled > locked > active +// (pending+disabled+locked → pending;disabled+locked → disabled); +// 非法取值 → 400; +// ④ 访问策略:staff 无前缀(管理列表)→ 403;staff 带 accountPrefix → 200 +// (前缀模式保留);匿名 → 401; +// ⑤ PATCH:role staff→admin→staff 往返 + localLocked 锁定(撤全部会话)/ +// 解锁(可再登录)+ displayName;sourceStatus 只读(出现即 400);空体/ +// role 非法值 → 400;自我保护:自改角色/自锁 → 400(admin 自锁会把唯一 +// 管理员锁在门外的安全事故面,displayName 自改允许);audit user.update +// 仅记变化字段 from→to(幂等 no-op 不重复落审计,同相册域先例); +// ⑥ reset-password:指定 newPassword → 旧密码 401 新密码 200;目标用户全部 +// 既有会话撤销(执行方 admin 自身会话不受影响);audit user.password_reset +// 绝不含密码明文;缺省 → 服务端生成(长度 ≥12、字母数字字符集、仅返回 +// 一次、两次生成不同、可登录);7/129 长度 → 400; +// ⑦ activate:pending→active;对 active 重复激活幂等 200(不重复落审计); +// 组合流:activate → reset-password(生成)→ 用生成密码登录 200 +// (§10 待激活用户闭环); +// ⑧ deactivate:localLocked=true + 目标全部会话撤销(双令牌均 401)+ 正确 +// 密码登录 401(守卫资格拒);audit user.deactivate;重复停用幂等无重复 +// 审计;重新启用路径 = PATCH localLocked=false(activate 只管 +// credentialStatus,解锁是 localLocked 维度)→ 原密码可再登录; +// ⑨ 权限与目标校验:非 admin 全端点 403;幽灵用户 404 NOT_FOUND。 +import { randomBytes } from 'node:crypto'; +import { type INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import request from 'supertest'; +import { PrismaClient } from '@photo/db-cn'; +import { ErrorCodes } from '@photo/contracts'; +import { AppCnModule } from '../src/app-cn.module'; +import { hashPassword } from '../src/auth/password'; +import { hashSessionToken } from '../src/auth/session.guard'; +import { API_PREFIX } from '../src/shared/constants'; +import { resetDb } from './reset'; + +jest.setTimeout(120_000); + +describe('账号管理 /admin/users 治理端点 (e2e)', () => { + let app: INestApplication; + let prisma: PrismaClient; + + let adminToken: string; + let adminId: string; + let staffToken: string; + + // 治理操作目标(各用例独立用户,避免用例间状态交叉)。 + let patchTargetId: string; // ⑤ PATCH role/localLocked/displayName + let resetTargetId: string; // ⑥ reset-password(指定 + 生成两路径) + let pendingTargetId: string; // ⑦ activate 组合流(credentialStatus=pending) + let deactivateTargetId: string; // ⑧ deactivate(双会话撤销) + + // 显式 createdAt(T0 + i 分钟):管理列表 createdAt 倒序的确定性断言基础。 + const T0 = Date.UTC(2026, 8, 20, 8, 0, 0); + const at = (minutes: number) => new Date(T0 + minutes * 60_000); + + /** 分页组:agov-page-01..05(createdAt 递增 → 列表倒序 05..01)。 */ + const PAGE_ACCOUNTS = Array.from( + { length: 5 }, + (_, i) => `agov-page-${String(i + 1).padStart(2, '0')}`, + ); + + beforeAll(async () => { + await resetDb(); + const moduleRef = await Test.createTestingModule({ + imports: [AppCnModule], + }).compile(); + app = moduleRef.createNestApplication(); + app.setGlobalPrefix(API_PREFIX); + await app.init(); + + prisma = new PrismaClient(); + + const mkUser = ( + account: string, + displayName: string, + extra: { + role?: string; + sourceStatus?: string; + credentialStatus?: string; + localLocked?: boolean; + passwordHash?: string; + createdAt?: Date; + } = {}, + ) => + prisma.users.create({ + data: { account, passwordHash: 'x', displayName, ...extra }, + select: { id: true }, + }); + + // ① 分页组(互不重叠的前缀,隔离断言域)。 + for (let i = 0; i < PAGE_ACCOUNTS.length; i++) { + await mkUser(PAGE_ACCOUNTS[i], `分页${i + 1}`, { createdAt: at(i + 1) }); + } + // ② displayName 前缀命中组(account 与查询前缀无关)。 + await mkUser('agov-dn', '岳飞鹏', { createdAt: at(30) }); + // ③ status 归类组:active / pending / disabled / locked 各一。 + await mkUser('agov-status-a', '状态活', { createdAt: at(40) }); + await mkUser('agov-status-p', '待激活', { + credentialStatus: 'pending', + createdAt: at(41), + }); + await mkUser('agov-status-d', '上游停', { + sourceStatus: 'disabled', + createdAt: at(42), + }); + await mkUser('agov-status-l', '本地锁', { + localLocked: true, + createdAt: at(43), + }); + // ③ 归类优先级组:pending+disabled+locked → pending;disabled+locked → disabled。 + await mkUser('agov-prio-b', '多态备', { + credentialStatus: 'pending', + sourceStatus: 'disabled', + localLocked: true, + createdAt: at(44), + }); + await mkUser('agov-prio-dl', '双态锁', { + sourceStatus: 'disabled', + localLocked: true, + createdAt: at(45), + }); + + // ⑤ PATCH 目标:staff + 真实密码 + 一个会话(锁定撤会话断言)。 + const patchTarget = await mkUser('agov-patch', '.patch目标', { + passwordHash: await hashPassword('Patch-Old-123'), + createdAt: at(50), + }); + patchTargetId = patchTarget.id; + // ⑥ reset 目标:真实密码 + 会话(旧密码失效/会话撤销断言)。 + const resetTarget = await mkUser('agov-reset', '.reset目标', { + passwordHash: await hashPassword('Reset-Old-123'), + createdAt: at(51), + }); + resetTargetId = resetTarget.id; + // ⑦ activate 目标:同步创建语义的待激活用户(真实密码,激活后经重置出凭据)。 + const pendingTarget = await mkUser('agov-pend2', '.待激活目标', { + credentialStatus: 'pending', + passwordHash: await hashPassword('Pending-Old-9'), + createdAt: at(52), + }); + pendingTargetId = pendingTarget.id; + // ⑧ deactivate 目标:双会话(全部撤销断言)。 + const deactivateTarget = await mkUser('agov-deact', '.停用目标', { + passwordHash: await hashPassword('Deact-Old-123'), + createdAt: at(53), + }); + deactivateTargetId = deactivateTarget.id; + + // 请求方:admin 与非 admin staff(createdAt 最晚,不出现在过滤断言域)。 + adminId = ( + await mkUser('agov-admin', '管理员', { + role: 'admin', + createdAt: at(90), + }) + ).id; + const staffId = ( + await mkUser('agov-staff', '普通员工', { createdAt: at(91) }) + ).id; + + const mkToken = async (userId: string): Promise => { + const token = randomBytes(32).toString('hex'); + await prisma.sessions.create({ + data: { + userId, + tokenHash: hashSessionToken(token), + expiresAt: new Date(Date.now() + 3_600_000), + }, + }); + return token; + }; + adminToken = await mkToken(adminId); + staffToken = await mkToken(staffId); + // ⑤ PATCH 锁定撤会话:目标自带一个会话。 + patchTargetToken = await mkToken(patchTargetId); + // ⑥ reset:目标会话(应撤销,admin 自身不受影响)。 + resetTargetToken = await mkToken(resetTargetId); + // ⑧ deactivate:目标双会话(两条都应 401)。 + deactivateToken1 = await mkToken(deactivateTargetId); + deactivateToken2 = await mkToken(deactivateTargetId); + }); + + afterAll(async () => { + await app.close(); + await prisma.$disconnect(); + }); + + // —— 会话令牌(beforeAll 内赋值;撤会话断言用)—— + let patchTargetToken: string; + let resetTargetToken: string; + let deactivateToken1: string; + let deactivateToken2: string; + + function listUsers( + token: string | null, + query?: Record, + ) { + const req = request(app.getHttpServer()).get(`/${API_PREFIX}/admin/users`); + if (token !== null) { + req.set('Authorization', `Bearer ${token}`); + } + return req.query(query ?? {}); + } + + function patchUser(token: string, userId: string, body: object) { + return request(app.getHttpServer()) + .patch(`/${API_PREFIX}/admin/users/${userId}`) + .set('Authorization', `Bearer ${token}`) + .send(body); + } + + function resetPassword(token: string, userId: string, body?: object) { + const req = request(app + .getHttpServer()) + .post(`/${API_PREFIX}/admin/users/${userId}/reset-password`) + .set('Authorization', `Bearer ${token}`); + return body === undefined ? req.send(undefined) : req.send(body); + } + + function activate(token: string, userId: string) { + return request(app.getHttpServer()) + .post(`/${API_PREFIX}/admin/users/${userId}/activate`) + .set('Authorization', `Bearer ${token}`) + .send({}); + } + + function deactivate(token: string, userId: string) { + return request(app.getHttpServer()) + .post(`/${API_PREFIX}/admin/users/${userId}/deactivate`) + .set('Authorization', `Bearer ${token}`) + .send({}); + } + + /** 真实登录(限速键 = account|IP;每账号失败次数控制在本 spec 远低于 5)。 */ + function login(account: string, password: string) { + return request(app.getHttpServer()) + .post(`/${API_PREFIX}/auth/password`) + .send({ account, password }); + } + + /** 守卫放行性探针:任意受保护端点(此处用管理列表)验证令牌是否仍有效。 */ + function probeToken(token: string) { + return request(app.getHttpServer()) + .get(`/${API_PREFIX}/admin/users`) + .set('Authorization', `Bearer ${token}`) + .query({ query: 'agov-page' }); + } + + it('① 管理列表:无参数 admin → 含全部治理状态;createdAt 倒序游标分页走全量', async () => { + // 管理列表要「看见所有人」:pending/disabled/locked 与 active 并列出现 + //(前缀模式只回激活资格用户,两个模式语义互补)。 + const all = await listUsers(adminToken, { query: 'agov-status' }); + expect(all.status).toBe(200); + expect( + (all.body.items as { account: string }[]).map((i) => i.account).sort(), + ).toEqual( + ['agov-status-a', 'agov-status-p', 'agov-status-d', 'agov-status-l'].sort(), + ); + + // 游标分页:limit=2 三页走完 5 条,倒序 05→01。 + const accounts: string[] = []; + let cursor: string | undefined; + let pages = 0; + for (;;) { + const res = await listUsers(adminToken, { + query: 'agov-page', + limit: 2, + ...(cursor === undefined ? {} : { cursor }), + }); + expect(res.status).toBe(200); + const items = res.body.items as { account: string }[]; + accounts.push(...items.map((i) => i.account)); + // CursorPageMeta 不变式:hasMore=true ⟺ nextCursor≠null。 + expect(res.body.hasMore).toBe(res.body.nextCursor !== null); + cursor = res.body.nextCursor; + pages += 1; + if (cursor === null || cursor === undefined || pages > 5) { + break; + } + } + expect(pages).toBe(3); // 2 + 2 + 1 + expect(accounts).toEqual([...PAGE_ACCOUNTS].reverse()); + }); + + it('② query 过滤:account/displayName 二进制前缀;大小写敏感;空串/超长 400', async () => { + const upper = await listUsers(adminToken, { query: 'AGOV-PAGE' }); + expect(upper.status).toBe(200); + expect(upper.body.items).toEqual([]); + + // 'agov-page-0' 是 01..05 全体的前缀(05 亦命中)→ 5 条全中。 + const hit = await listUsers(adminToken, { query: 'agov-page-0' }); + expect(hit.status).toBe(200); + expect((hit.body.items as { account: string }[]).map((i) => i.account)).toEqual( + [...PAGE_ACCOUNTS].reverse(), + ); + + // 精确到单条的前缀。 + const one = await listUsers(adminToken, { query: 'agov-page-03' }); + expect(one.status).toBe(200); + expect((one.body.items as { account: string }[]).map((i) => i.account)).toEqual( + ['agov-page-03'], + ); + + // displayName 前缀(account 'agov-dn' 与 '岳' 无关)。 + const byName = await listUsers(adminToken, { query: '岳飞' }); + expect(byName.status).toBe(200); + expect(byName.body.items).toEqual([ + expect.objectContaining({ account: 'agov-dn', displayName: '岳飞鹏' }), + ]); + + for (const bad of ['', 'x'.repeat(65)]) { + const res = await listUsers(adminToken, { query: bad }); + expect(res.status).toBe(400); + expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED); + } + }); + + it('③ status 过滤矩阵:归类优先级 pending > disabled > locked > active;非法值 400', async () => { + const pick = async (status: string) => { + const res = await listUsers(adminToken, { status }); + expect(res.status).toBe(200); + return (res.body.items as { account: string }[]).map((i) => i.account); + }; + // 优先级裁决:agov-prio-b(pending+disabled+locked)归 pending; + // agov-prio-dl(disabled+locked)归 disabled(压过 locked)。 + // agov-pend2 此时仍是 pending(⑦ 才激活)——按 createdAt 倒序排首位。 + expect(await pick('pending')).toEqual([ + 'agov-pend2', + 'agov-prio-b', + 'agov-status-p', + ]); + expect(await pick('disabled')).toEqual(['agov-prio-dl', 'agov-status-d']); + expect(await pick('locked')).toEqual(['agov-status-l']); + expect(await pick('active')).toContain('agov-status-a'); + expect(await pick('active')).not.toContain('agov-status-p'); + expect(await pick('active')).not.toContain('agov-status-d'); + expect(await pick('active')).not.toContain('agov-status-l'); + + const bad = await listUsers(adminToken, { status: 'bogus' }); + expect(bad.status).toBe(400); + expect(bad.body.code).toBe(ErrorCodes.VALIDATION_FAILED); + }); + + it('④ 访问策略:staff 无前缀 → 403;staff 带 accountPrefix → 200(前缀模式保留);匿名 401', async () => { + const staffList = await listUsers(staffToken, {}); + expect(staffList.status).toBe(403); + expect(staffList.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN); + + const staffPrefix = await listUsers(staffToken, { + accountPrefix: 'agov-page', + }); + expect(staffPrefix.status).toBe(200); + // 前缀模式语义不变:仅激活资格用户、account 升序。 + expect( + (staffPrefix.body.items as { account: string }[]).map((i) => i.account), + ).toEqual(PAGE_ACCOUNTS); + + const anon = await listUsers(null, {}); + expect(anon.status).toBe(401); + expect(anon.body.code).toBe(ErrorCodes.AUTH_TOKEN_EXPIRED); + }); + + it('⑤ PATCH:role 往返 + displayName;audit user.update 记变化字段;幂等 no-op 不重复审计', async () => { + const up = await patchUser(adminToken, patchTargetId, { role: 'admin' }); + expect(up.status).toBe(200); + expect(up.body).toEqual( + expect.objectContaining({ id: patchTargetId, role: 'admin' }), + ); + // AdminUser 全字段形状(同 users-lookup spec ①,此处守护治理端点回显)。 + expect(Object.keys(up.body).sort()).toEqual([ + 'account', + 'createdAt', + 'credentialStatus', + 'displayName', + 'id', + 'localLocked', + 'locale', + 'role', + 'sourceStatus', + ]); + + const back = await patchUser(adminToken, patchTargetId, { role: 'staff' }); + expect(back.status).toBe(200); + expect(back.body.role).toBe('staff'); + + const renamed = await patchUser(adminToken, patchTargetId, { + displayName: '新展示名', + }); + expect(renamed.status).toBe(200); + expect(renamed.body.displayName).toBe('新展示名'); + + // 幂等 no-op:再提交同值 displayName → 200 但不重复落审计。 + const again = await patchUser(adminToken, patchTargetId, { + displayName: '新展示名', + }); + expect(again.status).toBe(200); + expect(again.body.displayName).toBe('新展示名'); + + const audits = await prisma.audit_logs.findMany({ + where: { action: 'user.update', resourceId: patchTargetId }, + orderBy: { createdAt: 'asc' }, + }); + expect(audits).toHaveLength(3); // admin→staff? no:staff→admin、admin→staff、displayName + expect(audits[0].actorId).toBe(adminId); + expect(audits[0].metadata).toEqual({ + changes: { role: { from: 'staff', to: 'admin' } }, + }); + expect(audits[1].metadata).toEqual({ + changes: { role: { from: 'admin', to: 'staff' } }, + }); + expect(audits[2].metadata).toEqual({ + changes: { displayName: { from: '.patch目标', to: '新展示名' } }, + }); + + const dbRow = await prisma.users.findUnique({ where: { id: patchTargetId } }); + expect(dbRow?.role).toBe('staff'); + expect(dbRow?.displayName).toBe('新展示名'); + }); + + it('⑤ PATCH localLocked:true → 目标会话全撤 + 登录 401;false → 原密码可再登录', async () => { + const lock = await patchUser(adminToken, patchTargetId, { + localLocked: true, + }); + expect(lock.status).toBe(200); + expect(lock.body.localLocked).toBe(true); + + // 既有会话即刻失效(守卫资格拒)。 + const probed = await probeToken(patchTargetToken); + expect(probed.status).toBe(401); + expect(probed.body.code).toBe(ErrorCodes.AUTH_TOKEN_EXPIRED); + + // 锁定期间正确密码也不可登录(资格不合格 → 同 401 INVALID_CREDENTIALS)。 + const lockedLogin = await login('agov-patch', 'Patch-Old-123'); + expect(lockedLogin.status).toBe(401); + expect(lockedLogin.body.code).toBe(ErrorCodes.AUTH_INVALID_CREDENTIALS); + + const lockAudit = await prisma.audit_logs.findFirst({ + where: { action: 'user.update', resourceId: patchTargetId }, + orderBy: { createdAt: 'desc' }, + }); + expect(lockAudit?.metadata).toEqual({ + changes: { localLocked: { from: false, to: true } }, + }); + + // 解锁(契约:localLocked=false 仅解除本地锁定):原密码未动,可再登录。 + const unlock = await patchUser(adminToken, patchTargetId, { + localLocked: false, + }); + expect(unlock.status).toBe(200); + expect(unlock.body.localLocked).toBe(false); + const relogin = await login('agov-patch', 'Patch-Old-123'); + expect(relogin.status).toBe(200); + }); + + it('⑤ PATCH 入参边界:sourceStatus 只读 → 400;空体/role 非法值 → 400;幽灵用户 404', async () => { + const src = await patchUser(adminToken, patchTargetId, { + sourceStatus: 'disabled', + } as object); + expect(src.status).toBe(400); + expect(src.body.code).toBe(ErrorCodes.VALIDATION_FAILED); + + const empty = await patchUser(adminToken, patchTargetId, {}); + expect(empty.status).toBe(400); + expect(empty.body.code).toBe(ErrorCodes.VALIDATION_FAILED); + + const badRole = await patchUser(adminToken, patchTargetId, { role: 'boss' }); + expect(badRole.status).toBe(400); + expect(badRole.body.code).toBe(ErrorCodes.VALIDATION_FAILED); + + const ghost = await patchUser(adminToken, `ghost-${Date.now()}`, { + displayName: '谁', + }); + expect(ghost.status).toBe(404); + expect(ghost.body.code).toBe(ErrorCodes.NOT_FOUND); + }); + + it('⑤ PATCH 自我保护:admin 自改角色/自锁 → 400;自改 displayName → 200', async () => { + const selfRole = await patchUser(adminToken, adminId, { role: 'staff' }); + expect(selfRole.status).toBe(400); + expect(selfRole.body.code).toBe(ErrorCodes.VALIDATION_FAILED); + + const selfLock = await patchUser(adminToken, adminId, { localLocked: true }); + expect(selfLock.status).toBe(400); + expect(selfLock.body.code).toBe(ErrorCodes.VALIDATION_FAILED); + + const selfName = await patchUser(adminToken, adminId, { + displayName: '管理员改己名', + }); + expect(selfName.status).toBe(200); + expect(selfName.body.displayName).toBe('管理员改己名'); + }); + + it('⑥ reset-password 指定:旧密码 401/新密码 200;目标会话撤销;执行方不受影响;audit 无密码', async () => { + const res = await resetPassword(adminToken, resetTargetId, { + newPassword: 'Brand-New-456', + }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ newPassword: 'Brand-New-456' }); + + const oldLogin = await login('agov-reset', 'Reset-Old-123'); + expect(oldLogin.status).toBe(401); + expect(oldLogin.body.code).toBe(ErrorCodes.AUTH_INVALID_CREDENTIALS); + + const newLogin = await login('agov-reset', 'Brand-New-456'); + expect(newLogin.status).toBe(200); + expect(typeof newLogin.body.sessionToken).toBe('string'); + + // 目标既有会话全撤(新登录产生的会话不在其列——旧令牌作废)。 + const probed = await probeToken(resetTargetToken); + expect(probed.status).toBe(401); + + // 执行方 admin 自身会话不受影响(撤销只作用于目标用户)。 + const adminStill = await listUsers(adminToken, { query: 'agov-reset' }); + expect(adminStill.status).toBe(200); + + // 审计落账且绝不含密码明文(含请求指定值与任何可能的生成值形态)。 + const audit = await prisma.audit_logs.findFirst({ + where: { action: 'user.password_reset', resourceId: resetTargetId }, + }); + expect(audit).not.toBeNull(); + expect(audit?.actorId).toBe(adminId); + expect(JSON.stringify(audit?.metadata)).not.toContain('Brand-New-456'); + expect(JSON.stringify(audit?.metadata)).not.toContain('password'); + }); + + it('⑥ reset-password 生成:长度 ≥12、字母数字字符集、两次不同、可登录;长度边界 400', async () => { + const res = await resetPassword(adminToken, resetTargetId); + expect(res.status).toBe(200); + const generated = res.body.newPassword as string; + expect(generated.length).toBeGreaterThanOrEqual(12); + expect(generated).toMatch(/^[A-Za-z0-9]+$/); + + const loginRes = await login('agov-reset', generated); + expect(loginRes.status).toBe(200); + + // 随机性:再次生成的值不同(一次性凭据不可预测的最低限度证据)。 + const res2 = await resetPassword(adminToken, resetTargetId, {}); + expect(res2.status).toBe(200); + expect(res2.body.newPassword).not.toBe(generated); + + for (const bad of ['short7!', 'x'.repeat(129)]) { + const res3 = await resetPassword(adminToken, resetTargetId, { + newPassword: bad, + }); + expect(res3.status).toBe(400); + expect(res3.body.code).toBe(ErrorCodes.VALIDATION_FAILED); + } + }); + + it('⑦ activate:pending→active;重复激活幂等(不重复审计);组合流 activate→reset→登录 200', async () => { + const first = await activate(adminToken, pendingTargetId); + expect(first.status).toBe(200); + expect(first.body).toEqual( + expect.objectContaining({ + id: pendingTargetId, + credentialStatus: 'active', + }), + ); + + // 幂等:对 active 用户重复激活返回当前条目,不重复落审计。 + const second = await activate(adminToken, pendingTargetId); + expect(second.status).toBe(200); + expect(second.body.credentialStatus).toBe('active'); + + const activateAudits = await prisma.audit_logs.findMany({ + where: { action: 'user.activate', resourceId: pendingTargetId }, + }); + expect(activateAudits).toHaveLength(1); + + // §10 闭环:激活后经 reset-password 出初始凭据 → 用户可登录。 + const reset = await resetPassword(adminToken, pendingTargetId, {}); + expect(reset.status).toBe(200); + const initial = reset.body.newPassword as string; + expect(initial.length).toBeGreaterThanOrEqual(12); + const loginRes = await login('agov-pend2', initial); + expect(loginRes.status).toBe(200); + }); + + it('⑧ deactivate:锁定 + 双会话撤销 + 登录 401;幂等无重复审计;PATCH 解锁后原密码可登录', async () => { + const res = await deactivate(adminToken, deactivateTargetId); + expect(res.status).toBe(200); + expect(res.body).toEqual( + expect.objectContaining({ id: deactivateTargetId, localLocked: true }), + ); + + for (const token of [deactivateToken1, deactivateToken2]) { + const probed = await probeToken(token); + expect(probed.status).toBe(401); + expect(probed.body.code).toBe(ErrorCodes.AUTH_TOKEN_EXPIRED); + } + + const lockedLogin = await login('agov-deact', 'Deact-Old-123'); + expect(lockedLogin.status).toBe(401); + expect(lockedLogin.body.code).toBe(ErrorCodes.AUTH_INVALID_CREDENTIALS); + + // 幂等:重复停用 200,不重复落审计。 + const again = await deactivate(adminToken, deactivateTargetId); + expect(again.status).toBe(200); + expect(again.body.localLocked).toBe(true); + const audits = await prisma.audit_logs.findMany({ + where: { action: 'user.deactivate', resourceId: deactivateTargetId }, + }); + expect(audits).toHaveLength(1); + expect(audits[0].actorId).toBe(adminId); + + // 重新启用 = PATCH localLocked=false(activate 只管 credentialStatus); + // 密码未动 → 原密码可再登录。 + const unlock = await patchUser(adminToken, deactivateTargetId, { + localLocked: false, + }); + expect(unlock.status).toBe(200); + expect(unlock.body.localLocked).toBe(false); + const relogin = await login('agov-deact', 'Deact-Old-123'); + expect(relogin.status).toBe(200); + }); + + it('⑨ 权限与目标校验:非 admin 全端点 403;幽灵用户 404', async () => { + const ghost = `ghost-${Date.now()}`; + + const list = await listUsers(staffToken, { query: 'agov' }); + expect(list.status).toBe(403); + expect(list.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN); + + const patch = await patchUser(staffToken, patchTargetId, { + displayName: '越权', + }); + expect(patch.status).toBe(403); + expect(patch.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN); + + const reset = await resetPassword(staffToken, patchTargetId, { + newPassword: 'Hacked-12345', + }); + expect(reset.status).toBe(403); + expect(reset.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN); + + const act = await activate(staffToken, ghost); + expect(act.status).toBe(403); + expect(act.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN); + + const deact = await deactivate(staffToken, ghost); + expect(deact.status).toBe(403); + expect(deact.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN); + + const resetGhost = await resetPassword(adminToken, ghost, { + newPassword: 'Ghost-12345', + }); + expect(resetGhost.status).toBe(404); + expect(resetGhost.body.code).toBe(ErrorCodes.NOT_FOUND); + + const actGhost = await activate(adminToken, ghost); + expect(actGhost.status).toBe(404); + expect(actGhost.body.code).toBe(ErrorCodes.NOT_FOUND); + + const deactGhost = await deactivate(adminToken, ghost); + expect(deactGhost.status).toBe(404); + expect(deactGhost.body.code).toBe(ErrorCodes.NOT_FOUND); + }); +}); diff --git a/services/api/test/e2e.full-chain.e2e-spec.ts b/services/api/test/e2e.full-chain.e2e-spec.ts index ffeaa83..ec835a7 100644 --- a/services/api/test/e2e.full-chain.e2e-spec.ts +++ b/services/api/test/e2e.full-chain.e2e-spec.ts @@ -293,14 +293,32 @@ describe('full-chain (e2e)', () => { connection: workerConnection, }); try { + // 轮询条件须覆盖**被断言的全部终态**:processor 里 photo ready(发布 + // 事务提交)与 photo_jobs done(其后的独立 updateMany)是两次落库, + // 只轮询 photo 状态会在两写之间闯入断言窗口(P4a Task 6 全套件时序 + // 下命中:job 仍 active 而 photo 已 ready)——一并轮询 job 终态。 await waitFor( - async () => - ( - await prisma.photos.findMany({ + async () => { + const [photos, jobs] = await Promise.all([ + prisma.photos.findMany({ where: { id: { in: [uploadA.photoId, uploadB.photoId] } }, select: { processingState: true }, - }) - ).every((p) => p.processingState === 'ready'), + }), + prisma.photo_jobs.findMany({ + where: { + photoId: { in: [uploadA.photoId, uploadB.photoId] }, + type: 'process', + }, + select: { state: true }, + }), + ]); + return ( + photos.length === 2 && + photos.every((p) => p.processingState === 'ready') && + jobs.length === 2 && + jobs.every((j) => j.state === 'done') + ); + }, ); for (const { photoId } of [uploadA, uploadB]) { diff --git a/services/api/test/users-lookup.e2e-spec.ts b/services/api/test/users-lookup.e2e-spec.ts index 324c524..a861939 100644 --- a/services/api/test/users-lookup.e2e-spec.ts +++ b/services/api/test/users-lookup.e2e-spec.ts @@ -10,8 +10,8 @@ // 'carol' → 空、'Carol' → 命中; // ④ LIKE 通配符转义(ESCAPE 子句):前缀中的 _ / % 按字面匹配,不作单字符/ // 任意串通配(esc_ 仅命中字面下划线账号;al% 空); -// ⑤ 参数边界:accountPrefix 缺失(过渡期实现仍 400,Task 6 起进入管理列表 -// 模式)/1 字符/65 字符 → 400;2 与 64 字符合法;limit 缺省 20(P1b 时 +// ⑤ 参数边界:accountPrefix 缺失 → 管理列表模式分流(staff 403 / admin +// 200,Task 6 双模式收口后语义)/1 字符/65 字符 → 400;2 与 64 字符合法;limit 缺省 20(P1b 时 // 为 10,P4a 统一分页 bounds)、1..100 界内、0/101/非整数 → 400; // ⑥ 访问策略(P1b 决策,契约描述):未认证 401;任何已认证工作人员(含非 // admin 的 staff)200;admin 200。 @@ -197,13 +197,30 @@ describe('账号查询 GET /admin/users (e2e)', () => { expect(percent.body.items).toEqual([]); }); - it('accountPrefix 边界:过渡期缺失/空串仍 400;1 字符/65 字符 → 400;64 字符 → 200 空', async () => { - // Task 6 起缺失 accountPrefix 进入管理列表模式(仅 admin);当前实现仅 - // 前缀模式,缺失/空串仍 400(控制器文件头登记的过渡期边界)。 - const missing = await lookup(staffToken, {}); - expect(missing.status).toBe(400); - expect(missing.body.code).toBe(ErrorCodes.VALIDATION_FAILED); + it('accountPrefix 边界:缺失 → 双模式分流(staff 403 / admin 200 管理列表);1/65 字符 → 400;64 → 200 空', async () => { + // Task 6 起缺失 accountPrefix 进入管理列表模式(仅 admin):staff 无前缀 + // → 403 AUTH_FORBIDDEN;admin → 200 管理列表(全量用户,含非激活态—— + // 深度断言由 admin-users.e2e-spec 守护,此处只断言模式分流与响应形状)。 + const staffMissing = await lookup(staffToken, {}); + expect(staffMissing.status).toBe(403); + expect(staffMissing.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN); + const adminMissing = await lookup(adminToken, {}); + expect(adminMissing.status).toBe(200); + expect(Object.keys(adminMissing.body).sort()).toEqual([ + 'hasMore', + 'items', + 'nextCursor', + ]); + // 管理列表包含前缀模式排除的非激活用户(本 spec 的排除组)。 + const adminAccounts = ( + adminMissing.body.items as { account: string }[] + ).map((item) => item.account); + expect(adminAccounts).toContain('aldis'); + expect(adminAccounts).toContain('alpend'); + expect(adminAccounts).toContain('allock'); + + // 显式空串是「提供了非法值」而非「未提供」→ 仍 400。 const empty = await lookup(staffToken, { accountPrefix: '' }); expect(empty.status).toBe(400); expect(empty.body.code).toBe(ErrorCodes.VALIDATION_FAILED);