2f6b6afc92
Co-Authored-By: Claude Code <noreply@anthropic.com>
667 lines
27 KiB
TypeScript
667 lines
27 KiB
TypeScript
// 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);
|
||
});
|
||
});
|