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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user