From 953bc0ecdece07d811912a6adf5c874ecfdb16cf Mon Sep 17 00:00:00 2001 From: Gauransh Mathur Date: Fri, 16 Oct 2026 10:13:07 +0000 Subject: [PATCH 1/4] feat(beyondleetcode): C34b Instance sets the Registration Mode, Users action switches it Co-Authored-By: Claude Sonnet 5.5 --- src/lib/server/instance.test.ts | 217 +++++++++++++++++++++++++- src/lib/server/instance.ts | 49 +++++- src/routes/users/+page.server.ts | 46 +++++- src/routes/users/users.test.ts | 253 ++++++++++++++++++++++++++++++- 4 files changed, 555 insertions(+), 10 deletions(-) diff --git a/src/lib/server/instance.test.ts b/src/lib/server/instance.test.ts index e88ad9c3..ef8d3947 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,212 @@ 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 can be held, failed or delayed, to pin the order of the two. */ +function interceptWrites( + db: Db, + intercept: (args: unknown, write: () => Promise) => Promise +): 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 (method !== 'updateMany') 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'); +}); diff --git a/src/lib/server/instance.ts b/src/lib/server/instance.ts index 78468b65..d805f424 100644 --- a/src/lib/server/instance.ts +++ b/src/lib/server/instance.ts @@ -8,6 +8,13 @@ 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. 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 +31,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 +56,9 @@ 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'; + let registration = (row?.registrationMode as RegistrationMode | undefined) ?? '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(); /** A refused claim means another writer got there first: adopt the stored mode instead of a stale cache. */ const refuse = async (): Promise => { @@ -43,6 +70,26 @@ 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 () => { + 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; + }); + 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/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'); + }); +}); From c915e5c38793f514910bf99327be326ce3ee3499 Mon Sep 17 00:00:00 2001 From: Gauransh Mathur Date: Fri, 16 Oct 2026 10:22:57 +0000 Subject: [PATCH 2/4] feat(beyondleetcode): C34b Users page Registration section and browser tests Co-Authored-By: Claude Sonnet 5.5 --- src/routes/users/+page.svelte | 130 ++++++++- src/routes/users/multi-fixture.ts | 10 +- src/routes/users/multi-server.ts | 128 +++++++++ src/routes/users/registration.multi.e2e.ts | 295 +++++++++++++++++++++ 4 files changed, 559 insertions(+), 4 deletions(-) create mode 100644 src/routes/users/multi-server.ts create mode 100644 src/routes/users/registration.multi.e2e.ts 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..26491395 --- /dev/null +++ b/src/routes/users/registration.multi.e2e.ts @@ -0,0 +1,295 @@ +import { test as base, expect, type Page, type PlaywrightWorkerArgs } from '@playwright/test'; +import { E2E_ADMIN } from '../../lib/e2e-admin.ts'; +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). +// +// Files run in parallel on the shared Multi-user server (4174), and signup.multi.e2e.ts expects it to stay +// invite-only, so this file never switches it. The first describe only reads that server and tries to +// switch it as people who may not (the refusals change nothing). Everything that really switches the mode +// runs on a Multi-user server of its own (multi-server.ts): one running server from start to finish. + +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 the shared server, which stays invite-only', () => { + let fixture: Awaited>; + let admin: Session; + let lin: Session; + let sam: Session; + + test.beforeAll(async () => { + fixture = await openMultiUserDatabase(); + 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(E2E_ADMIN.email, E2E_ADMIN.password); + lin = await fixture.signIn(LIN.email, LEARNER_PASSWORD); + sam = await fixture.signIn(SAM.email, LEARNER_PASSWORD); + }); + + test.afterAll(async () => { + await fixture.close(); + }); + + test('the admin sees who can create an account, above the list of users, and a new instance is invite-only', async ({ + context, + page + }) => { + await context.addCookies(admin.cookies); + 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'); + }); + + test('a learner cannot switch it, and sign-up stays closed', async ({ playwright }) => { + const origin = 'http://localhost:4174'; + const learner = await playwright.request.newContext({ + baseURL: origin, + storageState: lin + }); + const signedOut = await playwright.request.newContext({ baseURL: origin }); + + const response = await learner.post('/users?/registration', { + headers: { origin, accept: 'text/html' }, + form: { registration: 'open' } + }); + const anonymous = await signedOut.post('/users?/registration', { + headers: { origin, accept: 'text/html' }, + form: { registration: 'open' }, + maxRedirects: 0 + }); + + expect(response.status()).toBe(403); + expect(anonymous.status()).toBe(303); + expect(anonymous.headers().location).toMatch(/^\/signin\?next=/); + expect(await signupFor(playwright, origin)).toBe('closed'); + await learner.dispose(); + await signedOut.dispose(); + }); + + test('a user whose stored role merely contains admin cannot switch it either', async ({ + playwright + }) => { + const origin = 'http://localhost:4174'; + const user = await playwright.request.newContext({ baseURL: origin, storageState: sam }); + + const response = await user.post('/users?/registration', { + headers: { origin, accept: 'text/html' }, + form: { registration: 'open' } + }); + + expect(response.status()).toBe(403); + expect(await signupFor(playwright, origin)).toBe('closed'); + await user.dispose(); + }); +}); + +test.describe('on a server of its own', () => { + test.describe.configure({ mode: 'serial' }); + + let fixture: Awaited>; + let admin: Session; + + test.beforeAll(async ({ own }) => { + fixture = await openMultiUserDatabase(own); + admin = await fixture.signIn(OWN_SERVER_ADMIN.email, OWN_SERVER_ADMIN.password); + }); + + 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 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(); + }); +}); From 4b5f0c0a3dcff719f23090787a1b72e353c7742e Mon Sep 17 00:00:00 2001 From: Gauransh Mathur Date: Fri, 16 Oct 2026 10:38:12 +0000 Subject: [PATCH 3/4] fix(beyondleetcode): C34b registration switch fails closed after a failed write and an unreadable stored mode Co-Authored-By: Claude Sonnet 5.5 --- src/lib/server/instance.test.ts | 105 +++++++++++++++++++++++++++++++- src/lib/server/instance.ts | 53 +++++++++++----- 2 files changed, 141 insertions(+), 17 deletions(-) diff --git a/src/lib/server/instance.test.ts b/src/lib/server/instance.test.ts index ef8d3947..c23cc756 100644 --- a/src/lib/server/instance.test.ts +++ b/src/lib/server/instance.test.ts @@ -173,10 +173,11 @@ it.each([ // setRegistrationMode: the admin's toggle (C34b). The database is written first, memory after. -/** A database whose Instance writes can be held, failed or delayed, to pin the order of the two. */ +/** 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 + intercept: (args: unknown, write: () => Promise) => Promise, + methods: string[] = ['updateMany'] ): Db { return new Proxy(db, { get(target, prop) { @@ -185,7 +186,7 @@ function interceptWrites( return new Proxy(value, { get(delegate, method) { const member = Reflect.get(delegate, method); - if (method !== 'updateMany') return member; + if (typeof method !== 'string' || !methods.includes(method)) return member; return (args: unknown) => intercept(args, () => member.call(delegate, args)); } }); @@ -379,3 +380,101 @@ it('a switch that fails does not stop the next one', async () => { 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 d805f424..83fcca2b 100644 --- a/src/lib/server/instance.ts +++ b/src/lib/server/instance.ts @@ -11,8 +11,10 @@ export interface Instance { /** * 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. 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. + * 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. */ @@ -56,10 +58,27 @@ 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; - let 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 => { const stored = await db.instance.findUnique({ where: { id: 'instance' } }); @@ -74,18 +93,24 @@ export async function createInstance(db: Db): Promise { // 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 () => { - 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(); + 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; } - registration = next; }); switching = run.catch(() => {}); return run; From 5daeba2535975909bbdf9012da3e0e5129212e9d Mon Sep 17 00:00:00 2001 From: Gauransh Mathur Date: Fri, 16 Oct 2026 10:45:31 +0000 Subject: [PATCH 4/4] test(beyondleetcode): C34b registration browser tests use only their own server Co-Authored-By: Claude Sonnet 5.5 --- src/routes/users/registration.multi.e2e.ts | 106 ++++++++------------- 1 file changed, 42 insertions(+), 64 deletions(-) diff --git a/src/routes/users/registration.multi.e2e.ts b/src/routes/users/registration.multi.e2e.ts index 26491395..79ce3d4a 100644 --- a/src/routes/users/registration.multi.e2e.ts +++ b/src/routes/users/registration.multi.e2e.ts @@ -1,5 +1,4 @@ import { test as base, expect, type Page, type PlaywrightWorkerArgs } from '@playwright/test'; -import { E2E_ADMIN } from '../../lib/e2e-admin.ts'; import { LEARNER_PASSWORD, openMultiUserDatabase, @@ -10,10 +9,10 @@ import { OWN_SERVER_ADMIN, startMultiUserServer } from './multi-server.ts'; // The Registration section of the Users page (C34b). // -// Files run in parallel on the shared Multi-user server (4174), and signup.multi.e2e.ts expects it to stay -// invite-only, so this file never switches it. The first describe only reads that server and tries to -// switch it as people who may not (the refusals change nothing). Everything that really switches the mode -// runs on a Multi-user server of its own (multi-server.ts): one running server from start to finish. +// 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.'; @@ -63,32 +62,34 @@ async function signupFor(playwright: PlaywrightWorkerArgs['playwright'], baseURL const LIN = { name: 'Lin Registration', email: 'lin.registration@example.com' }; const SAM = { name: 'Sam Registration', email: 'sam.registration@example.com' }; -test.describe('on the shared server, which stays invite-only', () => { +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; - test.beforeAll(async () => { - fixture = await openMultiUserDatabase(); + 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(E2E_ADMIN.email, E2E_ADMIN.password); + 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.afterAll(async () => { - await fixture.close(); - }); - test('the admin sees who can create an account, above the list of users, and a new instance is invite-only', async ({ - context, - page + browser, + own }) => { - await context.addCookies(admin.cookies); + 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 }); @@ -99,60 +100,36 @@ test.describe('on the shared server, which stays invite-only', () => { 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 cannot switch it, and sign-up stays closed', async ({ playwright }) => { - const origin = 'http://localhost:4174'; - const learner = await playwright.request.newContext({ - baseURL: origin, - storageState: lin - }); - const signedOut = await playwright.request.newContext({ baseURL: origin }); + 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 response = await learner.post('/users?/registration', { - headers: { origin, accept: 'text/html' }, - form: { registration: 'open' } - }); - const anonymous = await signedOut.post('/users?/registration', { - headers: { origin, accept: 'text/html' }, - form: { registration: 'open' }, - maxRedirects: 0 - }); + const learner = await post(lin); + const sneaky = await post(sam); + const anonymous = await post(); - expect(response.status()).toBe(403); + expect(learner.status()).toBe(403); + expect(sneaky.status()).toBe(403); expect(anonymous.status()).toBe(303); expect(anonymous.headers().location).toMatch(/^\/signin\?next=/); - expect(await signupFor(playwright, origin)).toBe('closed'); - await learner.dispose(); - await signedOut.dispose(); - }); - - test('a user whose stored role merely contains admin cannot switch it either', async ({ - playwright - }) => { - const origin = 'http://localhost:4174'; - const user = await playwright.request.newContext({ baseURL: origin, storageState: sam }); - - const response = await user.post('/users?/registration', { - headers: { origin, accept: 'text/html' }, - form: { registration: 'open' } - }); - - expect(response.status()).toBe(403); - expect(await signupFor(playwright, origin)).toBe('closed'); - await user.dispose(); - }); -}); - -test.describe('on a server of its own', () => { - test.describe.configure({ mode: 'serial' }); - - let fixture: Awaited>; - let admin: Session; - - test.beforeAll(async ({ own }) => { - fixture = await openMultiUserDatabase(own); - admin = await fixture.signIn(OWN_SERVER_ADMIN.email, OWN_SERVER_ADMIN.password); + expect(await storedMode()).toBe('invite'); + expect(await signupFor(playwright, own.url)).toBe('closed'); }); test.afterAll(async () => { @@ -226,6 +203,7 @@ test.describe('on a server of its own', () => { }); expect(response.status()).toBe(403); + expect(await storedMode()).toBe('invite'); expect(await signupFor(playwright, own.url)).toBe('closed'); await request.dispose(); });