import { createHmac, randomBytes } from "node:crypto"; import { mkdtemp, rm } from "node:fs/promises"; import type { AddressInfo } from "node:net"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { createBootstrapIdentityCreator, createIdentityDrizzleDatabase, type IdentityNotificationFailure, } from "@ekklesia/core/identity"; import { applyModuleMigrations, createPostgresMigrationDatabase, discoverAllMigrations, } from "@ekklesia/core/migrations"; import { Client, type QueryResultRow } from "pg"; import { SMTPServer } from "smtp-server"; import { afterAll, afterEach, describe, expect, it } from "vitest"; import { createApiRuntimeConfig } from "../src/config.js"; import { type RunningApi, startApi } from "../src/server.js"; /** * Email verification and password recovery, end to end (ADR 0038). * * Everything here is real: a disposable PostgreSQL, the actual HTTP transport, the actual Better * Auth endpoints, the actual SMTP provider, and an actual SMTP server accepting the message. The * verification and reset links are **extracted from the delivered message** and then followed, which * is the only way to prove the whole path rather than the pieces — a fake mail function would prove * that a callback was invoked and nothing about whether the resulting link works. * * `smtp-server` runs in-process and is never given a live credential, so nothing here reaches the * internet and no real email is ever sent. * * Public signup is disabled in every scenario, matching production. Accounts that are not the * bootstrap owner are created through the same trusted server-side creator the bootstrap CLI uses, * which is deliberately not reachable over HTTP. */ const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL"; const repositoryRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); const coreMigrationsDirectory = path.join(repositoryRoot, "core", "drizzle"); const trackedDatabases = new Set(); let databaseCounter = 0; const dashboardOrigin = "http://dashboard.localhost:5173"; const integrationSecret = "integration-test-secret-0123456789abcdefgh"; const initialPassword = "correct horse battery staple"; const replacementPassword = "an entirely different passphrase"; class IntegrationHarnessError extends Error { constructor(message: string) { super(message); this.name = "IntegrationHarnessError"; this.stack = `${this.name}: ${this.message}`; } } function baseDatabaseUrl(): string { const url = process.env[integrationDatabaseUrlEnvironment]; if (typeof url !== "string" || url.trim() === "") { throw new IntegrationHarnessError("The integration database URL is not configured."); } return url; } function databaseUrl(name: string): string { const url = new URL(baseDatabaseUrl()); url.pathname = `/${name}`; return url.toString(); } async function openAdministrativeClient(): Promise { const client = new Client({ connectionString: baseDatabaseUrl() }); await client.connect(); return client; } async function safeQuery( client: Client, text: string, values: readonly unknown[] = [], ): Promise { try { const result = await client.query(text, [...values]); return result.rows; } catch { // Never rethrows the driver error: it can carry SQL, a connection string, and a host. throw new IntegrationHarnessError("An integration database statement failed."); } } async function applyCoreMigrations(url: string): Promise { const emptyModules = await mkdtemp(path.join(tmpdir(), "ekklesia-verify-no-modules-")); try { const aggregate = await discoverAllMigrations({ coreMigrationsDirectory, moduleDiscovery: { moduleSdkVersion: "0.1.0", modulesDirectory: emptyModules, platformVersion: "0.1.0", }, }); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); } finally { await rm(emptyModules, { force: true, recursive: true }); } } async function freshInstallation(label: string): Promise { databaseCounter += 1; const name = `ekk_verify_${label}_${databaseCounter}_${randomBytes(4).toString("hex")}`.toLowerCase(); const admin = await openAdministrativeClient(); try { await safeQuery(admin, `create database "${name}"`); } finally { await admin.end().catch(() => undefined); } trackedDatabases.add(name); const url = databaseUrl(name); await applyCoreMigrations(url); return url; } /** A delivered message, as the SMTP server actually received it. */ interface DeliveredMessage { readonly to: readonly string[]; readonly data: string; } interface MailHarness { readonly port: number; readonly delivered: DeliveredMessage[]; readonly close: () => Promise; } const openResources: Array<() => Promise> = []; afterEach(async () => { for (const close of openResources.splice(0).reverse()) { await close().catch(() => undefined); } }); /** * Picks a free port before the API is configured. * * The identity base URL is what the emailed link is built from, so it has to be known *before* * `startApi` binds. Without this the test could only assert the link's shape and would have to * rewrite its host before following it — which is exactly the part worth proving. Reserving and * releasing a port leaves a theoretical race, but nothing else on the loopback interface is * competing for ephemeral ports inside this suite. */ async function reservePort(): Promise { const { createServer } = await import("node:net"); const server = createServer(); const port = await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", () => { const address = server.address() as AddressInfo; resolve(address.port); }); }); await new Promise((resolve) => server.close(() => resolve())); return port; } async function startMailServer(): Promise { const delivered: DeliveredMessage[] = []; const server = new SMTPServer({ authOptional: true, disabledCommands: ["STARTTLS"], onData(stream, session, callback) { let data = ""; stream.on("data", (chunk: Buffer) => { data += chunk.toString("utf8"); }); stream.on("end", () => { delivered.push({ data, to: session.envelope.rcptTo.map((recipient) => recipient.address), }); callback(); }); }, }); await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", () => resolve()); }); const address = server.server.address() as AddressInfo; const harness: MailHarness = { close: async () => await new Promise((resolve) => { server.close(() => resolve()); }), delivered, port: address.port, }; openResources.push(harness.close); return harness; } interface Installation { readonly url: string; readonly api: RunningApi; readonly origin: string; readonly mail: MailHarness | undefined; readonly notificationFailures: IdentityNotificationFailure[]; readonly createIdentity: (input: { readonly email: string; readonly password: string; readonly name: string; }) => Promise; } /** * A migrated installation with the API running, optionally with a working SMTP relay. * * `plaintext` SMTP and an `http` Dashboard origin are only acceptable because this is explicitly the * development environment; production configuration refuses both, which `identity-config` and * `mail-contract` assert separately. */ async function installation( label: string, options: { readonly mail?: boolean } = {}, ): Promise { const url = await freshInstallation(label); const mail = options.mail === false ? undefined : await startMailServer(); const notificationFailures: IdentityNotificationFailure[] = []; const identity = createIdentityDrizzleDatabase(url, {}); openResources.push(async () => { await identity.close().catch(() => undefined); }); const apiPort = await reservePort(); const identityBaseUrl = `http://127.0.0.1:${apiPort}`; const config = createApiRuntimeConfig( { databaseUrl: url, host: "127.0.0.1", identity: { baseUrl: identityBaseUrl, dashboardOrigin, publicSignupEnabled: false, secret: integrationSecret, trustedOrigins: [dashboardOrigin], }, ...(mail ? { mail: { fromAddress: "ekklesia@example.test", fromName: "Ekklesia", host: "127.0.0.1", port: String(mail.port), security: "plaintext", }, } : {}), port: apiPort, }, { environment: "development" }, ); const api = await startApi(config, { rateLimitOverride: { max: 1000, window: 1 }, reportNotificationFailure: (failure) => notificationFailures.push(failure), }); openResources.push(async () => { await api.shutdown().catch(() => undefined); }); const address = api.server.address(); if (!address || typeof address !== "object" || address.port !== apiPort) { throw new IntegrationHarnessError("The API integration server did not bind the reserved port."); } return { api, createIdentity: createBootstrapIdentityCreator(identity.db), mail, notificationFailures, origin: identityBaseUrl, url, }; } async function post( origin: string, path: string, body: unknown, headers: Readonly> = {}, ): Promise { return await fetch(`${origin}${path}`, { body: JSON.stringify(body), headers: { "content-type": "application/json", origin: dashboardOrigin, ...headers }, method: "POST", redirect: "manual", }); } function firstCookiePair(response: Response): string | undefined { const setCookie = response.headers.get("set-cookie"); return setCookie === null ? undefined : setCookie.split(",")[0]?.split(";")[0]?.trim(); } async function signIn( origin: string, email: string, password: string, ): Promise<{ readonly response: Response; readonly cookiePair: string | undefined }> { const response = await post(origin, "/api/auth/sign-in/email", { email, password }); return { cookiePair: firstCookiePair(response), response }; } async function principalKind(origin: string, cookiePair: string | undefined): Promise { const response = await fetch(`${origin}/api/v1/principal`, { headers: cookiePair ? { cookie: cookiePair } : {}, }); const body = (await response.json()) as { readonly kind?: unknown }; return String(body.kind); } /** * The one place a test reads a token, and only ever out of a message this suite's own SMTP server * received. Nothing in the platform exposes one. * * Quoted-printable soft line breaks are undone first: a URL longer than 76 characters is split by * the encoder, and reassembling it here is what makes the extracted link the one a mail client would * actually resolve. */ function extractLink(message: DeliveredMessage, contains: string): URL { const unwrapped = message.data.replaceAll("=\r\n", "").replaceAll("=\n", ""); const decoded = unwrapped.replaceAll(/=([0-9A-F]{2})/gu, (_match, hex: string) => String.fromCharCode(Number.parseInt(hex, 16)), ); const match = decoded.match(/https?:\/\/[^\s"<>]+/gu)?.find((url) => url.includes(contains)); if (!match) { throw new IntegrationHarnessError(`No ${contains} link was delivered.`); } return new URL(match); } function lastMessage(mail: MailHarness): DeliveredMessage { const message = mail.delivered.at(-1); if (!message) { throw new IntegrationHarnessError("No message was delivered."); } return message; } async function emailVerifiedFlag(url: string, email: string): Promise { const client = new Client({ connectionString: url }); await client.connect(); try { const rows = await safeQuery<{ email_verified: boolean }>( client, 'select "email_verified" from "ekklesia_core_user" where lower("email") = $1', [email.toLowerCase()], ); return rows[0]?.email_verified; } finally { await client.end().catch(() => undefined); } } /** * An account that has completed the real verification round trip. * * Recovery is only meaningful for a verified account, and building one through the actual flow — * rather than by writing `email_verified` directly — keeps every recovery test standing on a * verification path that genuinely works. */ async function verifiedAccountFor(install: Installation, email: string): Promise { await install.createIdentity({ email, name: "Member", password: initialPassword }); await signIn(install.origin, email, initialPassword); const link = extractLink(lastMessage(install.mail as MailHarness), "/verify-email"); await fetch(link, { redirect: "manual" }); (install.mail as MailHarness).delivered.length = 0; } afterAll(async () => { const admin = await openAdministrativeClient().catch(() => undefined); if (!admin) { return; } try { for (const name of trackedDatabases) { await safeQuery(admin, `drop database if exists "${name}" with (force)`).catch( () => undefined, ); } } finally { await admin.end().catch(() => undefined); } }); /** * Forges a verification token that is structurally valid but already expired. * * Better Auth's verification token is a plain HS256 JWS over the auth secret (`signJWT`, pinned * release) — not a database row — so an expired one can be produced here from the same secret this * suite configured, with no library internals and no waiting an hour. This is the only place any * test constructs a token rather than reading one out of a delivered message. */ function forgeExpiredVerificationToken(email: string): string { const encode = (value: object): string => Buffer.from(JSON.stringify(value)).toString("base64url"); const issued = Math.floor(Date.now() / 1000) - 7200; const signingInput = `${encode({ alg: "HS256", typ: "JWT" })}.${encode({ email: email.toLowerCase(), exp: issued + 3600, iat: issued, })}`; const signature = createHmac("sha256", integrationSecret) .update(signingInput) .digest("base64url"); return `${signingInput}.${signature}`; } describe("email verification, end to end", () => { it("takes a new human account from unverified to signed in through a real emailed link", async () => { const install = await installation("flow"); const email = "member@example.org"; await install.createIdentity({ email, name: "Member", password: initialPassword }); // 1. Unverified. A correct password does not produce a session — the identity system refuses // before creating one, so there is no half-authenticated state anywhere (ADR 0038). expect(await emailVerifiedFlag(install.url, email)).toBe(false); const refused = await signIn(install.origin, email, initialPassword); expect(refused.response.status).toBe(403); expect(await refused.response.clone().json()).toMatchObject({ code: "EMAIL_NOT_VERIFIED" }); expect(refused.cookiePair).toBeUndefined(); expect(await principalKind(install.origin, refused.cookiePair)).toBe("anonymous"); // 2. That attempt delivered a verification message through the real SMTP provider. const mail = install.mail as MailHarness; expect(mail.delivered).toHaveLength(1); expect(lastMessage(mail).to).toEqual([email]); expect(lastMessage(mail).data).toContain("Verify your Ekklesia email address"); // 3. Follow the link exactly as it was delivered. Nothing rewrites it. const link = extractLink(lastMessage(mail), "/verify-email"); expect(link.origin).toBe(install.origin); const verified = await fetch(link, { redirect: "manual" }); // 4. Better Auth redirects to the Dashboard landing this deployment configured — never to the // API origin, and never to anything a request supplied. expect(verified.status).toBe(302); const landing = new URL(verified.headers.get("location") ?? ""); expect(landing.origin).toBe(dashboardOrigin); expect(landing.searchParams.get("ekklesia_auth")).toBe("verify-email"); expect(landing.searchParams.get("error")).toBeNull(); // No session was minted by following an emailed link. expect(verified.headers.get("set-cookie")).toBeNull(); expect(await emailVerifiedFlag(install.url, email)).toBe(true); // 5. The same credentials now work, and the principal is a fully authenticated human. const accepted = await signIn(install.origin, email, initialPassword); expect(accepted.response.status).toBe(200); expect(accepted.cookiePair).toBeDefined(); expect(await principalKind(install.origin, accepted.cookiePair)).toBe("authenticated"); }); it("refuses a malformed token and an expired token, each with its own reason", async () => { const install = await installation("bad-token"); const email = "expired@example.org"; await install.createIdentity({ email, name: "Expired", password: initialPassword }); const callback = encodeURIComponent(`${dashboardOrigin}/?ekklesia_auth=verify-email`); for (const [token, reason] of [ ["not-a-jwt", "INVALID_TOKEN"], [forgeExpiredVerificationToken(email), "TOKEN_EXPIRED"], ] as const) { const response = await fetch( `${install.origin}/api/auth/verify-email?token=${encodeURIComponent(token)}&callbackURL=${callback}`, { redirect: "manual" }, ); expect(response.status).toBe(302); const landing = new URL(response.headers.get("location") ?? ""); // The failure still lands on the host's own screen rather than a raw JSON error page. expect(landing.origin).toBe(dashboardOrigin); expect(landing.searchParams.get("error")).toBe(reason); expect(await emailVerifiedFlag(install.url, email)).toBe(false); } }); it("is idempotent when a link is followed twice, because the token is a signed claim", async () => { const install = await installation("replay"); const email = "replay@example.org"; await install.createIdentity({ email, name: "Replay", password: initialPassword }); await signIn(install.origin, email, initialPassword); const link = extractLink(lastMessage(install.mail as MailHarness), "/verify-email"); const first = await fetch(link, { redirect: "manual" }); const second = await fetch(link, { redirect: "manual" }); // Documented honestly (ADR 0038): the verification token is a signed JWT, not a consumed // database row, so it is **not** single-use — it is valid until it expires. Replaying it is a // no-op on an already-verified address and mints no session, which is what makes that safe. // Mail scanners and prefetchers dereference links, so idempotence is a requirement here. expect(first.status).toBe(302); expect(second.status).toBe(302); expect(second.headers.get("set-cookie")).toBeNull(); expect(await emailVerifiedFlag(install.url, email)).toBe(true); }); it("resends on request without revealing whether the address has an account", async () => { const install = await installation("resend"); const email = "resend@example.org"; await install.createIdentity({ email, name: "Resend", password: initialPassword }); const mail = install.mail as MailHarness; const known = await post(install.origin, "/api/auth/send-verification-email", { email }); const unknown = await post(install.origin, "/api/auth/send-verification-email", { email: "nobody@example.org", }); const verifiedAlready = await post(install.origin, "/api/auth/send-verification-email", { email: "member-who-does-not-exist@example.org", }); // Identical answers. The only difference anywhere is that a message was delivered, which the // requester cannot observe. expect(known.status).toBe(200); expect(unknown.status).toBe(200); expect(verifiedAlready.status).toBe(200); expect(await known.clone().json()).toEqual(await unknown.clone().json()); expect(mail.delivered.map((message) => message.to)).toEqual([[email]]); }); }); describe("password recovery, end to end", () => { it("replaces the password through a real emailed link and invalidates the old one", async () => { const install = await installation("reset"); const email = "reset@example.org"; await verifiedAccountFor(install, email); const mail = install.mail as MailHarness; const requested = await post(install.origin, "/api/auth/request-password-reset", { email }); expect(requested.status).toBe(200); expect(mail.delivered).toHaveLength(1); expect(lastMessage(mail).to).toEqual([email]); expect(lastMessage(mail).data).toContain("Reset your Ekklesia password"); // Follow the emailed link. Better Auth validates the token and hands the browser on to the // Dashboard landing with it attached — which is where the host's reset form picks it up. const link = extractLink(lastMessage(mail), "/reset-password/"); expect(link.origin).toBe(install.origin); const handoff = await fetch(link, { redirect: "manual" }); expect(handoff.status).toBe(302); const landing = new URL(handoff.headers.get("location") ?? ""); expect(landing.origin).toBe(dashboardOrigin); expect(landing.searchParams.get("ekklesia_auth")).toBe("reset-password"); const token = landing.searchParams.get("token") ?? ""; expect(token).not.toBe(""); const completed = await post(install.origin, "/api/auth/reset-password", { newPassword: replacementPassword, token, }); expect(completed.status).toBe(200); const oldCredential = await signIn(install.origin, email, initialPassword); expect(oldCredential.response.status).toBe(401); const newCredential = await signIn(install.origin, email, replacementPassword); expect(newCredential.response.status).toBe(200); expect(await principalKind(install.origin, newCredential.cookiePair)).toBe("authenticated"); }); it("consumes the reset token, so a captured link cannot be replayed", async () => { const install = await installation("reset-replay"); const email = "replay-reset@example.org"; await verifiedAccountFor(install, email); await post(install.origin, "/api/auth/request-password-reset", { email }); const link = extractLink(lastMessage(install.mail as MailHarness), "/reset-password/"); const handoff = await fetch(link, { redirect: "manual" }); const token = new URL(handoff.headers.get("location") ?? "").searchParams.get("token") ?? ""; const first = await post(install.origin, "/api/auth/reset-password", { newPassword: replacementPassword, token, }); const second = await post(install.origin, "/api/auth/reset-password", { newPassword: "yet another different passphrase", token, }); // Unlike the verification token, this one is a database row Better Auth consumes. expect(first.status).toBe(200); expect(second.status).toBe(400); expect(await second.json()).toMatchObject({ code: "INVALID_TOKEN" }); expect((await signIn(install.origin, email, replacementPassword)).response.status).toBe(200); }); it("signs out every existing session for that account", async () => { const install = await installation("reset-sessions"); const email = "sessions@example.org"; await verifiedAccountFor(install, email); const before = await signIn(install.origin, email, initialPassword); expect(await principalKind(install.origin, before.cookiePair)).toBe("authenticated"); await post(install.origin, "/api/auth/request-password-reset", { email }); const link = extractLink(lastMessage(install.mail as MailHarness), "/reset-password/"); const handoff = await fetch(link, { redirect: "manual" }); const token = new URL(handoff.headers.get("location") ?? "").searchParams.get("token") ?? ""; await post(install.origin, "/api/auth/reset-password", { newPassword: replacementPassword, token, }); // The whole point of recovery is that it works when the credential is already compromised, so // an attacker's existing session must not survive it (`revokeSessionsOnPasswordReset`). expect(await principalKind(install.origin, before.cookiePair)).toBe("anonymous"); }); it("enforces the identity system's own password policy, with no reset-specific rule", async () => { const install = await installation("reset-policy"); const email = "policy@example.org"; await verifiedAccountFor(install, email); await post(install.origin, "/api/auth/request-password-reset", { email }); const link = extractLink(lastMessage(install.mail as MailHarness), "/reset-password/"); const handoff = await fetch(link, { redirect: "manual" }); const token = new URL(handoff.headers.get("location") ?? "").searchParams.get("token") ?? ""; const tooShort = await post(install.origin, "/api/auth/reset-password", { newPassword: "short", token, }); expect(tooShort.status).toBe(400); expect(await tooShort.json()).toMatchObject({ code: "PASSWORD_TOO_SHORT" }); // The old password still works: a rejected reset changes nothing. expect((await signIn(install.origin, email, initialPassword)).response.status).toBe(200); }); it("answers a request for an address with no account exactly as it answers a real one", async () => { const install = await installation("enumeration"); const email = "known@example.org"; await verifiedAccountFor(install, email); const mail = install.mail as MailHarness; const known = await post(install.origin, "/api/auth/request-password-reset", { email }); const unknown = await post(install.origin, "/api/auth/request-password-reset", { email: "stranger@example.org", }); expect(known.status).toBe(unknown.status); expect(await known.clone().json()).toEqual(await unknown.clone().json()); expect(JSON.stringify(await unknown.json())).not.toContain("stranger"); // Only the real account received anything. expect(mail.delivered.map((message) => message.to)).toEqual([[email]]); }); }); describe("recovery when the installation has no outbound email", () => { it("answers identically and records the operational failure out of band", async () => { const install = await installation("no-mail", { mail: false }); const email = "nomail@example.org"; await install.createIdentity({ email, name: "No Mail", password: initialPassword }); const reset = await post(install.origin, "/api/auth/request-password-reset", { email }); const resend = await post(install.origin, "/api/auth/send-verification-email", { email }); // The user-facing answer must not change: "we could not send it" for a real address and a // generic success for an unknown one is an enumeration oracle that appears when SMTP breaks. expect(reset.status).toBe(200); expect(resend.status).toBe(200); // The operator still learns about it, with a sanitized code and no address or token. expect(install.notificationFailures).toEqual([ { kind: "password-reset", reason: "MAIL_NOT_CONFIGURED" }, { kind: "email-verification", reason: "MAIL_NOT_CONFIGURED" }, ]); expect(JSON.stringify(install.notificationFailures)).not.toContain("nomail@example.org"); }); it("still starts, serves the API, and refuses an unverified sign-in", async () => { const install = await installation("no-mail-runs", { mail: false }); const email = "nomail-signin@example.org"; await install.createIdentity({ email, name: "No Mail", password: initialPassword }); // Absent mail is a complete deployment (ADR 0037) and does **not** relax the verification // policy — which is exactly why the initial owner is created already verified. expect((await fetch(`${install.origin}/api/v1/principal`)).status).toBe(200); expect((await signIn(install.origin, email, initialPassword)).response.status).toBe(403); }); }); describe("recovery cannot be turned into a redirect or an account factory", () => { it("refuses an attacker-supplied reset destination", async () => { const install = await installation("redirect"); const email = "redirect@example.org"; await install.createIdentity({ email, name: "Redirect", password: initialPassword }); const external = await post(install.origin, "/api/auth/request-password-reset", { email, redirectTo: "https://evil.example.test/steal", }); expect(external.status).toBe(403); expect(await external.json()).toMatchObject({ code: "INVALID_REDIRECT_URL" }); expect((install.mail as MailHarness).delivered).toHaveLength(0); }); it("refuses an attacker-supplied verification destination", async () => { const install = await installation("redirect-verify"); for (const callbackURL of [ "https://evil.example.test", "//evil.example.test", "/\\evil.example.test", "javascript:alert(1)", ]) { const response = await fetch( `${install.origin}/api/auth/verify-email?token=x&callbackURL=${encodeURIComponent(callbackURL)}`, { redirect: "manual" }, ); expect(response.status).toBe(403); expect(response.headers.get("location")).toBeNull(); } }); it("ignores a supplied destination even where one is accepted, because the link is rebuilt", async () => { const install = await installation("redirect-ignored"); const email = "ignored@example.org"; await verifiedAccountFor(install, email); // A *trusted* origin passes Better Auth's own check, so this is the case where the emailed // link's destination would otherwise be caller-chosen. It is not: the message is built from // configuration and the supplied value never reaches it. await post(install.origin, "/api/auth/request-password-reset", { email, redirectTo: `${dashboardOrigin}/somewhere-else`, }); const link = extractLink(lastMessage(install.mail as MailHarness), "/reset-password/"); const callback = new URL(link.searchParams.get("callbackURL") ?? ""); expect(callback.origin).toBe(dashboardOrigin); expect(callback.pathname).toBe("/"); expect(callback.searchParams.get("ekklesia_auth")).toBe("reset-password"); expect(link.href).not.toContain("somewhere-else"); }); it("keeps public signup disabled now that verification exists", async () => { const install = await installation("signup"); const response = await post(install.origin, "/api/auth/sign-up/email", { email: "intruder@example.org", name: "Intruder", password: initialPassword, }); // Email verification is not a licence to self-register. Future accounts arrive through // deliberate administration, which this pass does not build. expect(response.status).toBe(400); expect(await response.json()).toMatchObject({ code: "EMAIL_PASSWORD_SIGN_UP_DISABLED" }); expect(await emailVerifiedFlag(install.url, "intruder@example.org")).toBeUndefined(); }); }); describe("managed devices are untouched by human email identity", () => { it("leaves a paired device intact across a human password reset", async () => { const install = await installation("devices"); const email = "device-owner@example.org"; await verifiedAccountFor(install, email); // A device is a row with a credential digest and no email, no password, and no verification // state (ADR 0036). It is inserted directly here because pairing it through HTTP would prove // something this test is not about. const client = new Client({ connectionString: install.url }); await client.connect(); try { await safeQuery( client, 'insert into "ekklesia_core_device" ("name", "credential_hash") values ($1, $2)', ["Sanctuary display", "a".repeat(64)], ); await post(install.origin, "/api/auth/request-password-reset", { email }); const link = extractLink(lastMessage(install.mail as MailHarness), "/reset-password/"); const handoff = await fetch(link, { redirect: "manual" }); const token = new URL(handoff.headers.get("location") ?? "").searchParams.get("token") ?? ""; await post(install.origin, "/api/auth/reset-password", { newPassword: replacementPassword, token, }); const devices = await safeQuery<{ credential_hash: string; revoked_at: Date | null }>( client, 'select "credential_hash", "revoked_at" from "ekklesia_core_device"', ); expect(devices).toEqual([{ credential_hash: "a".repeat(64), revoked_at: null }]); // The device table has no email or verification concept for a human flow to reach. const columns = await safeQuery<{ column_name: string }>( client, "select column_name from information_schema.columns where table_name = 'ekklesia_core_device'", ); const names = columns.map((column) => column.column_name).join(" "); expect(names).not.toContain("email"); expect(names).not.toContain("verified"); expect(names).not.toContain("password"); } finally { await client.end().catch(() => undefined); } }); it("sends a device no mail, because a device has no address to send to", async () => { const install = await installation("devices-mail"); await post(install.origin, "/api/auth/request-password-reset", { email: "sanctuary-display@example.org", }); // Nothing about device identity participates in either flow. expect((install.mail as MailHarness).delivered).toHaveLength(0); expect(install.notificationFailures).toEqual([]); }); });