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

359 lines
15 KiB
TypeScript
Raw Normal View History

// services/api/test/users-lookup.e2e-spec.ts —— P1b Task 4 账号前缀查询 e2e
// (先写后实现);P4a Task 1 契约统一后同步修订。真实 app + MySQL。覆盖:
// ① 前缀命中:prefix 'al' → albert+alice(account 升序);条目为统一
// AdminUser 字段集(id/account/displayName + 治理字段 role/sourceStatus/
// localLocked/credentialStatus/locale/createdAt;无邮箱/手机/凭据字段);
// 响应为 AdminUserList(items + nextCursor/hasMore 游标元信息);
// ② 账号资格过滤:credentialStatus=pending / sourceStatus=disabled /
// localLocked=true 均排除(只留「激活」用户);
// ③ 大小写敏感(库级 utf8mb4_0900_bin 二进制校对,契约已注明):'AL' → 空、
// 'carol' → 空、'Carol' → 命中;
// ④ LIKE 通配符转义(ESCAPE 子句):前缀中的 _ / % 按字面匹配,不作单字符/
// 任意串通配(esc_ 仅命中字面下划线账号;al% 空);
// ⑤ 参数边界:accountPrefix 缺失 → 管理列表模式分流(staff 403 / admin
// 200,Task 6 双模式收口后语义)/1 字符/65 字符 → 400;2 与 64 字符合法;limit 缺省 20(P1b 时
// 为 10,P4a 统一分页 bounds)、1..100 界内、0/101/非整数 → 400;
// ⑥ 访问策略(P1b 决策,契约描述):未认证 401;任何已认证工作人员(含非
// admin 的 staff)200;admin 200。
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 { hashSessionToken } from '../src/auth/session.guard';
import { API_PREFIX } from '../src/shared/constants';
import { resetDb } from './reset';
jest.setTimeout(120_000);
describe('账号查询 GET /admin/users (e2e)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let staffToken: string; // 非 admin 工作人员(访问策略断言用)
let adminToken: string;
let aliceId: string;
let albertId: string;
let carolId: string;
let escUnderscoreId: string;
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;
} = {},
) =>
prisma.users.create({
data: {
account,
passwordHash: 'x',
displayName,
...extra,
},
select: { id: true },
});
aliceId = (await mkUser('alice', '爱丽')).id;
albertId = (await mkUser('albert', '艾伯')).id;
await mkUser('bob', '博伟');
carolId = (await mkUser('Carol', '卡萝')).id; // 大写 C——大小写敏感断言
// 资格排除组(前缀均 'al' 命中域,但不应出现在结果里)。
await mkUser('aldis', '停用者', { sourceStatus: 'disabled' });
await mkUser('alpend', '待激活', { credentialStatus: 'pending' });
await mkUser('allock', '锁定者', { localLocked: true });
// LIKE 通配符转义断言组:esc_ 前缀若未转义 _,会同时命中 escshape。
escUnderscoreId = (await mkUser('esc_ape', '下划线')).id;
await mkUser('escshape', '形状');
// 默认 limit=10 截断断言组:12 个 zz-look-* 账号(升序 01..12)。
for (let i = 1; i <= 12; i++) {
await mkUser(`zz-look-${String(i).padStart(2, '0')}`, `批量${i}`);
}
// 请求方:staff(非 admin)与 admin。
const staffId = (await mkUser('ul-staff', '陆石')).id;
const adminId = (await mkUser('ul-admin', '路安', { role: 'admin' })).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;
};
staffToken = await mkToken(staffId);
adminToken = await mkToken(adminId);
});
afterAll(async () => {
await app.close();
await prisma.$disconnect();
});
function lookup(
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 ?? {});
}
it("前缀 'al' → albert+alice 升序;禁用/待激活/锁定排除;条目与响应形状(AdminUserList)", async () => {
const res = await lookup(staffToken, { accountPrefix: 'al' });
expect(res.status).toBe(200);
// 响应为统一 AdminUserList(P4a):items + 游标元信息(前缀模式即取即用
// → nextCursor=null / hasMore=false)。
expect(Object.keys(res.body).sort()).toEqual(['hasMore', 'items', 'nextCursor']);
expect(res.body.nextCursor).toBeNull();
expect(res.body.hasMore).toBe(false);
const items = res.body.items as Record<string, unknown>[];
// account 升序:albert < alice;恰好两条(排除组全不出现在结果)。
expect(items.map((item) => item.account)).toEqual(['albert', 'alice']);
expect(items.map((item) => item.id)).toEqual([albertId, aliceId]);
expect(items.map((item) => item.displayName)).toEqual(['艾伯', '爱丽']);
// 条目形状:AdminUser 字段集(治理字段随 P4a 统一暴露;仍无邮箱/手机/
// 密码哈希等凭据字段)。注意数组序 = Array#sort 的码点序(大小写敏感):
// 'localLocked'(大写 L,0x4C)排在 'locale'(e,0x65)之前。
for (const item of items) {
expect(Object.keys(item).sort()).toEqual([
'account',
'createdAt',
'credentialStatus',
'displayName',
'id',
'localLocked',
'locale',
'role',
'sourceStatus',
]);
expect(item.role).toBe('staff');
expect(item.sourceStatus).toBe('local');
expect(item.localLocked).toBe(false);
expect(item.credentialStatus).toBe('active');
}
});
it('大小写敏感(bin 校对):“AL”/“carol” → 空;“Carol” → 命中', async () => {
const upper = await lookup(staffToken, { accountPrefix: 'AL' });
expect(upper.status).toBe(200);
expect(upper.body.items).toEqual([]);
const lowerCarol = await lookup(staffToken, { accountPrefix: 'carol' });
expect(lowerCarol.status).toBe(200);
expect(lowerCarol.body.items).toEqual([]);
const exact = await lookup(staffToken, { accountPrefix: 'Carol' });
expect(exact.status).toBe(200);
// 条目为 AdminUser 全字段:以关键字段子集断言(字段全集见用例 ①)。
expect(exact.body.items).toEqual([
expect.objectContaining({
id: carolId,
account: 'Carol',
displayName: '卡萝',
}),
]);
});
it('LIKE 通配符按字面匹配(ESCAPE):“esc_” 仅命中字面下划线账号;“al%” 空', async () => {
// 未转义时 _ 作单字符通配会同时命中 escshape——转义后只命中 esc_ape。
const underscore = await lookup(staffToken, { accountPrefix: 'esc_' });
expect(underscore.status).toBe(200);
expect(underscore.body.items).toEqual([
expect.objectContaining({
id: escUnderscoreId,
account: 'esc_ape',
displayName: '下划线',
}),
]);
// 未转义时 al% 作任意串通配会命中 albert/alice 等——转义后按字面前缀匹配。
const percent = await lookup(staffToken, { accountPrefix: 'al%' });
expect(percent.status).toBe(200);
expect(percent.body.items).toEqual([]);
});
it('accountPrefix 边界:缺失 → 双模式分流(staff 403 / admin 200 管理列表);1/65 字符 → 400;64 → 200 空', async () => {
// Task 6 起缺失 accountPrefix 进入管理列表模式(仅 admin):staff 无前缀
// → 403 AUTH_FORBIDDEN;admin → 200 管理列表(全量用户,含非激活态——
// 深度断言由 admin-users.e2e-spec 守护,此处只断言模式分流与响应形状)。
const staffMissing = await lookup(staffToken, {});
expect(staffMissing.status).toBe(403);
expect(staffMissing.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN);
const adminMissing = await lookup(adminToken, {});
expect(adminMissing.status).toBe(200);
expect(Object.keys(adminMissing.body).sort()).toEqual([
'hasMore',
'items',
'nextCursor',
'total',
]);
// 管理列表包含前缀模式排除的非激活用户(本 spec 的排除组)。
const adminAccounts = (
adminMissing.body.items as { account: string }[]
).map((item) => item.account);
expect(adminAccounts).toContain('aldis');
expect(adminAccounts).toContain('alpend');
expect(adminAccounts).toContain('allock');
// 显式空串是「提供了非法值」而非「未提供」→ 仍 400。
const empty = await lookup(staffToken, { accountPrefix: '' });
expect(empty.status).toBe(400);
expect(empty.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
const short = await lookup(staffToken, { accountPrefix: 'a' });
expect(short.status).toBe(400);
expect(short.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
const long = await lookup(staffToken, { accountPrefix: 'a'.repeat(65) });
expect(long.status).toBe(400);
expect(long.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
const max = await lookup(staffToken, { accountPrefix: 'a'.repeat(64) });
expect(max.status).toBe(200);
expect(max.body.items).toEqual([]);
});
it('limit:缺省 20(P4a 统一 bounds);5 合法;0/101/非整数 → 400;100 合法', async () => {
// zz-look-* 共 12 个激活账号:缺省 limit=20 > 存量 12 → 全量升序返回。
const def = await lookup(staffToken, { accountPrefix: 'zz-look-' });
expect(def.status).toBe(200);
expect(
(def.body.items as { account: string }[]).map((item) => item.account),
).toEqual(Array.from({ length: 12 }, (_, i) => `zz-look-${String(i + 1).padStart(2, '0')}`));
const five = await lookup(staffToken, {
accountPrefix: 'zz-look-',
limit: 5,
});
expect(five.status).toBe(200);
expect(five.body.items).toHaveLength(5);
const top = await lookup(staffToken, {
accountPrefix: 'zz-look-',
limit: 100,
});
expect(top.status).toBe(200);
expect(top.body.items).toHaveLength(12); // 上限 100 > 存量 12,全量返回
for (const limit of [0, 101, 'abc']) {
const res = await lookup(staffToken, {
accountPrefix: 'zz-look-',
limit,
});
expect(res.status).toBe(400);
expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
}
});
it('访问策略:未认证 401;已认证 staff(非 admin)200;admin 200', async () => {
const anon = await lookup(null, { accountPrefix: 'al' });
expect(anon.status).toBe(401);
expect(anon.body.code).toBe(ErrorCodes.AUTH_TOKEN_EXPIRED);
// P1b 决策(契约描述):邀请发起方不限于 admin/owner,任何已认证工作人员
// 可用——此处显式断言非 admin 的 staff 可 200。
const staff = await lookup(staffToken, { accountPrefix: 'al' });
expect(staff.status).toBe(200);
expect(staff.body.items).toHaveLength(2);
const admin = await lookup(adminToken, { accountPrefix: 'al' });
expect(admin.status).toBe(200);
expect(admin.body.items).toHaveLength(2);
});
it('page 模式:page/pageSize 偏移分页 + total;synced 档案快照合并;游标/前缀模式形状不受染', async () => {
// 档案种子:albert 挂映射带 profile 快照(G4);alice 无映射。
await prisma.external_user_mappings.create({
data: {
userId: albertId,
sourceSystem: 'uniservice',
externalUserId: 'ext-albert',
sourceVersion: 1,
profile: { sn: 'A001', email: 'albert@x.com', companyAbbr: '合塑' },
},
});
// 'al' 前缀域恒 5 账号:alice/albert/aldis/alpend/allock(顺序按 createdAt
// +ID 倒序,不依赖种子时序断言具体页位)。
const page1 = await lookup(adminToken, { query: 'al', page: 1, pageSize: 2 });
expect(page1.status).toBe(200);
expect(page1.body.total).toBe(5);
expect(page1.body.hasMore).toBe(false);
expect(page1.body.nextCursor).toBeNull();
expect(page1.body.items).toHaveLength(2);
const page2 = await lookup(adminToken, { query: 'al', page: 2, pageSize: 2 });
const page3 = await lookup(adminToken, { query: 'al', page: 3, pageSize: 2 });
expect(page2.body.items).toHaveLength(2);
expect(page3.body.items).toHaveLength(1);
// 走全量:三页并集 = 5 账号、无重复。
const all = [
...(page1.body.items as { account: string }[]),
...(page2.body.items as { account: string }[]),
...(page3.body.items as { account: string }[]),
];
expect(new Set(all.map((item) => item.account)).size).toBe(5);
// synced 合并:albert(有映射)带快照且字段原样;其余行 synced=null。
const albert = all
.concat() as unknown as { account: string; synced: unknown }[];
const albertRow = albert.find((item) => item.account === 'albert');
expect(albertRow?.synced).toEqual({
sn: 'A001',
email: 'albert@x.com',
companyAbbr: '合塑',
});
const aliceRow = albert.find((item) => item.account === 'alice');
expect(aliceRow?.synced).toBeNull();
// 越界页:空 items、total 仍返回。
const beyond = await lookup(adminToken, { query: 'al', page: 4, pageSize: 2 });
expect(beyond.status).toBe(200);
expect(beyond.body.items).toEqual([]);
expect(beyond.body.total).toBe(5);
// 游标模式(不带 page):total=null(键存在但空——兼容旧客户端语义)。
const cursorMode = await lookup(adminToken, { query: 'al', limit: 2 });
expect(cursorMode.status).toBe(200);
expect(cursorMode.body.total).toBeNull();
// 前缀模式条目干净:不合并 synced(邀请候选无需档案)。
const prefix = await lookup(staffToken, { accountPrefix: 'al' });
expect(prefix.status).toBe(200);
for (const item of prefix.body.items as object[]) {
expect('synced' in item).toBe(false);
}
// 参数校验:page=0 / pageSize=101 / 非整数 → 400。
expect((await lookup(adminToken, { page: 0 })).status).toBe(400);
expect((await lookup(adminToken, { page: 1, pageSize: 101 })).status).toBe(400);
expect((await lookup(adminToken, { page: 'x' })).status).toBe(400);
});
});