A fresh production installation deadlocked. ADR 0034 gave Ekklesia a login
page and ADR 0019 gave it a single-use platform-owner bootstrap, but that
bootstrap grants ownership to a user id that already exists and has no way
to bring one into being. Public signup is disabled by default and
production cannot enable it at all, so a newly deployed instance had a
login page, no account, and no supported way to create one — only hand-
written SQL or briefly opening public registration.
pnpm identity:create-initial-owner -- --email [email protected]
creates the identity and grants it ownership, in the same shape as the
other operator commands: trusted local tooling, reachable only by someone
who already holds the database credentials, serving no HTTP route. No
/signup, /register, or /setup route is added, no bootstrap endpoint, and
no setup token; public signup stays disabled in every environment and a
test asserts a freshly bootstrapped deployment still refuses
/api/auth/sign-up/email.
The identity is created through Better Auth's own sign-up path, so
hashing, salting, normalization and the 8-128 bound are the identity
system's rather than a second implementation that could drift. Because the
server's instance refuses account creation, the command builds its own
in-process instance; nothing in apps/api imports it and the running
server's posture is untouched.
The sign-up result is verified rather than trusted. Better Auth returns a
fabricated success for an already-registered address — a user object whose
id was never persisted, database unchanged. That is correct enumeration
defence on a public endpoint and a trap here, so the identity is always
read back from the database and that read, not the response, is what the
grant uses.
An existing usable identity is adopted: ownership is granted and its
password left alone, reported as an adoption rather than a creation. This
is the recovery path for the one partial failure possible, since sign-up
commits on Better Auth's connection and the grant is a separate
transaction. A credential-less row is refused instead — making it an owner
would consume the single use on an identity nobody can authenticate as. A
password is never reset and an account is never taken over.
Whole invocations are serialized by an advisory lock on their own key, so
a race cannot leave a stray signable-in account behind; the grant's own
locked single-use transaction is unchanged. There is no --force or reset.
The password is prompted for invisibly or read from stdin, never from
argv (--password is refused with an explanation) and never from an
environment variable, so nothing about it becomes permanent deployment
configuration. The command needs DATABASE_URL and nothing else — verified
that sign-up needs no secret, base URL, or trusted origins, and that the
resulting account signs in through a separately configured server — so the
first owner can be created before TLS or DNS exist. It never migrates.
Proven end to end rather than at the database: a fresh installation is
bootstrapped, signed in through the real browser login transport with
signup disabled, its principal read, and used to open an owner-protected
resource that stays closed to anonymous callers.
Co-Authored-By: Claude Opus 5 <[email protected]>
688 lines
26 KiB
TypeScript
688 lines
26 KiB
TypeScript
import { execFile } from "node:child_process";
|
||
import { randomBytes } from "node:crypto";
|
||
import { mkdtemp, rm } from "node:fs/promises";
|
||
import { tmpdir } from "node:os";
|
||
import path from "node:path";
|
||
import { fileURLToPath } from "node:url";
|
||
import { promisify } from "node:util";
|
||
import {
|
||
createAuthorizationDrizzleDatabase,
|
||
createInitialPlatformOwner,
|
||
InitialPlatformOwnerError,
|
||
} from "@ekklesia/core/authorization";
|
||
import {
|
||
createBootstrapIdentityCreator,
|
||
createIdentityDrizzleDatabase,
|
||
} from "@ekklesia/core/identity";
|
||
import {
|
||
applyModuleMigrations,
|
||
createPostgresMigrationDatabase,
|
||
discoverAllMigrations,
|
||
} from "@ekklesia/core/migrations";
|
||
import { Client, type QueryResultRow } from "pg";
|
||
import { afterAll, describe, expect, it } from "vitest";
|
||
import { createApiRuntimeConfig } from "../src/config.js";
|
||
import { type RunningApi, startApi } from "../src/server.js";
|
||
|
||
/**
|
||
* The initial platform-owner bootstrap, against a real PostgreSQL and the real HTTP transport
|
||
* (ADR 0035).
|
||
*
|
||
* The point of this suite is that it does **not** stop at asserting rows. A fresh installation is
|
||
* bootstrapped and then the resulting account is signed in through the exact browser-facing login
|
||
* transport Phase 7B Pass 1 established, its principal is read back, and it is used to reach a
|
||
* platform-owner-protected resource. Anything less would prove the command wrote a user, not that
|
||
* an operator can actually get into their dashboard — which is the only thing this pass is for.
|
||
*
|
||
* Every scenario runs with `publicSignupEnabled: false`, the production posture, so nothing here
|
||
* can accidentally depend on the signup route the real deployment does not have.
|
||
*/
|
||
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 bootstrapCliPath = path.join(
|
||
repositoryRoot,
|
||
"core",
|
||
"dist",
|
||
"cli",
|
||
"create-initial-owner.js",
|
||
);
|
||
const trackedDatabases = new Set<string>();
|
||
let databaseCounter = 0;
|
||
|
||
const identityBaseUrl = "https://identity.example.test";
|
||
const dashboardOrigin = "https://dashboard.example.test";
|
||
const ownerPassword = "correct horse battery staple";
|
||
|
||
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();
|
||
}
|
||
|
||
function makeDatabaseName(label: string): string {
|
||
databaseCounter += 1;
|
||
return `ekk_owner_${label}_${databaseCounter}_${randomBytes(4).toString("hex")}`.toLowerCase();
|
||
}
|
||
|
||
async function openAdministrativeClient(): Promise<Client> {
|
||
const client = new Client({ connectionString: baseDatabaseUrl() });
|
||
await client.connect();
|
||
return client;
|
||
}
|
||
|
||
async function safeQuery<T extends QueryResultRow>(
|
||
client: Client,
|
||
text: string,
|
||
values: readonly unknown[] = [],
|
||
): Promise<T[]> {
|
||
try {
|
||
const result = await client.query<T>(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 createDisposableDatabase(label: string): Promise<string> {
|
||
const name = makeDatabaseName(label);
|
||
const admin = await openAdministrativeClient();
|
||
try {
|
||
await safeQuery(admin, `create database "${name}"`);
|
||
} finally {
|
||
await admin.end().catch(() => undefined);
|
||
}
|
||
trackedDatabases.add(name);
|
||
return databaseUrl(name);
|
||
}
|
||
|
||
async function applyCoreMigrations(url: string): Promise<void> {
|
||
const emptyModules = await mkdtemp(path.join(tmpdir(), "ekklesia-owner-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 });
|
||
}
|
||
}
|
||
|
||
/** A fresh, migrated installation with no identity and no owner — exactly what an operator has
|
||
* after `docker compose up` and `pnpm migrations:apply`. */
|
||
async function freshInstallation(label: string): Promise<string> {
|
||
const url = await createDisposableDatabase(label);
|
||
await applyCoreMigrations(url);
|
||
return url;
|
||
}
|
||
|
||
interface BootstrapHandles {
|
||
readonly run: (
|
||
email: string,
|
||
password?: string,
|
||
name?: string,
|
||
) => ReturnType<typeof createInitialPlatformOwner>;
|
||
readonly close: () => Promise<void>;
|
||
}
|
||
|
||
/** Drives the same operation the CLI drives, with the same production composition. */
|
||
function bootstrapAgainst(url: string): BootstrapHandles {
|
||
const authorization = createAuthorizationDrizzleDatabase(url);
|
||
const identity = createIdentityDrizzleDatabase(url, {});
|
||
return {
|
||
close: async () => {
|
||
await Promise.all([
|
||
authorization.close().catch(() => undefined),
|
||
identity.close().catch(() => undefined),
|
||
]);
|
||
},
|
||
run: (email, password = ownerPassword, name = "Platform Owner") =>
|
||
createInitialPlatformOwner(
|
||
{
|
||
authorizationDb: authorization.db,
|
||
createIdentity: createBootstrapIdentityCreator(identity.db),
|
||
identityDb: identity.db,
|
||
},
|
||
{ email, name, password },
|
||
),
|
||
};
|
||
}
|
||
|
||
/** The production posture: public signup disabled, exactly as a real deployment must run. */
|
||
async function startApiWithoutSignup(url: string): Promise<RunningApi> {
|
||
const config = createApiRuntimeConfig(
|
||
{
|
||
databaseUrl: url,
|
||
host: "127.0.0.1",
|
||
identity: {
|
||
baseUrl: identityBaseUrl,
|
||
publicSignupEnabled: false,
|
||
secret: "integration-test-secret-0123456789abcdefgh",
|
||
trustedOrigins: [identityBaseUrl, dashboardOrigin],
|
||
},
|
||
port: 0,
|
||
},
|
||
{ environment: "development" },
|
||
);
|
||
return startApi(config, { rateLimitOverride: { max: 1000, window: 1 } });
|
||
}
|
||
|
||
function apiOrigin(running: RunningApi): string {
|
||
const address = running.server.address();
|
||
if (!address || typeof address !== "object") {
|
||
throw new IntegrationHarnessError("The API integration server has no bound address.");
|
||
}
|
||
return `http://127.0.0.1:${address.port}`;
|
||
}
|
||
|
||
function firstCookiePair(setCookieHeader: string): string {
|
||
return setCookieHeader.split(",")[0]?.split(";")[0]?.trim() ?? "";
|
||
}
|
||
|
||
async function signIn(
|
||
origin: string,
|
||
email: string,
|
||
password: string,
|
||
): Promise<{ cookiePair?: string; response: Response }> {
|
||
const response = await fetch(`${origin}/api/auth/sign-in/email`, {
|
||
body: JSON.stringify({ email, password }),
|
||
headers: { "content-type": "application/json", origin: dashboardOrigin },
|
||
method: "POST",
|
||
});
|
||
const setCookie = response.headers.get("set-cookie");
|
||
return setCookie === null ? { response } : { cookiePair: firstCookiePair(setCookie), response };
|
||
}
|
||
|
||
const execFileAsync = promisify(execFile);
|
||
|
||
interface CliOutcome {
|
||
readonly code: number;
|
||
readonly stdout: string;
|
||
readonly stderr: string;
|
||
}
|
||
|
||
/** Runs the real compiled CLI as a real child process, with the password on stdin and never in
|
||
* `argv`. */
|
||
async function runBootstrapCli(
|
||
databaseUrl: string | undefined,
|
||
args: readonly string[],
|
||
password: string | undefined,
|
||
): Promise<CliOutcome> {
|
||
const environment: Record<string, string> = {
|
||
...(process.env as Record<string, string>),
|
||
NODE_ENV: "development",
|
||
};
|
||
if (databaseUrl === undefined) {
|
||
delete environment.DATABASE_URL;
|
||
} else {
|
||
environment.DATABASE_URL = databaseUrl;
|
||
}
|
||
const child = execFileAsync(process.execPath, [bootstrapCliPath, ...args], {
|
||
env: environment,
|
||
});
|
||
child.child.stdin?.end(password ?? "");
|
||
try {
|
||
const { stdout, stderr } = await child;
|
||
return { code: 0, stderr, stdout };
|
||
} catch (error) {
|
||
const failure = error as { code?: number; stdout?: string; stderr?: string };
|
||
return {
|
||
code: typeof failure.code === "number" ? failure.code : 1,
|
||
stderr: failure.stderr ?? "",
|
||
stdout: failure.stdout ?? "",
|
||
};
|
||
}
|
||
}
|
||
|
||
async function countRows(url: string, table: string): Promise<number> {
|
||
const client = new Client({ connectionString: url });
|
||
await client.connect();
|
||
try {
|
||
const rows = await safeQuery<{ count: string }>(
|
||
client,
|
||
`select count(*)::text from "${table}"`,
|
||
);
|
||
return Number(rows[0]?.count ?? "0");
|
||
} finally {
|
||
await client.end().catch(() => undefined);
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
});
|
||
|
||
describe.sequential("initial platform-owner bootstrap", () => {
|
||
it("takes a fresh installation all the way to an authenticated owner in the browser", async () => {
|
||
const url = await freshInstallation("browserproof");
|
||
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
|
||
|
||
const bootstrap = bootstrapAgainst(url);
|
||
let created: Awaited<ReturnType<typeof createInitialPlatformOwner>>;
|
||
try {
|
||
created = await bootstrap.run(email);
|
||
} finally {
|
||
await bootstrap.close();
|
||
}
|
||
expect(created.identity).toBe("created");
|
||
expect(created.email).toBe(email.toLowerCase());
|
||
|
||
const api = await startApiWithoutSignup(url);
|
||
try {
|
||
const origin = apiOrigin(api);
|
||
|
||
// 1. The exact browser-facing login transport, on a deployment where signup does not exist.
|
||
const session = await signIn(origin, email, ownerPassword);
|
||
expect(session.response.status).toBe(200);
|
||
expect(session.cookiePair).toBeDefined();
|
||
const cookie = session.cookiePair as string;
|
||
|
||
// 2. The principal the browser bootstrap reads.
|
||
const principalResponse = await fetch(`${origin}/api/v1/principal`, { headers: { cookie } });
|
||
expect(principalResponse.status).toBe(200);
|
||
const principal = (await principalResponse.json()) as {
|
||
kind: string;
|
||
userId?: string;
|
||
};
|
||
expect(principal.kind).toBe("authenticated");
|
||
expect(principal.userId).toBe(created.userId);
|
||
|
||
// 3. A platform-owner-protected resource actually opens for them. This is the assertion that
|
||
// separates "a row was written" from "the operator can run their platform".
|
||
const grants = await fetch(`${origin}/api/v1/grants?userId=${created.userId}`, {
|
||
headers: { cookie },
|
||
});
|
||
expect(grants.status).toBe(200);
|
||
|
||
// 4. And the same resource is still closed to everyone else.
|
||
const anonymous = await fetch(`${origin}/api/v1/grants?userId=${created.userId}`);
|
||
expect(anonymous.status).toBe(403);
|
||
} finally {
|
||
await api.shutdown();
|
||
}
|
||
});
|
||
|
||
it("leaves public signup disabled — the owner exists without a signup route existing", async () => {
|
||
const url = await freshInstallation("nosignup");
|
||
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
|
||
const bootstrap = bootstrapAgainst(url);
|
||
try {
|
||
await bootstrap.run(email);
|
||
} finally {
|
||
await bootstrap.close();
|
||
}
|
||
|
||
const api = await startApiWithoutSignup(url);
|
||
try {
|
||
const origin = apiOrigin(api);
|
||
const attempted = await fetch(`${origin}/api/auth/sign-up/email`, {
|
||
body: JSON.stringify({
|
||
email: `intruder-${randomBytes(4).toString("hex")}@example.test`,
|
||
name: "Intruder",
|
||
password: ownerPassword,
|
||
}),
|
||
headers: { "content-type": "application/json", origin: dashboardOrigin },
|
||
method: "POST",
|
||
});
|
||
|
||
// The bootstrap command created an account; it did not open a door for anyone else to.
|
||
expect(attempted.status).toBe(400);
|
||
expect(await attempted.text()).toContain("EMAIL_PASSWORD_SIGN_UP_DISABLED");
|
||
expect(await countRows(url, "ekklesia_core_user")).toBe(1);
|
||
|
||
// And the owner can still sign in, so disabling signup did not disable the account.
|
||
expect((await signIn(origin, email, ownerPassword)).response.status).toBe(200);
|
||
} finally {
|
||
await api.shutdown();
|
||
}
|
||
});
|
||
|
||
describe("the guard", () => {
|
||
it("refuses a second bootstrap, changes no password, and creates no second owner", async () => {
|
||
const url = await freshInstallation("guard");
|
||
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
|
||
const bootstrap = bootstrapAgainst(url);
|
||
try {
|
||
await bootstrap.run(email);
|
||
|
||
// Same address, different password: must not become a password reset.
|
||
await expect(bootstrap.run(email, "an entirely different password")).rejects.toMatchObject({
|
||
code: "INITIAL_OWNER_ALREADY_COMPLETED",
|
||
});
|
||
// A different address must not become a second owner.
|
||
await expect(
|
||
bootstrap.run(`second-${randomBytes(4).toString("hex")}@example.test`),
|
||
).rejects.toMatchObject({ code: "INITIAL_OWNER_ALREADY_COMPLETED" });
|
||
} finally {
|
||
await bootstrap.close();
|
||
}
|
||
|
||
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(1);
|
||
// The refused second attempt with a new address created no stray identity: the sentinel is
|
||
// checked before anything is created, not after.
|
||
expect(await countRows(url, "ekklesia_core_user")).toBe(1);
|
||
|
||
const api = await startApiWithoutSignup(url);
|
||
try {
|
||
const origin = apiOrigin(api);
|
||
expect((await signIn(origin, email, ownerPassword)).response.status).toBe(200);
|
||
expect(
|
||
(await signIn(origin, email, "an entirely different password")).response.status,
|
||
).not.toBe(200);
|
||
} finally {
|
||
await api.shutdown();
|
||
}
|
||
});
|
||
});
|
||
|
||
describe("validation reuses the identity system's own policy", () => {
|
||
it("rejects an invalid email, and a password outside the 8–128 bound", async () => {
|
||
const url = await freshInstallation("validation");
|
||
const bootstrap = bootstrapAgainst(url);
|
||
try {
|
||
await expect(bootstrap.run("not-an-email-address")).rejects.toMatchObject({
|
||
code: "INITIAL_OWNER_EMAIL_INVALID",
|
||
});
|
||
await expect(
|
||
bootstrap.run(`short-${randomBytes(4).toString("hex")}@example.test`, "1234567"),
|
||
).rejects.toMatchObject({ code: "INITIAL_OWNER_PASSWORD_TOO_SHORT" });
|
||
await expect(
|
||
bootstrap.run(`long-${randomBytes(4).toString("hex")}@example.test`, "x".repeat(129)),
|
||
).rejects.toMatchObject({ code: "INITIAL_OWNER_PASSWORD_TOO_LONG" });
|
||
|
||
// Exactly the boundary the browser accepts, so the two can never diverge.
|
||
await expect(
|
||
bootstrap.run(`edge-${randomBytes(4).toString("hex")}@example.test`, "x".repeat(128)),
|
||
).resolves.toMatchObject({ identity: "created" });
|
||
} finally {
|
||
await bootstrap.close();
|
||
}
|
||
|
||
// Every rejection happened before anything was written, and the one success is the only row.
|
||
expect(await countRows(url, "ekklesia_core_user")).toBe(1);
|
||
});
|
||
|
||
it("never puts the password into an error", async () => {
|
||
const url = await freshInstallation("nosecrets");
|
||
const secret = "sup3rSecretBootstrapPassword!";
|
||
const bootstrap = bootstrapAgainst(url);
|
||
try {
|
||
let caught: unknown;
|
||
try {
|
||
await bootstrap.run("not-an-email-address", secret);
|
||
} catch (error) {
|
||
caught = error;
|
||
}
|
||
expect(caught).toBeInstanceOf(InitialPlatformOwnerError);
|
||
const rendered = `${String(caught)} ${JSON.stringify(caught)} ${(caught as Error).stack ?? ""}`;
|
||
expect(rendered).not.toContain(secret);
|
||
expect(rendered).not.toContain("postgres");
|
||
expect(rendered).not.toContain("select");
|
||
} finally {
|
||
await bootstrap.close();
|
||
}
|
||
});
|
||
});
|
||
|
||
describe("an identity that already exists", () => {
|
||
it("adopts a usable account and leaves its password untouched", async () => {
|
||
const url = await freshInstallation("adopt");
|
||
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
|
||
|
||
// Exactly the partial failure this guards: the identity committed, the grant never ran.
|
||
const identity = createIdentityDrizzleDatabase(url, {});
|
||
try {
|
||
await createBootstrapIdentityCreator(identity.db)({
|
||
email,
|
||
name: "Half Finished",
|
||
password: ownerPassword,
|
||
});
|
||
} finally {
|
||
await identity.close();
|
||
}
|
||
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(0);
|
||
|
||
const bootstrap = bootstrapAgainst(url);
|
||
try {
|
||
const result = await bootstrap.run(email, "a different password than before");
|
||
expect(result.identity).toBe("adopted");
|
||
} finally {
|
||
await bootstrap.close();
|
||
}
|
||
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(1);
|
||
expect(await countRows(url, "ekklesia_core_user")).toBe(1);
|
||
|
||
const api = await startApiWithoutSignup(url);
|
||
try {
|
||
const origin = apiOrigin(api);
|
||
// Adoption granted ownership. It did not take the account over: the original password still
|
||
// works and the one supplied to the rerun does not.
|
||
expect((await signIn(origin, email, ownerPassword)).response.status).toBe(200);
|
||
expect(
|
||
(await signIn(origin, email, "a different password than before")).response.status,
|
||
).not.toBe(200);
|
||
} finally {
|
||
await api.shutdown();
|
||
}
|
||
});
|
||
|
||
it("refuses a credential-less row rather than consuming bootstrap on an unusable owner", async () => {
|
||
const url = await freshInstallation("unusable");
|
||
const client = new Client({ connectionString: url });
|
||
await client.connect();
|
||
try {
|
||
await safeQuery(
|
||
client,
|
||
`insert into "ekklesia_core_user" (id, name, email, email_verified, created_at, updated_at)
|
||
values ($1, $2, $3, false, now(), now())`,
|
||
["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "Shell Row", "[email protected]"],
|
||
);
|
||
} finally {
|
||
await client.end().catch(() => undefined);
|
||
}
|
||
|
||
const bootstrap = bootstrapAgainst(url);
|
||
try {
|
||
await expect(bootstrap.run("[email protected]")).rejects.toMatchObject({
|
||
code: "INITIAL_OWNER_IDENTITY_UNUSABLE",
|
||
});
|
||
} finally {
|
||
await bootstrap.close();
|
||
}
|
||
|
||
// The decisive part: bootstrap is still available. Granting ownership to a row nobody can
|
||
// authenticate as would have consumed the single use and left the installation unusable with
|
||
// no second chance.
|
||
expect(await countRows(url, "ekklesia_core_platform_owner_bootstrap")).toBe(0);
|
||
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(0);
|
||
});
|
||
});
|
||
|
||
describe("partial failure", () => {
|
||
it("reports a failed grant honestly and stays recoverable by rerunning", async () => {
|
||
const url = await freshInstallation("partial");
|
||
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
|
||
|
||
const authorization = createAuthorizationDrizzleDatabase(url);
|
||
const identity = createIdentityDrizzleDatabase(url, {});
|
||
try {
|
||
await expect(
|
||
createInitialPlatformOwner(
|
||
{
|
||
authorizationDb: authorization.db,
|
||
createIdentity: createBootstrapIdentityCreator(identity.db),
|
||
grantOwnership: () => {
|
||
throw new Error("the grant failed after the identity committed");
|
||
},
|
||
identityDb: identity.db,
|
||
},
|
||
{ email, name: "Owner", password: ownerPassword },
|
||
),
|
||
).rejects.toMatchObject({ code: "INITIAL_OWNER_OWNERSHIP_GRANT_FAILED" });
|
||
} finally {
|
||
await Promise.all([authorization.close(), identity.close()]);
|
||
}
|
||
|
||
// The identity committed; the grant did not; the single-use sentinel was never consumed.
|
||
expect(await countRows(url, "ekklesia_core_user")).toBe(1);
|
||
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(0);
|
||
expect(await countRows(url, "ekklesia_core_platform_owner_bootstrap")).toBe(0);
|
||
|
||
// Rerunning the identical command finishes the job, which is the whole recovery story.
|
||
const retry = bootstrapAgainst(url);
|
||
try {
|
||
await expect(retry.run(email)).resolves.toMatchObject({ identity: "adopted" });
|
||
} finally {
|
||
await retry.close();
|
||
}
|
||
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(1);
|
||
});
|
||
});
|
||
|
||
describe("concurrency", () => {
|
||
it("gives exactly one owner when several bootstraps race", async () => {
|
||
const url = await freshInstallation("race");
|
||
const attempts = 5;
|
||
const handles = Array.from({ length: attempts }, () => bootstrapAgainst(url));
|
||
try {
|
||
const outcomes = await Promise.allSettled(
|
||
handles.map((handle, index) =>
|
||
handle.run(`racer-${index}-${randomBytes(3).toString("hex")}@example.test`),
|
||
),
|
||
);
|
||
const fulfilled = outcomes.filter((outcome) => outcome.status === "fulfilled");
|
||
expect(fulfilled).toHaveLength(1);
|
||
for (const outcome of outcomes.filter((candidate) => candidate.status === "rejected")) {
|
||
expect((outcome as PromiseRejectedResult).reason).toMatchObject({
|
||
code: "INITIAL_OWNER_ALREADY_COMPLETED",
|
||
});
|
||
}
|
||
} finally {
|
||
await Promise.all(handles.map((handle) => handle.close()));
|
||
}
|
||
|
||
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(1);
|
||
expect(await countRows(url, "ekklesia_core_platform_owner_bootstrap")).toBe(1);
|
||
// The losers created nothing at all. Serializing whole invocations — not just the grant — is
|
||
// what keeps a race from leaving signable-in accounts that own nothing behind.
|
||
expect(await countRows(url, "ekklesia_core_user")).toBe(1);
|
||
});
|
||
});
|
||
|
||
describe("the command itself", () => {
|
||
it("succeeds, prints something useful, and leaks nothing", async () => {
|
||
const url = await freshInstallation("cli");
|
||
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
|
||
const result = await runBootstrapCli(url, ["--email", email], ownerPassword);
|
||
|
||
expect(result.code).toBe(0);
|
||
expect(result.stdout).toContain("Initial platform owner created successfully.");
|
||
expect(result.stdout).toContain(email.toLowerCase());
|
||
const everything = `${result.stdout} ${result.stderr}`;
|
||
expect(everything).not.toContain(ownerPassword);
|
||
expect(everything).not.toContain("postgresql://");
|
||
expect(everything).not.toContain("password");
|
||
|
||
const api = await startApiWithoutSignup(url);
|
||
try {
|
||
// The command's real product: an operator who can now sign in.
|
||
expect((await signIn(apiOrigin(api), email, ownerPassword)).response.status).toBe(200);
|
||
} finally {
|
||
await api.shutdown();
|
||
}
|
||
});
|
||
|
||
it("refuses a password supplied on the command line", async () => {
|
||
const url = await freshInstallation("argv");
|
||
const result = await runBootstrapCli(
|
||
url,
|
||
["--email", "[email protected]", "--password", "hunter2hunter2"],
|
||
undefined,
|
||
);
|
||
|
||
expect(result.code).toBe(1);
|
||
expect(result.stderr).toContain("--password is not supported");
|
||
expect(await countRows(url, "ekklesia_core_user")).toBe(0);
|
||
});
|
||
|
||
it("fails clearly without a database, and refuses to migrate one itself", async () => {
|
||
const missing = await runBootstrapCli(
|
||
undefined,
|
||
["--email", "[email protected]"],
|
||
"whatever",
|
||
);
|
||
expect(missing.code).toBe(1);
|
||
expect(missing.stderr).toContain("DATABASE_URL is required.");
|
||
|
||
// A database that exists but was never migrated is a deployment step that has not happened,
|
||
// and this command says so rather than quietly running migrations for a production operator.
|
||
const unmigrated = await createDisposableDatabase("unmigrated");
|
||
const result = await runBootstrapCli(
|
||
unmigrated,
|
||
["--email", "[email protected]"],
|
||
ownerPassword,
|
||
);
|
||
expect(result.code).toBe(1);
|
||
expect(result.stderr).toContain("INITIAL_OWNER_SCHEMA_UNAVAILABLE");
|
||
|
||
const client = new Client({ connectionString: unmigrated });
|
||
await client.connect();
|
||
try {
|
||
const tables = await safeQuery<{ count: string }>(
|
||
client,
|
||
`select count(*)::text from information_schema.tables where table_schema = 'public'`,
|
||
);
|
||
expect(Number(tables[0]?.count ?? "0")).toBe(0);
|
||
} finally {
|
||
await client.end().catch(() => undefined);
|
||
}
|
||
});
|
||
|
||
it("refuses a second run at the command level too", async () => {
|
||
const url = await freshInstallation("clirerun");
|
||
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
|
||
expect((await runBootstrapCli(url, ["--email", email], ownerPassword)).code).toBe(0);
|
||
|
||
const second = await runBootstrapCli(url, ["--email", email], "a different password");
|
||
expect(second.code).toBe(1);
|
||
expect(second.stderr).toContain("INITIAL_OWNER_ALREADY_COMPLETED");
|
||
expect(second.stderr).not.toContain("a different password");
|
||
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(1);
|
||
});
|
||
});
|
||
});
|