// services/api/test/permissions.spec.ts —— 相册角色权限服务集成测试(Task 14, // 真实 MySQL,经 reset.ts 隔离,同 seed.spec.ts 的直连 harness 模式)。 // 权限矩阵来源:docs/01-需求说明书.md §2 角色与权限表(A02:协作者不能修改 // 他人照片,创建者不能越权其他相册)。 // 种子矩阵:admin(公司管理员,无任何成员行)、ownerA(albumA1 创建者)、 // collabB(albumA1 协作者,同时是 albumB1 创建者——验证同一人跨相册角色独立)、 // outsiderC(无任何成员关系)、removedD(曾是 albumA1 协作者,测试中删除成员行)。 // 关键断言组: // 1) canViewAlbum / canManageAlbum / canUploadToAlbum 的角色矩阵; // 2) canManagePhoto:创建者管全册、协作者只管自己上传的、跨相册不越权; // 3) 逐次查库(A02):删除成员行后,同一服务实例的下一次调用即刻变 false // ——不重新实例化服务,证明无跨调用缓存。 // 服务返回纯 boolean;404/403 映射是调用方(Task 15 控制器)的职责,此处只测 // true/false 语义。 import { PrismaService } from '../src/prisma/prisma.service'; import { PermissionsService } from '../src/albums/permissions.service'; import type { AuthUser } from '../src/auth/session.guard'; import { resetDb } from './reset'; /** 把 users 行映射成 SessionGuard 挂载的 req.user 形状(AuthUser)。 */ function asAuthUser(row: { id: string; account: string; role: string; locale: string | null; }): AuthUser { return { id: row.id, account: row.account, role: row.role, locale: row.locale }; } describe('PermissionsService (real DB, album role matrix)', () => { let prisma: PrismaService; let service: PermissionsService; let admin: AuthUser; let ownerA: AuthUser; let collabB: AuthUser; let outsiderC: AuthUser; let removedD: AuthUser; let albumA1: { id: string }; let albumB1: { id: string }; let photoA1ByOwner: { id: string }; let photoA1ByCollab: { id: string }; let photoA1ByRemoved: { id: string }; let photoB1ByCollab: { id: string }; beforeAll(async () => { await resetDb(); prisma = new PrismaService(); await prisma.$connect(); service = new PermissionsService(prisma); const mkUser = (account: string, role = 'staff') => prisma.users.create({ data: { account, role, passwordHash: 'x', displayName: account }, select: { id: true, account: true, role: true, locale: true }, }); admin = asAuthUser(await mkUser('perm-admin', 'admin')); ownerA = asAuthUser(await mkUser('perm-owner-a')); collabB = asAuthUser(await mkUser('perm-collab-b')); outsiderC = asAuthUser(await mkUser('perm-outsider-c')); removedD = asAuthUser(await mkUser('perm-removed-d')); const mkAlbum = (token: string, ownerId: string) => prisma.albums.create({ data: { publicToken: token, ownerId, startAt: new Date('2026-09-01T00:00:00Z'), defaultLocale: 'zh-CN', }, select: { id: true }, }); albumA1 = await mkAlbum('perm-token-a1', ownerA.id); albumB1 = await mkAlbum('perm-token-b1', collabB.id); await prisma.album_members.createMany({ data: [ { albumId: albumA1.id, userId: ownerA.id, role: 'owner' }, { albumId: albumA1.id, userId: collabB.id, role: 'collaborator' }, // removedD 的行在「逐次查库」用例中删除——先种上以证明“曾是成员”。 { albumId: albumA1.id, userId: removedD.id, role: 'collaborator' }, { albumId: albumB1.id, userId: collabB.id, role: 'owner' }, ], }); const mkPhoto = (albumId: string, uploaderId: string, key: string) => prisma.photos.create({ data: { albumId, uploaderId, objectKey: key }, select: { id: true }, }); photoA1ByOwner = await mkPhoto(albumA1.id, ownerA.id, 'perm/a1-by-owner.jpg'); photoA1ByCollab = await mkPhoto(albumA1.id, collabB.id, 'perm/a1-by-collab.jpg'); photoA1ByRemoved = await mkPhoto(albumA1.id, removedD.id, 'perm/a1-by-removed.jpg'); photoB1ByCollab = await mkPhoto(albumB1.id, collabB.id, 'perm/b1-by-collab.jpg'); }); afterAll(async () => { await prisma.$disconnect(); }); it('admin passes every check with no membership row at all', async () => { await expect( service.canViewAlbum(admin, albumA1.id), ).resolves.toBe(true); await expect( service.canManageAlbum(admin, albumA1.id), ).resolves.toBe(true); await expect( service.canUploadToAlbum(admin, albumA1.id), ).resolves.toBe(true); await expect( service.canManagePhoto(admin, photoA1ByOwner.id), ).resolves.toBe(true); }); it('album owner can view, manage, and upload to own album', async () => { await expect(service.canViewAlbum(ownerA, albumA1.id)).resolves.toBe(true); await expect(service.canManageAlbum(ownerA, albumA1.id)).resolves.toBe(true); await expect(service.canUploadToAlbum(ownerA, albumA1.id)).resolves.toBe(true); }); it('collaborator can view and upload but not manage (配置相册/管理成员)', async () => { await expect(service.canViewAlbum(collabB, albumA1.id)).resolves.toBe(true); await expect(service.canUploadToAlbum(collabB, albumA1.id)).resolves.toBe(true); await expect(service.canManageAlbum(collabB, albumA1.id)).resolves.toBe(false); }); it('outsider gets false everywhere (caller maps to 404-able denial)', async () => { await expect(service.canViewAlbum(outsiderC, albumA1.id)).resolves.toBe(false); await expect(service.canManageAlbum(outsiderC, albumA1.id)).resolves.toBe(false); await expect(service.canUploadToAlbum(outsiderC, albumA1.id)).resolves.toBe(false); await expect( service.canManagePhoto(outsiderC, photoA1ByOwner.id), ).resolves.toBe(false); }); it('album roles do not leak across albums (A02): ownerA has no rights in albumB1', async () => { await expect(service.canViewAlbum(ownerA, albumB1.id)).resolves.toBe(false); await expect(service.canManageAlbum(ownerA, albumB1.id)).resolves.toBe(false); await expect(service.canUploadToAlbum(ownerA, albumB1.id)).resolves.toBe(false); // 同一人在相册 A 是协作者、在相册 B 是创建者——B 中具备管理权。 await expect(service.canManageAlbum(collabB, albumB1.id)).resolves.toBe(true); }); it('canManagePhoto: owner manages every photo in own album', async () => { await expect( service.canManagePhoto(ownerA, photoA1ByOwner.id), ).resolves.toBe(true); await expect( service.canManagePhoto(ownerA, photoA1ByCollab.id), ).resolves.toBe(true); }); it('canManagePhoto: collaborator manages only photos they uploaded', async () => { await expect( service.canManagePhoto(collabB, photoA1ByCollab.id), ).resolves.toBe(true); // 他人上传的照片(即便是同一相册)不可管理。 await expect( service.canManagePhoto(collabB, photoA1ByOwner.id), ).resolves.toBe(false); }); it('canManagePhoto: membership in one album grants nothing in another', async () => { // ownerA 是 albumA1 创建者,但 albumB1 的照片与其无关。 await expect( service.canManagePhoto(ownerA, photoB1ByCollab.id), ).resolves.toBe(false); // collabB 在 albumB1 是创建者 → 可管理(既是 owner 也是上传者)。 await expect( service.canManagePhoto(collabB, photoB1ByCollab.id), ).resolves.toBe(true); }); it('canManagePhoto: nonexistent photo yields false, not a throw', async () => { await expect( service.canManagePhoto(collabB, 'no-such-photo-id'), ).resolves.toBe(false); }); it('reflects member removal on the very next call, same service instance (逐次查库)', async () => { // 删除前:removedD 是 albumA1 的有效协作者。 await expect(service.canViewAlbum(removedD, albumA1.id)).resolves.toBe(true); await expect(service.canUploadToAlbum(removedD, albumA1.id)).resolves.toBe(true); // 连续两次调用都应查库(第二次仍 true,不能因“刚查过”而走缓存)。 await expect(service.canUploadToAlbum(removedD, albumA1.id)).resolves.toBe(true); // 有效协作者可管理自己上传的照片(§2「管理照片:自己上传的」)。 await expect( service.canManagePhoto(removedD, photoA1ByRemoved.id), ).resolves.toBe(true); // 移除成员行(需求 §2:协作者被移除后不得继续操作——行删除即移除)。 await prisma.album_members.deleteMany({ where: { albumId: albumA1.id, userId: removedD.id }, }); // 不重新实例化 service:下一次调用必须即刻反映移除。 await expect(service.canViewAlbum(removedD, albumA1.id)).resolves.toBe(false); await expect(service.canUploadToAlbum(removedD, albumA1.id)).resolves.toBe(false); await expect(service.canManageAlbum(removedD, albumA1.id)).resolves.toBe(false); // 被移除的协作者不能再管理自己上传的照片(“当时是成员”不构成既得权限)。 await expect( service.canManagePhoto(removedD, photoA1ByRemoved.id), ).resolves.toBe(false); await expect( service.canManagePhoto(removedD, photoA1ByCollab.id), ).resolves.toBe(false); // 管理员与不相干用户不受影响。 await expect(service.canViewAlbum(admin, albumA1.id)).resolves.toBe(true); await expect(service.canViewAlbum(outsiderC, albumA1.id)).resolves.toBe(false); }); it('collaborator membership row with non-active status denies (status is checked)', async () => { // 软移除预留语义:status != active 的行视同无权限(当前移除=删行,行为一致)。 const { id } = await prisma.album_members.create({ data: { albumId: albumB1.id, userId: outsiderC.id, role: 'collaborator', status: 'revoked' }, select: { id: true }, }); await expect(service.canViewAlbum(outsiderC, albumB1.id)).resolves.toBe(false); await expect(service.canUploadToAlbum(outsiderC, albumB1.id)).resolves.toBe(false); await expect(service.canManageAlbum(outsiderC, albumB1.id)).resolves.toBe(false); await prisma.album_members.delete({ where: { id } }).catch(() => undefined); }); });