Files
photo-live/services/api/test/permissions.spec.ts
T
浅念 2d9b9f9fad feat(api): server-side album role permission checks
Task 14 (A02 foundation): PermissionsService with fresh-DB-per-call
canViewAlbum/canManageAlbum/canUploadToAlbum/canManagePhoto over the
album_members @@unique([albumId,userId]) compound key; admin bypass,
owner/collaborator matrix per requirements doc section 2, removed
members lose access on the very next call (no caching). Boolean-only
returns; callers map 404/403. AlbumsModule shell wired into app module
(controllers arrive in Task 15). jest maxWorkers=1: DB-backed specs
share one MySQL database and reset it via TRUNCATE, so parallel spec
files wipe each other's seed data.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-17 21:34:22 +08:00

226 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);
});
});