feat(perms): canRead/canEdit with ancestor walk + tests

This commit is contained in:
2026-05-21 00:07:05 +02:00
parent 262ac8b162
commit 66363b8094
3 changed files with 158 additions and 2 deletions

View 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);
});
});

View 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 };
}

View File

@@ -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();
}