diff --git a/services/api/src/admin/admin.module.ts b/services/api/src/admin/admin.module.ts new file mode 100644 index 0000000..3f9e51c --- /dev/null +++ b/services/api/src/admin/admin.module.ts @@ -0,0 +1,12 @@ +// services/api/src/admin/admin.module.ts —— 管理辅助域模块(P1b Task 4)。 +// 装配 UsersLookupController/UsersLookupService;仅依赖 @Global 的 PrismaService +// (SessionGuard 同样只依赖 Prisma),无需导入其他功能模块。 +import { Module } from '@nestjs/common'; +import { UsersLookupController } from './users-lookup.controller'; +import { UsersLookupService } from './users-lookup.service'; + +@Module({ + controllers: [UsersLookupController], + providers: [UsersLookupService], +}) +export class AdminModule {} diff --git a/services/api/src/admin/users-lookup.controller.ts b/services/api/src/admin/users-lookup.controller.ts new file mode 100644 index 0000000..6e91d6d --- /dev/null +++ b/services/api/src/admin/users-lookup.controller.ts @@ -0,0 +1,78 @@ +// services/api/src/admin/users-lookup.controller.ts —— /admin/users 端点 +// (P1b Task 4)。方法名与契约 operationId 一一对应(lookupUsersByAccount)。 +// 控制器级 SessionGuard:访问策略=任何已认证工作人员(契约描述的 P1b 决策, +// 见服务文件头),认证通过即可,无角色判定。控制器只做查询参数规整 +// (accountPrefix 必填 2-64 字符;limit 缺省 10、1..20——本端点独立 bounds, +// 同公开照片列表在 public.controller 本地维护 parsePublicPhotoLimit 的先例, +// 勿混用 shared/pagination 的 1..100);语义在服务层。 +import { + Controller, + Get, + HttpStatus, + Query, + UseGuards, +} from '@nestjs/common'; +import { ErrorCodes } from '@photo/contracts'; +import { ApiException } from '../api-exception'; +import { SessionGuard } from '../auth/session.guard'; +import { validationFailed } from '../albums/support'; +import { + UsersLookupService, + type AdminUserLookupListResponse, +} from './users-lookup.service'; + +/** 契约 lookupUsersByAccount.limit:缺省 10,上下界 1..20。 */ +export const DEFAULT_LOOKUP_LIMIT = 10; +export const MAX_LOOKUP_LIMIT = 20; + +/** accountPrefix 查询参数:必填(缺失/空串 400),长度 2..64(契约 bounds)。 */ +export function parseAccountPrefix(raw: string | undefined): string { + if (raw === undefined || raw === '') { + throw validationFailed('accountPrefix 必填(2-64 字符)'); + } + if (raw.length < 2 || raw.length > 64) { + throw validationFailed('accountPrefix 须为 2-64 字符'); + } + return raw; +} + +/** limit 查询参数:缺省 10,须为 1..20 的十进制整数(契约 bounds)。 */ +export function parseLookupLimit(raw: string | undefined): number { + if (raw === undefined || raw === '') { + return DEFAULT_LOOKUP_LIMIT; + } + if (!/^\d+$/.test(raw)) { + throw new ApiException( + ErrorCodes.VALIDATION_FAILED, + 'limit 必须是 1-20 的整数', + HttpStatus.BAD_REQUEST, + ); + } + const limit = Number.parseInt(raw, 10); + if (limit < 1 || limit > MAX_LOOKUP_LIMIT) { + throw new ApiException( + ErrorCodes.VALIDATION_FAILED, + 'limit 必须是 1-20 的整数', + HttpStatus.BAD_REQUEST, + ); + } + return limit; +} + +@Controller('admin/users') +@UseGuards(SessionGuard) +export class UsersLookupController { + constructor(private readonly usersLookupService: UsersLookupService) {} + + /** 账号前缀查询:激活用户脱敏列表(account 升序,无游标)。 */ + @Get() + lookupUsersByAccount( + @Query('accountPrefix') accountPrefix: string | undefined, + @Query('limit') limit: string | undefined, + ): Promise { + return this.usersLookupService.lookup( + parseAccountPrefix(accountPrefix), + parseLookupLimit(limit), + ); + } +} diff --git a/services/api/src/admin/users-lookup.service.ts b/services/api/src/admin/users-lookup.service.ts new file mode 100644 index 0000000..bb8791a --- /dev/null +++ b/services/api/src/admin/users-lookup.service.ts @@ -0,0 +1,70 @@ +// services/api/src/admin/users-lookup.service.ts —— 账号前缀查询(P1b Task 4, +// 契约 lookupUsersByAccount,tag [admin])。邀请协作者的前置查询:按 account +// 前缀搜索激活用户,返回 {id,account,displayName} 脱敏列表(不含邮箱/手机/ +// 状态/凭据字段)。 +// 访问策略(P1b 决策,契约描述):任何已认证工作人员可用——邀请发起方不限于 +// 创建者/管理员,泄露面仅为激活用户的最小展示字段、接收方为已认证内部人员; +// 未认证一律 401(控制器级 SessionGuard 承担,服务层不再重复角色判定)。 +// 实现要点: +// · 前缀匹配大小写敏感:库级 collation utf8mb4_0900_bin(schema.prisma 头注 +// 约定),LIKE 按二进制逐字节比较——'AL' 不命中 'alice'(契约已注明)。 +// · LIKE 通配符显式转义:Prisma startsWith 对 %/_ 的转义行为无契约保证 +// (引擎版本相关),此处 $queryRaw 显式 `ESCAPE '!'`,前缀中 !/%/_/\ 四 +// 字符前置 ! 后按字面匹配——'esc_' 只命中字面下划线账号,不作单字符通配。 +// · 资格过滤与 SessionGuard 同一谓词(credentialStatus=active 且 +// sourceStatus≠disabled 且 !localLocked):被停用/待激活/锁定账号不可被 +// 邀请,也就不应出现在候选列表(与 members.add 的目标校验一致)。 +// · 无游标:前缀搜索 + 小上限(1..20 缺省 10)即取即用(契约决策),按 +// account 升序返回确定性输出。 +import { Injectable } from '@nestjs/common'; +import type { components } from '@photo/contracts'; +import { PrismaService } from '../prisma/prisma.service'; + +export type AdminUserLookupListResponse = + components['schemas']['AdminUserLookupList']; + +/** $queryRaw 行形状(SELECT 列即脱敏字段全集——不选取任何凭据/状态列)。 */ +interface LookupRow { + id: string; + account: string; + displayName: string; +} + +/** + * LIKE 前缀转义(ESCAPE '!'):! 自身、% 与 _(MySQL 通配符)及 \(历史转义 + * 字符,防御不同 SQL_MODE 下的语义漂移)前置 ! 后按字面匹配。 + */ +export function escapeLikePrefix(prefix: string): string { + return prefix.replace(/[!%_\\]/g, (ch) => `!${ch}`); +} + +@Injectable() +export class UsersLookupService { + constructor(private readonly prisma: PrismaService) {} + + /** 前缀查询(契约 lookupUsersByAccount):激活用户、account 升序、limit 截断。 */ + async lookup( + accountPrefix: string, + limit: number, + ): Promise { + const pattern = `${escapeLikePrefix(accountPrefix)}%`; + // 参数经 $queryRaw 模板绑定(无拼接注入面);LIMIT ? 为 MySQL 合法占位。 + const rows = await this.prisma.$queryRaw` + SELECT id, account, displayName + FROM users + WHERE account LIKE ${pattern} ESCAPE '!' + AND credentialStatus = 'active' + AND sourceStatus <> 'disabled' + AND localLocked = false + ORDER BY account ASC + LIMIT ${limit} + `; + return { + items: rows.map((row) => ({ + id: row.id, + account: row.account, + displayName: row.displayName, + })), + }; + } +} diff --git a/services/api/src/app-cn.module.ts b/services/api/src/app-cn.module.ts index 62b195d..9124618 100644 --- a/services/api/src/app-cn.module.ts +++ b/services/api/src/app-cn.module.ts @@ -16,6 +16,7 @@ import { APP_FILTER, APP_PIPE } from '@nestjs/core'; import { LoggerModule } from 'nestjs-pino'; import type { IncomingMessage, ServerResponse } from 'node:http'; import { AlbumsModule } from './albums/albums.module'; +import { AdminModule } from './admin/admin.module'; import { AllExceptionsFilter } from './all-exceptions.filter'; import { AuthModule } from './auth/auth.module'; import { HealthController } from './health.controller'; @@ -38,8 +39,11 @@ import type { ServerRequestWithId } from './shared/request-with-id'; UploadsModule, // 公开(观众端)域(Task 22 getPublicAlbum;Task 23 公开照片读)。 PublicModule, - // 照片管理域(Task 24:可见性生命周期 PATCH/DELETE)。 + // 照片管理域(Task 24:可见性生命周期 PATCH/DELETE;P1b Task 4 增补 + // /albums/{albumId}/photos 管理列表)。 PhotosModule, + // 管理辅助域(P1b Task 4:/admin/users 账号前缀查询)。 + AdminModule, LoggerModule.forRoot({ pinoHttp: { // 与 RequestIdMiddleware 共用同一解析函数:优先透传 x-request-id, diff --git a/services/api/src/photos/album-photos.controller.ts b/services/api/src/photos/album-photos.controller.ts new file mode 100644 index 0000000..7f0af1f --- /dev/null +++ b/services/api/src/photos/album-photos.controller.ts @@ -0,0 +1,48 @@ +// services/api/src/photos/album-photos.controller.ts —— /albums/{albumId}/photos +// 管理列表端点(P1b Task 4)。方法名与契约 operationId 一一对应 +// (listAlbumPhotos,tag [photos]——路由挂在相册子路径下,域仍属照片管理)。 +// 控制器级 SessionGuard:端点需认证。控制器只做认证用户透传 + 查询参数规整 +// (visibility 本域 dto / categoryId 空串归一 / cursor+limit 走 shared/pagination, +// 同契约 bounds);权限与 HTTP 映射由 PhotosService 抛 ApiException +// (404 ALBUM_NOT_FOUND / 403 AUTH_FORBIDDEN / 400 VALIDATION_FAILED)。 +import { + Controller, + Get, + Param, + Query, + Req, + UseGuards, +} from '@nestjs/common'; +import { userOf } from '../albums/albums.controller'; +import { AuthenticatedRequest, SessionGuard } from '../auth/session.guard'; +import { parseCursor, parseLimit } from '../shared/pagination'; +import { parseVisibilityFilter } from './dto'; +import { PhotosService, type AdminPhotoListResponse } from './photos.service'; + +@Controller('albums/:albumId/photos') +@UseGuards(SessionGuard) +export class AlbumPhotosController { + constructor(private readonly photosService: PhotosService) {} + + /** + * 管理照片列表:全部可见性状态可见(区别于公开列表仅 published), + * (uploadedAt,id) 稳定倒序键集游标;条目内嵌 uploader 脱敏信息。 + */ + @Get() + listAlbumPhotos( + @Param('albumId') albumId: string, + @Query('visibility') visibility: string | undefined, + @Query('categoryId') categoryId: string | undefined, + @Query('cursor') cursor: string | undefined, + @Query('limit') limit: string | undefined, + @Req() req: AuthenticatedRequest, + ): Promise { + return this.photosService.listAlbumPhotos(userOf(req), albumId, { + visibility: parseVisibilityFilter(visibility), + // 空串视为缺省(同公开列表 category 规整惯例)。 + categoryId: categoryId === '' ? undefined : categoryId, + cursor: parseCursor(cursor), + limit: parseLimit(limit), + }); + } +} diff --git a/services/api/src/photos/dto.ts b/services/api/src/photos/dto.ts index b340678..b1d7325 100644 --- a/services/api/src/photos/dto.ts +++ b/services/api/src/photos/dto.ts @@ -1,8 +1,12 @@ -// services/api/src/photos/dto.ts —— 照片可见性端点入参 DTO(Task 24)。 -// 校验约束对齐契约 PhotoVisibilityUpdateRequest:visibility 仅允许 -// hidden/published(enum 之外任何值 → 400 VALIDATION_FAILED);deleted 只能经 -// DELETE 进入,不开放 PATCH 直达(单向终态,见 photos.service 状态机)。 +// services/api/src/photos/dto.ts —— 照片域入参规整(Task 24 起步:PATCH body; +// P1b Task 4 增补:管理列表 visibility 查询参数)。校验约束对齐契约: +// PhotoVisibilityUpdateRequest.visibility 仅允许 hidden/published(enum 之外 +// 任何值 → 400 VALIDATION_FAILED);deleted 只能经 DELETE 进入,不开放 PATCH +// 直达(单向终态,见 photos.service 状态机)。 +import { HttpStatus } from '@nestjs/common'; import { IsIn } from 'class-validator'; +import { ErrorCodes } from '@photo/contracts'; +import { ApiException } from '../api-exception'; /** 契约 PhotoVisibilityUpdateRequest.visibility 取值(hidden↔published 间切换)。 */ export const PHOTO_VISIBILITY_TARGETS = ['hidden', 'published'] as const; @@ -12,3 +16,33 @@ export class PhotoVisibilityUpdateDto { @IsIn(PHOTO_VISIBILITY_TARGETS) visibility!: PhotoVisibilityTarget; } + +/** 契约 PhotoVisibility 全集(管理列表 visibility 过滤参数的合法值域)。 */ +export const PHOTO_VISIBILITIES = [ + 'unpublished', + 'published', + 'hidden', + 'deleted', +] as const; +export type PhotoVisibility = (typeof PHOTO_VISIBILITIES)[number]; + +/** + * 管理列表 visibility 查询参数(P1b Task 4):空串视为缺省(同 categoryId + * 的规整惯例,?visibility= 不构成显式过滤);非空须为契约 PhotoVisibility + * 枚举值,否则 400 VALIDATION_FAILED(契约 listAlbumPhotos 400 语义)。 + */ +export function parseVisibilityFilter( + raw: string | undefined, +): PhotoVisibility | undefined { + if (raw === undefined || raw === '') { + return undefined; + } + if (!(PHOTO_VISIBILITIES as readonly string[]).includes(raw)) { + throw new ApiException( + ErrorCodes.VALIDATION_FAILED, + 'visibility 取值非法', + HttpStatus.BAD_REQUEST, + ); + } + return raw as PhotoVisibility; +} diff --git a/services/api/src/photos/photos.module.ts b/services/api/src/photos/photos.module.ts index 8a34d8a..141964b 100644 --- a/services/api/src/photos/photos.module.ts +++ b/services/api/src/photos/photos.module.ts @@ -1,14 +1,16 @@ -// services/api/src/photos/photos.module.ts —— 照片管理模块(Task 24)。 -// 装配:PhotosController/PhotosService + PermissionsService(经 AlbumsModule -// 导入复用 A02 逐次权限判定;同 uploads 模块的装配先例)。 +// services/api/src/photos/photos.module.ts —— 照片管理模块(Task 24;P1b Task 4 +// 增补管理列表控制器)。装配:PhotosController/AlbumPhotosController/ +// PhotosService + PermissionsService(经 AlbumsModule 导入复用 A02 逐次权限 +// 判定;同 uploads 模块的装配先例)。 import { Module } from '@nestjs/common'; import { AlbumsModule } from '../albums/albums.module'; +import { AlbumPhotosController } from './album-photos.controller'; import { PhotosController } from './photos.controller'; import { PhotosService } from './photos.service'; @Module({ imports: [AlbumsModule], - controllers: [PhotosController], + controllers: [PhotosController, AlbumPhotosController], providers: [PhotosService], }) export class PhotosModule {} diff --git a/services/api/src/photos/photos.service.ts b/services/api/src/photos/photos.service.ts index 19c2bf8..2c8e5b7 100644 --- a/services/api/src/photos/photos.service.ts +++ b/services/api/src/photos/photos.service.ts @@ -1,6 +1,7 @@ // services/api/src/photos/photos.service.ts —— 照片可见性生命周期(Task 24: -// updatePhoto 隐藏/恢复发布、deletePhoto 软删除)。契约权威:openapi.yaml tags -// [photos]。状态机(visibility,docs §5.5「对外可见性」): +// updatePhoto 隐藏/恢复发布、deletePhoto 软删除)+ 管理照片列表(P1b Task 4: +// listAlbumPhotos,全状态可见 + uploader 脱敏内嵌 + (uploadedAt,id) 键集游标)。 +// 契约权威:openapi.yaml tags [photos]。状态机(visibility,docs §5.5「对外可见性」): // unpublished ──(worker 发布事务, Task 21)──▶ published // published ◀──────(manager)──────▶ hidden 双向,管理者可来回 // unpublished/published/hidden ──(manager)──▶ deleted 单向终态(软删除) @@ -26,8 +27,18 @@ import type { Prisma } from '@photo/db-cn'; import { ApiException } from '../api-exception'; import type { AuthUser } from '../auth/session.guard'; import { PermissionsService } from '../albums/permissions.service'; +import { + albumForbidden, + albumNotFound, + validationFailed, +} from '../albums/support'; import { PrismaService } from '../prisma/prisma.service'; -import type { PhotoVisibilityTarget } from './dto'; +import { + decodeKeysetCursor, + encodeKeysetCursor, + keysetWhereDesc, +} from '../shared/cursor'; +import type { PhotoVisibility, PhotoVisibilityTarget } from './dto'; import { photoForbidden, photoNotFound, @@ -35,6 +46,7 @@ import { } from './support'; export type PhotoRecord = components['schemas']['PhotoRecord']; +export type AdminPhotoListResponse = components['schemas']['AdminPhotoList']; type PhotoRow = Prisma.photosGetPayload; type PhotoVariantRow = Prisma.photo_variantsGetPayload< @@ -68,6 +80,126 @@ export class PhotosService { private readonly permissions: PermissionsService, ) {} + /** + * 管理照片列表(P1b Task 4,契约 listAlbumPhotos):区别于公开列表——全部 + * 可见性状态可见(unpublished/published/hidden/deleted),条目内嵌 uploader + * 脱敏信息(account/displayName,无凭据字段)。权限顺序防枚举(同 members): + * canViewAlbum=false → 404 ALBUM_NOT_FOUND;可见但 canManageAlbum=false + * (协作者)→ 403 AUTH_FORBIDDEN;admin 对幽灵 id 权限恒真,空相册兜底 404。 + * 排序 (uploadedAt,id) DESC 键集游标(shared/cursor)。 + * + * 未完成上传(pending_upload、uploadedAt NULL)不入列表:uploadedAt 既是契约 + * 必返字段又是键集排序键——NULL 行既无法以契约形状返回(toPhotoRecord 的 + * new Date(0) 兜底会伪造 1970 时间轴),也无法参与键集谓词(SQL 对 NULL 的 + * 比较恒 UNKNOWN,跨页会被静默跳过)。进行中的会话由 uploads 历史端点表达。 + */ + async listAlbumPhotos( + user: AuthUser, + albumId: string, + query: { + visibility?: PhotoVisibility; + categoryId?: string; + cursor?: string; + limit: number; + }, + ): Promise { + if (!(await this.permissions.canViewAlbum(user, albumId))) { + throw albumNotFound(); + } + if (!(await this.permissions.canManageAlbum(user, albumId))) { + throw albumForbidden('仅创建者或管理员可访问管理列表'); + } + // admin 对不存在 id 的 canView/canManage 恒真(非 admin 的可见成员行本身 + // 蕴含相册存在),空相册兜底同码 404——契约「相册不存在,或当前用户不可见」。 + const albumExists = await this.prisma.albums.findUnique({ + where: { id: albumId }, + select: { id: true }, + }); + if (albumExists === null) { + throw albumNotFound(); + } + + // categoryId:须为本相册分类;幽灵/跨册同码 400(同公开列表语义,不区分 + // 两种失败,防探测)。 + if (query.categoryId !== undefined) { + const category = await this.prisma.categories.findUnique({ + where: { id: query.categoryId }, + select: { albumId: true }, + }); + if (category === null || category.albumId !== albumId) { + throw validationFailed('categoryId 不属于该相册'); + } + } + + let decoded: ReturnType = null; + if (query.cursor !== undefined && query.cursor !== '') { + decoded = decodeKeysetCursor(query.cursor); + if (decoded === null) { + throw validationFailed('cursor 不合法'); + } + } + + const where: Prisma.photosWhereInput = { + albumId, + uploadedAt: { not: null }, + ...(query.visibility !== undefined + ? { visibility: query.visibility } + : {}), + ...(query.categoryId !== undefined + ? { categoryId: query.categoryId } + : {}), + }; + if (decoded !== null) { + Object.assign(where, keysetWhereDesc('uploadedAt', 'id', decoded)); + } + const rows = await this.prisma.photos.findMany({ + where, + orderBy: [{ uploadedAt: 'desc' }, { id: 'desc' }], + take: query.limit + 1, // 多取一条探测 hasMore(同相册/成员列表) + }); + const hasMore = rows.length > query.limit; + const page = hasMore ? rows.slice(0, query.limit) : rows; + + // users 无 relation(扁平外键):二次查询应用层组装 uploader 脱敏信息。 + const users = + page.length === 0 + ? [] + : await this.prisma.users.findMany({ + where: { id: { in: page.map((p) => p.uploaderId) } }, + select: { id: true, account: true, displayName: true }, + }); + const userById = new Map(users.map((u) => [u.id, u])); + + const items = page.flatMap((row) => { + const uploader = userById.get(row.uploaderId); + // 用户行缺失(P1a 无用户删除,仅可能来自手工改库):跳过而非 5xx(同 + // 成员列表先例,单条脏数据不拖垮整页)。 + return uploader === undefined + ? [] + : [ + { + ...toPhotoRecord(row), + uploader: { + userId: uploader.id, + account: uploader.account, + displayName: uploader.displayName, + }, + }, + ]; + }); + + const last = page.at(-1); + const nextCursor = + hasMore && last !== undefined + ? encodeKeysetCursor({ + // where 已排除 NULL:断言收窄供类型系统(键集取页末行 DB 位置)。 + sort: (last.uploadedAt ?? new Date(0)).toISOString(), + id: last.id, + }) + : null; + return { items, nextCursor, hasMore: nextCursor !== null }; + } + /** * 调整可见性(契约 updatePhoto):hidden↔published 间切换。权限 → 状态机 → * 条件更新事务(entityVersion+1 + outbox + audit)→ 回读回显。 diff --git a/services/api/test/photos-admin.e2e-spec.ts b/services/api/test/photos-admin.e2e-spec.ts new file mode 100644 index 0000000..bc3c877 --- /dev/null +++ b/services/api/test/photos-admin.e2e-spec.ts @@ -0,0 +1,485 @@ +// services/api/test/photos-admin.e2e-spec.ts —— P1b Task 4 管理照片列表 e2e +// (先写后实现)。真实 app + MySQL。覆盖: +// ① owner GET /albums/{albumId}/photos:全部可见性状态可见(unpublished/ +// published/hidden/deleted,含 uploaded/processing/processing_failed 处理 +// 态),(uploadedAt,id) DESC 稳定倒序(同 uploadedAt 以 id DESC 决胜), +// 条目 = PhotoRecord + uploader{userId,account,displayName} 脱敏内嵌 +// (无邮箱/密码/状态字段;契约 PhotoRecord 不含 errorCode——管理侧对失败 +// 的可见性经 processingState=processing_failed 表达);未完成上传 +// (pending_upload、uploadedAt NULL)不入列表(排序键未就绪,见服务注释); +// 他册照片不入列表; +// ② visibility 过滤(四态逐一 + 非法值 400);categoryId 过滤(含与 +// visibility 组合;幽灵/跨册分类 400); +// ③ 游标分页:limit=3 三页跨页无重复、尾页 hasMore=false;非法 cursor / +// limit 越界 → 400; +// ④ 权限:协作者(非 owner 成员)403 AUTH_FORBIDDEN;外人员工 404 +// ALBUM_NOT_FOUND;公司 admin 200;幽灵相册 404;未认证 401。 +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); + +/** 契约 pattern ^[0-9a-z]{20,32}$:24 字符 [0-9a-z](同 albums.service 生成式)。 */ +function makePublicToken(): string { + const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz'; + let token = ''; + const bytes = randomBytes(24); + for (let i = 0; i < 24; i++) { + token += alphabet[bytes[i] % alphabet.length]; + } + return token; +} + +/** 契约 PhotoRecord 必返字段 + 管理列表内嵌 uploader(形状精确断言用)。 */ +const ADMIN_PHOTO_KEYS = [ + 'albumId', + 'bytes', + 'categoryId', + 'height', + 'likeCount', + 'photoId', + 'processingState', + 'publishedAt', + 'shotAt', + 'uploadedAt', + 'uploader', + 'uploaderId', + 'visibility', + 'width', +].sort(); + +/** 固定时间基(分钟偏移构造确定性 uploadedAt 序列)。 */ +const T0 = Date.UTC(2026, 8, 12, 8, 0, 0); +const atMinute = (m: number): Date => new Date(T0 + m * 60_000); + +describe('管理照片列表 GET /albums/{albumId}/photos (e2e)', () => { + let app: INestApplication; + let prisma: PrismaClient; + + let albumId: string; + let catAId: string; + let catBId: string; + let catOtherId: string; + + let ownerId: string; + let collabId: string; + let ownerToken: string; + let collabToken: string; + let adminToken: string; + let outsiderToken: string; + + // 种子照片(显式 ID + 显式 uploadedAt,期望序可静态推出)。 + let p1: string; // published / collab / catA / t+50 + let p2: string; // published / owner / 无分类 / t+40 + let p3: string; // hidden / collab / catA / t+30 + let p4: string; // deleted / owner / t+20 + let p5: string; // unpublished processing_failed / collab / catB / t+10 + let tieA: string; // unpublished processing / owner / t+0(与 tieB 同刻) + let tieB: string; // unpublished uploaded / collab / catB / t+0(同刻 id 决胜) + + /** 全量无过滤期望序(uploadedAt DESC, id DESC;tie-b > tie-a)。 */ + const FULL_ORDER = () => [p1, p2, p3, p4, p5, tieB, tieA]; + + 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(); + + // 账号:owner / collab(上传协作者)/ admin / outsider(外人员工)。 + // displayName 与 account 不同值,验证 uploader 映射取的是资料字段。 + const mkUser = ( + account: string, + displayName: string, + role = 'staff', + ) => + prisma.users.create({ + data: { account, role, passwordHash: 'x', displayName }, + select: { id: true }, + }); + ownerId = (await mkUser('pa-owner', '潘欧文')).id; + collabId = (await mkUser('pa-collab', '程澜')).id; + const adminId = (await mkUser('pa-admin', '艾管', 'admin')).id; + const outsiderId = (await mkUser('pa-outsider', '韦外')).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; + }; + ownerToken = await mkToken(ownerId); + collabToken = await mkToken(collabId); + adminToken = await mkToken(adminId); + outsiderToken = await mkToken(outsiderId); + + // 主相册:owner + collab 成员行;分类 catA/catB。 + const album = await prisma.albums.create({ + data: { + publicToken: makePublicToken(), + ownerId, + startAt: new Date(), + defaultLocale: 'zh-CN', + }, + select: { id: true }, + }); + albumId = album.id; + await prisma.album_translations.create({ + data: { albumId, locale: 'zh-CN', title: '管理列表相册' }, + }); + await prisma.album_members.createMany({ + data: [ + { albumId, userId: ownerId, role: 'owner', status: 'active' }, + { albumId, userId: collabId, role: 'collaborator', status: 'active' }, + ], + }); + const catA = await prisma.categories.create({ + data: { albumId, sortOrder: 1 }, + select: { id: true }, + }); + const catB = await prisma.categories.create({ + data: { albumId, sortOrder: 2 }, + select: { id: true }, + }); + catAId = catA.id; + catBId = catB.id; + await prisma.category_translations.createMany({ + data: [ + { categoryId: catAId, locale: 'zh-CN', name: '晚宴' }, + { categoryId: catBId, locale: 'zh-CN', name: '后台' }, + ], + }); + + // 他册(跨册分类/照片排除用):owner 名下独立相册 + 分类 + 一张 published。 + const otherAlbum = await prisma.albums.create({ + data: { + publicToken: makePublicToken(), + ownerId, + startAt: new Date(), + defaultLocale: 'zh-CN', + }, + select: { id: true }, + }); + const catOther = await prisma.categories.create({ + data: { albumId: otherAlbum.id, sortOrder: 1 }, + select: { id: true }, + }); + catOtherId = catOther.id; + await seedPhoto({ + id: 'pa-photo-other', + targetAlbumId: otherAlbum.id, + uploaderId: ownerId, + visibility: 'published', + processingState: 'ready', + uploadedAt: atMinute(45), + }); + + // 主册种子:两上传人 × 全可见性状态 × 处理态变体;tie 对同 uploadedAt。 + p1 = await seedPhoto({ + id: 'pa-photo-p1', + targetAlbumId: albumId, + uploaderId: collabId, + visibility: 'published', + processingState: 'ready', + categoryId: catAId, + uploadedAt: atMinute(50), + }); + p2 = await seedPhoto({ + id: 'pa-photo-p2', + targetAlbumId: albumId, + uploaderId: ownerId, + visibility: 'published', + processingState: 'ready', + uploadedAt: atMinute(40), + }); + p3 = await seedPhoto({ + id: 'pa-photo-p3', + targetAlbumId: albumId, + uploaderId: collabId, + visibility: 'hidden', + processingState: 'ready', + categoryId: catAId, + uploadedAt: atMinute(30), + }); + p4 = await seedPhoto({ + id: 'pa-photo-p4', + targetAlbumId: albumId, + uploaderId: ownerId, + visibility: 'deleted', + processingState: 'ready', + uploadedAt: atMinute(20), + }); + p5 = await seedPhoto({ + id: 'pa-photo-p5', + targetAlbumId: albumId, + uploaderId: collabId, + visibility: 'unpublished', + processingState: 'processing_failed', + categoryId: catBId, + uploadedAt: atMinute(10), + }); + tieA = await seedPhoto({ + id: 'pa-photo-tie-a', + targetAlbumId: albumId, + uploaderId: ownerId, + visibility: 'unpublished', + processingState: 'processing', + uploadedAt: atMinute(0), + }); + tieB = await seedPhoto({ + id: 'pa-photo-tie-b', + targetAlbumId: albumId, + uploaderId: collabId, + visibility: 'unpublished', + processingState: 'uploaded', + categoryId: catBId, + uploadedAt: atMinute(0), + }); + + // 未完成上传(init 后未 complete 的会话占位行):uploadedAt NULL → 不入列表。 + await prisma.photos.create({ + data: { + id: 'pa-photo-pending', + albumId, + uploaderId: collabId, + objectKey: `photo-live/dev/albums/${albumId}/originals/pa-photo-pending.jpg`, + visibility: 'unpublished', + processingState: 'pending_upload', + }, + }); + }); + + afterAll(async () => { + await app.close(); + await prisma.$disconnect(); + }); + + /** + * 直种照片行(管理列表不触及对象存储/变体——契约 PhotoRecord 无 URL 字段, + * 故无需 MinIO 对象与 photo_variants 行)。 + */ + async function seedPhoto(spec: { + id: string; + targetAlbumId: string; + uploaderId: string; + visibility: string; + processingState: string; + categoryId?: string; + uploadedAt: Date; + }): Promise { + await prisma.photos.create({ + data: { + id: spec.id, + albumId: spec.targetAlbumId, + uploaderId: spec.uploaderId, + categoryId: spec.categoryId ?? null, + objectKey: `photo-live/dev/albums/${spec.targetAlbumId}/originals/${spec.id}.jpg`, + bytes: 12345, + width: 800, + height: 500, + uploadedAt: spec.uploadedAt, + publishedAt: + spec.visibility === 'unpublished' ? null : new Date(spec.uploadedAt), + processingState: spec.processingState, + visibility: spec.visibility, + entityVersion: 2, + }, + }); + return spec.id; + } + + function listMgmt( + token: string, + query?: Record, + ) { + return request(app.getHttpServer()) + .get(`/${API_PREFIX}/albums/${albumId}/photos`) + .set('Authorization', `Bearer ${token}`) + .query(query ?? {}); + } + + function idsOf(body: { items: { photoId: string }[] }): string[] { + return body.items.map((item) => item.photoId); + } + + it('owner → 200:全状态可见、(uploadedAt,id) DESC、uploader 脱敏内嵌正确', async () => { + const res = await listMgmt(ownerToken); + expect(res.status).toBe(200); + expect(Object.keys(res.body).sort()).toEqual([ + 'hasMore', + 'items', + 'nextCursor', + ]); + expect(res.body.hasMore).toBe(false); + expect(res.body.nextCursor).toBeNull(); + + // 全状态 + 处理态变体全部可见;未完成上传(NULL uploadedAt)与他册照片排除。 + expect(idsOf(res.body)).toEqual(FULL_ORDER()); + expect(idsOf(res.body)).not.toContain('pa-photo-pending'); + expect(idsOf(res.body)).not.toContain('pa-photo-other'); + + // 条目形状 = PhotoRecord 全字段 + uploader(无任何凭据/状态冗余字段)。 + expect(Object.keys(res.body.items[0]).sort()).toEqual(ADMIN_PHOTO_KEYS); + + // uploader 内嵌:两上传人各自映射到 account/displayName(脱敏资料字段)。 + const byId = new Map( + (res.body.items as { + photoId: string; + uploader: { userId: string; account: string; displayName: string }; + processingState: string; + publishedAt: string | null; + categoryId: string | null; + }[]).map((item) => [item.photoId, item]), + ); + for (const item of byId.values()) { + expect(item.uploader).toEqual( + item.uploader.userId === ownerId + ? { userId: ownerId, account: 'pa-owner', displayName: '潘欧文' } + : { userId: collabId, account: 'pa-collab', displayName: '程澜' }, + ); + } + // 处理态可见性(管理侧失败洞察):processing_failed / processing / uploaded。 + expect(byId.get(p5)?.processingState).toBe('processing_failed'); + expect(byId.get(tieA)?.processingState).toBe('processing'); + expect(byId.get(tieB)?.processingState).toBe('uploaded'); + // 未发布 publishedAt=null(与 published 行对照);分类映射。 + expect(byId.get(p5)?.publishedAt).toBeNull(); + expect(byId.get(p1)?.publishedAt).not.toBeNull(); + expect(byId.get(p1)?.categoryId).toBe(catAId); + expect(byId.get(p2)?.categoryId).toBeNull(); + }); + + it('visibility 过滤:published/unpublished/hidden/deleted 逐一命中;非法值 → 400', async () => { + const published = await listMgmt(ownerToken, { visibility: 'published' }); + expect(published.status).toBe(200); + expect(idsOf(published.body)).toEqual([p1, p2]); + + const unpublished = await listMgmt(ownerToken, { + visibility: 'unpublished', + }); + expect(unpublished.status).toBe(200); + expect(idsOf(unpublished.body)).toEqual([p5, tieB, tieA]); + + const hidden = await listMgmt(ownerToken, { visibility: 'hidden' }); + expect(hidden.status).toBe(200); + expect(idsOf(hidden.body)).toEqual([p3]); + + const deleted = await listMgmt(ownerToken, { visibility: 'deleted' }); + expect(deleted.status).toBe(200); + expect(idsOf(deleted.body)).toEqual([p4]); + + for (const garbage of ['public', 'PUBLISHED', '42']) { + const res = await listMgmt(ownerToken, { visibility: garbage }); + expect(res.status).toBe(400); + expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED); + } + }); + + it('categoryId 过滤与组合;幽灵/跨册 categoryId → 400', async () => { + const catA = await listMgmt(ownerToken, { categoryId: catAId }); + expect(catA.status).toBe(200); + expect(idsOf(catA.body)).toEqual([p1, p3]); + + const catB = await listMgmt(ownerToken, { categoryId: catBId }); + expect(catB.status).toBe(200); + expect(idsOf(catB.body)).toEqual([p5, tieB]); + + const combined = await listMgmt(ownerToken, { + visibility: 'published', + categoryId: catAId, + }); + expect(combined.status).toBe(200); + expect(idsOf(combined.body)).toEqual([p1]); + + const ghost = await listMgmt(ownerToken, { categoryId: 'no-such-cat' }); + expect(ghost.status).toBe(400); + expect(ghost.body.code).toBe(ErrorCodes.VALIDATION_FAILED); + + // 跨册分类:真实存在但不属于本相册——同码 400(不区分两种失败,防探测)。 + const cross = await listMgmt(ownerToken, { categoryId: catOtherId }); + expect(cross.status).toBe(400); + expect(cross.body.code).toBe(ErrorCodes.VALIDATION_FAILED); + }); + + it('游标分页:limit=3 三页跨页无重复,尾页 hasMore=false / nextCursor=null', async () => { + const seen: string[] = []; + let cursor: string | undefined; + let pages = 0; + for (;;) { + const res = await listMgmt( + ownerToken, + cursor === undefined ? { limit: 3 } : { limit: 3, cursor }, + ); + expect(res.status).toBe(200); + pages += 1; + seen.push(...idsOf(res.body)); + if (res.body.hasMore === false) { + expect(res.body.nextCursor).toBeNull(); + expect(pages).toBe(3); + break; + } + expect(res.body.nextCursor).toEqual(expect.any(String)); + cursor = res.body.nextCursor as string; + } + expect(seen).toEqual(FULL_ORDER()); + expect(new Set(seen).size).toBe(seen.length); // 无重复 + }); + + it('非法 cursor / limit 越界 → 400 VALIDATION_FAILED', async () => { + const badCursor = await listMgmt(ownerToken, { cursor: 'not-a-cursor!' }); + expect(badCursor.status).toBe(400); + expect(badCursor.body.code).toBe(ErrorCodes.VALIDATION_FAILED); + + for (const limit of [0, 101, 'abc']) { + const res = await listMgmt(ownerToken, { limit }); + expect(res.status).toBe(400); + expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED); + } + // 合法界内:1 与 100 均 200。 + expect((await listMgmt(ownerToken, { limit: 1 })).status).toBe(200); + expect((await listMgmt(ownerToken, { limit: 100 })).status).toBe(200); + }); + + it('权限:collab 403 / outsider 404 / admin 200 / 幽灵相册 404 / 未认证 401', async () => { + const collabRes = await listMgmt(collabToken); + expect(collabRes.status).toBe(403); + expect(collabRes.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN); + + const outsiderRes = await listMgmt(outsiderToken); + expect(outsiderRes.status).toBe(404); + expect(outsiderRes.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND); + + const adminRes = await listMgmt(adminToken, { visibility: 'deleted' }); + expect(adminRes.status).toBe(200); + expect(idsOf(adminRes.body)).toEqual([p4]); + + const ghost = await request(app.getHttpServer()) + .get(`/${API_PREFIX}/albums/no-such-album/photos`) + .set('Authorization', `Bearer ${ownerToken}`); + expect(ghost.status).toBe(404); + expect(ghost.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND); + + const anon = await request(app.getHttpServer()) + .get(`/${API_PREFIX}/albums/${albumId}/photos`); + expect(anon.status).toBe(401); + expect(anon.body.code).toBe(ErrorCodes.AUTH_TOKEN_EXPIRED); + }); +}); diff --git a/services/api/test/users-lookup.e2e-spec.ts b/services/api/test/users-lookup.e2e-spec.ts new file mode 100644 index 0000000..d2bad2c --- /dev/null +++ b/services/api/test/users-lookup.e2e-spec.ts @@ -0,0 +1,247 @@ +// 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 => { + 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 升序;禁用/待激活/锁定排除;条目与响应形状脱敏", 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); + }); +});