diff --git a/packages/backend/src/services/permissions.test.ts b/packages/backend/src/services/permissions.test.ts new file mode 100644 index 0000000..c37a18c --- /dev/null +++ b/packages/backend/src/services/permissions.test.ts @@ -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; +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); + }); +}); diff --git a/packages/backend/src/services/permissions.ts b/packages/backend/src/services/permissions.ts new file mode 100644 index 0000000..0befb4d --- /dev/null +++ b/packages/backend/src/services/permissions.ts @@ -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 { + const path: AncestorRow[] = []; + let cursor: number | null = lessonId; + const seen = new Set(); + 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 { + 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 { + 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 }; +} diff --git a/packages/backend/src/tests/dbHelper.ts b/packages/backend/src/tests/dbHelper.ts index d3c4f28..a64aa0d 100644 --- a/packages/backend/src/tests/dbHelper.ts +++ b/packages/backend/src/tests/dbHelper.ts @@ -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 { + 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 { + db.insert(lessonSubscriptions).values({ userId, lessonId }).run(); +}