2d9b9f9fad
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>
226 lines
10 KiB
TypeScript
226 lines
10 KiB
TypeScript
// 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);
|
||
});
|
||
});
|