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

667 lines
27 KiB
TypeScript
Raw Normal View History

// 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);
});
});