Files
Ekklesia/core/tests/doctor-report.test.ts
T
ekklesiaandClaude Opus 5 fa3021e49b
CI / verify (pull_request) Successful in 42s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 1m23s
feat: add a host-provided outbound email transport
Ekklesia could authenticate people and pair devices but could not send a
single message. Better Auth has been configured with emailAndPassword
since ADR 0011 and neither of its mail hooks — sendResetPassword and
emailVerification.sendVerificationEmail — was ever implemented, so email
verification, password recovery, and invitations were all blocked on the
same missing thing.

That missing thing is a transport, not a Google Workspace integration.
Email is now a host-provided infrastructure service with a provider seam,
shaped exactly like object storage: feature code says "send this
transactional message" and the provider owns the host, port, TLS,
credentials, connection behaviour, and every transport error. No
provider's hostname appears anywhere in Core, and a test asserts it.
Google Workspace SMTP Relay is the expected first deployment and a
compatibility target — the same variables address Microsoft 365, a
transactional provider, or a local Postfix.

The contract is deliberately tiny: one recipient or a small bounded list,
a subject, text and/or HTML, an optional reply-to, and nothing else. There
is no `from` field at all — the sender is host configuration, so no
feature and no future module can choose who mail appears to come from,
which is how a platform otherwise acquires an impersonation primitive.
Control characters are refused rather than stripped in addresses, display
names, subjects, and reply-to values; writing that rule down was not
enough, because the first implementation trimmed an address before
checking it and silently accepted a trailing carriage return until a test
caught it.

Security modes are named rather than inferred from a boolean and a port,
which is genuinely ambiguous: `secure: false` on 587 means "connect in the
clear and hope STARTTLS is offered". `starttls` sets requireTLS so a
stripped upgrade fails the send instead of continuing in plaintext,
production refuses `plaintext` outright, and certificate verification has
no off switch. Credentials are optional as a pair, because a relay
authorized by source IP has neither and requiring them would make the
intended production arrangement unconfigurable; half a pair fails loudly.

Connection, greeting, and socket timeouts are all explicit — the
object-storage pass already showed what a network client on library
defaults does to a request. Nothing retries: a failure after the message
data has gone out is ambiguous, and retrying it turns one password-reset
request into several emails, so this is a single bounded attempt and
deliberately not an exactly-once promise.

Email stays optional. An installation with no SMTP host is a complete
deployment, nothing sends at startup, and constructing the transport opens
no socket. There is no POST /api/v1/send-email and no route that touches
the provider — an authenticated send endpoint is an open relay with extra
steps. Modules get nothing either; services are added one at a time when
something real needs one, and nothing does yet.

Proven against a real in-process SMTP server rather than a mock: delivery
with the host's sender on the wire, authentication and its sanitized
refusal, a rejected mailbox distinguished from an outage, a refused
connection, a silent server hitting its deadline, and a starttls transport
refusing to continue when the upgrade is not offered. No test sends real
email and no live credential exists in this repository or in CI.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-24 18:37:16 -05:00

205 lines
6.5 KiB
TypeScript

import { afterEach, describe, expect, it } from "vitest";
import {
type DoctorDependencies,
diagnoseEkklesia,
formatDoctorReport,
} from "../src/diagnostics/index.js";
import type { MigrationDatabase } from "../src/migrations/index.js";
import type { ModuleMigrationAggregate, ModuleSchemaAggregate } from "../src/modules/index.js";
import { composePlatformPermissionRegistry } from "../src/modules/permissions.js";
const originalNoColor = process.env.NO_COLOR;
afterEach(() => {
if (originalNoColor === undefined) {
delete process.env.NO_COLOR;
} else {
process.env.NO_COLOR = originalNoColor;
}
});
function zeroSchemaAggregate(): ModuleSchemaAggregate {
return Object.freeze({
discoveredModules: Object.freeze([]),
drizzleSchema: Object.freeze({}),
modules: Object.freeze([]),
tables: Object.freeze([]),
});
}
function zeroMigrationAggregate(): ModuleMigrationAggregate {
return Object.freeze({
discoveredModules: Object.freeze([]),
migrations: Object.freeze([]),
modules: Object.freeze([]),
});
}
function noConfigurationDependencies(): DoctorDependencies {
return {
composePermissions: composePlatformPermissionRegistry,
createMigrationDatabase: () => {
throw new Error("database must not be constructed");
},
discoverMigrations: async () => zeroMigrationAggregate(),
discoverModules: async () => Object.freeze([]),
discoverSchemas: async () => zeroSchemaAggregate(),
planMigrations: async () => {
throw new Error("database must not be planned");
},
probeMail: async () => {
throw new Error("mail must not be probed");
},
probeStorage: async () => {
throw new Error("storage must not be probed");
},
readModuleConfiguration: async () => {
throw new Error("module configuration must not be read");
},
readModuleLifecycle: async () => {
throw new Error("module lifecycle configuration must not be read");
},
runtimeSatisfies: () => true,
};
}
describe("Ekklesia Doctor terminal report", () => {
it("renders the real zero-module and no-database shape exactly", async () => {
const report = await diagnoseEkklesia(
{
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
modulesDirectory: "/not-rendered/modules",
platformVersion: "0.1.0",
},
runtimeRange: ">=24.11.0 <25",
runtimeVersion: "24.15.0",
},
noConfigurationDependencies(),
);
expect(formatDoctorReport(report).join("\n")).toBe(`Ekklesia Doctor
Platform
✓ Runtime version supported
Active Node.js satisfies the declared supported range.
Modules
✓ Module discovery complete
0 modules installed
✓ Dependency graph valid
Dependency order is empty.
✓ Permission registry composed
6 permissions registered
○ Module lifecycle skipped
DATABASE_URL is not configured.
○ Module configuration skipped
DATABASE_URL is not configured.
Data definitions
✓ Schemas validated
0 modules · 0 tables
✓ Migrations validated
0 migrations · checksums valid
PostgreSQL
✓ Database TLS verified
TLS required with certificate and hostname verification.
○ Connection check skipped
DATABASE_URL is not configured.
○ Migration plan skipped
A validated migration inventory and configured PostgreSQL connection are required.
Identity
○ Identity configuration skipped
Core identity schema defines 4 tables (ekklesia_core_account, ekklesia_core_session, ekklesia_core_user, ekklesia_core_verification).
Identity runtime configuration (signing key, base URL, trusted origins) is not set.
✓ Direct client-address mode
Trusts only the actual immediate socket peer for rate limiting; no forwarded header is trusted.
Object storage
○ Object storage not configured
No S3_ENDPOINT is set. This is a complete deployment unless a module requires storage.
✓ No module requires object storage
0 modules declare a logical storage bucket.
Result: READY WITH WARNINGS`);
});
it("does not depend on NO_COLOR or terminal interactivity", async () => {
const report = await diagnoseEkklesia(
{
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
modulesDirectory: "/not-rendered/modules",
platformVersion: "0.1.0",
},
runtimeRange: ">=24.11.0 <25",
runtimeVersion: "24.15.0",
},
noConfigurationDependencies(),
);
const baseline = formatDoctorReport(report);
const originalIsTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
try {
process.env.NO_COLOR = "1";
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true });
const interactiveWithoutColor = formatDoctorReport(report);
process.env.NO_COLOR = "";
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: false });
const nonInteractive = formatDoctorReport(report);
expect(interactiveWithoutColor).toEqual(baseline);
expect(nonInteractive).toEqual(baseline);
expect(interactiveWithoutColor.some((line) => line.includes("\u001B"))).toBe(false);
expect(Object.isFrozen(interactiveWithoutColor)).toBe(true);
} finally {
if (originalIsTtyDescriptor) {
Object.defineProperty(process.stdout, "isTTY", originalIsTtyDescriptor);
} else {
delete (process.stdout as { isTTY?: boolean }).isTTY;
}
}
});
it("never renders secrets from a hostile database exception", async () => {
const hostile =
"postgresql://admin:[email protected]/db SELECT password FROM users /home/me/.env";
const database = Object.freeze({}) as MigrationDatabase;
const dependencies: DoctorDependencies = {
...noConfigurationDependencies(),
createMigrationDatabase: () => database,
planMigrations: async () => {
throw new Error(hostile);
},
};
const report = await diagnoseEkklesia(
{
databaseUrl: "postgresql://another:[email protected]/db",
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
modulesDirectory: "/not-rendered/modules",
platformVersion: "0.1.0",
},
runtimeRange: ">=24.11.0 <25",
runtimeVersion: "24.15.0",
},
dependencies,
);
const output = formatDoctorReport(report).join("\n");
for (const forbidden of [
"admin",
"secret",
"host.invalid",
"SELECT",
"password",
"/home/me",
"postgresql://",
]) {
expect(output).not.toContain(forbidden);
}
});
});