Files
Ekklesia/apps/api/tests/initial-owner.integration.ts
T
ekklesiaandClaude Opus 5 c8fa13dd6a
CI / verify (pull_request) Successful in 41s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 1m18s
feat: create the initial platform owner from an operator command
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]>
2026-08-23 22:32:53 -05:00

688 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});
});