fix(ai): lessonUrl builder in one place; authoring follows core canView
- Learner links go through lessonUrl/courseUrl (src/web/urls.ts). - Generation, the studio course list and file listing require an authoring right and core canView, so a global author needs a grant for a restricted course. Contract requests 8 (global author in canView) and 9 (STC_AI_ALLOWED_BASE_URLS in AppConfig). Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ead7327bb1
commit
f3ce2e34bf
@@ -218,3 +218,29 @@ forEachDb("provider errors on a user's own key show the status and a fixed messa
|
||||
const t2 = await (await call("POST", "/api/ai/courses/sample/chat", "owner", { message: "hello" })).text();
|
||||
expect(t2).toContain("502: INTERNAL-SECRET-BODY");
|
||||
});
|
||||
|
||||
forEachDb("authoring uses core canView: a global author needs a grant for a restricted course", async (db) => {
|
||||
const { call } = await setup(db);
|
||||
await call("PUT", "/api/ai/admin/config", "owner", { provider: { kind: "anthropic", apiKey: "k" }, mcpServers: [] });
|
||||
const body = { courseId: "secret", kind: "questions", style: "pure-snake", topic: "Adding", sources: [{ kind: "lesson", lessonId: "m2-text" }] };
|
||||
const courses = async (u: string) => ((await (await call("GET", "/api/ai/studio/courses", u)).json()) as { id: string }[]).map((c) => c.id).sort();
|
||||
// Public course: the author role is enough. Restricted course: not without a grant.
|
||||
expect(await courses("auth")).toEqual(["sample"]);
|
||||
expect((await call("GET", "/api/ai/studio/sources?courseId=secret", "auth")).status).toBe(403);
|
||||
expect((await call("POST", "/api/ai/studio/generate", "auth", body)).status).toBe(403);
|
||||
// The owner sees everything.
|
||||
expect(await courses("owner")).toEqual(["sample", "secret"]);
|
||||
expect((await call("POST", "/api/ai/studio/generate", "owner", body)).status).toBe(202);
|
||||
// An ACL grant (core canView) lets the global author in.
|
||||
await db.sql`INSERT INTO course_acl (course_id, principal_type, principal_id) VALUES ('secret', 'user', 'auth')`;
|
||||
expect(await courses("auth")).toEqual(["sample", "secret"]);
|
||||
expect((await call("POST", "/api/ai/studio/generate", "auth", body)).status).toBe(202);
|
||||
// A course_authors row makes a learner an author of that course only.
|
||||
expect((await call("POST", "/api/ai/studio/generate", "bob", body)).status).toBe(403);
|
||||
await db.sql`INSERT INTO course_authors (course_id, user_id) VALUES ('secret', 'bob')`;
|
||||
expect(await courses("bob")).toEqual(["secret"]);
|
||||
expect((await call("POST", "/api/ai/studio/generate", "bob", body)).status).toBe(202);
|
||||
// A grant to view alone does not make a learner an author.
|
||||
await db.sql`INSERT INTO course_acl (course_id, principal_type, principal_id) VALUES ('secret', 'user', 'ann')`;
|
||||
expect((await call("POST", "/api/ai/studio/generate", "ann", body)).status).toBe(403);
|
||||
});
|
||||
|
||||
@@ -34,3 +34,13 @@ test("web routes", () => {
|
||||
expect(aiWeb.routes.map((r) => r.path)).toEqual(["/c/:courseId/assistant", "/admin/ai", "/account/ai", "/studio/generate"]);
|
||||
expect(aiWeb.routes.find((r) => r.path === "/admin/ai")!.nav).toEqual({ label: "AI", area: "admin" });
|
||||
});
|
||||
|
||||
test("lesson links go through one builder", async () => {
|
||||
const { lessonUrl, courseUrl } = await import("../src/web/urls");
|
||||
expect(lessonUrl("sample", "m2-ex-add")).toBe("/c/sample/m2-ex-add");
|
||||
expect(lessonUrl("S08 - Docker", "l 1/x")).toBe("/c/S08%20-%20Docker/l%201%2Fx");
|
||||
expect(courseUrl("sample")).toBe("/c/sample");
|
||||
const src = await Bun.file(new URL("../src/web/AssistantPage.tsx", import.meta.url)).text();
|
||||
expect(src).toContain("lessonUrl(courseId, c.lessonId)");
|
||||
expect(src).not.toMatch(/`\/c\//);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user