feat(api): admin account governance endpoints

P4a Task 6: GET /admin/users 双模式收口(无 accountPrefix 进入管理列表
模式:全量用户、query/status 过滤、createdAt+ID 倒序键集游标、仅 admin;
前缀模式 P1b 语义保留)+ PATCH / reset-password / activate / deactivate
治理端点(sourceStatus 只读拒绝、自我保护、会话撤销、审计、生成初始
密码)。附带修复 full-chain e2e 的 job-done 断言竞态(轮询条件补齐
photo_jobs 终态)。api e2e 214 → 227 全绿。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-20 19:31:00 +08:00
parent 4937eeecff
commit f4a1ba7a14
9 changed files with 1326 additions and 60 deletions
@@ -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<AdminUserResponse> {
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<AdminPasswordResetResponse> {
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<AdminUserResponse> {
return this.adminUsersService.activate(userOf(req), userId);
}
/** 停用用户(localLocked=true + 撤销全部会话,幂等)。 */
@Post(':userId/deactivate')
@HttpCode(HttpStatus.OK)
deactivateAdminUser(
@Param('userId') userId: string,
@Req() req: AuthenticatedRequest,
): Promise<AdminUserResponse> {
return this.adminUsersService.deactivate(userOf(req), userId);
}
}
+65
View File
@@ -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;
}
@@ -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<AdminUserListResponse> {
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<AdminUserRow[]>`
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<AdminUserResponse> {
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<AdminPasswordResetResponse> {
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<AdminUserResponse> {
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<AdminUserResponse> {
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);
}
}
+10 -5
View File
@@ -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 {}
@@ -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<AdminUserListResponse> {
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,
);
}
}
+29 -21
View File
@@ -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,
};
+665
View File
@@ -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<string> => {
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<string, string | number>,
) {
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);
});
});
+23 -5
View File
@@ -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]) {
+25 -8
View File
@@ -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);