feat(perms): canRead/canEdit with ancestor walk + tests
This commit is contained in:
63
packages/backend/src/services/permissions.test.ts
Normal file
63
packages/backend/src/services/permissions.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { makeTestDb, createUserDirect, createLessonOwnedBy, subscribeUserToLesson } from '../tests/dbHelper.js';
|
||||
import { canEditLesson, canReadLesson } from './permissions.js';
|
||||
|
||||
let env: ReturnType<typeof makeTestDb>;
|
||||
beforeEach(() => { env = makeTestDb(); });
|
||||
|
||||
describe('permissions', () => {
|
||||
it('owner can edit and read', async () => {
|
||||
const u = await createUserDirect(env.db, { email: 'o@example.com' });
|
||||
const l = await createLessonOwnedBy(env.db, u.id, { name: 'L' });
|
||||
expect(await canEditLesson(env.db, u.id, l.id)).toBe(true);
|
||||
expect(await canReadLesson(env.db, u.id, l.id)).toBe(true);
|
||||
});
|
||||
|
||||
it('non-owner cannot edit a private lesson', async () => {
|
||||
const owner = await createUserDirect(env.db, { email: 'o@example.com' });
|
||||
const other = await createUserDirect(env.db, { email: 'x@example.com' });
|
||||
const l = await createLessonOwnedBy(env.db, owner.id, { name: 'L' });
|
||||
expect(await canEditLesson(env.db, other.id, l.id)).toBe(false);
|
||||
expect(await canReadLesson(env.db, other.id, l.id)).toBe(false);
|
||||
});
|
||||
|
||||
it('subscriber can read but not edit', async () => {
|
||||
const owner = await createUserDirect(env.db, { email: 'o@example.com' });
|
||||
const sub = await createUserDirect(env.db, { email: 's@example.com' });
|
||||
const l = await createLessonOwnedBy(env.db, owner.id, { name: 'L', visibility: 'shared' });
|
||||
await subscribeUserToLesson(env.db, sub.id, l.id);
|
||||
expect(await canReadLesson(env.db, sub.id, l.id)).toBe(true);
|
||||
expect(await canEditLesson(env.db, sub.id, l.id)).toBe(false);
|
||||
});
|
||||
|
||||
it('subscriber gains read access to sublessons via ancestor', async () => {
|
||||
const owner = await createUserDirect(env.db, { email: 'o@example.com' });
|
||||
const sub = await createUserDirect(env.db, { email: 's@example.com' });
|
||||
const parent = await createLessonOwnedBy(env.db, owner.id, { name: 'P', visibility: 'shared' });
|
||||
const child = await createLessonOwnedBy(env.db, owner.id, { name: 'C', parentId: parent.id });
|
||||
await subscribeUserToLesson(env.db, sub.id, parent.id);
|
||||
expect(await canReadLesson(env.db, sub.id, child.id)).toBe(true);
|
||||
});
|
||||
|
||||
it('curated lesson is readable for everyone without subscription', async () => {
|
||||
const owner = await createUserDirect(env.db, { email: 'o@example.com' });
|
||||
const other = await createUserDirect(env.db, { email: 'x@example.com' });
|
||||
const l = await createLessonOwnedBy(env.db, owner.id, { name: 'L', visibility: 'shared', isCurated: true });
|
||||
expect(await canReadLesson(env.db, other.id, l.id)).toBe(true);
|
||||
expect(await canEditLesson(env.db, other.id, l.id)).toBe(false);
|
||||
});
|
||||
|
||||
it('curated lesson grants read on descendants', async () => {
|
||||
const owner = await createUserDirect(env.db, { email: 'o@example.com' });
|
||||
const other = await createUserDirect(env.db, { email: 'x@example.com' });
|
||||
const parent = await createLessonOwnedBy(env.db, owner.id, { name: 'P', visibility: 'shared', isCurated: true });
|
||||
const child = await createLessonOwnedBy(env.db, owner.id, { name: 'C', parentId: parent.id });
|
||||
expect(await canReadLesson(env.db, other.id, child.id)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for unknown lesson id', async () => {
|
||||
const u = await createUserDirect(env.db, { email: 'u@example.com' });
|
||||
expect(await canReadLesson(env.db, u.id, 9999)).toBe(false);
|
||||
expect(await canEditLesson(env.db, u.id, 9999)).toBe(false);
|
||||
});
|
||||
});
|
||||
72
packages/backend/src/services/permissions.ts
Normal file
72
packages/backend/src/services/permissions.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
import type { Db } from '../db/client.js';
|
||||
import { lessons, lessonSubscriptions } from '../db/schema.js';
|
||||
|
||||
interface AncestorRow {
|
||||
id: number;
|
||||
parentId: number | null;
|
||||
ownerId: number | null;
|
||||
visibility: 'private' | 'shared';
|
||||
isCurated: boolean;
|
||||
}
|
||||
|
||||
async function walkAncestors(db: Db, lessonId: number): Promise<AncestorRow[]> {
|
||||
const path: AncestorRow[] = [];
|
||||
let cursor: number | null = lessonId;
|
||||
const seen = new Set<number>();
|
||||
while (cursor !== null && !seen.has(cursor)) {
|
||||
seen.add(cursor);
|
||||
const row = db.select({
|
||||
id: lessons.id,
|
||||
parentId: lessons.parentId,
|
||||
ownerId: lessons.ownerId,
|
||||
visibility: lessons.visibility,
|
||||
isCurated: lessons.isCurated,
|
||||
}).from(lessons).where(eq(lessons.id, cursor)).get();
|
||||
if (!row) break;
|
||||
path.push({
|
||||
id: row.id,
|
||||
parentId: row.parentId ?? null,
|
||||
ownerId: row.ownerId ?? null,
|
||||
visibility: row.visibility,
|
||||
isCurated: row.isCurated,
|
||||
});
|
||||
cursor = row.parentId ?? null;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
export async function canEditLesson(db: Db, userId: number, lessonId: number): Promise<boolean> {
|
||||
const row = db.select({ ownerId: lessons.ownerId }).from(lessons).where(eq(lessons.id, lessonId)).get();
|
||||
if (!row) return false;
|
||||
return row.ownerId === userId;
|
||||
}
|
||||
|
||||
export async function canReadLesson(db: Db, userId: number, lessonId: number): Promise<boolean> {
|
||||
const ancestors = await walkAncestors(db, lessonId);
|
||||
if (ancestors.length === 0) return false;
|
||||
|
||||
for (const a of ancestors) {
|
||||
if (a.ownerId === userId) return true;
|
||||
if (a.isCurated && a.visibility === 'shared') return true;
|
||||
}
|
||||
|
||||
const ids = ancestors.map((a) => a.id);
|
||||
const sub = db.select({ id: lessonSubscriptions.id })
|
||||
.from(lessonSubscriptions)
|
||||
.where(and(eq(lessonSubscriptions.userId, userId), inArray(lessonSubscriptions.lessonId, ids)))
|
||||
.get();
|
||||
return !!sub;
|
||||
}
|
||||
|
||||
export async function getLessonAccessFlags(
|
||||
db: Db, userId: number, lessonId: number
|
||||
): Promise<{ canEdit: boolean; isOwner: boolean; isSubscribed: boolean }> {
|
||||
const row = db.select({ ownerId: lessons.ownerId }).from(lessons).where(eq(lessons.id, lessonId)).get();
|
||||
const isOwner = !!row && row.ownerId === userId;
|
||||
const isSubscribed = !!db.select({ id: lessonSubscriptions.id })
|
||||
.from(lessonSubscriptions)
|
||||
.where(and(eq(lessonSubscriptions.userId, userId), eq(lessonSubscriptions.lessonId, lessonId)))
|
||||
.get();
|
||||
return { canEdit: isOwner, isOwner, isSubscribed };
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
|
||||
import { resolve } from 'node:path';
|
||||
import { createDb, type Db } from '../db/client.js';
|
||||
import { users } from '../db/schema.js';
|
||||
import type { UserRow } from '../db/schema.js';
|
||||
import { users, lessons, lessonSubscriptions } from '../db/schema.js';
|
||||
import type { UserRow, LessonRow } from '../db/schema.js';
|
||||
|
||||
export function makeTestDb(): { db: Db; close: () => void } {
|
||||
const { db, sqlite } = createDb(':memory:');
|
||||
@@ -24,3 +24,24 @@ export async function createUserDirect(
|
||||
}).returning().all();
|
||||
return row!;
|
||||
}
|
||||
|
||||
export async function createLessonOwnedBy(
|
||||
db: Db,
|
||||
ownerId: number,
|
||||
init: { name?: string; parentId?: number | null; visibility?: 'private' | 'shared'; isCurated?: boolean; bidirectional?: boolean } = {}
|
||||
): Promise<LessonRow> {
|
||||
const [row] = db.insert(lessons).values({
|
||||
name: init.name ?? 'Test lesson',
|
||||
parentId: init.parentId ?? null,
|
||||
ownerId,
|
||||
visibility: init.visibility ?? 'private',
|
||||
isCurated: init.isCurated ?? false,
|
||||
bidirectional: init.bidirectional ?? false,
|
||||
position: 0,
|
||||
}).returning().all();
|
||||
return row!;
|
||||
}
|
||||
|
||||
export async function subscribeUserToLesson(db: Db, userId: number, lessonId: number): Promise<void> {
|
||||
db.insert(lessonSubscriptions).values({ userId, lessonId }).run();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user