// 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 => { 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, ) { 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[]; // 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); }); });