diff --git a/src/lib/server/instance.test.ts b/src/lib/server/instance.test.ts index e88ad9c3..c23cc756 100644 --- a/src/lib/server/instance.test.ts +++ b/src/lib/server/instance.test.ts @@ -1,6 +1,12 @@ import { createClient } from '@libsql/client'; import { expect, it } from 'vitest'; -import { createInstance, InstanceAlreadySetUpError } from './instance.ts'; +import type { Db } from './db.ts'; +import { + createInstance, + InstanceAlreadySetUpError, + InvalidRegistrationModeError, + RegistrationNotAvailableError +} from './instance.ts'; import { createTestDb } from './test-db.ts'; const migrations = import.meta.glob('/prisma/migrations/*/migration.sql', { @@ -164,3 +170,311 @@ it.each([ expect(results.map((r) => r.status).sort()).toEqual(['fulfilled', 'rejected']); expect((await db.instance.findFirstOrThrow()).mode).toBe('multi'); }); + +// setRegistrationMode: the admin's toggle (C34b). The database is written first, memory after. + +/** A database whose Instance writes (and, when named, reads) can be held, failed or delayed, to pin the order of the two. */ +function interceptWrites( + db: Db, + intercept: (args: unknown, write: () => Promise) => Promise, + methods: string[] = ['updateMany'] +): Db { + return new Proxy(db, { + get(target, prop) { + const value = Reflect.get(target, prop); + if (prop !== 'instance') return value; + return new Proxy(value, { + get(delegate, method) { + const member = Reflect.get(delegate, method); + if (typeof method !== 'string' || !methods.includes(method)) return member; + return (args: unknown) => intercept(args, () => member.call(delegate, args)); + } + }); + } + }); +} + +const stored = async (db: Db) => + (await db.instance.findUniqueOrThrow({ where: { id: 'instance' } })).registrationMode; + +async function multiInstance() { + const db = await createTestDb(); + const instance = await createInstance(db); + await instance.chooseMulti(); + return { db, instance }; +} + +it('setRegistrationMode switches a Multi-user Instance both ways, in memory and in the database', async () => { + const { db, instance } = await multiInstance(); + + await instance.setRegistrationMode('open'); + + expect(instance.registrationMode()).toBe('open'); + expect(await stored(db)).toBe('open'); + expect((await createInstance(db)).registrationMode()).toBe('open'); + + await instance.setRegistrationMode('invite'); + + expect(instance.registrationMode()).toBe('invite'); + expect(await stored(db)).toBe('invite'); + expect((await createInstance(db)).registrationMode()).toBe('invite'); +}); + +it('setRegistrationMode keeps the mode it was given when set again', async () => { + const { instance } = await multiInstance(); + await instance.setRegistrationMode('open'); + + await instance.setRegistrationMode('open'); + + expect(instance.registrationMode()).toBe('open'); +}); + +it('setRegistrationMode writes the database before it changes memory', async () => { + const { db } = await multiInstance(); + let release: () => void = () => {}; + const held = new Promise((resolve) => { + release = resolve; + }); + const instance = await createInstance( + interceptWrites(db, async (_, write) => { + await held; + return write(); + }) + ); + + const switching = instance.setRegistrationMode('open'); + await new Promise((resolve) => setTimeout(resolve, 20)); + + // The write has not finished: memory and database both still say invite-only. + expect(instance.registrationMode()).toBe('invite'); + expect(await stored(db)).toBe('invite'); + + release(); + await switching; + + expect(instance.registrationMode()).toBe('open'); + expect(await stored(db)).toBe('open'); +}); + +it('a failed write leaves memory as it was, so a failure never leaves it Open over an Invite-only database', async () => { + const { db } = await multiInstance(); + const instance = await createInstance( + interceptWrites(db, async () => { + throw new Error('disk full'); + }) + ); + + await expect(instance.setRegistrationMode('open')).rejects.toThrow('disk full'); + + expect(instance.registrationMode()).toBe('invite'); + expect(await stored(db)).toBe('invite'); +}); + +it('a failed write back to Invite-only does not claim it succeeded', async () => { + const { db } = await multiInstance(); + let failing = false; + const instance = await createInstance( + interceptWrites(db, async (_, write) => { + if (failing) throw new Error('disk full'); + return write(); + }) + ); + await instance.setRegistrationMode('open'); + failing = true; + + await expect(instance.setRegistrationMode('invite')).rejects.toThrow('disk full'); + + // Memory still matches the database (Open); the caller was told the switch did not happen. + expect(instance.registrationMode()).toBe('open'); + expect(await stored(db)).toBe('open'); +}); + +it.each(['everyone', 'OPEN', 'Invite', '', 'open ', 'invite-only', undefined, null, 1, {}])( + 'setRegistrationMode refuses %j and changes nothing', + async (value) => { + const { db, instance } = await multiInstance(); + await instance.setRegistrationMode('open'); + + await expect(instance.setRegistrationMode(value as never)).rejects.toThrow( + InvalidRegistrationModeError + ); + + expect(instance.registrationMode()).toBe('open'); + expect(await stored(db)).toBe('open'); + } +); + +it('setRegistrationMode refuses in Local mode and writes nothing', async () => { + const db = await createTestDb(); + const instance = await createInstance(db); + await instance.chooseLocal(); + + await expect(instance.setRegistrationMode('open')).rejects.toThrow(RegistrationNotAvailableError); + + expect(instance.registrationMode()).toBe('invite'); + expect(await stored(db)).toBe('invite'); +}); + +it('setRegistrationMode refuses before the Instance is set up and creates no row', async () => { + const db = await createTestDb(); + const instance = await createInstance(db); + + await expect(instance.setRegistrationMode('open')).rejects.toThrow(RegistrationNotAvailableError); + + expect(instance.registrationMode()).toBe('invite'); + expect(await db.instance.count()).toBe(0); +}); + +it('setRegistrationMode on a module that thinks the Instance is multi, over a database that is not, writes nothing', async () => { + const { db, instance } = await multiInstance(); + // Someone else undid the switch behind this module's back. + await db.instance.update({ where: { id: 'instance' }, data: { mode: 'local' } }); + + await expect(instance.setRegistrationMode('open')).rejects.toThrow(RegistrationNotAvailableError); + + expect(instance.mode()).toBe('local'); + expect(instance.registrationMode()).toBe('invite'); + expect(await stored(db)).toBe('invite'); +}); + +it('two switches at once end with memory and database agreeing, the later call winning', async () => { + const { db } = await multiInstance(); + // The first write reaches the database but its answer is slow, so the second could overtake it. + let first = true; + const instance = await createInstance( + interceptWrites(db, async (_, write) => { + const result = await write(); + if (first) { + first = false; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + return result; + }) + ); + + await Promise.all([instance.setRegistrationMode('open'), instance.setRegistrationMode('invite')]); + + expect(await stored(db)).toBe('invite'); + expect(instance.registrationMode()).toBe('invite'); +}); + +it('a switch that fails does not stop the next one', async () => { + const { db } = await multiInstance(); + let failNext = true; + const instance = await createInstance( + interceptWrites(db, async (_, write) => { + if (failNext) { + failNext = false; + throw new Error('disk full'); + } + return write(); + }) + ); + + const results = await Promise.allSettled([ + instance.setRegistrationMode('open'), + instance.setRegistrationMode('open') + ]); + + expect(results.map((r) => r.status)).toEqual(['rejected', 'fulfilled']); + expect(instance.registrationMode()).toBe('open'); + expect(await stored(db)).toBe('open'); +}); + +it('a write that commits and then rejects leaves memory on what the database holds, and still rejects', async () => { + const { db } = await multiInstance(); + let faulty = false; + const instance = await createInstance( + interceptWrites(db, async (_, write) => { + const result = await write(); + // The row is changed, then the driver reports a fault: the caller cannot tell it was written. + if (faulty) throw new Error('driver fault after commit'); + return result; + }) + ); + await instance.setRegistrationMode('open'); + faulty = true; + + await expect(instance.setRegistrationMode('invite')).rejects.toThrow('driver fault after commit'); + + // Memory follows the database (Invite-only), not the Open it held before: it never fails open. + expect(await stored(db)).toBe('invite'); + expect(instance.registrationMode()).toBe('invite'); +}); + +it('a write that commits Open and then rejects leaves memory Open, as the database is', async () => { + const { db } = await multiInstance(); + const instance = await createInstance( + interceptWrites(db, async (_, write) => { + await write(); + throw new Error('driver fault after commit'); + }) + ); + + await expect(instance.setRegistrationMode('open')).rejects.toThrow('driver fault after commit'); + + expect(await stored(db)).toBe('open'); + expect(instance.registrationMode()).toBe('open'); +}); + +it('a failed write whose re-read fails too leaves memory Invite-only, never Open', async () => { + const { db } = await multiInstance(); + let faulty = false; + const instance = await createInstance( + interceptWrites( + db, + async (_, write) => { + if (faulty) throw new Error('disk gone'); + return write(); + }, + ['updateMany', 'findUnique'] + ) + ); + await instance.setRegistrationMode('open'); + faulty = true; + + await expect(instance.setRegistrationMode('invite')).rejects.toThrow('disk gone'); + + // Nothing could be read to say what the database holds (it still says Open here): closed is the safe guess. + expect(instance.registrationMode()).toBe('invite'); + expect(await stored(db)).toBe('open'); +}); + +it('after a failed switch the next one still works', async () => { + const { db } = await multiInstance(); + let faulty = false; + const instance = await createInstance( + interceptWrites( + db, + async (_, write) => { + if (faulty) throw new Error('disk gone'); + return write(); + }, + ['updateMany', 'findUnique'] + ) + ); + faulty = true; + await instance.setRegistrationMode('invite').catch(() => {}); + faulty = false; + + await instance.setRegistrationMode('open'); + + expect(instance.registrationMode()).toBe('open'); + expect(await stored(db)).toBe('open'); +}); + +it.each(['everyone', 'OPEN', '', ' open'])( + 'a stored Registration Mode of %j is read as invite-only at start', + async (value) => { + const db = await createTestDb(); + await db.instance.create({ data: { mode: 'multi', registrationMode: value } }); + + const instance = await createInstance(db); + + expect(instance.registrationMode()).toBe('invite'); + // And the unreadable value does not stop an admin from choosing one. + await instance.setRegistrationMode('open'); + expect(instance.registrationMode()).toBe('open'); + expect(await stored(db)).toBe('open'); + } +); diff --git a/src/lib/server/instance.ts b/src/lib/server/instance.ts index 78468b65..83fcca2b 100644 --- a/src/lib/server/instance.ts +++ b/src/lib/server/instance.ts @@ -8,6 +8,15 @@ export interface Instance { /** The stored mode, or null when the Instance is not set up yet. */ mode(): InstanceMode | null; registrationMode(): RegistrationMode; + /** + * Switches the Registration Mode of a Multi-user Instance. The database is written first and memory + * changes only once the write has succeeded, so a failure never leaves memory Open over an Invite-only + * database. If a switch fails after all (a driver can fail after it commits), memory is set to what the + * database holds, or to invite-only when that cannot be read, and the error is still thrown. Takes effect + * on the next sign-up, with no restart. Throws `InvalidRegistrationModeError` for anything but 'open' or + * 'invite', and `RegistrationNotAvailableError` in Local mode or before Setup. + */ + setRegistrationMode(next: RegistrationMode): Promise; /** Sets the Instance up as Local. Throws if it is already set up; never overwrites. */ chooseLocal(): Promise; /** @@ -24,6 +33,24 @@ export class InstanceAlreadySetUpError extends Error { } } +export class InvalidRegistrationModeError extends Error { + constructor() { + super("The Registration Mode is 'open' or 'invite'."); + this.name = 'InvalidRegistrationModeError'; + } +} + +export class RegistrationNotAvailableError extends Error { + constructor() { + super('Only a Multi-user Instance has a Registration Mode to switch.'); + this.name = 'RegistrationNotAvailableError'; + } +} + +/** Whether a value read from outside (a form, a row) is one of the two Registration Modes. */ +export const isRegistrationMode = (value: unknown): value is RegistrationMode => + value === 'open' || value === 'invite'; + const isUniqueViolation = (error: unknown) => error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002'; @@ -31,7 +58,26 @@ const isUniqueViolation = (error: unknown) => export async function createInstance(db: Db): Promise { const row = await db.instance.findUnique({ where: { id: 'instance' } }); let mode = (row?.mode as InstanceMode | undefined) ?? null; - const registration = (row?.registrationMode as RegistrationMode | undefined) ?? 'invite'; + // A stored value that is neither mode (a hand edit, a bad migration) reads as invite-only: unknown is closed. + let registration: RegistrationMode = isRegistrationMode(row?.registrationMode) + ? row.registrationMode + : 'invite'; + /** Registration switches run one at a time, so the last one to start is the last to write and the one memory keeps. */ + let switching: Promise = Promise.resolve(); + + /** + * What the database holds, for after a switch that failed: the caller cannot tell whether the write + * landed (a driver can fail after it commits), so memory follows the row, not the old value. When the + * row cannot be read either, or holds something that is not a mode, invite-only: never fail open. + */ + const storedRegistration = async (): Promise => { + try { + const stored = await db.instance.findUnique({ where: { id: 'instance' } }); + return isRegistrationMode(stored?.registrationMode) ? stored.registrationMode : 'invite'; + } catch { + return 'invite'; + } + }; /** A refused claim means another writer got there first: adopt the stored mode instead of a stale cache. */ const refuse = async (): Promise => { @@ -43,6 +89,32 @@ export async function createInstance(db: Db): Promise { return { mode: () => mode, registrationMode: () => registration, + setRegistrationMode(next) { + // Before the queue: a refused value must not wait behind, or be hidden by, another switch. + if (!isRegistrationMode(next)) return Promise.reject(new InvalidRegistrationModeError()); + const run = switching.then(async () => { + try { + if (mode !== 'multi') throw new RegistrationNotAvailableError(); + // The row's mode is the claim, as in chooseMulti: nothing is written unless it is still multi. + const { count } = await db.instance.updateMany({ + where: { id: 'instance', mode: 'multi' }, + data: { registrationMode: next } + }); + if (count !== 1) { + const stored = await db.instance.findUnique({ where: { id: 'instance' } }); + mode = (stored?.mode as InstanceMode | undefined) ?? null; + throw new RegistrationNotAvailableError(); + } + registration = next; + } catch (error) { + // Whatever went wrong, memory ends on what the database holds, and the caller still sees the failure. + registration = await storedRegistration(); + throw error; + } + }); + switching = run.catch(() => {}); + return run; + }, async chooseLocal() { if (mode !== null) throw new InstanceAlreadySetUpError(); try { diff --git a/src/routes/users/+page.server.ts b/src/routes/users/+page.server.ts index e3f14d6c..5a044c97 100644 --- a/src/routes/users/+page.server.ts +++ b/src/routes/users/+page.server.ts @@ -1,11 +1,12 @@ -import { error } from '@sveltejs/kit'; +import { error, fail } from '@sveltejs/kit'; import { listAccounts, NotAdminError } from '$lib/server/identity/accounts'; -import type { PageServerLoad } from './$types'; +import { isRegistrationMode, RegistrationNotAvailableError } from '$lib/server/instance'; +import type { Actions, PageServerLoad } from './$types'; /** - * Every Account, for the admin. The hook's gate has already sent a signed-out visitor to Sign in, - * given a learner 403 and hidden the page in Local Mode (404); this load checks again and fails - * closed, because it is the one that reads the Accounts. + * Every Account, for the admin, and the Registration Mode they can switch. The hook's gate has + * already sent a signed-out visitor to Sign in, given a learner 403 and hidden the page in Local + * Mode (404); this load checks again and fails closed, because it is the one that reads the Accounts. */ export const load: PageServerLoad = async ({ locals, request, setHeaders }) => { const { auth, identity } = locals; @@ -16,9 +17,42 @@ export const load: PageServerLoad = async ({ locals, request, setHeaders }) => { const accounts = await listAccounts(auth, { who: identity, headers: request.headers }); // Names and emails of everyone: not for a shared cache, nor for the back button after sign-out. setHeaders({ 'Cache-Control': 'no-store' }); - return { accounts }; + return { accounts, registration: locals.instance.registrationMode() }; } catch (thrown) { if (thrown instanceof NotAdminError) error(403, 'Forbidden'); throw thrown; } }; + +export const actions: Actions = { + /** + * Switches who may create an Account. It decides who can, so it checks like the load does, and + * first: the role must be `admin` exactly (not merely a role that contains it). The gate has + * already judged the request; this does not rely on it. Cross-site posts are refused by + * SvelteKit's origin check on form actions. + */ + registration: async ({ locals, request }) => { + const { auth, identity, instance } = locals; + if (identity?.role !== 'admin') error(403, 'Forbidden'); + // Local Mode has no Accounts to register, whatever role it gives the Local Learner. + if (!auth) error(404, 'Not found'); + + const choice = (await request.formData()).get('registration'); + if (!isRegistrationMode(choice)) { + return fail(400, { message: 'Choose invite only or open.' }); + } + try { + // Writes the database, then memory: the next sign-up sees it, with no restart. + await instance.setRegistrationMode(choice); + } catch (thrown) { + if (thrown instanceof RegistrationNotAvailableError) error(404, 'Not found'); + // Only the kind of failure is logged: an error's text may hold paths and query text. + console.error( + 'Users: switching the Registration Mode failed:', + thrown instanceof Error ? thrown.name : typeof thrown + ); + return fail(500, { message: 'Registration could not be changed. Try again.' }); + } + return { registration: choice }; + } +}; diff --git a/src/routes/users/+page.svelte b/src/routes/users/+page.svelte index 677860b8..a67903e4 100644 --- a/src/routes/users/+page.svelte +++ b/src/routes/users/+page.svelte @@ -1,7 +1,32 @@ @@ -14,6 +39,30 @@ let { data }: PageProps = $props();

Users

+
+
+

Registration

+

Who can create an account.

+
+
+ + {#if !enhanced} + + {/if} +

{explanation[data.registration]}

+ {#if form?.message}{/if} +
+
+

All users

@@ -88,6 +137,85 @@ let { data }: PageProps = $props(); font-size: 30px; line-height: 1.2; } + .setting { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + column-gap: 32px; + row-gap: 24px; + margin: 56px clamp(16px, 6.7vw, 96px) 0; + padding-top: 32px; + border-top: 1px solid var(--ink); + } + .label { + grid-column: span 4; + display: flex; + flex-direction: column; + gap: 10px; + } + .label p { + margin: 0; + font-size: 17px; + line-height: 1.5; + color: var(--muted); + } + .control { + grid-column: 6 / span 6; + display: flex; + flex-direction: column; + gap: 16px; + } + @media (max-width: 800px) { + .label, + .control { + grid-column: 1 / -1; + } + } + .control label { + display: flex; + flex-direction: column; + gap: 8px; + font-family: var(--font-mono); + font-size: 13px; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--muted); + } + .control select { + height: 52px; + box-sizing: border-box; + max-width: 320px; + padding: 0 16px; + background: var(--field); + border: 1px solid var(--ink); + border-radius: 2px; + color: var(--ink); + font-family: var(--font-serif); + font-size: 19px; + letter-spacing: 0; + text-transform: none; + } + .control button { + align-self: flex-start; + height: 52px; + padding: 0 28px; + background: var(--ink); + color: var(--on-ink); + border: 0; + border-radius: 2px; + font-family: var(--font-mono); + font-size: 15px; + font-weight: 500; + } + .lead { + margin: 0; + font-size: 18px; + line-height: 1.5; + color: var(--body); + } + .error { + margin: 0; + color: var(--accent); + } /* The table keeps its columns; a narrow screen scrolls it sideways instead of the page. */ .scroll { overflow-x: auto; diff --git a/src/routes/users/multi-fixture.ts b/src/routes/users/multi-fixture.ts index 219ab2b7..b273b28a 100644 --- a/src/routes/users/multi-fixture.ts +++ b/src/routes/users/multi-fixture.ts @@ -12,7 +12,7 @@ import { createAuth } from '../../lib/server/identity/auth.ts'; const MULTI_PORT = '4174'; /** What the Multi-user server was started with: the Playwright config puts a fresh temp database path in its environment. */ -function serverEnvironment() { +function serverEnvironment(): ServerTarget { // Linux: `/proc//environ` is the environment a process started with. The server is the process // started with PORT=4174 (the shell that runs it has no PORT of its own). for (const pid of readdirSync('/proc').filter((name) => /^\d+$/.test(name))) { @@ -39,6 +39,9 @@ function serverEnvironment() { ); } +/** A Multi-user server's database file, signing secret and public origin: what a second Better Auth needs to act on it. */ +export type ServerTarget = { database: string; secret: string; origin: string }; + export const LEARNER_PASSWORD = 'correct horse battery staple'; /** The `Set-Cookie` lines of a sign-in as Playwright `storageState`: this browser context is that user. */ @@ -65,8 +68,9 @@ function sessionOf(setCookies: string[]) { export type Session = ReturnType; -export async function openMultiUserDatabase() { - const { database, secret, origin } = serverEnvironment(); +/** The shared 4174 server's database, or another Multi-user server's when `target` names it. */ +export async function openMultiUserDatabase(target: ServerTarget = serverEnvironment()) { + const { database, secret, origin } = target; const db = createDb(database); // Admits every sign-up: this instance is only ever driven from the test process, never over HTTP. const auth = createAuth({ db, origin, secret, admitSignUp: () => ({}) }); diff --git a/src/routes/users/multi-server.ts b/src/routes/users/multi-server.ts new file mode 100644 index 00000000..44f42e06 --- /dev/null +++ b/src/routes/users/multi-server.ts @@ -0,0 +1,128 @@ +import { type ChildProcess, execFileSync, spawn } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { createServer } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createDb } from '../../lib/server/db.ts'; +import { createAuth } from '../../lib/server/identity/auth.ts'; +import { createRegistration } from '../../lib/server/identity/registration.ts'; +import { createFirstAdmin } from '../../lib/server/identity/setup.ts'; +import { createInstance } from '../../lib/server/instance.ts'; +import type { ServerTarget } from './multi-fixture.ts'; + +// Test-only. A Multi-user server of its own, from the same build as the two Playwright servers, on a fresh +// database and a free port, for browser tests that change the Instance for everyone (the Registration +// Mode). Files run in parallel on the shared 4174 server (see playwright.config.ts) and other files rely on +// it staying as setup left it (signup.multi.e2e.ts expects it invite-only), so a test that switches it does +// it here, where nobody else is looking. The app gains nothing from this file. + +const SECRET = 'e2e-only-secret-e2e-only-secret-e2e-only-0123'; + +export const OWN_SERVER_ADMIN = { + name: 'Grace Admin', + email: 'grace.admin@example.com', + password: 'correct horse battery staple' +} as const; + +export type MultiUserServer = ServerTarget & { + /** Where the server listens, e.g. `http://localhost:41234`. */ + url: string; + stop(): Promise; +}; + +/** + * Migrates a fresh database, makes it a Multi-user Instance with one admin (through the app's own + * first-admin step, so the rows are what Setup leaves) and starts the build on it. Registration starts + * invite-only, as on every new Instance. + */ +export async function startMultiUserServer(): Promise { + const dir = mkdtempSync(join(tmpdir(), 'beyondleetcode-users-')); + const database = `file:${join(dir, 'own.db')}`; + const port = await freePort(); + const origin = `http://localhost:${port}`; + const env: NodeJS.ProcessEnv = { + ...process.env, + DATABASE_URL: database, + CONTENT_DIR: 'src/lib/server/content/fixture', + ORIGIN: origin, + PORT: String(port), + BETTER_AUTH_SECRET: SECRET, + // A multi-user Instance never takes the test switches; the Runner is never called by these pages. + RUNNER_URL: 'http://127.0.0.1:9', + RUNNER_TOKEN: 'e2e-unused' + }; + delete env.IDENTITY_TEST_LEARNER; + delete env.RUNNER_ADAPTER; + + execFileSync('node_modules/.bin/prisma', ['migrate', 'deploy'], { env, stdio: 'pipe' }); + const db = createDb(database); + try { + const instance = await createInstance(db); + const auth = createAuth({ + db, + origin, + secret: SECRET, + admitSignUp: createRegistration({ instance }) + }); + await createFirstAdmin({ instance, auth, db }, OWN_SERVER_ADMIN); + } finally { + await db.$disconnect(); + } + + const server = spawn('bun', ['./build/index.js'], { env, stdio: ['ignore', 'pipe', 'pipe'] }); + let output = ''; + server.stdout?.on('data', (chunk) => { + output += chunk; + }); + server.stderr?.on('data', (chunk) => { + output += chunk; + }); + const stop = async () => { + await stopProcess(server); + rmSync(dir, { recursive: true, force: true }); + }; + try { + await ready(server, `${origin}/signup`, () => output); + } catch (error) { + await stop(); + throw error; + } + return { url: origin, origin, database, secret: SECRET, stop }; +} + +async function freePort(): Promise { + return new Promise((resolve, reject) => { + const probe = createServer(); + probe.once('error', reject); + probe.listen(0, '127.0.0.1', () => { + const { port } = probe.address() as { port: number }; + probe.close(() => resolve(port)); + }); + }); +} + +async function ready(server: ChildProcess, url: string, output: () => string) { + const stopped = () => server.exitCode !== null || server.signalCode !== null; + for (let i = 0; i < 150; i++) { + if (stopped()) throw new Error(`the Multi-user test server exited:\n${output()}`); + try { + if ((await fetch(url)).ok) return; + } catch { + // not listening yet + } + await new Promise((resolve) => setTimeout(resolve, 200)); + } + throw new Error(`the Multi-user test server did not answer in time:\n${output()}`); +} + +async function stopProcess(server: ChildProcess) { + if (server.exitCode !== null || server.signalCode !== null) return; + const exited = new Promise((resolve) => server.once('exit', resolve)); + server.kill('SIGTERM'); + const timedOut = Symbol('timeout'); + const result = await Promise.race([ + exited, + new Promise((resolve) => setTimeout(() => resolve(timedOut), 5_000)) + ]); + if (result === timedOut) server.kill('SIGKILL'); +} diff --git a/src/routes/users/registration.multi.e2e.ts b/src/routes/users/registration.multi.e2e.ts new file mode 100644 index 00000000..79ce3d4a --- /dev/null +++ b/src/routes/users/registration.multi.e2e.ts @@ -0,0 +1,273 @@ +import { test as base, expect, type Page, type PlaywrightWorkerArgs } from '@playwright/test'; +import { + LEARNER_PASSWORD, + openMultiUserDatabase, + type ServerTarget, + type Session +} from './multi-fixture.ts'; +import { OWN_SERVER_ADMIN, startMultiUserServer } from './multi-server.ts'; + +// The Registration section of the Users page (C34b). +// +// Every test here runs on a Multi-user server of its own (multi-server.ts): same build, fresh database, +// one running server from start to finish. Not on the shared one (4174): files run in parallel there, +// signup.multi.e2e.ts expects it to stay invite-only, and a second file writing to its SQLite database at +// the same time as users.multi.e2e.ts made that file's setup time out in CI. + +const INVITE_ONLY = 'Invite only: a new account needs an invite link from you.'; +const OPEN = 'Open: anyone who can reach this instance can create an account.'; + +type Own = ServerTarget & { url: string }; +const test = base.extend({ + own: [ + // biome-ignore lint/correctness/noEmptyPattern: Playwright reads the fixtures this one needs from this pattern + async ({}, use) => { + const server = await startMultiUserServer(); + try { + await use(server); + } finally { + await server.stop(); + } + }, + { scope: 'worker', timeout: 90_000 } + ] +}); + +const registration = (page: Page) => page.getByRole('combobox', { name: 'Registration' }); + +// Choosing only submits once the page has hydrated (the Save button goes away then), and the choice counts +// once the server has answered. +async function choose(page: Page, mode: 'Open' | 'Invite only') { + await expect(page.getByRole('button', { name: 'Save registration' })).toHaveCount(0); + const saved = page.waitForResponse( + (r) => r.url().includes('?/registration') && r.request().method() === 'POST' + ); + await registration(page).selectOption({ label: mode }); + expect((await saved).status()).toBe(200); +} + +/** What a visitor who is not signed in (a signed-in one is sent to the Map) gets from /signup. */ +async function signupFor(playwright: PlaywrightWorkerArgs['playwright'], baseURL: string) { + const visitor = await playwright.request.newContext({ baseURL }); + try { + const html = await (await visitor.get('/signup')).text(); + if (html.includes('Sign-up is closed.')) return 'closed'; + if (html.includes('Create account')) return 'form'; + return 'something else'; + } finally { + await visitor.dispose(); + } +} + +const LIN = { name: 'Lin Registration', email: 'lin.registration@example.com' }; +const SAM = { name: 'Sam Registration', email: 'sam.registration@example.com' }; + +test.describe('on a Multi-user server of its own', () => { + test.describe.configure({ mode: 'serial' }); + + let fixture: Awaited>; + let admin: Session; + let lin: Session; + let sam: Session; + + const storedMode = async () => + (await fixture.db.instance.findUniqueOrThrow({ where: { id: 'instance' } })).registrationMode; + + test.beforeAll(async ({ own }) => { + fixture = await openMultiUserDatabase(own); + await fixture.ensureLearner(LIN); + await fixture.ensureLearner(SAM); + // Sam's stored role only contains admin: the plugin would call that an admin, the app does not. + await fixture.db.user.update({ where: { email: SAM.email }, data: { role: 'admin,learner' } }); + admin = await fixture.signIn(OWN_SERVER_ADMIN.email, OWN_SERVER_ADMIN.password); + lin = await fixture.signIn(LIN.email, LEARNER_PASSWORD); + sam = await fixture.signIn(SAM.email, LEARNER_PASSWORD); + }); + + test('the admin sees who can create an account, above the list of users, and a new instance is invite-only', async ({ + browser, + own + }) => { + const context = await browser.newContext({ baseURL: own.url, storageState: admin }); + const page = await context.newPage(); + await page.goto('/users'); + + const headings = page.getByRole('heading', { level: 2 }); + await expect(headings.first()).toHaveText('Registration'); + await expect(page.getByText('Who can create an account.')).toBeVisible(); + await expect(registration(page)).toHaveValue('invite'); + await expect(registration(page).getByRole('option')).toHaveText(['Invite only', 'Open']); + await expect(page.getByText(INVITE_ONLY)).toBeVisible(); + await expect(page.getByText(OPEN)).toHaveCount(0); + await expect(headings.nth(1)).toHaveText('All users'); + await context.close(); + }); + + test('a learner, a user whose stored role merely contains admin and a visitor who is signed out cannot switch it', async ({ + playwright, + own + }) => { + const post = async (storageState?: Session) => { + const request = await playwright.request.newContext({ baseURL: own.url, storageState }); + try { + return await request.post('/users?/registration', { + headers: { origin: own.url, accept: 'text/html' }, + form: { registration: 'open' }, + maxRedirects: 0 + }); + } finally { + await request.dispose(); + } + }; + + const learner = await post(lin); + const sneaky = await post(sam); + const anonymous = await post(); + + expect(learner.status()).toBe(403); + expect(sneaky.status()).toBe(403); + expect(anonymous.status()).toBe(303); + expect(anonymous.headers().location).toMatch(/^\/signin\?next=/); + expect(await storedMode()).toBe('invite'); + expect(await signupFor(playwright, own.url)).toBe('closed'); + }); + + test.afterAll(async () => { + await fixture?.close(); + }); + + test('switching to Open opens /signup on the next request, and switching back closes it, with no restart', async ({ + browser, + own + }) => { + const adminContext = await browser.newContext({ baseURL: own.url, storageState: admin }); + const visitorContext = await browser.newContext({ baseURL: own.url }); + const adminPage = await adminContext.newPage(); + const visitor = await visitorContext.newPage(); + const closed = async () => { + await visitor.goto('/signup'); + await expect(visitor.getByRole('heading', { level: 1 })).toHaveText('Sign-up is closed.'); + await expect(visitor.getByLabel('Password')).toHaveCount(0); + // A form posted anyway, the way a script would, is refused too. + const response = await visitorContext.request.post('/signup', { + headers: { origin: own.url, accept: 'text/html' }, + form: { + name: 'Late Comer', + email: 'late.comer@example.com', + password: 'a long enough secret' + } + }); + expect(response.status()).toBe(403); + }; + + // A new instance is invite-only. + await closed(); + + await adminPage.goto('/users'); + await expect(registration(adminPage)).toHaveValue('invite'); + await choose(adminPage, 'Open'); + await expect(adminPage.getByText(OPEN)).toBeVisible(); + await expect(adminPage.getByText(INVITE_ONLY)).toHaveCount(0); + + // The very next request to /signup offers the form. + await visitor.goto('/signup'); + await expect(visitor.getByRole('heading', { level: 1 })).toHaveText('Start reading.'); + await expect(visitor.getByLabel('Password')).toBeVisible(); + await expect(visitor.getByRole('button', { name: 'Create account →' })).toBeVisible(); + + // The choice is the stored one: a reload shows it, and so does a fresh page. + await adminPage.reload(); + await expect(registration(adminPage)).toHaveValue('open'); + + await choose(adminPage, 'Invite only'); + await expect(adminPage.getByText(INVITE_ONLY)).toBeVisible(); + + // The next request after switching back is closed again, and an Account cannot be made. + await closed(); + await adminPage.reload(); + await expect(registration(adminPage)).toHaveValue('invite'); + expect( + await fixture.db.user.findUnique({ where: { email: 'late.comer@example.com' } }) + ).toBeNull(); + + await adminContext.close(); + await visitorContext.close(); + }); + + test('a post from another site is refused, and changes nothing', async ({ playwright, own }) => { + const request = await playwright.request.newContext({ baseURL: own.url, storageState: admin }); + + const response = await request.post('/users?/registration', { + headers: { origin: 'http://evil.example', accept: 'text/html' }, + form: { registration: 'open' } + }); + + expect(response.status()).toBe(403); + expect(await storedMode()).toBe('invite'); + expect(await signupFor(playwright, own.url)).toBe('closed'); + await request.dispose(); + }); + + test('a value that is neither mode is refused, and changes nothing', async ({ + playwright, + own + }) => { + const request = await playwright.request.newContext({ baseURL: own.url, storageState: admin }); + + const response = await request.post('/users?/registration', { + headers: { origin: own.url, accept: 'text/html' }, + form: { registration: 'everyone' } + }); + + expect(response.status()).toBe(400); + expect(await response.text()).toContain('Choose invite only or open.'); + expect(await signupFor(playwright, own.url)).toBe('closed'); + await request.dispose(); + }); + + test('it works without JavaScript: the Save button posts the choice', async ({ + browser, + playwright, + own + }) => { + const context = await browser.newContext({ + baseURL: own.url, + storageState: admin, + javaScriptEnabled: false + }); + const page = await context.newPage(); + try { + await page.goto('/users'); + await registration(page).selectOption({ label: 'Open' }); + await page.getByRole('button', { name: 'Save registration' }).click(); + + await expect(registration(page)).toHaveValue('open'); + await expect(page.getByText(OPEN)).toBeVisible(); + expect(await signupFor(playwright, own.url)).toBe('form'); + } finally { + // Back to invite-only, so nothing later depends on this test's end state, passed or failed. + await page.goto('/users'); + await registration(page).selectOption({ label: 'Invite only' }); + await page.getByRole('button', { name: 'Save registration' }).click(); + await expect(registration(page)).toHaveValue('invite'); + await context.close(); + } + }); + + test('on a phone the section fits without scrolling sideways', async ({ browser, own }) => { + const context = await browser.newContext({ + baseURL: own.url, + storageState: admin, + viewport: { width: 390, height: 844 } + }); + const page = await context.newPage(); + await page.goto('/users'); + + await expect(registration(page)).toBeVisible(); + const overflow = await page.evaluate( + () => document.documentElement.scrollWidth - document.documentElement.clientWidth + ); + expect(overflow).toBeLessThanOrEqual(0); + await context.close(); + }); +}); diff --git a/src/routes/users/users.test.ts b/src/routes/users/users.test.ts index f8ccadac..9654873a 100644 --- a/src/routes/users/users.test.ts +++ b/src/routes/users/users.test.ts @@ -1,16 +1,18 @@ import type { RequestEvent, ResolveOptions } from '@sveltejs/kit'; import { describe, expect, it, vi } from 'vitest'; import type { App } from '$lib/server/app'; +import type { Db } from '$lib/server/db'; import { createHandle } from '$lib/server/handle'; import { createAuth } from '$lib/server/identity/auth'; import { createLocalIdentity } from '$lib/server/identity/index'; import { createMultiUserIdentity } from '$lib/server/identity/multi-user'; import { createRegistration } from '$lib/server/identity/registration'; import { createFirstAdmin } from '$lib/server/identity/setup'; -import { createInstance, type Instance } from '$lib/server/instance'; +import { createInstance, type Instance, RegistrationNotAvailableError } from '$lib/server/instance'; import { createFileTestDb } from '$lib/server/test-db'; import { load as layoutLoad } from '../+layout.server'; -import { load } from './+page.server'; +import { load as signupLoad } from '../signup/+page.server'; +import { actions, load } from './+page.server'; // The Users page, through the real hook and the real Better Auth: who is let in, and what they see. @@ -84,6 +86,38 @@ async function visit( return { response, resolve, setHeaders, locals: event.locals }; } +/** Posts the Registration form through the real hook; the page's action runs where SvelteKit would run it. */ +async function submit( + handle: ReturnType, + cookie: string, + fields: Record, + routeId = '/users' +) { + const resolve = vi.fn(async (event: RequestEvent, _opts?: ResolveOptions) => { + const result = await actions.registration(event as never); + return Response.json(result ?? null, { status: result?.status ?? 200 }); + }); + const event = { + url: new URL('/users?/registration', 'http://localhost'), + route: { id: routeId }, + request: new Request('http://localhost/users?/registration', { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded', + ...(cookie ? { cookie } : {}) + }, + body: new URLSearchParams(fields) + }), + cookies: { get: () => undefined, set: vi.fn() }, + locals: {} + } as unknown as RequestEvent; + const response = await handle({ event, resolve }); + return { response, resolve }; +} + +const storedRegistration = async (db: Db) => + (await db.instance.findUniqueOrThrow({ where: { id: 'instance' } })).registrationMode; + describe('the Users page in Multi-user Mode', () => { it('shows the admin every Account, and is never cached', async () => { const { handle, admin, learner } = await setup(); @@ -218,3 +252,218 @@ describe('the nav', () => { expect(layout('multi')).toMatchObject({ showUsers: false }); }); }); + +describe('the Registration switch', () => { + it('shows the admin the Registration Mode the Instance holds', async () => { + const { handle, admin, instance } = await setup(); + + const invite = await (await visit(handle, '/users', admin)).response.json(); + await instance.setRegistrationMode('open'); + const open = await (await visit(handle, '/users', admin)).response.json(); + + expect(invite.registration).toBe('invite'); + expect(open.registration).toBe('open'); + }); + + it('opens sign-up on the next request and closes it again on the one after, with no restart', async () => { + const { handle, admin, auth, instance, db } = await setup(); + const signUp = (email: string) => + auth.api.signUpEmail({ body: { name: 'Newcomer', email, password } }); + const signupPage = () => signupLoad({ locals: { instance } } as never); + // A new Instance starts invite-only: the page is closed and the hook refuses an Account. + expect(await signupPage()).toEqual({ open: false }); + await expect(signUp('first@example.com')).rejects.toMatchObject({ statusCode: 403 }); + + const toOpen = await submit(handle, admin, { registration: 'open' }); + + expect(toOpen.response.status).toBe(200); + expect(await storedRegistration(db)).toBe('open'); + expect(await signupPage()).toEqual({ open: true }); + await expect(signUp('second@example.com')).resolves.toBeDefined(); + + const toInvite = await submit(handle, admin, { registration: 'invite' }); + + expect(toInvite.response.status).toBe(200); + expect(await storedRegistration(db)).toBe('invite'); + expect(await signupPage()).toEqual({ open: false }); + await expect(signUp('third@example.com')).rejects.toMatchObject({ statusCode: 403 }); + expect(await db.user.findUnique({ where: { email: 'third@example.com' } })).toBeNull(); + expect(await db.user.findUnique({ where: { email: 'second@example.com' } })).not.toBeNull(); + }); + + it('refuses a learner with 403 before the action runs, and changes nothing', async () => { + const { handle, learner, instance, db } = await setup(); + const lin = await learner('Lin Learner', 'lin@example.com'); + + const { response, resolve } = await submit(handle, lin, { registration: 'open' }); + + expect(response.status).toBe(403); + expect(resolve).not.toHaveBeenCalled(); + expect(instance.registrationMode()).toBe('invite'); + expect(await storedRegistration(db)).toBe('invite'); + }); + + it('refuses a user whose stored role merely contains admin with 403', async () => { + const { handle, learner, instance, db } = await setup(); + const sam = await learner('Sam Sneaky', 'sam@example.com'); + await db.user.update({ where: { email: 'sam@example.com' }, data: { role: 'admin,learner' } }); + + const { response, resolve } = await submit(handle, sam, { registration: 'open' }); + + expect(response.status).toBe(403); + expect(resolve).not.toHaveBeenCalled(); + expect(instance.registrationMode()).toBe('invite'); + expect(await storedRegistration(db)).toBe('invite'); + }); + + it('sends a signed-out visitor to Sign in and changes nothing', async () => { + const { handle, instance, db } = await setup(); + + const { response, resolve } = await submit(handle, '', { registration: 'open' }); + + expect(response.status).toBe(303); + expect(response.headers.get('location')).toMatch(/^\/signin\?next=/); + expect(resolve).not.toHaveBeenCalled(); + expect(instance.registrationMode()).toBe('invite'); + expect(await storedRegistration(db)).toBe('invite'); + }); + + it('treats an admin whose session was ended as signed out', async () => { + const { handle, admin, instance, db } = await setup(); + await db.session.deleteMany(); + + const { response, resolve } = await submit(handle, admin, { registration: 'open' }); + + expect(response.status).toBe(303); + expect(resolve).not.toHaveBeenCalled(); + expect(instance.registrationMode()).toBe('invite'); + }); + + it.each([ + ['something else', { registration: 'everyone' }], + ['the page label instead of the value', { registration: 'Open' }], + ['nothing', {}], + ['an empty value', { registration: '' }] + ])('refuses %s with 400 and changes nothing', async (_, fields) => { + const { handle, admin, instance, db } = await setup(); + + const { response } = await submit(handle, admin, fields); + + expect(response.status).toBe(400); + expect(instance.registrationMode()).toBe('invite'); + expect(await storedRegistration(db)).toBe('invite'); + }); + + it('answers a failed write with a fixed message and leaves the mode as it was', async () => { + const { handle, admin, instance, db } = await setup(); + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(instance, 'setRegistrationMode').mockRejectedValueOnce( + new Error('SQLITE_FULL: /var/lib/beyondleetcode/app.db') + ); + + const { response } = await submit(handle, admin, { registration: 'open' }); + + expect(response.status).toBe(500); + const body = JSON.stringify(await response.json()); + expect(body).toContain('could not be changed'); + expect(body).not.toMatch(/SQLITE|beyondleetcode\/app\.db/); + expect(instance.registrationMode()).toBe('invite'); + expect(await storedRegistration(db)).toBe('invite'); + // The log names the kind of failure, never the message with its paths. + expect(JSON.stringify(logged.mock.calls)).not.toContain('/var/lib'); + }); + + it('does not exist in Local Mode: 404 for the Local Learner, who is an admin there, and nothing changes', async () => { + const db = await createFileTestDb(); + const instance = await createInstance(db); + await instance.chooseLocal(); + const identity = await createLocalIdentity(db); + await identity.ensureLocalLearner(); + const core = { forLearner: (learnerId: string) => ({ learnerId }) } as unknown as App['core']; + const handle = createHandle({ + instance, + identity, + core, + multiUser: null, + setup: { multiUser: { kind: 'later' }, createFirstAdmin: vi.fn() } as App['setup'] + }); + + const { response, resolve } = await submit(handle, '', { registration: 'open' }); + + expect(response.status).toBe(404); + expect(resolve).not.toHaveBeenCalled(); + expect(instance.registrationMode()).toBe('invite'); + expect(await storedRegistration(db)).toBe('invite'); + }); +}); + +describe('the Registration action, if the hook ever let a request through', () => { + const admin = { learnerId: 'l', displayName: 'N', role: 'admin' as const }; + const learner = { ...admin, role: 'learner' as const }; + const request = (formData = vi.fn(async () => new URLSearchParams({ registration: 'open' }))) => + ({ formData }) as unknown as Request; + const instance = () => ({ setRegistrationMode: vi.fn(async () => {}) }); + const call = (locals: Record, req: Request) => + actions.registration({ locals, request: req } as never); + + it('is 403 for a learner, before it reads the form or touches the Instance', async () => { + const i = instance(); + const formData = vi.fn(); + + await expect( + call({ auth: {}, identity: learner, instance: i }, request(formData as never)) + ).rejects.toMatchObject({ status: 403 }); + + expect(formData).not.toHaveBeenCalled(); + expect(i.setRegistrationMode).not.toHaveBeenCalled(); + }); + + it('is 403 for nobody', async () => { + const i = instance(); + + await expect(call({ auth: {}, instance: i }, request())).rejects.toMatchObject({ status: 403 }); + + expect(i.setRegistrationMode).not.toHaveBeenCalled(); + }); + + it('is 403 for any role that is not admin exactly', async () => { + const i = instance(); + const odd = { ...admin, role: 'admin,learner' as never }; + + await expect(call({ auth: {}, identity: odd, instance: i }, request())).rejects.toMatchObject({ + status: 403 + }); + + expect(i.setRegistrationMode).not.toHaveBeenCalled(); + }); + + it('is 404 without Better Auth, as in Local Mode where the Local Learner is an admin', async () => { + const i = instance(); + + await expect(call({ identity: admin, instance: i }, request())).rejects.toMatchObject({ + status: 404 + }); + + expect(i.setRegistrationMode).not.toHaveBeenCalled(); + }); + + it('is 404 when the Instance says it has no Registration Mode to switch', async () => { + const i = { + setRegistrationMode: vi.fn(async () => { + throw new RegistrationNotAvailableError(); + }) + }; + + await expect(call({ auth: {}, identity: admin, instance: i }, request())).rejects.toMatchObject( + { status: 404 } + ); + }); + + it('hands the Instance only the value from the form', async () => { + const i = instance(); + + await call({ auth: {}, identity: admin, instance: i }, request()); + + expect(i.setRegistrationMode).toHaveBeenCalledExactlyOnceWith('open'); + }); +});