8ba98ace63
P1b Task 4: listAlbumPhotos (GET /albums/{albumId}/photos, tag
[photos]) + lookupUsersByAccount (GET /admin/users, tag [admin]).
- management list: all visibility states visible (unpublished/
published/hidden/deleted) for owner/admin; collaborator 403,
outsider/ghost album 404 (canView -> canManage ordering, same
anti-enumeration as members). Items = PhotoRecord + uploader
{userId, account, displayName} (flat-FK second query, redacted).
Keyset (uploadedAt,id) DESC via shared/cursor; visibility filter
validated against PhotoVisibility enum (400 otherwise), categoryId
must belong to the album (ghost/cross-album same-code 400).
pending_upload rows (uploadedAt NULL) excluded: the sort key is
both the contract-required field and the keyset predicate - NULL
can neither be shaped as PhotoRecord nor compared; in-flight
uploads are covered by the uploads history endpoint.
- account lookup (new src/admin module): prefix search over active
users only (credentialStatus=active, sourceStatus!=disabled,
!localLocked - same eligibility as SessionGuard/members.add),
account ASC, no cursor (small cap). $queryRaw LIKE prefix% with
explicit ESCAPE '!': !/%/_/\ escaped to literal matching (Prisma
startsWith escaping is not contractual); utf8mb4_0900_bin makes
the prefix match case-sensitive ('AL' misses 'alice'), documented
in contract + tests. Access policy per contract: any authenticated
staff (deviation from plan's admin/owner), unauthenticated 401.
- e2e: photos-admin (6) + users-lookup (6) cases; api e2e 136 -> 148.
Co-Authored-By: Claude Code <noreply@anthropic.com>
248 lines
9.7 KiB
TypeScript
248 lines
9.7 KiB
TypeScript
// services/api/test/users-lookup.e2e-spec.ts —— P1b Task 4 账号前缀查询 e2e
|
||
// (先写后实现)。真实 app + MySQL。覆盖:
|
||
// ① 前缀命中:prefix 'al' → albert+alice(account 升序);条目仅
|
||
// id/account/displayName(脱敏,无邮箱/手机/状态/凭据字段);响应仅 items
|
||
// (无游标——前缀搜索 + 小上限即取即用);
|
||
// ② 账号资格过滤:credentialStatus=pending / sourceStatus=disabled /
|
||
// localLocked=true 均排除(只留「激活」用户);
|
||
// ③ 大小写敏感(库级 utf8mb4_0900_bin 二进制校对,契约已注明):'AL' → 空、
|
||
// 'carol' → 空、'Carol' → 命中;
|
||
// ④ LIKE 通配符转义(ESCAPE 子句):前缀中的 _ / % 按字面匹配,不作单字符/
|
||
// 任意串通配(esc_ 仅命中字面下划线账号;al% 空);
|
||
// ⑤ 参数边界:accountPrefix 缺失/1 字符/65 字符 → 400;2 与 64 字符合法;
|
||
// limit 缺省 10、1..20 界内、0/21/非整数 → 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 升序;禁用/待激活/锁定排除;条目与响应形状脱敏", async () => {
|
||
const res = await lookup(staffToken, { accountPrefix: 'al' });
|
||
expect(res.status).toBe(200);
|
||
// 响应仅 items(契约 AdminUserLookupList:无游标元信息)。
|
||
expect(Object.keys(res.body).sort()).toEqual(['items']);
|
||
const items = res.body.items as {
|
||
id: string;
|
||
account: string;
|
||
displayName: string;
|
||
}[];
|
||
// 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(['艾伯', '爱丽']);
|
||
// 条目形状:仅三个脱敏字段(无密码哈希/状态/角色等)。
|
||
for (const item of items) {
|
||
expect(Object.keys(item).sort()).toEqual([
|
||
'account',
|
||
'displayName',
|
||
'id',
|
||
]);
|
||
}
|
||
});
|
||
|
||
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);
|
||
expect(exact.body.items).toEqual([
|
||
{ 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([
|
||
{ 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 边界:缺失/空串/1 字符/65 字符 → 400;64 字符 → 200 空', async () => {
|
||
const missing = await lookup(staffToken, {});
|
||
expect(missing.status).toBe(400);
|
||
expect(missing.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||
|
||
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:缺省 10 且升序截断;5 合法;0/21/非整数 → 400;20 合法', async () => {
|
||
// zz-look-* 共 12 个激活账号:缺省 limit=10 → 恰前 10 个(01..10 升序)。
|
||
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: 10 }, (_, 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: 20,
|
||
});
|
||
expect(top.status).toBe(200);
|
||
expect(top.body.items).toHaveLength(12); // 上限 20 > 存量 12,全量返回
|
||
|
||
for (const limit of [0, 21, '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);
|
||
});
|
||
});
|