2026-09-20 19:31:00 +08:00
|
|
|
|
// 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',
|
2026-09-22 18:06:12 +08:00
|
|
|
|
'synced',
|
2026-09-20 19:31:00 +08:00
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|