Files
photo-live/services/api/test/admin-users.e2e-spec.ts
T

667 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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',
'synced',
]);
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);
});
});