From 462481e3719120fa6de8a1cf7f6a8c7de1b98500 Mon Sep 17 00:00:00 2001 From: Gauransh Mathur Date: Fri, 16 Oct 2026 14:10:28 +0000 Subject: [PATCH 1/5] feat(beyondleetcode): C35a Invite schema and module Co-Authored-By: Claude Sonnet 5.5 --- .../20261016000000_invite/migration.sql | 16 ++ prisma/schema.prisma | 14 ++ src/lib/server/identity/invites.test.ts | 144 ++++++++++++++++++ src/lib/server/identity/invites.ts | 111 ++++++++++++++ 4 files changed, 285 insertions(+) create mode 100644 prisma/migrations/20261016000000_invite/migration.sql create mode 100644 src/lib/server/identity/invites.test.ts create mode 100644 src/lib/server/identity/invites.ts diff --git a/prisma/migrations/20261016000000_invite/migration.sql b/prisma/migrations/20261016000000_invite/migration.sql new file mode 100644 index 00000000..c58e3488 --- /dev/null +++ b/prisma/migrations/20261016000000_invite/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE "Invite" ( + "id" TEXT NOT NULL PRIMARY KEY, + "tokenHash" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" DATETIME NOT NULL, + "usedAt" DATETIME, + "createdByUserId" TEXT NOT NULL, + CONSTRAINT "Invite_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "Invite_tokenHash_key" ON "Invite"("tokenHash"); + +-- CreateIndex +CREATE INDEX "Invite_createdByUserId_idx" ON "Invite"("createdByUserId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 28571354..28955291 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -105,6 +105,7 @@ model User { mustChangePassword Boolean @default(false) sessions Session[] accounts Account[] + invites Invite[] } model Session { @@ -151,3 +152,16 @@ model Verification { @@index([identifier]) } + +/// An invite link for one new account. Only the SHA-256 of the token is stored; the link is shown once. +model Invite { + id String @id + tokenHash String @unique + createdAt DateTime @default(now()) + expiresAt DateTime + usedAt DateTime? + createdByUserId String + createdBy User @relation(fields: [createdByUserId], references: [id], onDelete: Cascade) + + @@index([createdByUserId]) +} diff --git a/src/lib/server/identity/invites.test.ts b/src/lib/server/identity/invites.test.ts new file mode 100644 index 00000000..9c039680 --- /dev/null +++ b/src/lib/server/identity/invites.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest'; +import { createTestDb } from '../test-db'; +import { NotAdminError } from './accounts'; +import { createInvites, hashToken, INVITE_LIFETIME_MS } from './invites'; + +const origin = 'https://learn.example.com'; +const now = new Date('2026-10-16T12:00:00.000Z'); +const admin = { learnerId: 'L-admin', displayName: 'Ada', role: 'admin' as const }; +const learner = { learnerId: 'L-lin', displayName: 'Lin', role: 'learner' as const }; + +async function setup() { + const db = await createTestDb(); + await db.learner.create({ data: { id: admin.learnerId } }); + await db.user.create({ + data: { id: 'u-admin', name: 'Ada', email: 'ada@example.com', role: 'admin', learnerId: 'L-admin' } + }); + return { db, invites: createInvites({ db, origin }) }; +} + +describe('create', () => { + it('returns a 43-character base64url token and a link on the origin', async () => { + const { invites } = await setup(); + const made = await invites.create({ who: admin, now }); + expect(made.token).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(made.link).toBe(`${origin}/invite/${made.token}`); + }); + + it('stores only the SHA-256 of the token, valid for 7 days', async () => { + const { db, invites } = await setup(); + const made = await invites.create({ who: admin, now }); + const rows = await db.invite.findMany(); + expect(rows).toHaveLength(1); + expect(rows[0].tokenHash).toBe(hashToken(made.token)); + expect(rows[0].tokenHash).not.toContain(made.token); + expect(JSON.stringify(rows)).not.toContain(made.token); + expect(rows[0].createdByUserId).toBe('u-admin'); + expect(rows[0].usedAt).toBeNull(); + expect(rows[0].expiresAt.getTime() - now.getTime()).toBe(INVITE_LIFETIME_MS); + expect(made.expiresAt).toBe(rows[0].expiresAt.toISOString()); + }); + + it('makes a different token each time', async () => { + const { invites } = await setup(); + const a = await invites.create({ who: admin, now }); + const b = await invites.create({ who: admin, now }); + expect(a.token).not.toBe(b.token); + }); + + it('refuses a learner and writes nothing', async () => { + const { db, invites } = await setup(); + await expect(invites.create({ who: learner, now })).rejects.toBeInstanceOf(NotAdminError); + expect(await db.invite.count()).toBe(0); + }); + + it('is removed with the admin who made it', async () => { + const { db, invites } = await setup(); + await invites.create({ who: admin, now }); + await db.user.delete({ where: { id: 'u-admin' } }); + expect(await db.invite.count()).toBe(0); + }); +}); + +describe('listPending', () => { + it('lists unused, unexpired Invites without the token or its hash', async () => { + const { db, invites } = await setup(); + const made = await invites.create({ who: admin, now }); + const rows = await invites.listPending({ who: admin, now }); + expect(rows).toEqual([ + { id: made.id, createdAt: now.toISOString(), expiresAt: made.expiresAt } + ]); + const text = JSON.stringify(rows); + expect(text).not.toContain(made.token); + const stored = await db.invite.findFirstOrThrow(); + expect(text).not.toContain(stored.tokenHash); + }); + + it('leaves out used and expired Invites', async () => { + const { db, invites } = await setup(); + const used = await invites.create({ who: admin, now }); + await db.invite.update({ where: { id: used.id }, data: { usedAt: now } }); + await invites.create({ who: admin, now }); + const later = new Date(now.getTime() + INVITE_LIFETIME_MS); + expect(await invites.listPending({ who: admin, now })).toHaveLength(1); + expect(await invites.listPending({ who: admin, now: later })).toHaveLength(0); + }); + + it('refuses a learner', async () => { + const { invites } = await setup(); + await expect(invites.listPending({ who: learner, now })).rejects.toBeInstanceOf(NotAdminError); + }); +}); + +describe('cancel', () => { + it('deletes a pending Invite, and its link no longer works', async () => { + const { invites } = await setup(); + const made = await invites.create({ who: admin, now }); + expect(await invites.cancel({ who: admin, id: made.id })).toBe(true); + expect(await invites.lookup(made.token, now)).toBeNull(); + expect(await invites.listPending({ who: admin, now })).toEqual([]); + }); + + it('is safe for an unknown, cancelled or used Invite', async () => { + const { db, invites } = await setup(); + const made = await invites.create({ who: admin, now }); + await invites.cancel({ who: admin, id: made.id }); + expect(await invites.cancel({ who: admin, id: made.id })).toBe(false); + expect(await invites.cancel({ who: admin, id: 'nope' })).toBe(false); + const used = await invites.create({ who: admin, now }); + await db.invite.update({ where: { id: used.id }, data: { usedAt: now } }); + expect(await invites.cancel({ who: admin, id: used.id })).toBe(false); + expect(await db.invite.count()).toBe(1); + }); + + it('refuses a learner and deletes nothing', async () => { + const { db, invites } = await setup(); + const made = await invites.create({ who: admin, now }); + await expect(invites.cancel({ who: learner, id: made.id })).rejects.toBeInstanceOf( + NotAdminError + ); + expect(await db.invite.count()).toBe(1); + }); +}); + +describe('lookup', () => { + it('finds a pending Invite by its token', async () => { + const { invites } = await setup(); + const made = await invites.create({ who: admin, now }); + expect(await invites.lookup(made.token, now)).toEqual({ id: made.id }); + }); + + it('refuses a missing, malformed, used or expired token', async () => { + const { db, invites } = await setup(); + const made = await invites.create({ who: admin, now }); + expect(await invites.lookup('A'.repeat(43), now)).toBeNull(); + expect(await invites.lookup('', now)).toBeNull(); + expect(await invites.lookup(hashToken(made.token), now)).toBeNull(); + const justBefore = new Date(now.getTime() + INVITE_LIFETIME_MS - 1); + expect(await invites.lookup(made.token, justBefore)).toEqual({ id: made.id }); + const atExpiry = new Date(now.getTime() + INVITE_LIFETIME_MS); + expect(await invites.lookup(made.token, atExpiry)).toBeNull(); + await db.invite.update({ where: { id: made.id }, data: { usedAt: now } }); + expect(await invites.lookup(made.token, now)).toBeNull(); + }); +}); diff --git a/src/lib/server/identity/invites.ts b/src/lib/server/identity/invites.ts new file mode 100644 index 00000000..fe361d4e --- /dev/null +++ b/src/lib/server/identity/invites.ts @@ -0,0 +1,111 @@ +import { createHash, randomBytes, randomUUID } from 'node:crypto'; +import type { Db } from '../db.ts'; +import { NotAdminError } from './accounts.ts'; +import type { Identity } from './index.ts'; + +/** An Invite works for 7 days from its creation. */ +export const INVITE_LIFETIME_MS = 7 * 24 * 60 * 60 * 1000; + +/** 32 random bytes as base64url: 43 characters, no padding. */ +const TOKEN_SHAPE = /^[A-Za-z0-9_-]{43}$/; + +/** A pending Invite as the Users page lists it. Nothing the token can be rebuilt from is in it. */ +export interface PendingInvite { + id: string; + /** ISO 8601. */ + createdAt: string; + /** ISO 8601. */ + expiresAt: string; +} + +/** What `create` returns: the one and only time the token and the link exist outside the invitee's hands. */ +export interface CreatedInvite { + id: string; + token: string; + link: string; + expiresAt: string; +} + +/** + * The Invite module. An Invite is a link that works once, for one new account, for 7 days. The token + * is 32 random bytes; only its SHA-256 is stored, so the table cannot give a link back. The admin + * sees the link once, when it is created. + * + * `create`, `listPending` and `cancel` are for the admin: `who.role` must be `admin` exactly. + * `lookup` is for the invitee, who has no Account; it judges against a `now` passed in. + */ +export interface Invites { + create(input: { who: Identity; now: Date }): Promise; + /** Not used, not expired; oldest first. */ + listPending(input: { who: Identity; now: Date }): Promise; + /** + * Deletes a pending Invite (the schema has no cancelled state). False, and nothing changed, when + * there is no such unused Invite: unknown, already cancelled and used look the same. + */ + cancel(input: { who: Identity; id: string }): Promise; + /** The Invite this token names, or null when it is missing, used, expired or cancelled. */ + lookup(token: string, now: Date): Promise<{ id: string } | null>; +} + +export const hashToken = (token: string) => createHash('sha256').update(token).digest('hex'); + +export function createInvites({ db, origin }: { db: Db; origin: string }): Invites { + const requireAdmin = (who: Identity) => { + if (who.role !== 'admin') throw new NotAdminError(); + }; + + return { + async create({ who, now }) { + requireAdmin(who); + // Identity names a Learner; the Invite belongs to the User that Learner signed in as. + const user = await db.user.findUnique({ + where: { learnerId: who.learnerId }, + select: { id: true } + }); + if (!user) throw new NotAdminError(); + const token = randomBytes(32).toString('base64url'); + const expiresAt = new Date(now.getTime() + INVITE_LIFETIME_MS); + const id = randomUUID(); + await db.invite.create({ + data: { + id, + tokenHash: hashToken(token), + createdAt: now, + expiresAt, + createdByUserId: user.id + } + }); + return { id, token, link: `${origin}/invite/${token}`, expiresAt: expiresAt.toISOString() }; + }, + + async listPending({ who, now }) { + requireAdmin(who); + const rows = await db.invite.findMany({ + where: { usedAt: null, expiresAt: { gt: now } }, + orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], + select: { id: true, createdAt: true, expiresAt: true } + }); + return rows.map((row) => ({ + id: row.id, + createdAt: row.createdAt.toISOString(), + expiresAt: row.expiresAt.toISOString() + })); + }, + + async cancel({ who, id }) { + requireAdmin(who); + const { count } = await db.invite.deleteMany({ where: { id, usedAt: null } }); + return count > 0; + }, + + async lookup(token, now) { + if (!TOKEN_SHAPE.test(token)) return null; + const row = await db.invite.findUnique({ + where: { tokenHash: hashToken(token) }, + select: { id: true, usedAt: true, expiresAt: true } + }); + if (!row || row.usedAt !== null || row.expiresAt.getTime() <= now.getTime()) return null; + return { id: row.id }; + } + }; +} From e0319758903ce14ba6b977f02acb3f445e4b9eb5 Mon Sep 17 00:00:00 2001 From: Gauransh Mathur Date: Fri, 16 Oct 2026 14:17:25 +0000 Subject: [PATCH 2/5] feat(beyondleetcode): C35a Users page creates and cancels Invites Co-Authored-By: Claude Sonnet 5.5 --- src/app.d.ts | 3 + src/lib/server/app.test.ts | 3 + src/lib/server/app.ts | 17 ++- src/lib/server/handle.test.ts | 19 ++- src/lib/server/handle.ts | 1 + src/lib/server/identity/invites.test.ts | 8 +- src/routes/signup/signup-gate.test.ts | 7 +- src/routes/users/+page.server.ts | 51 +++++++- src/routes/users/+page.svelte | 134 ++++++++++++++++++++ src/routes/users/users.test.ts | 161 +++++++++++++++++++++++- 10 files changed, 392 insertions(+), 12 deletions(-) diff --git a/src/app.d.ts b/src/app.d.ts index 13955339..13213b1f 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -1,6 +1,7 @@ import type { Setup } from '$lib/server/app'; import type { Identity } from '$lib/server/identity'; import type { Auth } from '$lib/server/identity/auth'; +import type { Invites } from '$lib/server/identity/invites'; import type { Instance } from '$lib/server/instance'; import type { Learner } from '$lib/server/learning/core'; @@ -19,6 +20,8 @@ declare global { setup: Setup; /** Better Auth; set only in Multi-user Mode, where the auth route mounts its handler. */ auth?: Auth; + /** Invite links; set exactly when `auth` is (Multi-user Mode), so Local Mode never has them. */ + invites?: Invites; /** Who this request is, from Identity. Unset only on /setup before the Instance is set up. */ identity: Identity; /** The Learning core acting for this request's Learner. Unset exactly when `identity` is. */ diff --git a/src/lib/server/app.test.ts b/src/lib/server/app.test.ts index 781225a8..1c9de95d 100644 --- a/src/lib/server/app.test.ts +++ b/src/lib/server/app.test.ts @@ -111,6 +111,9 @@ describe('Multi-user', () => { request: { headers: new Headers({ cookie }) } }); expect(who).toMatchObject({ displayName: 'Ada', role: 'admin', learnerId: 'local' }); + // Invite links carry the validated ORIGIN, the one Better Auth uses. + const made = await app.multiUser?.invites.create({ who: who as never, now: new Date() }); + expect(made?.link).toBe(`http://localhost:4174/invite/${made?.token}`); expect(await db.learner.findMany()).toEqual([expect.objectContaining({ id: 'local' })]); }, 30_000); diff --git a/src/lib/server/app.ts b/src/lib/server/app.ts index e5107df0..2b720950 100644 --- a/src/lib/server/app.ts +++ b/src/lib/server/app.ts @@ -2,6 +2,7 @@ import { type Catalogue, loadCatalogue } from './content/catalogue.ts'; import { createDb, type Db } from './db.ts'; import { type Auth, createAuth, isPlaceholderSecret, MIN_SECRET_LENGTH } from './identity/auth.ts'; import { createLocalIdentity, type IdentityResolver } from './identity/index.ts'; +import { createInvites, type Invites } from './identity/invites.ts'; import { createMultiUserIdentity } from './identity/multi-user.ts'; import { createRegistration } from './identity/registration.ts'; import { @@ -41,7 +42,7 @@ export interface App { /** The Instance's mode, read by the hook on every request. One per server: it caches the mode. */ instance: Instance; /** Better Auth and the Multi-user adapter; null when the secret or the origin is not set. */ - multiUser: { auth: Auth; identity: IdentityResolver } | null; + multiUser: { auth: Auth; identity: IdentityResolver; invites: Invites } | null; setup: Setup; } @@ -79,7 +80,7 @@ export async function createApp(env: { const db = createDb(env.DATABASE_URL); const instance = await createInstance(db); const auth = buildAuth(env, db, instance); - const multiUser = auth.kind === 'built' ? chooseMultiUser(auth.auth) : null; + const multiUser = auth.kind === 'built' ? chooseMultiUser(auth, db) : null; if (instance.mode() === 'multi') { // The test cookie is read only by the Local adapter, which a multi-user Instance never consults; // refuse the combination anyway. The browser tests' Multi-user server boots empty and becomes multi. @@ -146,7 +147,9 @@ function refuseAuthEnvOverrides(): void { } } -type BuiltAuth = { kind: 'built'; auth: Auth } | { kind: 'missing'; message: string }; +type BuiltAuth = + | { kind: 'built'; auth: Auth; origin: string } + | { kind: 'missing'; message: string }; /** * Better Auth, when the secret is set and long enough and ORIGIN is a real http(s) URL. Never with an @@ -190,6 +193,7 @@ function buildAuth( } return { kind: 'built', + origin, auth: createAuth({ db, origin, secret, admitSignUp: createRegistration({ instance }) }) }; } @@ -203,7 +207,12 @@ function originOf(value: string | undefined): string | null { } } -const chooseMultiUser = (auth: Auth) => ({ auth, identity: createMultiUserIdentity(auth) }); +const chooseMultiUser = (built: Extract, db: Db) => ({ + auth: built.auth, + identity: createMultiUserIdentity(built.auth), + // Invite links name the same ORIGIN the cookies and the origin check use. + invites: createInvites({ db, origin: built.origin }) +}); /** Multi-user is open to every Instance that has what Better Auth needs; otherwise Setup says what to add. */ function choice(auth: BuiltAuth): MultiUserChoice { diff --git a/src/lib/server/handle.test.ts b/src/lib/server/handle.test.ts index 7fc14822..9ffeb384 100644 --- a/src/lib/server/handle.test.ts +++ b/src/lib/server/handle.test.ts @@ -4,6 +4,7 @@ import type { App } from './app.ts'; import { createHandle } from './handle.ts'; import { createAuth } from './identity/auth.ts'; import { createLocalIdentity } from './identity/index.ts'; +import { createInvites } from './identity/invites.ts'; import { createMultiUserIdentity } from './identity/multi-user.ts'; import { createRegistration } from './identity/registration.ts'; import { createFirstAdmin } from './identity/setup.ts'; @@ -192,7 +193,11 @@ describe('Multi-user Mode', () => { .join('; '); const identity = await createLocalIdentity(db, options); const core = { forLearner: (learnerId: string) => ({ learnerId }) } as unknown as App['core']; - const multiUser = { auth, identity: createMultiUserIdentity(auth) }; + const multiUser = { + auth, + identity: createMultiUserIdentity(auth), + invites: createInvites({ db, origin: 'http://localhost:3000' }) + }; const setupPort = { multiUser: { kind: 'open' }, createFirstAdmin: vi.fn() } as App['setup']; return { db, @@ -204,6 +209,18 @@ describe('Multi-user Mode', () => { }; } + it('puts the Invites on locals in Multi-user Mode, and never in Local Mode', async () => { + const { handle, asking } = await multiSetup(); + const { event, locals, resolve } = asking('/', '/'); + await handle({ event, resolve }); + expect(locals.invites).toBeDefined(); + + const local = await setup({ local: true }); + const asked = request('/', '/'); + await local.handle({ event: asked.event, resolve: asked.resolve }); + expect(asked.locals.invites).toBeUndefined(); + }); + it('resolves the session cookie to its Account and binds the Learning core to its Learner', async () => { const { handle, asking } = await multiSetup(); const { event, locals, resolve } = asking('/', '/'); diff --git a/src/lib/server/handle.ts b/src/lib/server/handle.ts index dfe5cfbb..cd086995 100644 --- a/src/lib/server/handle.ts +++ b/src/lib/server/handle.ts @@ -30,6 +30,7 @@ export function createHandle( // Cannot happen at start (createApp refuses it); fail closed rather than serve nobody. if (!app.multiUser) return refuseFraming(refusal(503)); event.locals.auth = app.multiUser.auth; + event.locals.invites = app.multiUser.invites; who = await app.multiUser.identity.resolve(event); } // An active session was extended: its new cookie goes out with whatever this request answers (a diff --git a/src/lib/server/identity/invites.test.ts b/src/lib/server/identity/invites.test.ts index 9c039680..d7f5a59f 100644 --- a/src/lib/server/identity/invites.test.ts +++ b/src/lib/server/identity/invites.test.ts @@ -12,7 +12,13 @@ async function setup() { const db = await createTestDb(); await db.learner.create({ data: { id: admin.learnerId } }); await db.user.create({ - data: { id: 'u-admin', name: 'Ada', email: 'ada@example.com', role: 'admin', learnerId: 'L-admin' } + data: { + id: 'u-admin', + name: 'Ada', + email: 'ada@example.com', + role: 'admin', + learnerId: 'L-admin' + } }); return { db, invites: createInvites({ db, origin }) }; } diff --git a/src/routes/signup/signup-gate.test.ts b/src/routes/signup/signup-gate.test.ts index eddac6a9..ad522adb 100644 --- a/src/routes/signup/signup-gate.test.ts +++ b/src/routes/signup/signup-gate.test.ts @@ -4,6 +4,7 @@ import type { App } from '$lib/server/app'; import { createHandle } from '$lib/server/handle'; import { createAuth } from '$lib/server/identity/auth'; import { createLocalIdentity } from '$lib/server/identity/index'; +import { createInvites } from '$lib/server/identity/invites'; import { createMultiUserIdentity } from '$lib/server/identity/multi-user'; import { createRegistration } from '$lib/server/identity/registration'; import { createInstance } from '$lib/server/instance'; @@ -31,7 +32,11 @@ async function app(mode: 'local' | 'multi' | null) { secret: 'test-secret-test-secret-test-secret-0123', admitSignUp: createRegistration({ instance }) }); - multiUser = { auth, identity: createMultiUserIdentity(auth) }; + multiUser = { + auth, + identity: createMultiUserIdentity(auth), + invites: createInvites({ db, origin: 'http://localhost:3000' }) + }; } const core = { forLearner: (learnerId: string) => ({ learnerId }) } as unknown as App['core']; const setup = { multiUser: { kind: 'later' }, createFirstAdmin: vi.fn() } as App['setup']; diff --git a/src/routes/users/+page.server.ts b/src/routes/users/+page.server.ts index 5a044c97..db7212d6 100644 --- a/src/routes/users/+page.server.ts +++ b/src/routes/users/+page.server.ts @@ -9,15 +9,18 @@ import type { Actions, PageServerLoad } from './$types'; * 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; + const { auth, identity, invites } = locals; // Better Auth is set only in Multi-user Mode: Local Mode has no Accounts, whatever role it gives the Local Learner. if (!auth) error(404, 'Not found'); if (!identity) error(403, 'Forbidden'); try { 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. + // Set exactly when `auth` is; fail closed anyway. After the Accounts, whose read refuses a learner first. + if (!invites) error(404, 'Not found'); + const pending = await invites.listPending({ who: identity, now: new Date() }); + // Names and emails of everyone, and the pending Invites: not for a shared cache, nor for the back button after sign-out. setHeaders({ 'Cache-Control': 'no-store' }); - return { accounts, registration: locals.instance.registrationMode() }; + return { accounts, pending, registration: locals.instance.registrationMode() }; } catch (thrown) { if (thrown instanceof NotAdminError) error(403, 'Forbidden'); throw thrown; @@ -54,5 +57,47 @@ export const actions: Actions = { return fail(500, { message: 'Registration could not be changed. Try again.' }); } return { registration: choice }; + }, + + /** + * Makes an Invite and answers with its link: the one response that ever carries the token. It is + * never cached, never put in a redirect, and not stored (only its hash is). Admin only, checked first. + */ + invite: async ({ locals, setHeaders }) => { + const { identity, invites } = locals; + if (identity?.role !== 'admin') error(403, 'Forbidden'); + if (!invites) error(404, 'Not found'); + setHeaders({ 'Cache-Control': 'no-store' }); + try { + const made = await invites.create({ who: identity, now: new Date() }); + return { invite: { link: made.link, expiresAt: made.expiresAt } }; + } catch (thrown) { + if (thrown instanceof NotAdminError) error(403, 'Forbidden'); + console.error( + 'Users: creating an Invite failed:', + thrown instanceof Error ? thrown.name : typeof thrown + ); + return fail(500, { message: 'The invite could not be created. Try again.' }); + } + }, + + /** Cancels a pending Invite. An unknown, used or already-cancelled one gets the same fixed message. */ + cancel: async ({ locals, request }) => { + const { identity, invites } = locals; + if (identity?.role !== 'admin') error(403, 'Forbidden'); + if (!invites) error(404, 'Not found'); + const id = (await request.formData()).get('id'); + try { + const cancelled = typeof id === 'string' && (await invites.cancel({ who: identity, id })); + if (!cancelled) return fail(404, { message: 'That invite is no longer pending.' }); + } catch (thrown) { + if (thrown instanceof NotAdminError) error(403, 'Forbidden'); + console.error( + 'Users: cancelling an Invite failed:', + thrown instanceof Error ? thrown.name : typeof thrown + ); + return fail(500, { message: 'The invite could not be cancelled. Try again.' }); + } + return { cancelled: true }; } }; diff --git a/src/routes/users/+page.svelte b/src/routes/users/+page.svelte index a67903e4..73bef93f 100644 --- a/src/routes/users/+page.svelte +++ b/src/routes/users/+page.svelte @@ -18,6 +18,31 @@ const explanation = { // A refused or failed switch changed nothing: the select goes back to what the Instance holds, so it // never shows a mode that is not in force. +// "Done" hides the link; it cannot be shown again, so it stays hidden until the next Invite is made. +let dismissed = $state(false); +let copied = $state(false); +let linkField = $state(); +const invite = $derived(dismissed ? undefined : form && 'invite' in form ? form.invite : undefined); + +async function copyLink() { + if (!invite) return; + try { + await navigator.clipboard.writeText(invite.link); + copied = true; + } catch { + // No clipboard access (plain http, or refused): select the link so Ctrl+C works. + linkField?.select(); + } +} + +const makeInvite: SubmitFunction = () => { + dismissed = false; + copied = false; + return async ({ update }) => { + await update({ reset: false }); + }; +}; + const switchRegistration: SubmitFunction = ({ formElement }) => { return async ({ result, update }) => { await update({ reset: false }); @@ -63,6 +88,46 @@ const switchRegistration: SubmitFunction = ({ formElement }) => { +
+
+

Invite a user

+

Creates a link for one new account.

+
+
+
+ +
+ + {#if invite} +
+ Invite link ready +
+ e.currentTarget.select()} + /> + {#if enhanced} + + {/if} +
+ + There is no email. Copy this link and send it to the person yourself. It works for one + account, for 7 days. + + {#if enhanced} + + {/if} +
+ {/if} +
+
+

All users

@@ -93,6 +158,20 @@ const switchRegistration: SubmitFunction = ({ formElement }) => { {/each} + {#each data.pending as pending (pending.id)} + + Pending invite + — + — + invited + +
+ + +
+ + + {/each}
@@ -206,6 +285,61 @@ const switchRegistration: SubmitFunction = ({ formElement }) => { font-size: 15px; font-weight: 500; } + .control .outline { + background: transparent; + color: var(--ink); + border: 1px solid var(--ink); + } + .detail { + display: flex; + flex-direction: column; + gap: 12px; + padding: 20px 0 0; + border-top: 1px solid var(--rule); + } + .eyebrow.ink { + color: var(--ink); + font-size: 12px; + } + .copy { + display: flex; + gap: 12px; + flex-wrap: wrap; + } + .copy input { + flex: 1 1 280px; + min-width: 0; + height: 52px; + box-sizing: border-box; + padding: 0 16px; + background: var(--field); + border: 1px solid var(--ink); + border-radius: 2px; + color: var(--ink); + font-family: var(--font-mono); + font-size: 14px; + } + .small { + font-size: 16px; + } + .control .quiet, + td .quiet { + height: auto; + padding: 12px 0; + line-height: 20px; + background: transparent; + border: 0; + color: var(--ink); + font-family: var(--font-mono); + font-size: 14px; + font-weight: 400; + cursor: pointer; + } + .pending { + font-size: 21px; + font-style: italic; + color: var(--muted); + } .lead { margin: 0; font-size: 18px; diff --git a/src/routes/users/users.test.ts b/src/routes/users/users.test.ts index 9654873a..ec123c40 100644 --- a/src/routes/users/users.test.ts +++ b/src/routes/users/users.test.ts @@ -5,6 +5,7 @@ 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 { createInvites } from '$lib/server/identity/invites'; import { createMultiUserIdentity } from '$lib/server/identity/multi-user'; import { createRegistration } from '$lib/server/identity/registration'; import { createFirstAdmin } from '$lib/server/identity/setup'; @@ -51,16 +52,17 @@ async function setup() { return cookieOf(signedUp.headers); } + const invites = createInvites({ db, origin: 'http://localhost:3000' }); const core = { forLearner: (learnerId: string) => ({ learnerId }) } as unknown as App['core']; const setupPort = { multiUser: { kind: 'open' }, createFirstAdmin: vi.fn() } as App['setup']; const handle = createHandle({ instance, identity: await createLocalIdentity(db), core, - multiUser: { auth, identity: createMultiUserIdentity(auth) }, + multiUser: { auth, identity: createMultiUserIdentity(auth), invites }, setup: setupPort }); - return { db, auth, instance, admin, learner, handle }; + return { db, auth, instance, admin, learner, handle, invites }; } /** Runs one request through the hook; the page's load runs where SvelteKit would run it. */ @@ -467,3 +469,158 @@ describe('the Registration action, if the hook ever let a request through', () = expect(i.setRegistrationMode).toHaveBeenCalledExactlyOnceWith('open'); }); }); + +/** Posts to the Invite actions through the real hook. */ +async function post( + handle: ReturnType, + cookie: string, + name: 'invite' | 'cancel', + fields: Record = {} +) { + const setHeaders = vi.fn(); + const resolve = vi.fn(async (event: RequestEvent, _opts?: ResolveOptions) => { + const result = await actions[name]({ ...event, setHeaders } as never); + return Response.json(result ?? null, { status: result?.status ?? 200 }); + }); + const event = { + url: new URL(`/users?/${name}`, 'http://localhost'), + route: { id: '/users' }, + request: new Request(`http://localhost/users?/${name}`, { + 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, setHeaders }; +} + +describe('Invites on the Users page', () => { + it('lets the admin create an Invite: the link is in that one response, the database holds only a hash', async () => { + const { handle, admin, db } = await setup(); + + const { response, setHeaders } = await post(handle, admin, 'invite'); + + expect(response.status).toBe(200); + const text = JSON.stringify(await response.json()); + const link = text.match(/http:\\?\/\\?\/localhost:3000\\?\/invite\\?\/([\w-]{43})/); + expect(link).toBeTruthy(); + const token = (link as RegExpMatchArray)[1]; + expect(JSON.stringify(await db.invite.findMany())).not.toContain(token); + expect(setHeaders).toHaveBeenCalledWith({ 'Cache-Control': 'no-store' }); + }); + + it('lists pending Invites in the load, without any token', async () => { + const { handle, admin, invites } = await setup(); + const made = await invites.create({ + who: { learnerId: 'local', displayName: 'Ada', role: 'admin' }, + now: new Date() + }); + + const { response } = await visit(handle, '/users', admin); + + const body = await response.json(); + expect(body.pending).toEqual([expect.objectContaining({ id: made.id })]); + expect(JSON.stringify(body)).not.toContain(made.token); + }); + + it('cancels a pending Invite; the cancelled link no longer works', async () => { + const { handle, admin, invites } = await setup(); + const made = await invites.create({ + who: { learnerId: 'local', displayName: 'Ada', role: 'admin' }, + now: new Date() + }); + + const { response } = await post(handle, admin, 'cancel', { id: made.id }); + + expect(response.status).toBe(200); + expect(await invites.lookup(made.token, new Date())).toBeNull(); + }); + + it.each([ + ['an unknown id', { id: 'nope' }], + ['no id', {}] + ])('answers %s with a fixed message', async (_, fields) => { + const { handle, admin } = await setup(); + + const { response } = await post(handle, admin, 'cancel', fields); + + expect(response.status).toBe(404); + expect(JSON.stringify(await response.json())).toContain('no longer pending'); + }); + + it.each(['invite', 'cancel'] as const)( + '%s: refuses a learner with 403 and writes nothing', + async (name) => { + const { handle, learner, db, invites } = await setup(); + const lin = await learner('Lin Learner', 'lin@example.com'); + const made = await invites.create({ + who: { learnerId: 'local', displayName: 'Ada', role: 'admin' }, + now: new Date() + }); + + const { response, resolve } = await post(handle, lin, name, { id: made.id }); + + expect(response.status).toBe(403); + expect(resolve).not.toHaveBeenCalled(); + expect(await db.invite.count()).toBe(1); + } + ); + + it.each(['invite', 'cancel'] as const)( + '%s: sends a signed-out request to Sign in', + async (name) => { + const { handle, db } = await setup(); + + const { response, resolve } = await post(handle, '', name, { id: 'x' }); + + expect(response.status).toBe(303); + expect(resolve).not.toHaveBeenCalled(); + expect(await db.invite.count()).toBe(0); + } + ); + + it.each(['invite', 'cancel'] as const)('%s: does not exist in Local Mode', async (name) => { + 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 post(handle, '', name, { id: 'x' }); + + expect(response.status).toBe(404); + expect(resolve).not.toHaveBeenCalled(); + expect(await db.invite.count()).toBe(0); + }); + + it.each(['invite', 'cancel'] as const)( + '%s: is 403 for a non-admin, 404 without Invites, even past the hook', + async (name) => { + const learner = { learnerId: 'l', displayName: 'N', role: 'learner' as const }; + const admin = { ...learner, role: 'admin' as const }; + const event = (locals: Record) => + ({ locals, setHeaders: vi.fn(), request: { formData: vi.fn() } }) as never; + await expect(actions[name](event({ identity: learner, invites: {} }))).rejects.toMatchObject({ + status: 403 + }); + await expect(actions[name](event({}))).rejects.toMatchObject({ status: 403 }); + await expect(actions[name](event({ identity: admin }))).rejects.toMatchObject({ + status: 404 + }); + } + ); +}); From 461cb44be8f6d8ea5f0d299ab1e1f08f3c983bc6 Mon Sep 17 00:00:00 2001 From: Gauransh Mathur Date: Fri, 16 Oct 2026 14:22:55 +0000 Subject: [PATCH 3/5] test(beyondleetcode): C35a Users e2e heading order Co-Authored-By: Claude Sonnet 5.5 --- src/routes/users/registration.multi.e2e.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/routes/users/registration.multi.e2e.ts b/src/routes/users/registration.multi.e2e.ts index 79ce3d4a..c739f591 100644 --- a/src/routes/users/registration.multi.e2e.ts +++ b/src/routes/users/registration.multi.e2e.ts @@ -99,7 +99,8 @@ test.describe('on a Multi-user server of its own', () => { 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 expect(headings.nth(1)).toHaveText('Invite a user'); + await expect(headings.nth(2)).toHaveText('All users'); await context.close(); }); From bf44c4f991c01642bda551ac20f4279b5c1b2657 Mon Sep 17 00:00:00 2001 From: Gauransh Mathur Date: Sat, 17 Oct 2026 14:04:03 +0000 Subject: [PATCH 4/5] fix(beyondleetcode): C35a set no-store once for Users in the hook Co-Authored-By: Claude Sonnet 5.5 --- src/lib/server/handle.ts | 180 ++-- src/routes/users/+page.server.ts | 199 +++-- src/routes/users/+page.svelte | 4 +- src/routes/users/users.multi.e2e.ts | 260 ++++-- src/routes/users/users.test.ts | 1282 +++++++++++++++------------ 5 files changed, 1064 insertions(+), 861 deletions(-) diff --git a/src/lib/server/handle.ts b/src/lib/server/handle.ts index cd086995..24924bfe 100644 --- a/src/lib/server/handle.ts +++ b/src/lib/server/handle.ts @@ -1,8 +1,8 @@ -import type { Handle } from '@sveltejs/kit'; -import { parseTheme, THEME_COOKIE, withTheme } from '$lib/theme'; -import type { App } from './app.ts'; -import { gate } from './identity/gate.ts'; -import type { IdentityResolver } from './identity/index.ts'; +import type { Handle } from "@sveltejs/kit"; +import { parseTheme, THEME_COOKIE, withTheme } from "$lib/theme"; +import type { App } from "./app.ts"; +import { gate } from "./identity/gate.ts"; +import type { IdentityResolver } from "./identity/index.ts"; /** * Per request: refuse remote calls, read the Instance mode, resolve Identity with the adapter that mode @@ -11,98 +11,108 @@ import type { IdentityResolver } from './identity/index.ts'; * judges it like any page; an unmatched path has no route and the gate decides it too. */ export function createHandle( - app: Pick + app: Pick, ): Handle { - return async ({ event, resolve }) => { - const { url, request } = event; - // Remote calls take their pathname and route from client headers, so the gate cannot judge them; none exist yet. - // Static files never reach this hook (the server answers them first). - if (event.isRemoteRequest) return refuseFraming(refusal(404)); + return async ({ event, resolve }) => { + const { url, request } = event; + // Remote calls take their pathname and route from client headers, so the gate cannot judge them; none exist yet. + // Static files never reach this hook (the server answers them first). + if (event.isRemoteRequest) return refuseFraming(refusal(404)); - event.locals.instance = app.instance; - event.locals.setup = app.setup; - const mode = app.instance.mode(); - // Before Setup nobody is resolved, so no Local Learner is made. Only the adapter the mode names is - // asked: in Multi-user the Local adapter, and its test cookie, is never consulted. - let who: Awaited> = null; - if (mode === 'local') who = await app.identity.resolve(event); - if (mode === 'multi') { - // Cannot happen at start (createApp refuses it); fail closed rather than serve nobody. - if (!app.multiUser) return refuseFraming(refusal(503)); - event.locals.auth = app.multiUser.auth; - event.locals.invites = app.multiUser.invites; - who = await app.multiUser.identity.resolve(event); - } - // An active session was extended: its new cookie goes out with whatever this request answers (a - // redirect or refusal included), or users are signed out after 7 days of use. Better Auth's own - // Set-Cookie line, unchanged. Never over a cookie of the same name that the response sets itself: - // a sign-in as someone else, a sign-out or a password change answers with the cookie that must win. - const refreshed = who?.refreshedCookies ?? []; - const finish = (response: Response) => { - const own = new Set(response.headers.getSetCookie().map(cookieName)); - for (const cookie of refreshed) { - if (!own.has(cookieName(cookie))) response.headers.append('set-cookie', cookie); - } - return refuseFraming(response); - }; + event.locals.instance = app.instance; + event.locals.setup = app.setup; + const mode = app.instance.mode(); + // Before Setup nobody is resolved, so no Local Learner is made. Only the adapter the mode names is + // asked: in Multi-user the Local adapter, and its test cookie, is never consulted. + let who: Awaited> = null; + if (mode === "local") who = await app.identity.resolve(event); + if (mode === "multi") { + // Cannot happen at start (createApp refuses it); fail closed rather than serve nobody. + if (!app.multiUser) return refuseFraming(refusal(503)); + event.locals.auth = app.multiUser.auth; + event.locals.invites = app.multiUser.invites; + who = await app.multiUser.identity.resolve(event); + } + // An active session was extended: its new cookie goes out with whatever this request answers (a + // redirect or refusal included), or users are signed out after 7 days of use. Better Auth's own + // Set-Cookie line, unchanged. Never over a cookie of the same name that the response sets itself: + // a sign-in as someone else, a sign-out or a password change answers with the cookie that must win. + const refreshed = who?.refreshedCookies ?? []; + const finish = (response: Response) => { + const own = new Set(response.headers.getSetCookie().map(cookieName)); + for (const cookie of refreshed) { + if (!own.has(cookieName(cookie))) + response.headers.append("set-cookie", cookie); + } + return refuseFraming(response); + }; - const verdict = gate({ - mode, - who, - mustChangePassword: who?.mustChangePassword, - routeId: event.route.id, - isDataRequest: event.isDataRequest, - method: request.method, - pathname: url.pathname, - search: url.search - }); - if (verdict.kind === 'redirect') { - return finish( - new Response(null, { - status: verdict.status, - headers: { location: verdict.location, 'Cache-Control': 'no-store' } - }) - ); - } - if (verdict.kind === 'status') { - return finish(refusal(verdict.status)); - } + const verdict = gate({ + mode, + who, + mustChangePassword: who?.mustChangePassword, + routeId: event.route.id, + isDataRequest: event.isDataRequest, + method: request.method, + pathname: url.pathname, + search: url.search, + }); + if (verdict.kind === "redirect") { + return finish( + new Response(null, { + status: verdict.status, + headers: { location: verdict.location, "Cache-Control": "no-store" }, + }), + ); + } + if (verdict.kind === "status") { + return finish(refusal(verdict.status)); + } - // Unset where nobody is resolved: /setup before Setup, and the public pages of a multi-user Instance. - if (who) { - event.locals.identity = { - learnerId: who.learnerId, - displayName: who.displayName, - role: who.role - }; - event.locals.learning = app.core.forLearner(who.learnerId); - } - // No theme cookie means follow the device; a choice goes on in the server render, so no flash. - // Read when the page renders, not before: an action that just set the cookie is seen by its own response. - return finish( - await resolve(event, { - transformPageChunk: ({ html }) => - withTheme(html, parseTheme(event.cookies.get(THEME_COOKIE))) - }) - ); - }; + // Unset where nobody is resolved: /setup before Setup, and the public pages of a multi-user Instance. + if (who) { + event.locals.identity = { + learnerId: who.learnerId, + displayName: who.displayName, + role: who.role, + }; + event.locals.learning = app.core.forLearner(who.learnerId); + } + // No theme cookie means follow the device; a choice goes on in the server render, so no flash. + // Read when the page renders, not before: an action that just set the cookie is seen by its own response. + const response = await resolve(event, { + transformPageChunk: ({ html }) => + withTheme(html, parseTheme(event.cookies.get(THEME_COOKIE))), + }); + // Users shows everyone's names and emails and the one-time Invite link: never cached, whatever + // answers (the page, a data request, an action result, an error). Set here, once, because a + // route that sets it in both its action and its load makes SvelteKit throw on a no-JS form post. + if (event.route.id === "/users") + response.headers.set("Cache-Control", "no-store"); + return finish(response); + }; } /** The name of a `Set-Cookie` line: what comes before the first `=`. */ -const cookieName = (setCookie: string) => setCookie.slice(0, setCookie.indexOf('=')).trim(); +const cookieName = (setCookie: string) => + setCookie.slice(0, setCookie.indexOf("=")).trim(); /** A gate decision depends on the Instance state, so it is never cached. */ function refusal(status: 403 | 404 | 503): Response { - const text = { 403: 'Forbidden', 404: 'Not found', 503: 'Service unavailable' }[status]; - return new Response(text, { - status, - headers: { 'Cache-Control': 'no-store' } - }); + const text = { + 403: "Forbidden", + 404: "Not found", + 503: "Service unavailable", + }[status]; + return new Response(text, { + status, + headers: { "Cache-Control": "no-store" }, + }); } /** Reset progress is destructive: no other site may frame the app and trick a click (clickjacking). */ function refuseFraming(response: Response): Response { - response.headers.set('Content-Security-Policy', "frame-ancestors 'none'"); - response.headers.set('X-Frame-Options', 'DENY'); - return response; + response.headers.set("Content-Security-Policy", "frame-ancestors 'none'"); + response.headers.set("X-Frame-Options", "DENY"); + return response; } diff --git a/src/routes/users/+page.server.ts b/src/routes/users/+page.server.ts index db7212d6..55c7889a 100644 --- a/src/routes/users/+page.server.ts +++ b/src/routes/users/+page.server.ts @@ -1,103 +1,122 @@ -import { error, fail } from '@sveltejs/kit'; -import { listAccounts, NotAdminError } from '$lib/server/identity/accounts'; -import { isRegistrationMode, RegistrationNotAvailableError } from '$lib/server/instance'; -import type { Actions, PageServerLoad } from './$types'; +import { error, fail } from "@sveltejs/kit"; +import { listAccounts, NotAdminError } from "$lib/server/identity/accounts"; +import { + isRegistrationMode, + RegistrationNotAvailableError, +} from "$lib/server/instance"; +import type { Actions, PageServerLoad } from "./$types"; /** * 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, invites } = locals; - // Better Auth is set only in Multi-user Mode: Local Mode has no Accounts, whatever role it gives the Local Learner. - if (!auth) error(404, 'Not found'); - if (!identity) error(403, 'Forbidden'); - try { - const accounts = await listAccounts(auth, { who: identity, headers: request.headers }); - // Set exactly when `auth` is; fail closed anyway. After the Accounts, whose read refuses a learner first. - if (!invites) error(404, 'Not found'); - const pending = await invites.listPending({ who: identity, now: new Date() }); - // Names and emails of everyone, and the pending Invites: not for a shared cache, nor for the back button after sign-out. - setHeaders({ 'Cache-Control': 'no-store' }); - return { accounts, pending, registration: locals.instance.registrationMode() }; - } catch (thrown) { - if (thrown instanceof NotAdminError) error(403, 'Forbidden'); - throw thrown; - } +export const load: PageServerLoad = async ({ locals, request }) => { + const { auth, identity, invites } = locals; + // Better Auth is set only in Multi-user Mode: Local Mode has no Accounts, whatever role it gives the Local Learner. + if (!auth) error(404, "Not found"); + if (!identity) error(403, "Forbidden"); + try { + const accounts = await listAccounts(auth, { + who: identity, + headers: request.headers, + }); + // Set exactly when `auth` is; fail closed anyway. After the Accounts, whose read refuses a learner first. + if (!invites) error(404, "Not found"); + const pending = await invites.listPending({ + who: identity, + now: new Date(), + }); + return { + accounts, + pending, + 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'); + /** + * 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 }; - }, + 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 }; + }, - /** - * Makes an Invite and answers with its link: the one response that ever carries the token. It is - * never cached, never put in a redirect, and not stored (only its hash is). Admin only, checked first. - */ - invite: async ({ locals, setHeaders }) => { - const { identity, invites } = locals; - if (identity?.role !== 'admin') error(403, 'Forbidden'); - if (!invites) error(404, 'Not found'); - setHeaders({ 'Cache-Control': 'no-store' }); - try { - const made = await invites.create({ who: identity, now: new Date() }); - return { invite: { link: made.link, expiresAt: made.expiresAt } }; - } catch (thrown) { - if (thrown instanceof NotAdminError) error(403, 'Forbidden'); - console.error( - 'Users: creating an Invite failed:', - thrown instanceof Error ? thrown.name : typeof thrown - ); - return fail(500, { message: 'The invite could not be created. Try again.' }); - } - }, + /** + * Makes an Invite and answers with its link: the one response that ever carries the token. It is + * never cached (the hook sets `no-store` on every /users response), never put in a redirect, and not stored (only its hash is). Admin only, checked first. + */ + invite: async ({ locals }) => { + const { identity, invites } = locals; + if (identity?.role !== "admin") error(403, "Forbidden"); + if (!invites) error(404, "Not found"); + try { + const made = await invites.create({ who: identity, now: new Date() }); + return { invite: { link: made.link, expiresAt: made.expiresAt } }; + } catch (thrown) { + if (thrown instanceof NotAdminError) error(403, "Forbidden"); + console.error( + "Users: creating an Invite failed:", + thrown instanceof Error ? thrown.name : typeof thrown, + ); + return fail(500, { + message: "The invite could not be created. Try again.", + }); + } + }, - /** Cancels a pending Invite. An unknown, used or already-cancelled one gets the same fixed message. */ - cancel: async ({ locals, request }) => { - const { identity, invites } = locals; - if (identity?.role !== 'admin') error(403, 'Forbidden'); - if (!invites) error(404, 'Not found'); - const id = (await request.formData()).get('id'); - try { - const cancelled = typeof id === 'string' && (await invites.cancel({ who: identity, id })); - if (!cancelled) return fail(404, { message: 'That invite is no longer pending.' }); - } catch (thrown) { - if (thrown instanceof NotAdminError) error(403, 'Forbidden'); - console.error( - 'Users: cancelling an Invite failed:', - thrown instanceof Error ? thrown.name : typeof thrown - ); - return fail(500, { message: 'The invite could not be cancelled. Try again.' }); - } - return { cancelled: true }; - } + /** Cancels a pending Invite. An unknown, used or already-cancelled one gets the same fixed message. */ + cancel: async ({ locals, request }) => { + const { identity, invites } = locals; + if (identity?.role !== "admin") error(403, "Forbidden"); + if (!invites) error(404, "Not found"); + const id = (await request.formData()).get("id"); + try { + const cancelled = + typeof id === "string" && (await invites.cancel({ who: identity, id })); + if (!cancelled) + return fail(404, { message: "That invite is no longer pending." }); + } catch (thrown) { + if (thrown instanceof NotAdminError) error(403, "Forbidden"); + console.error( + "Users: cancelling an Invite failed:", + thrown instanceof Error ? thrown.name : typeof thrown, + ); + return fail(500, { + message: "The invite could not be cancelled. Try again.", + }); + } + return { cancelled: true }; + }, }; diff --git a/src/routes/users/+page.svelte b/src/routes/users/+page.svelte index 73bef93f..556bec0d 100644 --- a/src/routes/users/+page.svelte +++ b/src/routes/users/+page.svelte @@ -16,8 +16,6 @@ const explanation = { open: 'Open: anyone who can reach this instance can create an account.' }; -// A refused or failed switch changed nothing: the select goes back to what the Instance holds, so it -// never shows a mode that is not in force. // "Done" hides the link; it cannot be shown again, so it stays hidden until the next Invite is made. let dismissed = $state(false); let copied = $state(false); @@ -43,6 +41,8 @@ const makeInvite: SubmitFunction = () => { }; }; +// A refused or failed switch changed nothing: the select goes back to what the Instance holds, so it +// never shows a mode that is not in force. const switchRegistration: SubmitFunction = ({ formElement }) => { return async ({ result, update }) => { await update({ reset: false }); diff --git a/src/routes/users/users.multi.e2e.ts b/src/routes/users/users.multi.e2e.ts index c31f9ebd..15421e51 100644 --- a/src/routes/users/users.multi.e2e.ts +++ b/src/routes/users/users.multi.e2e.ts @@ -1,16 +1,20 @@ -import { expect, test } from '@playwright/test'; -import { E2E_ADMIN } from '../../lib/e2e-admin.ts'; -import { LEARNER_PASSWORD, openMultiUserDatabase, type Session } from './multi-fixture.ts'; +import { expect, test } from "@playwright/test"; +import { E2E_ADMIN } from "../../lib/e2e-admin.ts"; +import { + LEARNER_PASSWORD, + openMultiUserDatabase, + type Session, +} from "./multi-fixture.ts"; // The Users page on the Multi-user server (the "e2e-multi" project, after "setup-multi" made the admin). // Learners are made through multi-fixture.ts, because sign-up and invites have no screens yet. -const LIN = { name: 'Lin Learner', email: 'lin.users@example.com' }; -const SAM = { name: 'Sam Sneaky', email: 'sam.users@example.com' }; +const LIN = { name: "Lin Learner", email: "lin.users@example.com" }; +const SAM = { name: "Sam Sneaky", email: "sam.users@example.com" }; // A name is whatever the person typed when they signed up, and the admin reads it. const MARKUP = { - name: 'Mal', - email: 'mal.users@example.com' + name: 'Mal', + email: "mal.users@example.com", }; let fixture: Awaited>; @@ -19,114 +23,180 @@ let lin: Session; let sam: Session; test.beforeAll(async () => { - fixture = await openMultiUserDatabase(); - await fixture.ensureLearner(LIN); - await fixture.ensureLearner(SAM); - await fixture.ensureLearner(MARKUP); - // 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); + fixture = await openMultiUserDatabase(); + await fixture.ensureLearner(LIN); + await fixture.ensureLearner(SAM); + await fixture.ensureLearner(MARKUP); + // 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(); + await fixture.close(); }); -test('the admin finds Users in the nav and sees every Account on it', async ({ context, page }) => { - await context.addCookies(admin.cookies); - await page.goto('/'); - const nav = page.getByRole('navigation', { name: 'Primary' }); - await expect(nav.getByRole('link')).toHaveText(['Map', 'Users', E2E_ADMIN.name]); - await expect(nav.getByRole('link', { name: 'Users' })).not.toHaveAttribute('aria-current', /.*/); - - await nav.getByRole('link', { name: 'Users' }).click(); - - await expect(page).toHaveURL('/users'); - await expect(page.getByRole('heading', { level: 1 })).toHaveText('Users'); - await expect(nav.getByRole('link', { name: 'Users' })).toHaveAttribute('aria-current', 'page'); - await expect(nav.getByRole('link', { name: 'Map' })).not.toHaveAttribute('aria-current', /.*/); - const table = page.getByRole('table', { name: 'All users' }); - await expect(table.getByRole('columnheader')).toHaveText([ - 'name', - 'email', - 'role', - 'joined', - 'actions' - ]); - - // The database is the truth, and other files may add Accounts at any moment: compare what is stored - // now with what the page shows, until the two agree. - await expect(async () => { - const stored = await fixture.db.user.findMany({ orderBy: { createdAt: 'asc' } }); - await page.reload(); - const rows = table.locator('tbody tr'); - await expect(rows).toHaveCount(stored.length, { timeout: 2_000 }); - for (const [i, user] of stored.entries()) { - const cells = rows.nth(i).getByRole('cell'); - const you = user.email === E2E_ADMIN.email; - await expect(cells.nth(0)).toHaveText(you ? `${user.name} you` : user.name); - await expect(cells.nth(1)).toHaveText(user.email); - await expect(cells.nth(2)).toHaveText(user.role === 'admin' ? 'admin' : 'learner'); - await expect(cells.nth(3)).toHaveText(user.createdAt.toISOString().slice(0, 10)); - await expect(cells.nth(4)).toHaveText(you ? 'you cannot remove yourself' : ''); - } - }).toPass(); - - // The admin and the learners made above are among them; no row has an action yet (reset password and - // remove are later cards). - await expect( - table.getByRole('row', { name: new RegExp(`${E2E_ADMIN.email}.*admin`) }) - ).toBeVisible(); - await expect(table.getByRole('row', { name: new RegExp(`${LIN.email}.*learner`) })).toBeVisible(); - await expect(table.getByRole('button')).toHaveCount(0); +test("the admin finds Users in the nav and sees every Account on it", async ({ + context, + page, +}) => { + await context.addCookies(admin.cookies); + await page.goto("/"); + const nav = page.getByRole("navigation", { name: "Primary" }); + await expect(nav.getByRole("link")).toHaveText([ + "Map", + "Users", + E2E_ADMIN.name, + ]); + await expect(nav.getByRole("link", { name: "Users" })).not.toHaveAttribute( + "aria-current", + /.*/, + ); + + await nav.getByRole("link", { name: "Users" }).click(); + + await expect(page).toHaveURL("/users"); + await expect(page.getByRole("heading", { level: 1 })).toHaveText("Users"); + await expect(nav.getByRole("link", { name: "Users" })).toHaveAttribute( + "aria-current", + "page", + ); + await expect(nav.getByRole("link", { name: "Map" })).not.toHaveAttribute( + "aria-current", + /.*/, + ); + const table = page.getByRole("table", { name: "All users" }); + await expect(table.getByRole("columnheader")).toHaveText([ + "name", + "email", + "role", + "joined", + "actions", + ]); + + // The database is the truth, and other files may add Accounts at any moment: compare what is stored + // now with what the page shows, until the two agree. + await expect(async () => { + const stored = await fixture.db.user.findMany({ + orderBy: { createdAt: "asc" }, + }); + await page.reload(); + const rows = table.locator("tbody tr"); + await expect(rows).toHaveCount(stored.length, { timeout: 2_000 }); + for (const [i, user] of stored.entries()) { + const cells = rows.nth(i).getByRole("cell"); + const you = user.email === E2E_ADMIN.email; + await expect(cells.nth(0)).toHaveText( + you ? `${user.name} you` : user.name, + ); + await expect(cells.nth(1)).toHaveText(user.email); + await expect(cells.nth(2)).toHaveText( + user.role === "admin" ? "admin" : "learner", + ); + await expect(cells.nth(3)).toHaveText( + user.createdAt.toISOString().slice(0, 10), + ); + await expect(cells.nth(4)).toHaveText( + you ? "you cannot remove yourself" : "", + ); + } + }).toPass(); + + // The admin and the learners made above are among them; no row has an action yet (reset password and + // remove are later cards). + await expect( + table.getByRole("row", { name: new RegExp(`${E2E_ADMIN.email}.*admin`) }), + ).toBeVisible(); + await expect( + table.getByRole("row", { name: new RegExp(`${LIN.email}.*learner`) }), + ).toBeVisible(); + await expect(table.getByRole("button")).toHaveCount(0); }); -test('a name with markup in it is shown as text, never run', async ({ context, page }) => { - await context.addCookies(admin.cookies); +test("a name with markup in it is shown as text, never run", async ({ + context, + page, +}) => { + await context.addCookies(admin.cookies); - await page.goto('/users'); + await page.goto("/users"); - await expect(page.getByRole('cell', { name: MARKUP.name })).toBeVisible(); - await expect(page.locator('#users-xss')).toHaveCount(0); - expect(await page.evaluate(() => 'usersXss' in window)).toBe(false); + await expect(page.getByRole("cell", { name: MARKUP.name })).toBeVisible(); + await expect(page.locator("#users-xss")).toHaveCount(0); + expect(await page.evaluate(() => "usersXss" in window)).toBe(false); }); -test('a learner gets 403 on Users, sees nothing of it, and has no Users link', async ({ - context, - page +test("a learner gets 403 on Users, sees nothing of it, and has no Users link", async ({ + context, + page, }) => { - await context.addCookies(lin.cookies); - await page.goto('/'); - const nav = page.getByRole('navigation', { name: 'Primary' }); - await expect(nav.getByRole('link')).toHaveText(['Map', LIN.name]); + await context.addCookies(lin.cookies); + await page.goto("/"); + const nav = page.getByRole("navigation", { name: "Primary" }); + await expect(nav.getByRole("link")).toHaveText(["Map", LIN.name]); - const response = await page.goto('/users'); + const response = await page.goto("/users"); - expect(response?.status()).toBe(403); - await expect(page.locator('body')).not.toContainText(E2E_ADMIN.email); - await expect(page.getByRole('table')).toHaveCount(0); + expect(response?.status()).toBe(403); + await expect(page.locator("body")).not.toContainText(E2E_ADMIN.email); + await expect(page.getByRole("table")).toHaveCount(0); }); -test('a user whose stored role merely contains admin is a learner here too', async ({ - context, - page +test("a user whose stored role merely contains admin is a learner here too", async ({ + context, + page, }) => { - await context.addCookies(sam.cookies); - await page.goto('/'); - const nav = page.getByRole('navigation', { name: 'Primary' }); - await expect(nav.getByRole('link')).toHaveText(['Map', SAM.name]); + await context.addCookies(sam.cookies); + await page.goto("/"); + const nav = page.getByRole("navigation", { name: "Primary" }); + await expect(nav.getByRole("link")).toHaveText(["Map", SAM.name]); - const response = await page.goto('/users'); + const response = await page.goto("/users"); - expect(response?.status()).toBe(403); - await expect(page.getByRole('table')).toHaveCount(0); + expect(response?.status()).toBe(403); + await expect(page.getByRole("table")).toHaveCount(0); }); -test('a visitor who is signed out is sent to Sign in, not shown the page', async ({ page }) => { - await page.goto('/users'); +test("a visitor who is signed out is sent to Sign in, not shown the page", async ({ + page, +}) => { + await page.goto("/users"); + + await expect(page).toHaveURL(/\/signin\?next=%2Fusers$/); + await expect(page.getByRole("table")).toHaveCount(0); +}); - await expect(page).toHaveURL(/\/signin\?next=%2Fusers$/); - await expect(page.getByRole('table')).toHaveCount(0); +test.describe("without JavaScript", () => { + test.use({ javaScriptEnabled: false }); + + test("a plain form post creates an Invite, shows the link once and is never cached", async ({ + context, + page, + }) => { + await context.addCookies(admin.cookies); + await page.goto("/users"); + const posted = page.waitForResponse( + (r) => + r.request().method() === "POST" && r.url().includes("/users?/invite"), + ); + + await page.getByRole("button", { name: "Invite a user" }).click(); + + // SvelteKit ran the action and then the load in one request: the answer is the page, not a 500. + const response = await posted; + expect(response.status()).toBe(200); + expect(response.headers()["cache-control"]).toBe("no-store"); + await expect(page.getByLabel("Invite link")).toHaveValue( + /\/invite\/[\w-]{43}$/, + ); + + // Clean up, so a later file sees no stray pending Invite. + await page.getByRole("button", { name: "Cancel invite" }).first().click(); + await expect(page.getByLabel("Invite link")).toHaveCount(0); + }); }); diff --git a/src/routes/users/users.test.ts b/src/routes/users/users.test.ts index ec123c40..6d9aef8e 100644 --- a/src/routes/users/users.test.ts +++ b/src/routes/users/users.test.ts @@ -1,626 +1,730 @@ -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 { createInvites } from '$lib/server/identity/invites'; -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, RegistrationNotAvailableError } from '$lib/server/instance'; -import { createFileTestDb } from '$lib/server/test-db'; -import { load as layoutLoad } from '../+layout.server'; -import { load as signupLoad } from '../signup/+page.server'; -import { actions, load } from './+page.server'; +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 { createInvites } from "$lib/server/identity/invites"; +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, + RegistrationNotAvailableError, +} from "$lib/server/instance"; +import { createFileTestDb } from "$lib/server/test-db"; +import { load as layoutLoad } from "../+layout.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. -const secret = 'test-secret-test-secret-test-secret-0123'; -const password = 'correct horse battery staple'; +const secret = "test-secret-test-secret-test-secret-0123"; +const password = "correct horse battery staple"; const cookieOf = (headers: Headers) => - headers - .getSetCookie() - .map((c) => c.split(';')[0]) - .join('; '); + headers + .getSetCookie() + .map((c) => c.split(";")[0]) + .join("; "); async function setup() { - const db = await createFileTestDb(); - const instance = await createInstance(db); - let open = false; - const registration = createRegistration({ instance }); - const auth = createAuth({ - db, - origin: 'http://localhost:3000', - secret, - admitSignUp: (attempt) => (open ? {} : registration(attempt)) - }); - const { headers } = await createFirstAdmin( - { instance, auth, db }, - { name: 'Ada Admin', email: 'ada@example.com', password } - ); - const admin = cookieOf(headers); - - async function learner(name: string, email: string) { - open = true; - const signedUp = await auth.api.signUpEmail({ - body: { name, email, password }, - returnHeaders: true - }); - open = false; - return cookieOf(signedUp.headers); - } - - const invites = createInvites({ db, origin: 'http://localhost:3000' }); - const core = { forLearner: (learnerId: string) => ({ learnerId }) } as unknown as App['core']; - const setupPort = { multiUser: { kind: 'open' }, createFirstAdmin: vi.fn() } as App['setup']; - const handle = createHandle({ - instance, - identity: await createLocalIdentity(db), - core, - multiUser: { auth, identity: createMultiUserIdentity(auth), invites }, - setup: setupPort - }); - return { db, auth, instance, admin, learner, handle, invites }; + const db = await createFileTestDb(); + const instance = await createInstance(db); + let open = false; + const registration = createRegistration({ instance }); + const auth = createAuth({ + db, + origin: "http://localhost:3000", + secret, + admitSignUp: (attempt) => (open ? {} : registration(attempt)), + }); + const { headers } = await createFirstAdmin( + { instance, auth, db }, + { name: "Ada Admin", email: "ada@example.com", password }, + ); + const admin = cookieOf(headers); + + async function learner(name: string, email: string) { + open = true; + const signedUp = await auth.api.signUpEmail({ + body: { name, email, password }, + returnHeaders: true, + }); + open = false; + return cookieOf(signedUp.headers); + } + + const invites = createInvites({ db, origin: "http://localhost:3000" }); + const core = { + forLearner: (learnerId: string) => ({ learnerId }), + } as unknown as App["core"]; + const setupPort = { + multiUser: { kind: "open" }, + createFirstAdmin: vi.fn(), + } as App["setup"]; + const handle = createHandle({ + instance, + identity: await createLocalIdentity(db), + core, + multiUser: { auth, identity: createMultiUserIdentity(auth), invites }, + setup: setupPort, + }); + return { db, auth, instance, admin, learner, handle, invites }; } /** Runs one request through the hook; the page's load runs where SvelteKit would run it. */ async function visit( - handle: ReturnType, - path: string, - cookie: string, - routeId = '/users' + handle: ReturnType, + path: string, + cookie: string, + routeId = "/users", ) { - const setHeaders = vi.fn(); - const resolve = vi.fn(async (event: RequestEvent, _opts?: ResolveOptions) => { - const data = await load({ ...event, setHeaders } as never); - return Response.json(data); - }); - const event = { - url: new URL(path, 'http://localhost'), - route: { id: routeId }, - request: { method: 'GET', headers: new Headers(cookie ? { cookie } : {}) }, - cookies: { get: () => undefined, set: vi.fn() }, - locals: {} - } as unknown as RequestEvent; - const response = await handle({ event, resolve }); - return { response, resolve, setHeaders, locals: event.locals }; + const setHeaders = vi.fn(); + const resolve = vi.fn(async (event: RequestEvent, _opts?: ResolveOptions) => { + const data = await load({ ...event, setHeaders } as never); + return Response.json(data); + }); + const event = { + url: new URL(path, "http://localhost"), + route: { id: routeId }, + request: { method: "GET", headers: new Headers(cookie ? { cookie } : {}) }, + cookies: { get: () => undefined, set: vi.fn() }, + locals: {}, + } as unknown as RequestEvent; + const response = await handle({ event, resolve }); + 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' + 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 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(); - await learner('Lin Learner', 'lin@example.com'); - await learner('Max Learner', 'max@example.com'); - - const { response, setHeaders } = await visit(handle, '/users', admin); - - expect(response.status).toBe(200); - const { accounts } = await response.json(); - expect(accounts.map((a: { email: string }) => a.email)).toEqual([ - 'ada@example.com', - 'lin@example.com', - 'max@example.com' - ]); - expect(accounts.map((a: { you: boolean }) => a.you)).toEqual([true, false, false]); - expect(setHeaders).toHaveBeenCalledWith({ 'Cache-Control': 'no-store' }); - }); - - it('gives a learner 403 before the page loads', async () => { - const { handle, learner } = await setup(); - const lin = await learner('Lin Learner', 'lin@example.com'); - - const { response, resolve } = await visit(handle, '/users', lin); - - expect(response.status).toBe(403); - expect(resolve).not.toHaveBeenCalled(); - }); - - it('gives a user whose stored role merely contains admin 403 too', async () => { - const { handle, learner, 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 visit(handle, '/users', sam); - - expect(response.status).toBe(403); - expect(resolve).not.toHaveBeenCalled(); - }); - - it('sends a signed-out visitor to Sign in, remembering the Users page', async () => { - const { handle } = await setup(); - - const { response, resolve } = await visit(handle, '/users', ''); - - expect(response.status).toBe(303); - expect(response.headers.get('location')).toBe('/signin?next=%2Fusers'); - expect(resolve).not.toHaveBeenCalled(); - }); - - it('treats an admin whose session was ended as signed out', async () => { - const { handle, admin, db } = await setup(); - await db.session.deleteMany(); - - const { response, resolve } = await visit(handle, '/users', admin); - - expect(response.status).toBe(303); - expect(resolve).not.toHaveBeenCalled(); - }); + (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(); + await learner("Lin Learner", "lin@example.com"); + await learner("Max Learner", "max@example.com"); + + const { response } = await visit(handle, "/users", admin); + + expect(response.status).toBe(200); + const { accounts } = await response.json(); + expect(accounts.map((a: { email: string }) => a.email)).toEqual([ + "ada@example.com", + "lin@example.com", + "max@example.com", + ]); + expect(accounts.map((a: { you: boolean }) => a.you)).toEqual([ + true, + false, + false, + ]); + expect(response.headers.get("cache-control")).toBe("no-store"); + }); + + it("gives a learner 403 before the page loads", async () => { + const { handle, learner } = await setup(); + const lin = await learner("Lin Learner", "lin@example.com"); + + const { response, resolve } = await visit(handle, "/users", lin); + + expect(response.status).toBe(403); + expect(resolve).not.toHaveBeenCalled(); + }); + + it("gives a user whose stored role merely contains admin 403 too", async () => { + const { handle, learner, 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 visit(handle, "/users", sam); + + expect(response.status).toBe(403); + expect(resolve).not.toHaveBeenCalled(); + }); + + it("sends a signed-out visitor to Sign in, remembering the Users page", async () => { + const { handle } = await setup(); + + const { response, resolve } = await visit(handle, "/users", ""); + + expect(response.status).toBe(303); + expect(response.headers.get("location")).toBe("/signin?next=%2Fusers"); + expect(resolve).not.toHaveBeenCalled(); + }); + + it("treats an admin whose session was ended as signed out", async () => { + const { handle, admin, db } = await setup(); + await db.session.deleteMany(); + + const { response, resolve } = await visit(handle, "/users", admin); + + expect(response.status).toBe(303); + expect(resolve).not.toHaveBeenCalled(); + }); }); -describe('the Users page in Local Mode', () => { - it('does not exist: 404 for the Local Learner, whatever they are', 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 visit(handle, '/users', ''); - - expect(response.status).toBe(404); - expect(resolve).not.toHaveBeenCalled(); - }); +describe("the Users page in Local Mode", () => { + it("does not exist: 404 for the Local Learner, whatever they are", 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 visit(handle, "/users", ""); + + expect(response.status).toBe(404); + expect(resolve).not.toHaveBeenCalled(); + }); }); -describe('the load, if the hook ever let a request through', () => { - const event = (locals: Record) => - ({ locals, request: { headers: new Headers() }, setHeaders: vi.fn() }) as never; - const identity = (role: 'admin' | 'learner') => ({ learnerId: 'l', displayName: 'N', role }); - const auth = { api: { listUsers: vi.fn() } }; - - it('is 404 without Better Auth, as in Local Mode where the Local Learner is an admin', async () => { - await expect(load(event({ identity: identity('admin') }))).rejects.toMatchObject({ - status: 404 - }); - }); - - it('is 403 for nobody', async () => { - await expect(load(event({ auth }))).rejects.toMatchObject({ status: 403 }); - }); - - it('is 403 for a learner, and reads nothing', async () => { - await expect(load(event({ auth, identity: identity('learner') }))).rejects.toMatchObject({ - status: 403 - }); - expect(auth.api.listUsers).not.toHaveBeenCalled(); - }); +describe("the load, if the hook ever let a request through", () => { + const event = (locals: Record) => + ({ + locals, + request: { headers: new Headers() }, + setHeaders: vi.fn(), + }) as never; + const identity = (role: "admin" | "learner") => ({ + learnerId: "l", + displayName: "N", + role, + }); + const auth = { api: { listUsers: vi.fn() } }; + + it("is 404 without Better Auth, as in Local Mode where the Local Learner is an admin", async () => { + await expect( + load(event({ identity: identity("admin") })), + ).rejects.toMatchObject({ + status: 404, + }); + }); + + it("is 403 for nobody", async () => { + await expect(load(event({ auth }))).rejects.toMatchObject({ status: 403 }); + }); + + it("is 403 for a learner, and reads nothing", async () => { + await expect( + load(event({ auth, identity: identity("learner") })), + ).rejects.toMatchObject({ + status: 403, + }); + expect(auth.api.listUsers).not.toHaveBeenCalled(); + }); }); -describe('the nav', () => { - const layout = (mode: 'local' | 'multi' | null, role?: 'admin' | 'learner') => - layoutLoad({ - locals: { - instance: { mode: () => mode } as Instance, - identity: role && { learnerId: 'l', displayName: 'N', role } - } - } as never); - - it('offers Users to the admin of a Multi-user Instance', () => { - expect(layout('multi', 'admin')).toMatchObject({ displayName: 'N', showUsers: true }); - }); - - it('does not offer it to a learner', () => { - expect(layout('multi', 'learner')).toMatchObject({ showUsers: false }); - }); - - it('does not offer it in Local Mode, where the Local Learner is an admin but there are no Accounts', () => { - expect(layout('local', 'admin')).toMatchObject({ displayName: 'N', showUsers: false }); - }); - - it('does not offer it before anyone is resolved', () => { - expect(layout(null)).toMatchObject({ showUsers: false }); - expect(layout('multi')).toMatchObject({ showUsers: false }); - }); +describe("the nav", () => { + const layout = (mode: "local" | "multi" | null, role?: "admin" | "learner") => + layoutLoad({ + locals: { + instance: { mode: () => mode } as Instance, + identity: role && { learnerId: "l", displayName: "N", role }, + }, + } as never); + + it("offers Users to the admin of a Multi-user Instance", () => { + expect(layout("multi", "admin")).toMatchObject({ + displayName: "N", + showUsers: true, + }); + }); + + it("does not offer it to a learner", () => { + expect(layout("multi", "learner")).toMatchObject({ showUsers: false }); + }); + + it("does not offer it in Local Mode, where the Local Learner is an admin but there are no Accounts", () => { + expect(layout("local", "admin")).toMatchObject({ + displayName: "N", + showUsers: false, + }); + }); + + it("does not offer it before anyone is resolved", () => { + expect(layout(null)).toMatchObject({ showUsers: false }); + 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 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'); - }); +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"); + }); }); /** Posts to the Invite actions through the real hook. */ async function post( - handle: ReturnType, - cookie: string, - name: 'invite' | 'cancel', - fields: Record = {} + handle: ReturnType, + cookie: string, + name: "invite" | "cancel", + fields: Record = {}, ) { - const setHeaders = vi.fn(); - const resolve = vi.fn(async (event: RequestEvent, _opts?: ResolveOptions) => { - const result = await actions[name]({ ...event, setHeaders } as never); - return Response.json(result ?? null, { status: result?.status ?? 200 }); - }); - const event = { - url: new URL(`/users?/${name}`, 'http://localhost'), - route: { id: '/users' }, - request: new Request(`http://localhost/users?/${name}`, { - 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, setHeaders }; + const setHeaders = vi.fn(); + const resolve = vi.fn(async (event: RequestEvent, _opts?: ResolveOptions) => { + const result = await actions[name]({ ...event, setHeaders } as never); + return Response.json(result ?? null, { status: result?.status ?? 200 }); + }); + const event = { + url: new URL(`/users?/${name}`, "http://localhost"), + route: { id: "/users" }, + request: new Request(`http://localhost/users?/${name}`, { + 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, setHeaders }; } -describe('Invites on the Users page', () => { - it('lets the admin create an Invite: the link is in that one response, the database holds only a hash', async () => { - const { handle, admin, db } = await setup(); - - const { response, setHeaders } = await post(handle, admin, 'invite'); - - expect(response.status).toBe(200); - const text = JSON.stringify(await response.json()); - const link = text.match(/http:\\?\/\\?\/localhost:3000\\?\/invite\\?\/([\w-]{43})/); - expect(link).toBeTruthy(); - const token = (link as RegExpMatchArray)[1]; - expect(JSON.stringify(await db.invite.findMany())).not.toContain(token); - expect(setHeaders).toHaveBeenCalledWith({ 'Cache-Control': 'no-store' }); - }); - - it('lists pending Invites in the load, without any token', async () => { - const { handle, admin, invites } = await setup(); - const made = await invites.create({ - who: { learnerId: 'local', displayName: 'Ada', role: 'admin' }, - now: new Date() - }); - - const { response } = await visit(handle, '/users', admin); - - const body = await response.json(); - expect(body.pending).toEqual([expect.objectContaining({ id: made.id })]); - expect(JSON.stringify(body)).not.toContain(made.token); - }); - - it('cancels a pending Invite; the cancelled link no longer works', async () => { - const { handle, admin, invites } = await setup(); - const made = await invites.create({ - who: { learnerId: 'local', displayName: 'Ada', role: 'admin' }, - now: new Date() - }); - - const { response } = await post(handle, admin, 'cancel', { id: made.id }); - - expect(response.status).toBe(200); - expect(await invites.lookup(made.token, new Date())).toBeNull(); - }); - - it.each([ - ['an unknown id', { id: 'nope' }], - ['no id', {}] - ])('answers %s with a fixed message', async (_, fields) => { - const { handle, admin } = await setup(); - - const { response } = await post(handle, admin, 'cancel', fields); - - expect(response.status).toBe(404); - expect(JSON.stringify(await response.json())).toContain('no longer pending'); - }); - - it.each(['invite', 'cancel'] as const)( - '%s: refuses a learner with 403 and writes nothing', - async (name) => { - const { handle, learner, db, invites } = await setup(); - const lin = await learner('Lin Learner', 'lin@example.com'); - const made = await invites.create({ - who: { learnerId: 'local', displayName: 'Ada', role: 'admin' }, - now: new Date() - }); - - const { response, resolve } = await post(handle, lin, name, { id: made.id }); - - expect(response.status).toBe(403); - expect(resolve).not.toHaveBeenCalled(); - expect(await db.invite.count()).toBe(1); - } - ); - - it.each(['invite', 'cancel'] as const)( - '%s: sends a signed-out request to Sign in', - async (name) => { - const { handle, db } = await setup(); - - const { response, resolve } = await post(handle, '', name, { id: 'x' }); - - expect(response.status).toBe(303); - expect(resolve).not.toHaveBeenCalled(); - expect(await db.invite.count()).toBe(0); - } - ); - - it.each(['invite', 'cancel'] as const)('%s: does not exist in Local Mode', async (name) => { - 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 post(handle, '', name, { id: 'x' }); - - expect(response.status).toBe(404); - expect(resolve).not.toHaveBeenCalled(); - expect(await db.invite.count()).toBe(0); - }); - - it.each(['invite', 'cancel'] as const)( - '%s: is 403 for a non-admin, 404 without Invites, even past the hook', - async (name) => { - const learner = { learnerId: 'l', displayName: 'N', role: 'learner' as const }; - const admin = { ...learner, role: 'admin' as const }; - const event = (locals: Record) => - ({ locals, setHeaders: vi.fn(), request: { formData: vi.fn() } }) as never; - await expect(actions[name](event({ identity: learner, invites: {} }))).rejects.toMatchObject({ - status: 403 - }); - await expect(actions[name](event({}))).rejects.toMatchObject({ status: 403 }); - await expect(actions[name](event({ identity: admin }))).rejects.toMatchObject({ - status: 404 - }); - } - ); +describe("Invites on the Users page", () => { + it("lets the admin create an Invite: the link is in that one response, the database holds only a hash", async () => { + const { handle, admin, db } = await setup(); + + const { response } = await post(handle, admin, "invite"); + + expect(response.status).toBe(200); + const text = JSON.stringify(await response.json()); + const link = text.match( + /http:\\?\/\\?\/localhost:3000\\?\/invite\\?\/([\w-]{43})/, + ); + expect(link).toBeTruthy(); + const token = (link as RegExpMatchArray)[1]; + expect(JSON.stringify(await db.invite.findMany())).not.toContain(token); + expect(response.headers.get("cache-control")).toBe("no-store"); + }); + + it("lists pending Invites in the load, without any token", async () => { + const { handle, admin, invites } = await setup(); + const made = await invites.create({ + who: { learnerId: "local", displayName: "Ada", role: "admin" }, + now: new Date(), + }); + + const { response } = await visit(handle, "/users", admin); + + const body = await response.json(); + expect(body.pending).toEqual([expect.objectContaining({ id: made.id })]); + expect(JSON.stringify(body)).not.toContain(made.token); + }); + + it("cancels a pending Invite; the cancelled link no longer works", async () => { + const { handle, admin, invites } = await setup(); + const made = await invites.create({ + who: { learnerId: "local", displayName: "Ada", role: "admin" }, + now: new Date(), + }); + + const { response } = await post(handle, admin, "cancel", { id: made.id }); + + expect(response.status).toBe(200); + expect(await invites.lookup(made.token, new Date())).toBeNull(); + }); + + it.each([ + ["an unknown id", { id: "nope" }], + ["no id", {}], + ])("answers %s with a fixed message", async (_, fields) => { + const { handle, admin } = await setup(); + + const { response } = await post(handle, admin, "cancel", fields); + + expect(response.status).toBe(404); + expect(JSON.stringify(await response.json())).toContain( + "no longer pending", + ); + }); + + it.each(["invite", "cancel"] as const)( + "%s: refuses a learner with 403 and writes nothing", + async (name) => { + const { handle, learner, db, invites } = await setup(); + const lin = await learner("Lin Learner", "lin@example.com"); + const made = await invites.create({ + who: { learnerId: "local", displayName: "Ada", role: "admin" }, + now: new Date(), + }); + + const { response, resolve } = await post(handle, lin, name, { + id: made.id, + }); + + expect(response.status).toBe(403); + expect(resolve).not.toHaveBeenCalled(); + expect(await db.invite.count()).toBe(1); + }, + ); + + it.each(["invite", "cancel"] as const)( + "%s: sends a signed-out request to Sign in", + async (name) => { + const { handle, db } = await setup(); + + const { response, resolve } = await post(handle, "", name, { id: "x" }); + + expect(response.status).toBe(303); + expect(resolve).not.toHaveBeenCalled(); + expect(await db.invite.count()).toBe(0); + }, + ); + + it.each(["invite", "cancel"] as const)( + "%s: does not exist in Local Mode", + async (name) => { + 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 post(handle, "", name, { id: "x" }); + + expect(response.status).toBe(404); + expect(resolve).not.toHaveBeenCalled(); + expect(await db.invite.count()).toBe(0); + }, + ); + + it.each(["invite", "cancel"] as const)( + "%s: is 403 for a non-admin, 404 without Invites, even past the hook", + async (name) => { + const learner = { + learnerId: "l", + displayName: "N", + role: "learner" as const, + }; + const admin = { ...learner, role: "admin" as const }; + const event = (locals: Record) => + ({ + locals, + setHeaders: vi.fn(), + request: { formData: vi.fn() }, + }) as never; + await expect( + actions[name](event({ identity: learner, invites: {} })), + ).rejects.toMatchObject({ + status: 403, + }); + await expect(actions[name](event({}))).rejects.toMatchObject({ + status: 403, + }); + await expect( + actions[name](event({ identity: admin })), + ).rejects.toMatchObject({ + status: 404, + }); + }, + ); }); From 43f52a8ad2365592eee993d603bdc2257de4f39b Mon Sep 17 00:00:00 2001 From: Gauransh Mathur Date: Sat, 17 Oct 2026 14:07:56 +0000 Subject: [PATCH 5/5] fix(beyondleetcode): C35a set no-store once for Users in the hook The previous commit was reformatted by the wrong tool; this one restores the project's formatting. Co-Authored-By: Claude Sonnet 5.5 --- src/lib/server/handle.ts | 182 ++-- src/routes/users/+page.server.ts | 194 ++-- src/routes/users/users.multi.e2e.ts | 283 +++--- src/routes/users/users.test.ts | 1298 +++++++++++++-------------- 4 files changed, 898 insertions(+), 1059 deletions(-) diff --git a/src/lib/server/handle.ts b/src/lib/server/handle.ts index 24924bfe..ccb3aed3 100644 --- a/src/lib/server/handle.ts +++ b/src/lib/server/handle.ts @@ -1,8 +1,8 @@ -import type { Handle } from "@sveltejs/kit"; -import { parseTheme, THEME_COOKIE, withTheme } from "$lib/theme"; -import type { App } from "./app.ts"; -import { gate } from "./identity/gate.ts"; -import type { IdentityResolver } from "./identity/index.ts"; +import type { Handle } from '@sveltejs/kit'; +import { parseTheme, THEME_COOKIE, withTheme } from '$lib/theme'; +import type { App } from './app.ts'; +import { gate } from './identity/gate.ts'; +import type { IdentityResolver } from './identity/index.ts'; /** * Per request: refuse remote calls, read the Instance mode, resolve Identity with the adapter that mode @@ -11,108 +11,100 @@ import type { IdentityResolver } from "./identity/index.ts"; * judges it like any page; an unmatched path has no route and the gate decides it too. */ export function createHandle( - app: Pick, + app: Pick ): Handle { - return async ({ event, resolve }) => { - const { url, request } = event; - // Remote calls take their pathname and route from client headers, so the gate cannot judge them; none exist yet. - // Static files never reach this hook (the server answers them first). - if (event.isRemoteRequest) return refuseFraming(refusal(404)); + return async ({ event, resolve }) => { + const { url, request } = event; + // Remote calls take their pathname and route from client headers, so the gate cannot judge them; none exist yet. + // Static files never reach this hook (the server answers them first). + if (event.isRemoteRequest) return refuseFraming(refusal(404)); - event.locals.instance = app.instance; - event.locals.setup = app.setup; - const mode = app.instance.mode(); - // Before Setup nobody is resolved, so no Local Learner is made. Only the adapter the mode names is - // asked: in Multi-user the Local adapter, and its test cookie, is never consulted. - let who: Awaited> = null; - if (mode === "local") who = await app.identity.resolve(event); - if (mode === "multi") { - // Cannot happen at start (createApp refuses it); fail closed rather than serve nobody. - if (!app.multiUser) return refuseFraming(refusal(503)); - event.locals.auth = app.multiUser.auth; - event.locals.invites = app.multiUser.invites; - who = await app.multiUser.identity.resolve(event); - } - // An active session was extended: its new cookie goes out with whatever this request answers (a - // redirect or refusal included), or users are signed out after 7 days of use. Better Auth's own - // Set-Cookie line, unchanged. Never over a cookie of the same name that the response sets itself: - // a sign-in as someone else, a sign-out or a password change answers with the cookie that must win. - const refreshed = who?.refreshedCookies ?? []; - const finish = (response: Response) => { - const own = new Set(response.headers.getSetCookie().map(cookieName)); - for (const cookie of refreshed) { - if (!own.has(cookieName(cookie))) - response.headers.append("set-cookie", cookie); - } - return refuseFraming(response); - }; + event.locals.instance = app.instance; + event.locals.setup = app.setup; + const mode = app.instance.mode(); + // Before Setup nobody is resolved, so no Local Learner is made. Only the adapter the mode names is + // asked: in Multi-user the Local adapter, and its test cookie, is never consulted. + let who: Awaited> = null; + if (mode === 'local') who = await app.identity.resolve(event); + if (mode === 'multi') { + // Cannot happen at start (createApp refuses it); fail closed rather than serve nobody. + if (!app.multiUser) return refuseFraming(refusal(503)); + event.locals.auth = app.multiUser.auth; + event.locals.invites = app.multiUser.invites; + who = await app.multiUser.identity.resolve(event); + } + // An active session was extended: its new cookie goes out with whatever this request answers (a + // redirect or refusal included), or users are signed out after 7 days of use. Better Auth's own + // Set-Cookie line, unchanged. Never over a cookie of the same name that the response sets itself: + // a sign-in as someone else, a sign-out or a password change answers with the cookie that must win. + const refreshed = who?.refreshedCookies ?? []; + const finish = (response: Response) => { + const own = new Set(response.headers.getSetCookie().map(cookieName)); + for (const cookie of refreshed) { + if (!own.has(cookieName(cookie))) response.headers.append('set-cookie', cookie); + } + return refuseFraming(response); + }; - const verdict = gate({ - mode, - who, - mustChangePassword: who?.mustChangePassword, - routeId: event.route.id, - isDataRequest: event.isDataRequest, - method: request.method, - pathname: url.pathname, - search: url.search, - }); - if (verdict.kind === "redirect") { - return finish( - new Response(null, { - status: verdict.status, - headers: { location: verdict.location, "Cache-Control": "no-store" }, - }), - ); - } - if (verdict.kind === "status") { - return finish(refusal(verdict.status)); - } + const verdict = gate({ + mode, + who, + mustChangePassword: who?.mustChangePassword, + routeId: event.route.id, + isDataRequest: event.isDataRequest, + method: request.method, + pathname: url.pathname, + search: url.search + }); + if (verdict.kind === 'redirect') { + return finish( + new Response(null, { + status: verdict.status, + headers: { location: verdict.location, 'Cache-Control': 'no-store' } + }) + ); + } + if (verdict.kind === 'status') { + return finish(refusal(verdict.status)); + } - // Unset where nobody is resolved: /setup before Setup, and the public pages of a multi-user Instance. - if (who) { - event.locals.identity = { - learnerId: who.learnerId, - displayName: who.displayName, - role: who.role, - }; - event.locals.learning = app.core.forLearner(who.learnerId); - } - // No theme cookie means follow the device; a choice goes on in the server render, so no flash. - // Read when the page renders, not before: an action that just set the cookie is seen by its own response. - const response = await resolve(event, { - transformPageChunk: ({ html }) => - withTheme(html, parseTheme(event.cookies.get(THEME_COOKIE))), - }); - // Users shows everyone's names and emails and the one-time Invite link: never cached, whatever - // answers (the page, a data request, an action result, an error). Set here, once, because a - // route that sets it in both its action and its load makes SvelteKit throw on a no-JS form post. - if (event.route.id === "/users") - response.headers.set("Cache-Control", "no-store"); - return finish(response); - }; + // Unset where nobody is resolved: /setup before Setup, and the public pages of a multi-user Instance. + if (who) { + event.locals.identity = { + learnerId: who.learnerId, + displayName: who.displayName, + role: who.role + }; + event.locals.learning = app.core.forLearner(who.learnerId); + } + // No theme cookie means follow the device; a choice goes on in the server render, so no flash. + // Read when the page renders, not before: an action that just set the cookie is seen by its own response. + const response = await resolve(event, { + transformPageChunk: ({ html }) => withTheme(html, parseTheme(event.cookies.get(THEME_COOKIE))) + }); + // Users shows everyone's names and emails and the one-time Invite link: never cached, whatever + // answers (the page, a data request, an action result, an error). Set here, once, because a + // route that sets it in both its action and its load makes SvelteKit throw on a no-JS form post. + if (event.route.id === '/users') response.headers.set('Cache-Control', 'no-store'); + return finish(response); + }; } /** The name of a `Set-Cookie` line: what comes before the first `=`. */ -const cookieName = (setCookie: string) => - setCookie.slice(0, setCookie.indexOf("=")).trim(); +const cookieName = (setCookie: string) => setCookie.slice(0, setCookie.indexOf('=')).trim(); /** A gate decision depends on the Instance state, so it is never cached. */ function refusal(status: 403 | 404 | 503): Response { - const text = { - 403: "Forbidden", - 404: "Not found", - 503: "Service unavailable", - }[status]; - return new Response(text, { - status, - headers: { "Cache-Control": "no-store" }, - }); + const text = { 403: 'Forbidden', 404: 'Not found', 503: 'Service unavailable' }[status]; + return new Response(text, { + status, + headers: { 'Cache-Control': 'no-store' } + }); } /** Reset progress is destructive: no other site may frame the app and trick a click (clickjacking). */ function refuseFraming(response: Response): Response { - response.headers.set("Content-Security-Policy", "frame-ancestors 'none'"); - response.headers.set("X-Frame-Options", "DENY"); - return response; + response.headers.set('Content-Security-Policy', "frame-ancestors 'none'"); + response.headers.set('X-Frame-Options', 'DENY'); + return response; } diff --git a/src/routes/users/+page.server.ts b/src/routes/users/+page.server.ts index 55c7889a..591beced 100644 --- a/src/routes/users/+page.server.ts +++ b/src/routes/users/+page.server.ts @@ -1,10 +1,7 @@ -import { error, fail } from "@sveltejs/kit"; -import { listAccounts, NotAdminError } from "$lib/server/identity/accounts"; -import { - isRegistrationMode, - RegistrationNotAvailableError, -} from "$lib/server/instance"; -import type { Actions, PageServerLoad } from "./$types"; +import { error, fail } from '@sveltejs/kit'; +import { listAccounts, NotAdminError } from '$lib/server/identity/accounts'; +import { isRegistrationMode, RegistrationNotAvailableError } from '$lib/server/instance'; +import type { Actions, PageServerLoad } from './$types'; /** * Every Account, for the admin, and the Registration Mode they can switch. The hook's gate has @@ -12,111 +9,92 @@ import type { Actions, PageServerLoad } from "./$types"; * 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 }) => { - const { auth, identity, invites } = locals; - // Better Auth is set only in Multi-user Mode: Local Mode has no Accounts, whatever role it gives the Local Learner. - if (!auth) error(404, "Not found"); - if (!identity) error(403, "Forbidden"); - try { - const accounts = await listAccounts(auth, { - who: identity, - headers: request.headers, - }); - // Set exactly when `auth` is; fail closed anyway. After the Accounts, whose read refuses a learner first. - if (!invites) error(404, "Not found"); - const pending = await invites.listPending({ - who: identity, - now: new Date(), - }); - return { - accounts, - pending, - registration: locals.instance.registrationMode(), - }; - } catch (thrown) { - if (thrown instanceof NotAdminError) error(403, "Forbidden"); - throw thrown; - } + const { auth, identity, invites } = locals; + // Better Auth is set only in Multi-user Mode: Local Mode has no Accounts, whatever role it gives the Local Learner. + if (!auth) error(404, 'Not found'); + if (!identity) error(403, 'Forbidden'); + try { + const accounts = await listAccounts(auth, { who: identity, headers: request.headers }); + // Set exactly when `auth` is; fail closed anyway. After the Accounts, whose read refuses a learner first. + if (!invites) error(404, 'Not found'); + const pending = await invites.listPending({ who: identity, now: new Date() }); + return { accounts, pending, 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"); + /** + * 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 }; - }, + 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 }; + }, - /** - * Makes an Invite and answers with its link: the one response that ever carries the token. It is - * never cached (the hook sets `no-store` on every /users response), never put in a redirect, and not stored (only its hash is). Admin only, checked first. - */ - invite: async ({ locals }) => { - const { identity, invites } = locals; - if (identity?.role !== "admin") error(403, "Forbidden"); - if (!invites) error(404, "Not found"); - try { - const made = await invites.create({ who: identity, now: new Date() }); - return { invite: { link: made.link, expiresAt: made.expiresAt } }; - } catch (thrown) { - if (thrown instanceof NotAdminError) error(403, "Forbidden"); - console.error( - "Users: creating an Invite failed:", - thrown instanceof Error ? thrown.name : typeof thrown, - ); - return fail(500, { - message: "The invite could not be created. Try again.", - }); - } - }, + /** + * Makes an Invite and answers with its link: the one response that ever carries the token. It is + * never cached (the hook sets `no-store` on every /users response), never put in a redirect, and not stored (only its hash is). Admin only, checked first. + */ + invite: async ({ locals }) => { + const { identity, invites } = locals; + if (identity?.role !== 'admin') error(403, 'Forbidden'); + if (!invites) error(404, 'Not found'); + try { + const made = await invites.create({ who: identity, now: new Date() }); + return { invite: { link: made.link, expiresAt: made.expiresAt } }; + } catch (thrown) { + if (thrown instanceof NotAdminError) error(403, 'Forbidden'); + console.error( + 'Users: creating an Invite failed:', + thrown instanceof Error ? thrown.name : typeof thrown + ); + return fail(500, { message: 'The invite could not be created. Try again.' }); + } + }, - /** Cancels a pending Invite. An unknown, used or already-cancelled one gets the same fixed message. */ - cancel: async ({ locals, request }) => { - const { identity, invites } = locals; - if (identity?.role !== "admin") error(403, "Forbidden"); - if (!invites) error(404, "Not found"); - const id = (await request.formData()).get("id"); - try { - const cancelled = - typeof id === "string" && (await invites.cancel({ who: identity, id })); - if (!cancelled) - return fail(404, { message: "That invite is no longer pending." }); - } catch (thrown) { - if (thrown instanceof NotAdminError) error(403, "Forbidden"); - console.error( - "Users: cancelling an Invite failed:", - thrown instanceof Error ? thrown.name : typeof thrown, - ); - return fail(500, { - message: "The invite could not be cancelled. Try again.", - }); - } - return { cancelled: true }; - }, + /** Cancels a pending Invite. An unknown, used or already-cancelled one gets the same fixed message. */ + cancel: async ({ locals, request }) => { + const { identity, invites } = locals; + if (identity?.role !== 'admin') error(403, 'Forbidden'); + if (!invites) error(404, 'Not found'); + const id = (await request.formData()).get('id'); + try { + const cancelled = typeof id === 'string' && (await invites.cancel({ who: identity, id })); + if (!cancelled) return fail(404, { message: 'That invite is no longer pending.' }); + } catch (thrown) { + if (thrown instanceof NotAdminError) error(403, 'Forbidden'); + console.error( + 'Users: cancelling an Invite failed:', + thrown instanceof Error ? thrown.name : typeof thrown + ); + return fail(500, { message: 'The invite could not be cancelled. Try again.' }); + } + return { cancelled: true }; + } }; diff --git a/src/routes/users/users.multi.e2e.ts b/src/routes/users/users.multi.e2e.ts index 15421e51..7e49346f 100644 --- a/src/routes/users/users.multi.e2e.ts +++ b/src/routes/users/users.multi.e2e.ts @@ -1,20 +1,16 @@ -import { expect, test } from "@playwright/test"; -import { E2E_ADMIN } from "../../lib/e2e-admin.ts"; -import { - LEARNER_PASSWORD, - openMultiUserDatabase, - type Session, -} from "./multi-fixture.ts"; +import { expect, test } from '@playwright/test'; +import { E2E_ADMIN } from '../../lib/e2e-admin.ts'; +import { LEARNER_PASSWORD, openMultiUserDatabase, type Session } from './multi-fixture.ts'; // The Users page on the Multi-user server (the "e2e-multi" project, after "setup-multi" made the admin). // Learners are made through multi-fixture.ts, because sign-up and invites have no screens yet. -const LIN = { name: "Lin Learner", email: "lin.users@example.com" }; -const SAM = { name: "Sam Sneaky", email: "sam.users@example.com" }; +const LIN = { name: 'Lin Learner', email: 'lin.users@example.com' }; +const SAM = { name: 'Sam Sneaky', email: 'sam.users@example.com' }; // A name is whatever the person typed when they signed up, and the admin reads it. const MARKUP = { - name: 'Mal', - email: "mal.users@example.com", + name: 'Mal', + email: 'mal.users@example.com' }; let fixture: Awaited>; @@ -23,180 +19,141 @@ let lin: Session; let sam: Session; test.beforeAll(async () => { - fixture = await openMultiUserDatabase(); - await fixture.ensureLearner(LIN); - await fixture.ensureLearner(SAM); - await fixture.ensureLearner(MARKUP); - // 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); + fixture = await openMultiUserDatabase(); + await fixture.ensureLearner(LIN); + await fixture.ensureLearner(SAM); + await fixture.ensureLearner(MARKUP); + // 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(); + await fixture.close(); }); -test("the admin finds Users in the nav and sees every Account on it", async ({ - context, - page, -}) => { - await context.addCookies(admin.cookies); - await page.goto("/"); - const nav = page.getByRole("navigation", { name: "Primary" }); - await expect(nav.getByRole("link")).toHaveText([ - "Map", - "Users", - E2E_ADMIN.name, - ]); - await expect(nav.getByRole("link", { name: "Users" })).not.toHaveAttribute( - "aria-current", - /.*/, - ); - - await nav.getByRole("link", { name: "Users" }).click(); - - await expect(page).toHaveURL("/users"); - await expect(page.getByRole("heading", { level: 1 })).toHaveText("Users"); - await expect(nav.getByRole("link", { name: "Users" })).toHaveAttribute( - "aria-current", - "page", - ); - await expect(nav.getByRole("link", { name: "Map" })).not.toHaveAttribute( - "aria-current", - /.*/, - ); - const table = page.getByRole("table", { name: "All users" }); - await expect(table.getByRole("columnheader")).toHaveText([ - "name", - "email", - "role", - "joined", - "actions", - ]); - - // The database is the truth, and other files may add Accounts at any moment: compare what is stored - // now with what the page shows, until the two agree. - await expect(async () => { - const stored = await fixture.db.user.findMany({ - orderBy: { createdAt: "asc" }, - }); - await page.reload(); - const rows = table.locator("tbody tr"); - await expect(rows).toHaveCount(stored.length, { timeout: 2_000 }); - for (const [i, user] of stored.entries()) { - const cells = rows.nth(i).getByRole("cell"); - const you = user.email === E2E_ADMIN.email; - await expect(cells.nth(0)).toHaveText( - you ? `${user.name} you` : user.name, - ); - await expect(cells.nth(1)).toHaveText(user.email); - await expect(cells.nth(2)).toHaveText( - user.role === "admin" ? "admin" : "learner", - ); - await expect(cells.nth(3)).toHaveText( - user.createdAt.toISOString().slice(0, 10), - ); - await expect(cells.nth(4)).toHaveText( - you ? "you cannot remove yourself" : "", - ); - } - }).toPass(); - - // The admin and the learners made above are among them; no row has an action yet (reset password and - // remove are later cards). - await expect( - table.getByRole("row", { name: new RegExp(`${E2E_ADMIN.email}.*admin`) }), - ).toBeVisible(); - await expect( - table.getByRole("row", { name: new RegExp(`${LIN.email}.*learner`) }), - ).toBeVisible(); - await expect(table.getByRole("button")).toHaveCount(0); +test('the admin finds Users in the nav and sees every Account on it', async ({ context, page }) => { + await context.addCookies(admin.cookies); + await page.goto('/'); + const nav = page.getByRole('navigation', { name: 'Primary' }); + await expect(nav.getByRole('link')).toHaveText(['Map', 'Users', E2E_ADMIN.name]); + await expect(nav.getByRole('link', { name: 'Users' })).not.toHaveAttribute('aria-current', /.*/); + + await nav.getByRole('link', { name: 'Users' }).click(); + + await expect(page).toHaveURL('/users'); + await expect(page.getByRole('heading', { level: 1 })).toHaveText('Users'); + await expect(nav.getByRole('link', { name: 'Users' })).toHaveAttribute('aria-current', 'page'); + await expect(nav.getByRole('link', { name: 'Map' })).not.toHaveAttribute('aria-current', /.*/); + const table = page.getByRole('table', { name: 'All users' }); + await expect(table.getByRole('columnheader')).toHaveText([ + 'name', + 'email', + 'role', + 'joined', + 'actions' + ]); + + // The database is the truth, and other files may add Accounts at any moment: compare what is stored + // now with what the page shows, until the two agree. + await expect(async () => { + const stored = await fixture.db.user.findMany({ orderBy: { createdAt: 'asc' } }); + await page.reload(); + const rows = table.locator('tbody tr'); + await expect(rows).toHaveCount(stored.length, { timeout: 2_000 }); + for (const [i, user] of stored.entries()) { + const cells = rows.nth(i).getByRole('cell'); + const you = user.email === E2E_ADMIN.email; + await expect(cells.nth(0)).toHaveText(you ? `${user.name} you` : user.name); + await expect(cells.nth(1)).toHaveText(user.email); + await expect(cells.nth(2)).toHaveText(user.role === 'admin' ? 'admin' : 'learner'); + await expect(cells.nth(3)).toHaveText(user.createdAt.toISOString().slice(0, 10)); + await expect(cells.nth(4)).toHaveText(you ? 'you cannot remove yourself' : ''); + } + }).toPass(); + + // The admin and the learners made above are among them; no row has an action yet (reset password and + // remove are later cards). + await expect( + table.getByRole('row', { name: new RegExp(`${E2E_ADMIN.email}.*admin`) }) + ).toBeVisible(); + await expect(table.getByRole('row', { name: new RegExp(`${LIN.email}.*learner`) })).toBeVisible(); + await expect(table.getByRole('button')).toHaveCount(0); }); -test("a name with markup in it is shown as text, never run", async ({ - context, - page, -}) => { - await context.addCookies(admin.cookies); +test('a name with markup in it is shown as text, never run', async ({ context, page }) => { + await context.addCookies(admin.cookies); - await page.goto("/users"); + await page.goto('/users'); - await expect(page.getByRole("cell", { name: MARKUP.name })).toBeVisible(); - await expect(page.locator("#users-xss")).toHaveCount(0); - expect(await page.evaluate(() => "usersXss" in window)).toBe(false); + await expect(page.getByRole('cell', { name: MARKUP.name })).toBeVisible(); + await expect(page.locator('#users-xss')).toHaveCount(0); + expect(await page.evaluate(() => 'usersXss' in window)).toBe(false); }); -test("a learner gets 403 on Users, sees nothing of it, and has no Users link", async ({ - context, - page, +test('a learner gets 403 on Users, sees nothing of it, and has no Users link', async ({ + context, + page }) => { - await context.addCookies(lin.cookies); - await page.goto("/"); - const nav = page.getByRole("navigation", { name: "Primary" }); - await expect(nav.getByRole("link")).toHaveText(["Map", LIN.name]); + await context.addCookies(lin.cookies); + await page.goto('/'); + const nav = page.getByRole('navigation', { name: 'Primary' }); + await expect(nav.getByRole('link')).toHaveText(['Map', LIN.name]); - const response = await page.goto("/users"); + const response = await page.goto('/users'); - expect(response?.status()).toBe(403); - await expect(page.locator("body")).not.toContainText(E2E_ADMIN.email); - await expect(page.getByRole("table")).toHaveCount(0); + expect(response?.status()).toBe(403); + await expect(page.locator('body')).not.toContainText(E2E_ADMIN.email); + await expect(page.getByRole('table')).toHaveCount(0); }); -test("a user whose stored role merely contains admin is a learner here too", async ({ - context, - page, +test('a user whose stored role merely contains admin is a learner here too', async ({ + context, + page }) => { - await context.addCookies(sam.cookies); - await page.goto("/"); - const nav = page.getByRole("navigation", { name: "Primary" }); - await expect(nav.getByRole("link")).toHaveText(["Map", SAM.name]); + await context.addCookies(sam.cookies); + await page.goto('/'); + const nav = page.getByRole('navigation', { name: 'Primary' }); + await expect(nav.getByRole('link')).toHaveText(['Map', SAM.name]); - const response = await page.goto("/users"); + const response = await page.goto('/users'); - expect(response?.status()).toBe(403); - await expect(page.getByRole("table")).toHaveCount(0); + expect(response?.status()).toBe(403); + await expect(page.getByRole('table')).toHaveCount(0); }); -test("a visitor who is signed out is sent to Sign in, not shown the page", async ({ - page, -}) => { - await page.goto("/users"); +test('a visitor who is signed out is sent to Sign in, not shown the page', async ({ page }) => { + await page.goto('/users'); - await expect(page).toHaveURL(/\/signin\?next=%2Fusers$/); - await expect(page.getByRole("table")).toHaveCount(0); + await expect(page).toHaveURL(/\/signin\?next=%2Fusers$/); + await expect(page.getByRole('table')).toHaveCount(0); }); -test.describe("without JavaScript", () => { - test.use({ javaScriptEnabled: false }); - - test("a plain form post creates an Invite, shows the link once and is never cached", async ({ - context, - page, - }) => { - await context.addCookies(admin.cookies); - await page.goto("/users"); - const posted = page.waitForResponse( - (r) => - r.request().method() === "POST" && r.url().includes("/users?/invite"), - ); - - await page.getByRole("button", { name: "Invite a user" }).click(); - - // SvelteKit ran the action and then the load in one request: the answer is the page, not a 500. - const response = await posted; - expect(response.status()).toBe(200); - expect(response.headers()["cache-control"]).toBe("no-store"); - await expect(page.getByLabel("Invite link")).toHaveValue( - /\/invite\/[\w-]{43}$/, - ); - - // Clean up, so a later file sees no stray pending Invite. - await page.getByRole("button", { name: "Cancel invite" }).first().click(); - await expect(page.getByLabel("Invite link")).toHaveCount(0); - }); +test.describe('without JavaScript', () => { + test.use({ javaScriptEnabled: false }); + + test('a plain form post creates an Invite, shows the link once and is never cached', async ({ + context, + page + }) => { + await context.addCookies(admin.cookies); + await page.goto('/users'); + const posted = page.waitForResponse( + (r) => r.request().method() === 'POST' && r.url().includes('/users?/invite') + ); + + await page.getByRole('button', { name: 'Invite a user' }).click(); + + // SvelteKit ran the action and then the load in one request: the answer is the page, not a 500. + const response = await posted; + expect(response.status()).toBe(200); + expect(response.headers()['cache-control']).toBe('no-store'); + await expect(page.getByLabel('Invite link')).toHaveValue(/\/invite\/[\w-]{43}$/); + + // Clean up, so a later file sees no stray pending Invite. + await page.getByRole('button', { name: 'Cancel invite' }).first().click(); + await expect(page.getByLabel('Invite link')).toHaveCount(0); + }); }); diff --git a/src/routes/users/users.test.ts b/src/routes/users/users.test.ts index 6d9aef8e..10393cf8 100644 --- a/src/routes/users/users.test.ts +++ b/src/routes/users/users.test.ts @@ -1,730 +1,642 @@ -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 { createInvites } from "$lib/server/identity/invites"; -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, - RegistrationNotAvailableError, -} from "$lib/server/instance"; -import { createFileTestDb } from "$lib/server/test-db"; -import { load as layoutLoad } from "../+layout.server"; -import { load as signupLoad } from "../signup/+page.server"; -import { actions, load } from "./+page.server"; +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 { createInvites } from '$lib/server/identity/invites'; +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, RegistrationNotAvailableError } from '$lib/server/instance'; +import { createFileTestDb } from '$lib/server/test-db'; +import { load as layoutLoad } from '../+layout.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. -const secret = "test-secret-test-secret-test-secret-0123"; -const password = "correct horse battery staple"; +const secret = 'test-secret-test-secret-test-secret-0123'; +const password = 'correct horse battery staple'; const cookieOf = (headers: Headers) => - headers - .getSetCookie() - .map((c) => c.split(";")[0]) - .join("; "); + headers + .getSetCookie() + .map((c) => c.split(';')[0]) + .join('; '); async function setup() { - const db = await createFileTestDb(); - const instance = await createInstance(db); - let open = false; - const registration = createRegistration({ instance }); - const auth = createAuth({ - db, - origin: "http://localhost:3000", - secret, - admitSignUp: (attempt) => (open ? {} : registration(attempt)), - }); - const { headers } = await createFirstAdmin( - { instance, auth, db }, - { name: "Ada Admin", email: "ada@example.com", password }, - ); - const admin = cookieOf(headers); - - async function learner(name: string, email: string) { - open = true; - const signedUp = await auth.api.signUpEmail({ - body: { name, email, password }, - returnHeaders: true, - }); - open = false; - return cookieOf(signedUp.headers); - } - - const invites = createInvites({ db, origin: "http://localhost:3000" }); - const core = { - forLearner: (learnerId: string) => ({ learnerId }), - } as unknown as App["core"]; - const setupPort = { - multiUser: { kind: "open" }, - createFirstAdmin: vi.fn(), - } as App["setup"]; - const handle = createHandle({ - instance, - identity: await createLocalIdentity(db), - core, - multiUser: { auth, identity: createMultiUserIdentity(auth), invites }, - setup: setupPort, - }); - return { db, auth, instance, admin, learner, handle, invites }; + const db = await createFileTestDb(); + const instance = await createInstance(db); + let open = false; + const registration = createRegistration({ instance }); + const auth = createAuth({ + db, + origin: 'http://localhost:3000', + secret, + admitSignUp: (attempt) => (open ? {} : registration(attempt)) + }); + const { headers } = await createFirstAdmin( + { instance, auth, db }, + { name: 'Ada Admin', email: 'ada@example.com', password } + ); + const admin = cookieOf(headers); + + async function learner(name: string, email: string) { + open = true; + const signedUp = await auth.api.signUpEmail({ + body: { name, email, password }, + returnHeaders: true + }); + open = false; + return cookieOf(signedUp.headers); + } + + const invites = createInvites({ db, origin: 'http://localhost:3000' }); + const core = { forLearner: (learnerId: string) => ({ learnerId }) } as unknown as App['core']; + const setupPort = { multiUser: { kind: 'open' }, createFirstAdmin: vi.fn() } as App['setup']; + const handle = createHandle({ + instance, + identity: await createLocalIdentity(db), + core, + multiUser: { auth, identity: createMultiUserIdentity(auth), invites }, + setup: setupPort + }); + return { db, auth, instance, admin, learner, handle, invites }; } /** Runs one request through the hook; the page's load runs where SvelteKit would run it. */ async function visit( - handle: ReturnType, - path: string, - cookie: string, - routeId = "/users", + handle: ReturnType, + path: string, + cookie: string, + routeId = '/users' ) { - const setHeaders = vi.fn(); - const resolve = vi.fn(async (event: RequestEvent, _opts?: ResolveOptions) => { - const data = await load({ ...event, setHeaders } as never); - return Response.json(data); - }); - const event = { - url: new URL(path, "http://localhost"), - route: { id: routeId }, - request: { method: "GET", headers: new Headers(cookie ? { cookie } : {}) }, - cookies: { get: () => undefined, set: vi.fn() }, - locals: {}, - } as unknown as RequestEvent; - const response = await handle({ event, resolve }); - return { response, resolve, setHeaders, locals: event.locals }; + const setHeaders = vi.fn(); + const resolve = vi.fn(async (event: RequestEvent, _opts?: ResolveOptions) => { + const data = await load({ ...event, setHeaders } as never); + return Response.json(data); + }); + const event = { + url: new URL(path, 'http://localhost'), + route: { id: routeId }, + request: { method: 'GET', headers: new Headers(cookie ? { cookie } : {}) }, + cookies: { get: () => undefined, set: vi.fn() }, + locals: {} + } as unknown as RequestEvent; + const response = await handle({ event, resolve }); + 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", + 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 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(); - await learner("Lin Learner", "lin@example.com"); - await learner("Max Learner", "max@example.com"); - - const { response } = await visit(handle, "/users", admin); - - expect(response.status).toBe(200); - const { accounts } = await response.json(); - expect(accounts.map((a: { email: string }) => a.email)).toEqual([ - "ada@example.com", - "lin@example.com", - "max@example.com", - ]); - expect(accounts.map((a: { you: boolean }) => a.you)).toEqual([ - true, - false, - false, - ]); - expect(response.headers.get("cache-control")).toBe("no-store"); - }); - - it("gives a learner 403 before the page loads", async () => { - const { handle, learner } = await setup(); - const lin = await learner("Lin Learner", "lin@example.com"); - - const { response, resolve } = await visit(handle, "/users", lin); - - expect(response.status).toBe(403); - expect(resolve).not.toHaveBeenCalled(); - }); - - it("gives a user whose stored role merely contains admin 403 too", async () => { - const { handle, learner, 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 visit(handle, "/users", sam); - - expect(response.status).toBe(403); - expect(resolve).not.toHaveBeenCalled(); - }); - - it("sends a signed-out visitor to Sign in, remembering the Users page", async () => { - const { handle } = await setup(); - - const { response, resolve } = await visit(handle, "/users", ""); - - expect(response.status).toBe(303); - expect(response.headers.get("location")).toBe("/signin?next=%2Fusers"); - expect(resolve).not.toHaveBeenCalled(); - }); - - it("treats an admin whose session was ended as signed out", async () => { - const { handle, admin, db } = await setup(); - await db.session.deleteMany(); - - const { response, resolve } = await visit(handle, "/users", admin); - - expect(response.status).toBe(303); - expect(resolve).not.toHaveBeenCalled(); - }); + (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(); + await learner('Lin Learner', 'lin@example.com'); + await learner('Max Learner', 'max@example.com'); + + const { response } = await visit(handle, '/users', admin); + + expect(response.status).toBe(200); + const { accounts } = await response.json(); + expect(accounts.map((a: { email: string }) => a.email)).toEqual([ + 'ada@example.com', + 'lin@example.com', + 'max@example.com' + ]); + expect(accounts.map((a: { you: boolean }) => a.you)).toEqual([true, false, false]); + expect(response.headers.get('cache-control')).toBe('no-store'); + }); + + it('sets no-store in the hook, whatever the route answers, so a route never sets it twice', async () => { + const { handle, admin } = await setup(); + for (const status of [200, 500]) { + const event = { + url: new URL('/users', 'http://localhost'), + route: { id: '/users' }, + request: { method: 'POST', headers: new Headers({ cookie: admin }) }, + cookies: { get: () => undefined, set: vi.fn() }, + locals: {} + } as unknown as RequestEvent; + const resolve = vi.fn(async () => new Response('x', { status })); + const response = await handle({ event, resolve }); + expect(response.headers.get('cache-control'), `${status}`).toBe('no-store'); + } + }); + + it('gives a learner 403 before the page loads', async () => { + const { handle, learner } = await setup(); + const lin = await learner('Lin Learner', 'lin@example.com'); + + const { response, resolve } = await visit(handle, '/users', lin); + + expect(response.status).toBe(403); + expect(resolve).not.toHaveBeenCalled(); + }); + + it('gives a user whose stored role merely contains admin 403 too', async () => { + const { handle, learner, 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 visit(handle, '/users', sam); + + expect(response.status).toBe(403); + expect(resolve).not.toHaveBeenCalled(); + }); + + it('sends a signed-out visitor to Sign in, remembering the Users page', async () => { + const { handle } = await setup(); + + const { response, resolve } = await visit(handle, '/users', ''); + + expect(response.status).toBe(303); + expect(response.headers.get('location')).toBe('/signin?next=%2Fusers'); + expect(resolve).not.toHaveBeenCalled(); + }); + + it('treats an admin whose session was ended as signed out', async () => { + const { handle, admin, db } = await setup(); + await db.session.deleteMany(); + + const { response, resolve } = await visit(handle, '/users', admin); + + expect(response.status).toBe(303); + expect(resolve).not.toHaveBeenCalled(); + }); }); -describe("the Users page in Local Mode", () => { - it("does not exist: 404 for the Local Learner, whatever they are", 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 visit(handle, "/users", ""); - - expect(response.status).toBe(404); - expect(resolve).not.toHaveBeenCalled(); - }); +describe('the Users page in Local Mode', () => { + it('does not exist: 404 for the Local Learner, whatever they are', 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 visit(handle, '/users', ''); + + expect(response.status).toBe(404); + expect(resolve).not.toHaveBeenCalled(); + }); }); -describe("the load, if the hook ever let a request through", () => { - const event = (locals: Record) => - ({ - locals, - request: { headers: new Headers() }, - setHeaders: vi.fn(), - }) as never; - const identity = (role: "admin" | "learner") => ({ - learnerId: "l", - displayName: "N", - role, - }); - const auth = { api: { listUsers: vi.fn() } }; - - it("is 404 without Better Auth, as in Local Mode where the Local Learner is an admin", async () => { - await expect( - load(event({ identity: identity("admin") })), - ).rejects.toMatchObject({ - status: 404, - }); - }); - - it("is 403 for nobody", async () => { - await expect(load(event({ auth }))).rejects.toMatchObject({ status: 403 }); - }); - - it("is 403 for a learner, and reads nothing", async () => { - await expect( - load(event({ auth, identity: identity("learner") })), - ).rejects.toMatchObject({ - status: 403, - }); - expect(auth.api.listUsers).not.toHaveBeenCalled(); - }); +describe('the load, if the hook ever let a request through', () => { + const event = (locals: Record) => + ({ locals, request: { headers: new Headers() }, setHeaders: vi.fn() }) as never; + const identity = (role: 'admin' | 'learner') => ({ learnerId: 'l', displayName: 'N', role }); + const auth = { api: { listUsers: vi.fn() } }; + + it('is 404 without Better Auth, as in Local Mode where the Local Learner is an admin', async () => { + await expect(load(event({ identity: identity('admin') }))).rejects.toMatchObject({ + status: 404 + }); + }); + + it('is 403 for nobody', async () => { + await expect(load(event({ auth }))).rejects.toMatchObject({ status: 403 }); + }); + + it('is 403 for a learner, and reads nothing', async () => { + await expect(load(event({ auth, identity: identity('learner') }))).rejects.toMatchObject({ + status: 403 + }); + expect(auth.api.listUsers).not.toHaveBeenCalled(); + }); }); -describe("the nav", () => { - const layout = (mode: "local" | "multi" | null, role?: "admin" | "learner") => - layoutLoad({ - locals: { - instance: { mode: () => mode } as Instance, - identity: role && { learnerId: "l", displayName: "N", role }, - }, - } as never); - - it("offers Users to the admin of a Multi-user Instance", () => { - expect(layout("multi", "admin")).toMatchObject({ - displayName: "N", - showUsers: true, - }); - }); - - it("does not offer it to a learner", () => { - expect(layout("multi", "learner")).toMatchObject({ showUsers: false }); - }); - - it("does not offer it in Local Mode, where the Local Learner is an admin but there are no Accounts", () => { - expect(layout("local", "admin")).toMatchObject({ - displayName: "N", - showUsers: false, - }); - }); - - it("does not offer it before anyone is resolved", () => { - expect(layout(null)).toMatchObject({ showUsers: false }); - expect(layout("multi")).toMatchObject({ showUsers: false }); - }); +describe('the nav', () => { + const layout = (mode: 'local' | 'multi' | null, role?: 'admin' | 'learner') => + layoutLoad({ + locals: { + instance: { mode: () => mode } as Instance, + identity: role && { learnerId: 'l', displayName: 'N', role } + } + } as never); + + it('offers Users to the admin of a Multi-user Instance', () => { + expect(layout('multi', 'admin')).toMatchObject({ displayName: 'N', showUsers: true }); + }); + + it('does not offer it to a learner', () => { + expect(layout('multi', 'learner')).toMatchObject({ showUsers: false }); + }); + + it('does not offer it in Local Mode, where the Local Learner is an admin but there are no Accounts', () => { + expect(layout('local', 'admin')).toMatchObject({ displayName: 'N', showUsers: false }); + }); + + it('does not offer it before anyone is resolved', () => { + expect(layout(null)).toMatchObject({ showUsers: false }); + 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 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"); - }); +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'); + }); }); /** Posts to the Invite actions through the real hook. */ async function post( - handle: ReturnType, - cookie: string, - name: "invite" | "cancel", - fields: Record = {}, + handle: ReturnType, + cookie: string, + name: 'invite' | 'cancel', + fields: Record = {} ) { - const setHeaders = vi.fn(); - const resolve = vi.fn(async (event: RequestEvent, _opts?: ResolveOptions) => { - const result = await actions[name]({ ...event, setHeaders } as never); - return Response.json(result ?? null, { status: result?.status ?? 200 }); - }); - const event = { - url: new URL(`/users?/${name}`, "http://localhost"), - route: { id: "/users" }, - request: new Request(`http://localhost/users?/${name}`, { - 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, setHeaders }; + const setHeaders = vi.fn(); + const resolve = vi.fn(async (event: RequestEvent, _opts?: ResolveOptions) => { + const result = await actions[name]({ ...event, setHeaders } as never); + return Response.json(result ?? null, { status: result?.status ?? 200 }); + }); + const event = { + url: new URL(`/users?/${name}`, 'http://localhost'), + route: { id: '/users' }, + request: new Request(`http://localhost/users?/${name}`, { + 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, setHeaders }; } -describe("Invites on the Users page", () => { - it("lets the admin create an Invite: the link is in that one response, the database holds only a hash", async () => { - const { handle, admin, db } = await setup(); - - const { response } = await post(handle, admin, "invite"); - - expect(response.status).toBe(200); - const text = JSON.stringify(await response.json()); - const link = text.match( - /http:\\?\/\\?\/localhost:3000\\?\/invite\\?\/([\w-]{43})/, - ); - expect(link).toBeTruthy(); - const token = (link as RegExpMatchArray)[1]; - expect(JSON.stringify(await db.invite.findMany())).not.toContain(token); - expect(response.headers.get("cache-control")).toBe("no-store"); - }); - - it("lists pending Invites in the load, without any token", async () => { - const { handle, admin, invites } = await setup(); - const made = await invites.create({ - who: { learnerId: "local", displayName: "Ada", role: "admin" }, - now: new Date(), - }); - - const { response } = await visit(handle, "/users", admin); - - const body = await response.json(); - expect(body.pending).toEqual([expect.objectContaining({ id: made.id })]); - expect(JSON.stringify(body)).not.toContain(made.token); - }); - - it("cancels a pending Invite; the cancelled link no longer works", async () => { - const { handle, admin, invites } = await setup(); - const made = await invites.create({ - who: { learnerId: "local", displayName: "Ada", role: "admin" }, - now: new Date(), - }); - - const { response } = await post(handle, admin, "cancel", { id: made.id }); - - expect(response.status).toBe(200); - expect(await invites.lookup(made.token, new Date())).toBeNull(); - }); - - it.each([ - ["an unknown id", { id: "nope" }], - ["no id", {}], - ])("answers %s with a fixed message", async (_, fields) => { - const { handle, admin } = await setup(); - - const { response } = await post(handle, admin, "cancel", fields); - - expect(response.status).toBe(404); - expect(JSON.stringify(await response.json())).toContain( - "no longer pending", - ); - }); - - it.each(["invite", "cancel"] as const)( - "%s: refuses a learner with 403 and writes nothing", - async (name) => { - const { handle, learner, db, invites } = await setup(); - const lin = await learner("Lin Learner", "lin@example.com"); - const made = await invites.create({ - who: { learnerId: "local", displayName: "Ada", role: "admin" }, - now: new Date(), - }); - - const { response, resolve } = await post(handle, lin, name, { - id: made.id, - }); - - expect(response.status).toBe(403); - expect(resolve).not.toHaveBeenCalled(); - expect(await db.invite.count()).toBe(1); - }, - ); - - it.each(["invite", "cancel"] as const)( - "%s: sends a signed-out request to Sign in", - async (name) => { - const { handle, db } = await setup(); - - const { response, resolve } = await post(handle, "", name, { id: "x" }); - - expect(response.status).toBe(303); - expect(resolve).not.toHaveBeenCalled(); - expect(await db.invite.count()).toBe(0); - }, - ); - - it.each(["invite", "cancel"] as const)( - "%s: does not exist in Local Mode", - async (name) => { - 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 post(handle, "", name, { id: "x" }); - - expect(response.status).toBe(404); - expect(resolve).not.toHaveBeenCalled(); - expect(await db.invite.count()).toBe(0); - }, - ); - - it.each(["invite", "cancel"] as const)( - "%s: is 403 for a non-admin, 404 without Invites, even past the hook", - async (name) => { - const learner = { - learnerId: "l", - displayName: "N", - role: "learner" as const, - }; - const admin = { ...learner, role: "admin" as const }; - const event = (locals: Record) => - ({ - locals, - setHeaders: vi.fn(), - request: { formData: vi.fn() }, - }) as never; - await expect( - actions[name](event({ identity: learner, invites: {} })), - ).rejects.toMatchObject({ - status: 403, - }); - await expect(actions[name](event({}))).rejects.toMatchObject({ - status: 403, - }); - await expect( - actions[name](event({ identity: admin })), - ).rejects.toMatchObject({ - status: 404, - }); - }, - ); +describe('Invites on the Users page', () => { + it('lets the admin create an Invite: the link is in that one response, the database holds only a hash', async () => { + const { handle, admin, db } = await setup(); + + const { response } = await post(handle, admin, 'invite'); + + expect(response.status).toBe(200); + const text = JSON.stringify(await response.json()); + const link = text.match(/http:\\?\/\\?\/localhost:3000\\?\/invite\\?\/([\w-]{43})/); + expect(link).toBeTruthy(); + const token = (link as RegExpMatchArray)[1]; + expect(JSON.stringify(await db.invite.findMany())).not.toContain(token); + expect(response.headers.get('cache-control')).toBe('no-store'); + }); + + it('lists pending Invites in the load, without any token', async () => { + const { handle, admin, invites } = await setup(); + const made = await invites.create({ + who: { learnerId: 'local', displayName: 'Ada', role: 'admin' }, + now: new Date() + }); + + const { response } = await visit(handle, '/users', admin); + + const body = await response.json(); + expect(body.pending).toEqual([expect.objectContaining({ id: made.id })]); + expect(JSON.stringify(body)).not.toContain(made.token); + }); + + it('cancels a pending Invite; the cancelled link no longer works', async () => { + const { handle, admin, invites } = await setup(); + const made = await invites.create({ + who: { learnerId: 'local', displayName: 'Ada', role: 'admin' }, + now: new Date() + }); + + const { response } = await post(handle, admin, 'cancel', { id: made.id }); + + expect(response.status).toBe(200); + expect(await invites.lookup(made.token, new Date())).toBeNull(); + }); + + it.each([ + ['an unknown id', { id: 'nope' }], + ['no id', {}] + ])('answers %s with a fixed message', async (_, fields) => { + const { handle, admin } = await setup(); + + const { response } = await post(handle, admin, 'cancel', fields); + + expect(response.status).toBe(404); + expect(JSON.stringify(await response.json())).toContain('no longer pending'); + }); + + it.each(['invite', 'cancel'] as const)( + '%s: refuses a learner with 403 and writes nothing', + async (name) => { + const { handle, learner, db, invites } = await setup(); + const lin = await learner('Lin Learner', 'lin@example.com'); + const made = await invites.create({ + who: { learnerId: 'local', displayName: 'Ada', role: 'admin' }, + now: new Date() + }); + + const { response, resolve } = await post(handle, lin, name, { id: made.id }); + + expect(response.status).toBe(403); + expect(resolve).not.toHaveBeenCalled(); + expect(await db.invite.count()).toBe(1); + } + ); + + it.each(['invite', 'cancel'] as const)( + '%s: sends a signed-out request to Sign in', + async (name) => { + const { handle, db } = await setup(); + + const { response, resolve } = await post(handle, '', name, { id: 'x' }); + + expect(response.status).toBe(303); + expect(resolve).not.toHaveBeenCalled(); + expect(await db.invite.count()).toBe(0); + } + ); + + it.each(['invite', 'cancel'] as const)('%s: does not exist in Local Mode', async (name) => { + 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 post(handle, '', name, { id: 'x' }); + + expect(response.status).toBe(404); + expect(resolve).not.toHaveBeenCalled(); + expect(await db.invite.count()).toBe(0); + }); + + it.each(['invite', 'cancel'] as const)( + '%s: is 403 for a non-admin, 404 without Invites, even past the hook', + async (name) => { + const learner = { learnerId: 'l', displayName: 'N', role: 'learner' as const }; + const admin = { ...learner, role: 'admin' as const }; + const event = (locals: Record) => + ({ locals, setHeaders: vi.fn(), request: { formData: vi.fn() } }) as never; + await expect(actions[name](event({ identity: learner, invites: {} }))).rejects.toMatchObject({ + status: 403 + }); + await expect(actions[name](event({}))).rejects.toMatchObject({ status: 403 }); + await expect(actions[name](event({ identity: admin }))).rejects.toMatchObject({ + status: 404 + }); + } + ); });