diff --git a/AGENTS.md b/AGENTS.md index 86846e02..e3730468 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,6 +77,17 @@ from `showDriverStation` to make that possible. See `docs/decisions/041-project-preview.md` and [`docs/using-coderunner.md`](./docs/using-coderunner.md). +**Classroom join codes (post-V2):** admins start a short-lived classroom +(Admin → Classrooms) with a 6-digit code; students join at `/join` with the +code and their name and become guest users (`user.classroomId` set, role +`student`, no allowlist). A custom Better Auth plugin +(`auth/classroom-plugin.ts`) mints the session; `app/classroom-routes.ts` +rate-limits failed attempts and retires the browser's previous guest +session. Guest sessions die with the classroom (checked in +`getSessionFromRequest`), guests have a **Leave** button that stops their +container, and `ClassroomSweeper` deletes guests of ended/expired +classrooms. See `docs/decisions/043-classroom-join-codes.md`. + **Containerized control plane (post-V2):** the control plane ships as a Docker image (`containers/control/Dockerfile` → `ghcr.io/mathewdunne/coderunner-control`) and is deployed with docker compose (`docker-compose.yml` base + @@ -134,7 +145,7 @@ arch-independent). See `docs/decisions/035-multi-arch-images-and-workflow-split. ## Key References - `docs/` + `website/` — docs site content and Docusaurus config; published at `https://mathewdunne.github.io/CodeRunner/`; run `bun run docs:dev` to browse locally, `bun run docs:build` to build. -- `docs/decisions/` — all architecture decision logs (011–041 active; 001–010 archived under `docs/decisions/archive/`). +- `docs/decisions/` — all architecture decision logs (011–043 active; 001–010 archived under `docs/decisions/archive/`). - Pinned AdvantageScope submodule: `vendor/AdvantageScope` at tag `v26.0.2`. ## Commands diff --git a/apps/control/migrations/012_classrooms.sql b/apps/control/migrations/012_classrooms.sql new file mode 100644 index 00000000..1faf3b1c --- /dev/null +++ b/apps/control/migrations/012_classrooms.sql @@ -0,0 +1,17 @@ +-- Classroom join codes (decision 043). Guest users point at a classroom via +-- the Better Auth user.classroomId column, which Better Auth itself adds +-- (declared as an additionalField) — the user table does not exist yet when +-- these migrations run. +CREATE TABLE classrooms ( + id TEXT PRIMARY KEY, + code TEXT NOT NULL, + created_by TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + ended_at TEXT, + cleaned_at TEXT +); + +-- Codes are unique only among live classrooms; "live" depends on now(), so +-- uniqueness is enforced at creation time rather than by a constraint. +CREATE INDEX idx_classrooms_code ON classrooms(code); diff --git a/apps/control/src/__tests__/classroom-admin.test.ts b/apps/control/src/__tests__/classroom-admin.test.ts new file mode 100644 index 00000000..b26eff23 --- /dev/null +++ b/apps/control/src/__tests__/classroom-admin.test.ts @@ -0,0 +1,376 @@ +import { describe, expect, test } from "bun:test"; +import { dirname } from "node:path"; +import type { ControlApp } from "../app"; +import { ClassroomSweeper } from "../classroom-sweeper"; +import { createClassroom, getClassroom } from "../classrooms"; +import { deleteUserAndWorkspace } from "../user-deletion"; +import { + allCookiesFrom, + classroomJoinRequest, + cookieFrom, + exists, + login, + openRunSocket, + sessionCookieFrom, + withApp, +} from "./helpers"; + +type ClassroomView = { + id: string; + code: string; + expiresAt: string; + joinUrl: string; + guests: { displayName: string; slug: string | null }[]; +}; + +async function adminCookie(app: ControlApp): Promise { + return cookieFrom(await login(app, "coach", { role: "admin" })); +} + +function adminRequest( + cookie: string, + path: string, + init: { method?: string; body?: unknown } = {}, +): Request { + return new Request(`http://localhost${path}`, { + method: init.method ?? "GET", + headers: { cookie, "content-type": "application/json" }, + body: init.body === undefined ? null : JSON.stringify(init.body), + }); +} + +async function joinGuest(app: ControlApp, code: string, name: string) { + const response = await app.fetch(classroomJoinRequest({ code, name })); + expect(response.status).toBe(200); + return (await response.json()) as { userId: string }; +} + +function sweeperFor(app: ControlApp): ClassroomSweeper { + return new ClassroomSweeper({ + storage: app.storage, + deleteUser: (userId) => + deleteUserAndWorkspace( + { + storage: app.storage, + runs: app.runs, + runtimeProvider: app.runtime, + closeWorkspaceSockets: app.websocket.closeWorkspaceSockets, + }, + userId, + ), + }); +} + +describe("admin classroom routes", () => { + test("admin starts a classroom with the default 4 hours", async () => { + await withApp(async (app) => { + const cookie = await adminCookie(app); + const before = Date.now(); + const response = await app.fetch( + adminRequest(cookie, "/admin/classrooms", { method: "POST", body: {} }), + ); + expect(response.status).toBe(201); + const { classroom } = (await response.json()) as { + classroom: ClassroomView; + }; + expect(classroom.code).toMatch(/^\d{6}$/u); + expect(classroom.joinUrl).toBe( + `http://localhost:4000/join?code=${classroom.code}`, + ); + const minutes = + (new Date(classroom.expiresAt).getTime() - before) / 60_000; + expect(minutes).toBeGreaterThan(239.9); + expect(minutes).toBeLessThan(240.1); + const audit = app.storage.db + .query("SELECT COUNT(*) AS count FROM audit_log WHERE action = ?") + .get("classroom.create") as { count: number }; + expect(audit.count).toBe(1); + }); + }); + + test("rejects durations outside 60–720 whole minutes", async () => { + await withApp(async (app) => { + const cookie = await adminCookie(app); + for (const durationMinutes of [30, 721, 90.5, "240"]) { + const response = await app.fetch( + adminRequest(cookie, "/admin/classrooms", { + method: "POST", + body: { durationMinutes }, + }), + ); + expect(response.status).toBe(400); + } + }); + }); + + test("lists live classrooms with their guests", async () => { + await withApp(async (app) => { + const cookie = await adminCookie(app); + const classroom = createClassroom(app.storage.db, { + createdBy: "x", + durationMinutes: 60, + }); + await joinGuest(app, classroom.code, "Alex D"); + const response = await app.fetch( + adminRequest(cookie, "/admin/classrooms"), + ); + expect(response.status).toBe(200); + const { classrooms } = (await response.json()) as { + classrooms: ClassroomView[]; + }; + expect(classrooms.map((c) => c.id)).toEqual([classroom.id]); + expect(classrooms[0]?.guests).toMatchObject([ + { displayName: "Alex D", slug: "alex-d" }, + ]); + }); + }); + + test("End now deletes guests, their workspaces and files, and hides the classroom", async () => { + await withApp(async (app) => { + const cookie = await adminCookie(app); + const classroom = createClassroom(app.storage.db, { + createdBy: "x", + durationMinutes: 60, + }); + const { userId } = await joinGuest(app, classroom.code, "Alex D"); + const workspace = app.storage.findWorkspaceByUserId(userId)!; + expect(await exists(dirname(workspace.project_path))).toBe(true); + const run = openRunSocket(app, workspace); + + const response = await app.fetch( + adminRequest(cookie, `/admin/classrooms/${classroom.id}/end`, { + method: "POST", + }), + ); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ ok: true, guestCount: 1 }); + expect(run.closes).toHaveLength(1); + + const user = app.storage.db + .query("SELECT id FROM user WHERE id = ?") + .get(userId); + expect(user).toBeNull(); + expect(app.storage.findWorkspaceByUserId(userId)).toBeNull(); + expect(await exists(dirname(workspace.project_path))).toBe(false); + const row = getClassroom(app.storage.db, classroom.id); + expect(row?.ended_at).not.toBeNull(); + expect(row?.cleaned_at).not.toBeNull(); + + const list = await app.fetch(adminRequest(cookie, "/admin/classrooms")); + expect( + ((await list.json()) as { classrooms: ClassroomView[] }).classrooms, + ).toEqual([]); + }); + }); + + test("ending an unknown classroom is a 404", async () => { + await withApp(async (app) => { + const cookie = await adminCookie(app); + const response = await app.fetch( + adminRequest(cookie, "/admin/classrooms/cls_nope/end", { + method: "POST", + }), + ); + expect(response.status).toBe(404); + }); + }); + + test("students and classroom guests cannot use admin routes", async () => { + await withApp(async (app) => { + const student = cookieFrom(await login(app, "alice")); + expect( + (await app.fetch(adminRequest(student, "/admin/classrooms"))).status, + ).toBe(403); + + const classroom = createClassroom(app.storage.db, { + createdBy: "x", + durationMinutes: 60, + }); + const joined = await app.fetch( + classroomJoinRequest({ code: classroom.code, name: "Alex" }), + ); + const guest = sessionCookieFrom(joined); + expect( + (await app.fetch(adminRequest(guest, "/admin/classrooms"))).status, + ).toBe(403); + expect( + (await app.fetch(adminRequest(guest, "/admin/status"))).status, + ).toBe(403); + }); + }); + + test("classroom guests cannot be promoted to admin", async () => { + await withApp(async (app) => { + const cookie = await adminCookie(app); + const classroom = createClassroom(app.storage.db, { + createdBy: "x", + durationMinutes: 60, + }); + const { userId } = await joinGuest(app, classroom.code, "Alex"); + const response = await app.fetch( + adminRequest(cookie, `/admin/users/${userId}/promote`, { + method: "POST", + }), + ); + expect(response.status).toBe(409); + const row = app.storage.db + .query("SELECT role FROM user WHERE id = ?") + .get(userId) as { role: string }; + expect(row.role).toBe("student"); + }); + }); + + test("a guest whose row says admin still cannot use admin routes", async () => { + await withApp(async (app) => { + const classroom = createClassroom(app.storage.db, { + createdBy: "x", + durationMinutes: 60, + }); + const { userId } = await joinGuest(app, classroom.code, "Alex"); + app.storage.db + .query("UPDATE user SET role = 'admin' WHERE id = ?") + .run(userId); + // Rejoin so the session cookie cache is built from the admin row. + const rejoined = await app.fetch( + classroomJoinRequest({ + code: classroom.code, + name: "Alex", + confirmExisting: true, + }), + ); + expect(rejoined.status).toBe(200); + const guest = allCookiesFrom(rejoined); + expect( + (await app.fetch(adminRequest(guest, "/admin/classrooms"))).status, + ).toBe(403); + }); + }); + + test("End now during an in-flight join leaves no guest or workspace behind", async () => { + await withApp(async (app) => { + const cookie = await adminCookie(app); + const classroom = createClassroom(app.storage.db, { + createdBy: "x", + durationMinutes: 60, + }); + const workspaceCount = () => + ( + app.storage.db + .query("SELECT COUNT(*) AS count FROM workspaces") + .get() as { count: number } + ).count; + const workspacesBefore = workspaceCount(); + + // Pause the join inside createUser, after its liveness check. + const { internalAdapter } = await app.storage.auth.$context; + const createUser = internalAdapter.createUser; + let entered!: () => void; + const inCreateUser = new Promise((resolve) => { + entered = resolve; + }); + let release!: () => void; + const released = new Promise((resolve) => { + release = resolve; + }); + internalAdapter.createUser = (async (...args) => { + entered(); + await released; + return createUser.apply(internalAdapter, args); + }) as typeof createUser; + + const join = app.fetch( + classroomJoinRequest({ code: classroom.code, name: "Alex" }), + ); + await inCreateUser; + const end = app.fetch( + adminRequest(cookie, `/admin/classrooms/${classroom.id}/end`, { + method: "POST", + }), + ); + await Bun.sleep(20); + release(); + const [joined, ended] = await Promise.all([join, end]); + + expect(joined.status).toBe(404); + expect(ended.status).toBe(200); + expect( + app.storage.db + .query("SELECT id FROM user WHERE classroomId = ?") + .all(classroom.id), + ).toEqual([]); + expect(workspaceCount()).toBe(workspacesBefore); + expect( + getClassroom(app.storage.db, classroom.id)?.cleaned_at, + ).not.toBeNull(); + }); + }); +}); + +describe("ClassroomSweeper", () => { + test("deletes guests of expired classrooms once, and leaves live ones alone", async () => { + await withApp(async (app) => { + const expired = createClassroom(app.storage.db, { + createdBy: "x", + durationMinutes: 60, + }); + const live = createClassroom(app.storage.db, { + createdBy: "x", + durationMinutes: 60, + }); + await joinGuest(app, expired.code, "Alex"); + await joinGuest(app, expired.code, "Blake"); + const keeper = await joinGuest(app, live.code, "Casey"); + app.storage.db + .query("UPDATE classrooms SET expires_at = ? WHERE id = ?") + .run(new Date(Date.now() - 1000).toISOString(), expired.id); + + const sweeper = sweeperFor(app); + expect(await sweeper.sweep()).toEqual([expired.id]); + const remaining = app.storage.db + .query("SELECT id FROM user WHERE classroomId IS NOT NULL") + .all() as { id: string }[]; + expect(remaining.map((row) => row.id)).toEqual([keeper.userId]); + expect( + getClassroom(app.storage.db, expired.id)?.cleaned_at, + ).not.toBeNull(); + + expect(await sweeper.sweep()).toEqual([]); + const audit = app.storage.db + .query("SELECT metadata_json FROM audit_log WHERE action = ?") + .all("classroom.cleanup") as { metadata_json: string }[]; + expect(audit.map((row) => JSON.parse(row.metadata_json))).toEqual([ + { guestCount: 2 }, + ]); + }); + }); + + test("skips a sweep while the previous one is still running", async () => { + await withApp(async (app) => { + const expired = createClassroom(app.storage.db, { + createdBy: "x", + durationMinutes: 60, + }); + await joinGuest(app, expired.code, "Alex"); + app.storage.db + .query("UPDATE classrooms SET expires_at = ? WHERE id = ?") + .run(new Date(Date.now() - 1000).toISOString(), expired.id); + + let release = () => {}; + const deletes: string[] = []; + const sweeper = new ClassroomSweeper({ + storage: app.storage, + deleteUser: async (userId) => { + deletes.push(userId); + await new Promise((resolve) => { + release = resolve; + }); + }, + }); + const first = sweeper.sweep(); + expect(await sweeper.sweep()).toEqual([]); + release(); + expect(await first).toEqual([expired.id]); + expect(deletes.length).toBe(1); + }); + }); +}); diff --git a/apps/control/src/__tests__/classroom-dispatch.test.ts b/apps/control/src/__tests__/classroom-dispatch.test.ts new file mode 100644 index 00000000..83ea3105 --- /dev/null +++ b/apps/control/src/__tests__/classroom-dispatch.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, test } from "bun:test"; +import type { WorkspaceId } from "@frc-coderunner/contracts"; +import type { ControlApp } from "../app"; +import { createClassroom } from "../classrooms"; +import { + classroomJoinRequest, + cookieFrom, + login, + sessionCookieFrom, + withApp, +} from "./helpers"; + +function startClassroom(app: ControlApp) { + return createClassroom(app.storage.db, { + createdBy: "test-admin", + durationMinutes: 240, + }); +} + +/** Record stopWorkspace calls instead of talking to Docker. */ +function spyOnStops(app: ControlApp): WorkspaceId[] { + const stopped: WorkspaceId[] = []; + app.runtime.stopWorkspace = async (workspaceId) => { + stopped.push(workspaceId); + }; + return stopped; +} + +describe("classroom join dispatcher", () => { + test("locks out one IP after 10 bad codes without affecting other IPs", async () => { + await withApp(async (app) => { + const classroom = startClassroom(app); + const attacker = { "x-forwarded-for": "203.0.113.5" }; + for (let i = 0; i < 10; i += 1) { + const response = await app.fetch( + classroomJoinRequest({ code: "000000", name: "Bot" }, attacker), + ); + expect(response.status).toBe(404); + } + const blocked = await app.fetch( + classroomJoinRequest({ code: classroom.code, name: "Bot" }, attacker), + ); + expect(blocked.status).toBe(429); + expect(await blocked.json()).toMatchObject({ code: "RATE_LIMITED" }); + + const student = await app.fetch( + classroomJoinRequest( + { code: classroom.code, name: "Alex" }, + { "x-forwarded-for": "203.0.113.6" }, + ), + ); + expect(student.status).toBe(200); + }); + }); + + test("a parallel burst of bad codes is limited like a sequential one", async () => { + await withApp(async (app) => { + const attacker = { "x-forwarded-for": "203.0.113.7" }; + const responses = await Promise.all( + Array.from({ length: 30 }, () => + app.fetch( + classroomJoinRequest({ code: "000000", name: "Bot" }, attacker), + ), + ), + ); + const statuses = responses.map((response) => response.status); + expect(statuses.filter((status) => status === 404)).toHaveLength(10); + expect(statuses.filter((status) => status === 429)).toHaveLength(20); + }); + }); + + test("uses the rightmost X-Forwarded-For hop (the one our proxy appended)", async () => { + await withApp(async (app) => { + for (let i = 0; i < 10; i += 1) { + await app.fetch( + classroomJoinRequest( + { code: "000000", name: "Bot" }, + { "x-forwarded-for": `198.51.100.${i}, 203.0.113.9` }, + ), + ); + } + const blocked = await app.fetch( + classroomJoinRequest( + { code: "000000", name: "Bot" }, + { "x-forwarded-for": "198.51.100.200, 203.0.113.9" }, + ), + ); + expect(blocked.status).toBe(429); + }); + }); + + test("successful joins from one school IP are never rate limited", async () => { + await withApp(async (app) => { + const classroom = startClassroom(app); + const school = { "x-forwarded-for": "203.0.113.10" }; + for (let i = 0; i < 15; i += 1) { + const response = await app.fetch( + classroomJoinRequest( + { code: classroom.code, name: `Student ${i}` }, + school, + ), + ); + expect(response.status).toBe(200); + } + }); + }); + + test("joining over another guest's session retires it and stops that guest's container", async () => { + await withApp(async (app) => { + const stopped = spyOnStops(app); + const classroom = startClassroom(app); + const first = await app.fetch( + classroomJoinRequest({ code: classroom.code, name: "Alex" }), + ); + const firstBody = (await first.json()) as { userId: string }; + const firstCookie = sessionCookieFrom(first); + const firstWorkspace = app.storage.findWorkspaceByUserId( + firstBody.userId, + ); + + const second = await app.fetch( + classroomJoinRequest( + { code: classroom.code, name: "Blake" }, + { cookie: firstCookie }, + ), + ); + expect(second.status).toBe(200); + + expect(stopped).toEqual([firstWorkspace!.id]); + const firstSessions = app.storage.db + .query("SELECT COUNT(*) AS count FROM session WHERE userId = ?") + .get(firstBody.userId) as { count: number }; + expect(firstSessions.count).toBe(0); + // Alex's work is kept for a rejoin. + expect( + app.storage.findWorkspaceByUserId(firstBody.userId), + ).not.toBeNull(); + }); + }); + + test("a failed stop of the previous guest's container still completes the join", async () => { + await withApp(async (app) => { + app.runtime.stopWorkspace = async () => { + throw new Error("docker stop failed"); + }; + const classroom = startClassroom(app); + const first = await app.fetch( + classroomJoinRequest({ code: classroom.code, name: "Alex" }), + ); + const second = await app.fetch( + classroomJoinRequest( + { code: classroom.code, name: "Blake" }, + { cookie: sessionCookieFrom(first) }, + ), + ); + expect(second.status).toBe(200); + expect(sessionCookieFrom(second)).toMatch(/^coderunner_session=.+/u); + }); + }); + + test("a guest rejoining over their own session keeps their container", async () => { + await withApp(async (app) => { + const stopped = spyOnStops(app); + const classroom = startClassroom(app); + const first = await app.fetch( + classroomJoinRequest({ code: classroom.code, name: "Alex" }), + ); + const again = await app.fetch( + classroomJoinRequest( + { code: classroom.code, name: "Alex", confirmExisting: true }, + { cookie: sessionCookieFrom(first) }, + ), + ); + expect(again.status).toBe(200); + expect(stopped).toEqual([]); + }); + }); + + test("joining from a coach's signed-in browser replaces the session but leaves the coach alone", async () => { + await withApp(async (app) => { + const stopped = spyOnStops(app); + const coach = await login(app, "coach", { role: "admin" }); + const classroom = startClassroom(app); + const response = await app.fetch( + classroomJoinRequest( + { code: classroom.code, name: "Alex" }, + { cookie: cookieFrom(coach) }, + ), + ); + expect(response.status).toBe(200); + expect(stopped).toEqual([]); + const coachRow = app.storage.db + .query("SELECT role FROM user WHERE email = ?") + .get("coach@test.local") as { role: string } | null; + expect(coachRow?.role).toBe("admin"); + }); + }); + + test("is disabled in demo mode", async () => { + await withApp( + async (app) => { + const response = await app.fetch( + classroomJoinRequest({ code: "123456", name: "Alex" }), + ); + expect(response.status).toBe(404); + expect(await response.json()).toMatchObject({ code: "DISABLED" }); + }, + { demo: true }, + ); + }); + + test("GET /join serves the web shell", async () => { + await withApp(async (app) => { + const response = await app.fetch(new Request("http://localhost/join")); + expect(response.status).toBe(200); + expect(await response.text()).toContain("V2 test shell"); + }); + }); +}); diff --git a/apps/control/src/__tests__/classroom-join.test.ts b/apps/control/src/__tests__/classroom-join.test.ts new file mode 100644 index 00000000..bf3dd60e --- /dev/null +++ b/apps/control/src/__tests__/classroom-join.test.ts @@ -0,0 +1,517 @@ +import { describe, expect, test } from "bun:test"; +import { + sessionResponseSchema, + type WorkspaceId, +} from "@frc-coderunner/contracts"; +import type { ControlApp } from "../app"; +import { + CLASSROOM_GUEST_CAP, + createClassroom, + endClassroom, +} from "../classrooms"; +import { + allCookiesFrom, + classroomJoinRequest, + cookieFrom, + login, + openRunSocket, + sessionCookieFrom, + withApp, +} from "./helpers"; + +function startClassroom(app: ControlApp) { + return createClassroom(app.storage.db, { + createdBy: "test-admin", + durationMinutes: 240, + }); +} + +type GuestUser = { + id: string; + name: string; + email: string; + role: string; + slug: string; + classroomId: string; + guestNameKey: string; +}; + +function guestsOf(app: ControlApp, classroomId: string): GuestUser[] { + return app.storage.db + .query( + "SELECT id, name, email, role, slug, classroomId, guestNameKey FROM user WHERE classroomId = ?", + ) + .all(classroomId) as GuestUser[]; +} + +describe("POST /api/auth/classroom/join", () => { + test("creates a guest user, workspace, and a session that ends with the classroom", async () => { + await withApp(async (app) => { + // withApp starts with an empty allowlist, which blocks every OAuth + // sign-in — a successful join proves guests bypass it. + const classroom = startClassroom(app); + const response = await app.fetch( + classroomJoinRequest({ code: classroom.code, name: " Alex D " }), + ); + expect(response.status).toBe(200); + const body = (await response.json()) as { ok: boolean; userId: string }; + expect(body.ok).toBe(true); + + const [guest, ...rest] = guestsOf(app, classroom.id); + expect(rest).toEqual([]); + expect(guest?.id).toBe(body.userId); + expect(guest?.name).toBe("Alex D"); + expect(guest?.email).toEndWith("@classroom.invalid"); + expect(guest?.role).toBe("student"); + expect(guest?.slug).toBe("alex-d"); + expect(guest?.guestNameKey).toBe("alexd"); + + expect(app.storage.findWorkspaceByUserId(body.userId)?.slug).toBe( + "alex-d", + ); + + const session = app.storage.db + .query("SELECT expiresAt FROM session WHERE userId = ?") + .get(body.userId) as { expiresAt: string }; + expect( + Math.abs( + new Date(session.expiresAt).getTime() - + new Date(classroom.expires_at).getTime(), + ), + ).toBeLessThan(1000); + + const cookie = sessionCookieFrom(response); + const sessionResponse = await app.fetch( + new Request("http://localhost/u/alex-d/api/session", { + headers: { cookie }, + }), + ); + expect(sessionResponse.status).toBe(200); + + const audit = app.storage.db + .query("SELECT action FROM audit_log WHERE action = ?") + .all("classroom.guest_join"); + expect(audit.length).toBe(1); + }); + }); + + test("same name asks for confirmation, then reuses the same guest", async () => { + await withApp(async (app) => { + const classroom = startClassroom(app); + const first = await app.fetch( + classroomJoinRequest({ code: classroom.code, name: "Alex D" }), + ); + expect(first.status).toBe(200); + + const second = await app.fetch( + classroomJoinRequest({ code: classroom.code, name: "alex d." }), + ); + expect(second.status).toBe(409); + expect(await second.json()).toMatchObject({ + code: "NAME_TAKEN", + displayName: "Alex D", + }); + + const third = await app.fetch( + classroomJoinRequest({ + code: classroom.code, + name: "alex d.", + confirmExisting: true, + }), + ); + expect(third.status).toBe(200); + + const guests = guestsOf(app, classroom.id); + expect(guests.length).toBe(1); + const sessions = app.storage.db + .query("SELECT COUNT(*) AS count FROM session WHERE userId = ?") + .get(guests[0]?.id ?? "") as { count: number }; + expect(sessions.count).toBe(2); + }); + }); + + test("two simultaneous joins with the same name create exactly one guest", async () => { + await withApp(async (app) => { + const classroom = startClassroom(app); + const responses = await Promise.all([ + app.fetch(classroomJoinRequest({ code: classroom.code, name: "Sam" })), + app.fetch(classroomJoinRequest({ code: classroom.code, name: "Sam" })), + ]); + expect(responses.map((r) => r.status).sort()).toEqual([200, 409]); + expect(guestsOf(app, classroom.id).length).toBe(1); + }); + }); + + test("a name with no Latin letters still gets a valid workspace slug", async () => { + await withApp(async (app) => { + const classroom = startClassroom(app); + const response = await app.fetch( + classroomJoinRequest({ code: classroom.code, name: "李雷" }), + ); + expect(response.status).toBe(200); + const [guest] = guestsOf(app, classroom.id); + expect(guest?.guestNameKey).toBe("李雷"); + expect(guest?.slug).toMatch(/^student(-\d+)?$/u); + }); + }); + + test("many non-Latin names each get a distinct student slug", async () => { + await withApp(async (app) => { + const classroom = startClassroom(app); + // More than the 16 numbered candidates (student, student-1 … student-15). + const names = Array.from({ length: 18 }, (_, i) => + String.fromCodePoint(0x674e, 0x4e00 + i), + ); + for (const name of names) { + const response = await app.fetch( + classroomJoinRequest({ code: classroom.code, name }), + ); + expect(response.status).toBe(200); + } + const slugs = guestsOf(app, classroom.id).map((guest) => guest.slug); + expect(slugs.length).toBe(names.length); + for (const slug of slugs) expect(slug).toMatch(/^student(-\d+)?$/u); + expect(new Set(slugs).size).toBe(names.length); + }); + }, 30_000); + + test("unknown, expired, and ended codes are rejected the same way", async () => { + await withApp(async (app) => { + const unknown = await app.fetch( + classroomJoinRequest({ code: "000000", name: "Alex" }), + ); + expect(unknown.status).toBe(404); + expect(await unknown.json()).toMatchObject({ code: "INVALID_CODE" }); + + const expired = startClassroom(app); + app.storage.db + .query("UPDATE classrooms SET expires_at = ? WHERE id = ?") + .run(new Date(Date.now() - 1000).toISOString(), expired.id); + const expiredResponse = await app.fetch( + classroomJoinRequest({ code: expired.code, name: "Alex" }), + ); + expect(expiredResponse.status).toBe(404); + + const ended = startClassroom(app); + endClassroom(app.storage.db, ended.id); + const endedResponse = await app.fetch( + classroomJoinRequest({ code: ended.code, name: "Alex" }), + ); + expect(endedResponse.status).toBe(404); + }); + }); + + test("malformed input is a 400 with INVALID_INPUT", async () => { + await withApp(async (app) => { + const classroom = startClassroom(app); + for (const body of [ + { code: "12345", name: "Alex" }, + { code: classroom.code, name: "" }, + { code: classroom.code, name: "