Files
Ekklesia/core/tests/module-configuration.test.ts
T
ekklesia 49b4d4165d
CI / verify (pull_request) Successful in 1m0s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m53s
CI / image-build (pull_request) Successful in 23s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 1m30s
feat: deliver module database ABI by manifest
2026-10-07 17:29:32 -05:00

668 lines
26 KiB
TypeScript

import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import {
EMPTY_MODULE_CONFIGURATION,
type ModuleConfigurationDeclarationInput,
type ModuleSecretSource,
parseModuleManifest,
safeParseModuleManifest,
} from "@ekklesia/contracts";
import { afterEach, describe, expect, it } from "vitest";
import {
createEnvironmentModuleSecretSource,
moduleSecretEnvironmentVariableName,
resolveModuleConfigurationFor,
} from "../src/modules/configuration.js";
import { type DiscoveredModule, discoverModules } from "../src/modules/discovery.js";
function manifestWith(
configuration: readonly ModuleConfigurationDeclarationInput[],
id = "announcements",
) {
return parseModuleManifest({
manifestVersion: 8,
id,
name: `${id} module`,
version: "1.0.0",
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
configuration,
entrypoints: { server: "./dist/server/index.js" },
});
}
/** The stored document, in the shape the store produces. */
function settings(...records: readonly (readonly [string, string, string])[]) {
return {
records: records.map(([moduleId, key, value]) => ({ key, moduleId, value })),
};
}
function secretsFor(
map: Readonly<Record<string, Readonly<Record<string, string>>>>,
): ModuleSecretSource {
return { get: (moduleId, key) => map[moduleId]?.[key] };
}
/**
* A declaration set modelled on what the real Announcements product would need as an Ekklesia
* module — a slide duration with a default, a poll interval with a default, a bucket name, and a
* deployment-supplied storage credential.
*
* Modelled, never imported: the Announcements repository is a reference consumer for this design and
* is not a dependency of Ekklesia's tests. Nothing Announcements-specific exists in Core; this
* fixture only proves the *shape* of settings a real product needs can be declared, validated,
* defaulted, and delivered without the module reaching any platform infrastructure.
*/
const announcementsShapedDeclarations: readonly ModuleConfigurationDeclarationInput[] = [
{ key: "slideSeconds", type: "integer", default: 7, minimum: 1, maximum: 600 },
{ key: "pollIntervalSeconds", type: "integer", default: 30, minimum: 5, maximum: 3600 },
{ key: "bucket", type: "string", required: true, minLength: 1, maxLength: 63 },
{ key: "storageCredential", type: "secret", required: true },
];
describe("module configuration declarations", () => {
it("accepts a module that declares no configuration at all", () => {
const manifest = manifestWith([]);
expect(manifest.configuration).toEqual([]);
// A backend-only module stays exactly as valid as it was before configuration existed.
expect(resolveModuleConfigurationFor(manifest, { settings: settings() }).issue).toBeUndefined();
});
it("accepts every declared type with its own constraints", () => {
const manifest = manifestWith([
{ key: "bucket", type: "string", minLength: 1, maxLength: 63 },
{ key: "slideSeconds", type: "integer", default: 7, minimum: 1, maximum: 600 },
{ key: "shuffle", type: "boolean", default: false },
{ key: "fit", type: "enum", values: ["contain", "cover"], default: "contain" },
{ key: "webhook", type: "url" },
{ key: "apiToken", type: "secret" },
]);
expect(manifest.configuration.map((entry) => entry.type)).toEqual([
"string",
"integer",
"boolean",
"enum",
"url",
"secret",
]);
});
it("rejects two declarations of the same key, so an ordinary key and a secret can never collide", () => {
const result = safeParseModuleManifest({
manifestVersion: 8,
id: "announcements",
name: "announcements module",
version: "1.0.0",
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
configuration: [
{ key: "apiToken", type: "string", default: "not-a-secret" },
{ key: "apiToken", type: "secret", required: true },
],
entrypoints: { server: "./dist/server/index.js" },
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.map((issue) => issue.path.join("."))).toContain(
"configuration.1.key",
);
}
});
it("rejects a default on a secret, because a checked-in default credential is a leaked one", () => {
const result = safeParseModuleManifest({
manifestVersion: 8,
id: "announcements",
name: "announcements module",
version: "1.0.0",
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
configuration: [{ key: "apiToken", type: "secret", default: "hunter2" }],
entrypoints: { server: "./dist/server/index.js" },
});
// `.strict()` on the secret shape means there is no `default` field to populate at all — the
// rejection is structural rather than a rule someone has to remember to apply.
expect(result.success).toBe(false);
});
it("rejects a key that is both required and defaulted, which states an intent nothing can honour", () => {
const result = safeParseModuleManifest({
manifestVersion: 8,
id: "announcements",
name: "announcements module",
version: "1.0.0",
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
configuration: [{ key: "slideSeconds", type: "integer", required: true, default: 7 }],
entrypoints: { server: "./dist/server/index.js" },
});
expect(result.success).toBe(false);
});
it.each([
[
"an integer default below its own minimum",
{ key: "n", type: "integer", default: 0, minimum: 1 },
],
[
"an integer default above its own maximum",
{ key: "n", type: "integer", default: 9, maximum: 5 },
],
[
"a string default shorter than its own minLength",
{ key: "s", type: "string", default: "", minLength: 1 },
],
[
"an enum default outside its own values",
{ key: "e", type: "enum", values: ["a"], default: "b" },
],
[
"a url default that is not an absolute http(s) URL",
{ key: "u", type: "url", default: "/relative" },
],
[
"a url default with a dangerous protocol",
{ key: "u", type: "url", default: "file:///etc/passwd" },
],
])("rejects %s", (_label, declaration) => {
const result = safeParseModuleManifest({
manifestVersion: 8,
id: "announcements",
name: "announcements module",
version: "1.0.0",
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
configuration: [declaration],
entrypoints: { server: "./dist/server/index.js" },
});
expect(result.success).toBe(false);
});
it.each([
"__proto__",
"constructor",
"prototype",
"toString",
"valueOf",
])("rejects the reserved object property name %s as a configuration key", (key) => {
const result = safeParseModuleManifest({
manifestVersion: 8,
id: "announcements",
name: "announcements module",
version: "1.0.0",
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
configuration: [{ key, type: "string" }],
entrypoints: { server: "./dist/server/index.js" },
});
expect(result.success).toBe(false);
});
it.each([
"Slide",
"slide_seconds",
"slide.seconds",
"slide-seconds",
"9lives",
"",
])("rejects the malformed configuration key %s", (key) => {
const result = safeParseModuleManifest({
manifestVersion: 8,
id: "announcements",
name: "announcements module",
version: "1.0.0",
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
configuration: [{ key, type: "string" }],
entrypoints: { server: "./dist/server/index.js" },
});
expect(result.success).toBe(false);
});
});
describe("resolveModuleConfigurationFor — defaults and precedence", () => {
it("applies declared defaults when nothing is stored", () => {
const manifest = manifestWith(announcementsShapedDeclarations);
const resolved = resolveModuleConfigurationFor(manifest, {
secrets: secretsFor({ announcements: { storageCredential: "s3cret" } }),
settings: settings(["announcements", "bucket", "announcements"]),
});
expect(resolved.issue).toBeUndefined();
expect(resolved.configuration?.getInteger("slideSeconds")).toBe(7);
expect(resolved.configuration?.getInteger("pollIntervalSeconds")).toBe(30);
});
it("lets a stored value override a default", () => {
const manifest = manifestWith(announcementsShapedDeclarations);
const resolved = resolveModuleConfigurationFor(manifest, {
secrets: secretsFor({ announcements: { storageCredential: "s3cret" } }),
settings: settings(
["announcements", "bucket", "announcements"],
["announcements", "slideSeconds", "12"],
),
});
expect(resolved.configuration?.getInteger("slideSeconds")).toBe(12);
});
it("never falls back to the default when a stored value is invalid", () => {
const manifest = manifestWith(announcementsShapedDeclarations);
const resolved = resolveModuleConfigurationFor(manifest, {
secrets: secretsFor({ announcements: { storageCredential: "s3cret" } }),
settings: settings(
["announcements", "bucket", "announcements"],
["announcements", "slideSeconds", "99999"],
),
});
// Silently substituting 7 here would run the slideshow at a duration nobody chose while the
// operator believes theirs took effect.
expect(resolved.configuration).toBeUndefined();
expect(resolved.issue).toEqual({ key: "slideSeconds", reason: "configuration_invalid" });
});
it("reports a required key with no value and no default as missing", () => {
const manifest = manifestWith(announcementsShapedDeclarations);
const resolved = resolveModuleConfigurationFor(manifest, {
secrets: secretsFor({ announcements: { storageCredential: "s3cret" } }),
settings: settings(),
});
expect(resolved.issue).toEqual({ key: "bucket", reason: "configuration_missing" });
});
it("leaves an optional key with no value and no default simply absent", () => {
const manifest = manifestWith([{ key: "webhook", type: "url" }]);
const resolved = resolveModuleConfigurationFor(manifest, { settings: settings() });
expect(resolved.issue).toBeUndefined();
expect(resolved.configuration?.has("webhook")).toBe(false);
expect(resolved.configuration?.getUrl("webhook")).toBeUndefined();
expect(resolved.configuration?.keys()).toEqual([]);
});
it.each([
["a non-canonical integer", "integer", "07"],
["an integer with whitespace", "integer", " 7 "],
["a non-numeric integer", "integer", "seven"],
["a float for an integer", "integer", "7.5"],
["a non-canonical boolean", "boolean", "yes"],
["a numeric boolean", "boolean", "1"],
["a capitalized boolean", "boolean", "True"],
])("rejects %s rather than coercing it", (_label, type, stored) => {
const manifest = manifestWith([{ key: "value", type } as ModuleConfigurationDeclarationInput]);
const resolved = resolveModuleConfigurationFor(manifest, {
settings: settings(["announcements", "value", stored]),
});
expect(resolved.issue).toEqual({ key: "value", reason: "configuration_invalid" });
});
it("validates a stored value against every declared constraint", () => {
const manifest = manifestWith([
{ key: "fit", type: "enum", values: ["contain", "cover"] },
{ key: "webhook", type: "url" },
{ key: "bucket", type: "string", maxLength: 4 },
]);
for (const [key, bad] of [
["fit", "stretch"],
["webhook", "javascript:alert(1)"],
["bucket", "far-too-long"],
] as const) {
const resolved = resolveModuleConfigurationFor(manifest, {
settings: settings(["announcements", key, bad]),
});
expect(resolved.issue).toEqual({ key, reason: "configuration_invalid" });
}
});
it("accepts a valid stored value for every declared type", () => {
const manifest = manifestWith([
{ key: "bucket", type: "string" },
{ key: "slideSeconds", type: "integer" },
{ key: "shuffle", type: "boolean" },
{ key: "fit", type: "enum", values: ["contain", "cover"] },
{ key: "webhook", type: "url" },
]);
const resolved = resolveModuleConfigurationFor(manifest, {
settings: settings(
["announcements", "bucket", "announcements"],
["announcements", "slideSeconds", "12"],
["announcements", "shuffle", "true"],
["announcements", "fit", "cover"],
["announcements", "webhook", "https://hooks.example.test/notify"],
),
});
expect(resolved.issue).toBeUndefined();
expect(resolved.configuration?.getString("bucket")).toBe("announcements");
expect(resolved.configuration?.getInteger("slideSeconds")).toBe(12);
expect(resolved.configuration?.getBoolean("shuffle")).toBe(true);
expect(resolved.configuration?.getEnum("fit")).toBe("cover");
expect(resolved.configuration?.getUrl("webhook")).toBe("https://hooks.example.test/notify");
});
});
describe("resolveModuleConfigurationFor — keys the manifest no longer declares", () => {
it("ignores an undeclared stored key rather than failing, and reports it", () => {
const manifest = manifestWith([{ key: "slideSeconds", type: "integer", default: 7 }]);
const resolved = resolveModuleConfigurationFor(manifest, {
settings: settings(
["announcements", "slideSeconds", "9"],
["announcements", "transitionMilliseconds", "1100"],
),
});
expect(resolved.issue).toBeUndefined();
expect(resolved.configuration?.keys()).toEqual(["slideSeconds"]);
// Retained by the store and surfaced here, so a downgrade or a rename does not destroy an
// operator's value and is visible rather than merely tolerated.
expect(resolved.unknownKeys).toEqual(["transitionMilliseconds"]);
expect(resolved.configuration?.get("transitionMilliseconds")).toBeUndefined();
});
it("uses the default for a key a newer version added, without any migration step", () => {
// v1 stored only `slideSeconds`; v2 adds `transitionMilliseconds` with a default.
const v2 = manifestWith([
{ key: "slideSeconds", type: "integer", default: 7 },
{ key: "transitionMilliseconds", type: "integer", default: 1100 },
]);
const resolved = resolveModuleConfigurationFor(v2, {
settings: settings(["announcements", "slideSeconds", "9"]),
});
expect(resolved.issue).toBeUndefined();
expect(resolved.configuration?.getInteger("slideSeconds")).toBe(9);
expect(resolved.configuration?.getInteger("transitionMilliseconds")).toBe(1100);
});
it("blocks rather than guesses when a newer version adds a required key", () => {
const v2 = manifestWith([
{ key: "slideSeconds", type: "integer", default: 7 },
{ key: "bucket", type: "string", required: true },
]);
const resolved = resolveModuleConfigurationFor(v2, {
settings: settings(["announcements", "slideSeconds", "9"]),
});
expect(resolved.issue).toEqual({ key: "bucket", reason: "configuration_missing" });
});
});
describe("resolveModuleConfigurationFor — isolation", () => {
it("reads only its own module's records, never another module's", () => {
const manifest = manifestWith([{ key: "bucket", type: "string" }]);
const resolved = resolveModuleConfigurationFor(manifest, {
settings: settings(["announcements", "bucket", "mine"], ["calendar", "bucket", "not-mine"]),
});
expect(resolved.configuration?.getString("bucket")).toBe("mine");
expect(resolved.configuration?.keys()).toEqual(["bucket"]);
});
it("asks the secret source only for its own module id", () => {
const asked: string[] = [];
const manifest = manifestWith([{ key: "apiToken", type: "secret", required: true }]);
const resolved = resolveModuleConfigurationFor(manifest, {
secrets: {
get: (moduleId, key) => {
asked.push(`${moduleId}:${key}`);
return moduleId === "announcements" ? "s3cret" : "another-module-secret";
},
},
settings: settings(),
});
expect(asked).toEqual(["announcements:apiToken"]);
expect(resolved.configuration?.getSecret("apiToken")).toBe("s3cret");
});
it("gives a module no way to reach a key it never declared", () => {
const manifest = manifestWith([{ key: "bucket", type: "string", default: "b" }]);
const resolved = resolveModuleConfigurationFor(manifest, {
settings: settings(["announcements", "somebodyElsesKey", "value"]),
});
for (const probe of ["somebodyElsesKey", "__proto__", "constructor", "toString"]) {
expect(resolved.configuration?.get(probe)).toBeUndefined();
expect(resolved.configuration?.has(probe)).toBe(false);
}
});
});
describe("module configuration accessors", () => {
it("fails closed when the accessor does not match the declared type", () => {
const manifest = manifestWith([{ key: "slideSeconds", type: "integer", default: 7 }]);
const config = resolveModuleConfigurationFor(manifest, { settings: settings() }).configuration;
expect(config?.getInteger("slideSeconds")).toBe(7);
// An undeclared key and a declared key of another type are deliberately indistinguishable, in
// the same style as `PermissionRegistry.get`.
expect(config?.getString("slideSeconds")).toBeUndefined();
expect(config?.getBoolean("slideSeconds")).toBeUndefined();
expect(config?.getSecret("slideSeconds")).toBeUndefined();
expect(config?.getString("neverDeclared")).toBeUndefined();
});
it("does not expose a secret through any non-secret accessor", () => {
const manifest = manifestWith([{ key: "apiToken", type: "secret", required: true }]);
const config = resolveModuleConfigurationFor(manifest, {
secrets: secretsFor({ announcements: { apiToken: "s3cret" } }),
settings: settings(),
}).configuration;
expect(config?.getSecret("apiToken")).toBe("s3cret");
expect(config?.getString("apiToken")).toBeUndefined();
expect(config?.getUrl("apiToken")).toBeUndefined();
expect(config?.getEnum("apiToken")).toBeUndefined();
});
it("is frozen, sorted, and has no other surface", () => {
const manifest = manifestWith([
{ key: "zulu", type: "string", default: "z" },
{ key: "alpha", type: "string", default: "a" },
]);
const config = resolveModuleConfigurationFor(manifest, { settings: settings() }).configuration;
expect(Object.isFrozen(config)).toBe(true);
expect(config?.keys()).toEqual(["alpha", "zulu"]);
expect(Object.keys(config ?? {}).sort()).toEqual([
"get",
"getBoolean",
"getEnum",
"getInteger",
"getSecret",
"getString",
"getUrl",
"has",
"keys",
]);
});
it("supplies an empty configuration that behaves like any other, for a module declaring none", () => {
expect(EMPTY_MODULE_CONFIGURATION.keys()).toEqual([]);
expect(EMPTY_MODULE_CONFIGURATION.has("anything")).toBe(false);
expect(EMPTY_MODULE_CONFIGURATION.get("anything")).toBeUndefined();
expect(EMPTY_MODULE_CONFIGURATION.getSecret("anything")).toBeUndefined();
expect(Object.isFrozen(EMPTY_MODULE_CONFIGURATION)).toBe(true);
});
});
describe("deployment-supplied secrets", () => {
it("reports a required secret the deployment does not supply", () => {
const manifest = manifestWith([{ key: "storageCredential", type: "secret", required: true }]);
const resolved = resolveModuleConfigurationFor(manifest, { settings: settings() });
expect(resolved.configuration).toBeUndefined();
expect(resolved.issue).toEqual({ key: "storageCredential", reason: "secret_missing" });
});
it("treats an empty-string secret as not supplied", () => {
const manifest = manifestWith([{ key: "storageCredential", type: "secret", required: true }]);
const resolved = resolveModuleConfigurationFor(manifest, {
secrets: secretsFor({ announcements: { storageCredential: "" } }),
settings: settings(),
});
expect(resolved.issue).toEqual({ key: "storageCredential", reason: "secret_missing" });
});
it("leaves an optional secret absent rather than blocking", () => {
const manifest = manifestWith([{ key: "storageCredential", type: "secret" }]);
const resolved = resolveModuleConfigurationFor(manifest, { settings: settings() });
expect(resolved.issue).toBeUndefined();
expect(resolved.configuration?.has("storageCredential")).toBe(false);
});
it("never reads a secret from stored configuration, even if a row exists for that key", () => {
const manifest = manifestWith([{ key: "storageCredential", type: "secret", required: true }]);
const resolved = resolveModuleConfigurationFor(manifest, {
// A row that should be unreachable — the HTTP boundary refuses to write one, and this proves
// the resolver would ignore it even if one arrived by another route.
settings: settings(["announcements", "storageCredential", "planted-in-the-database"]),
});
expect(resolved.issue).toEqual({ key: "storageCredential", reason: "secret_missing" });
expect(resolved.configuration).toBeUndefined();
});
it("derives a deterministic, collision-free environment variable name", () => {
expect(moduleSecretEnvironmentVariableName("announcements", "storageCredential")).toBe(
"EKKLESIA_MODULE_SECRET__ANNOUNCEMENTS__STORAGE_CREDENTIAL",
);
expect(moduleSecretEnvironmentVariableName("media-storage", "accessKeyId")).toBe(
"EKKLESIA_MODULE_SECRET__MEDIA_STORAGE__ACCESS_KEY_ID",
);
// The double underscore is what keeps two different (module, key) pairs from folding onto one
// name once kebab-case and camelCase are both flattened to underscores.
expect(moduleSecretEnvironmentVariableName("media", "storageAccessKey")).not.toBe(
moduleSecretEnvironmentVariableName("media-storage", "accessKey"),
);
});
it("reads only the derived name from the supplied environment, never an arbitrary variable", () => {
const environment = {
DATABASE_URL: "postgresql://secret@host/db",
EKKLESIA_IDENTITY_SECRET: "platform-secret",
EKKLESIA_MODULE_SECRET__ANNOUNCEMENTS__STORAGE_CREDENTIAL: "s3cret",
};
const source = createEnvironmentModuleSecretSource(environment);
expect(source.get("announcements", "storageCredential")).toBe("s3cret");
// There is no accessor that takes a raw variable name, so a module can never reach these.
expect(source.get("announcements", "databaseUrl")).toBeUndefined();
expect(source.get("calendar", "storageCredential")).toBeUndefined();
expect(Object.keys(source)).toEqual(["get"]);
});
});
describe("configuration over a real discovered inventory", () => {
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
);
});
async function writeModule(
modulesDirectory: string,
id: string,
configuration: readonly ModuleConfigurationDeclarationInput[],
): Promise<void> {
const moduleDirectory = path.join(modulesDirectory, id);
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
await Promise.all([
writeFile(
path.join(moduleDirectory, "package.json"),
JSON.stringify({
ekklesia: { layer: "module" },
name: `@test/module-${id}`,
type: "module",
version: "1.0.0",
}),
),
writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
manifestVersion: 8,
id,
name: `${id} module`,
version: "1.0.0",
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
configuration,
entrypoints: { server: "./dist/server/index.js" },
}),
),
writeFile(
path.join(moduleDirectory, "dist/server/index.js"),
'throw new Error("configuration discovery must never import a module server entrypoint");',
),
]);
}
it("discovers declarations without importing a single line of module code", async () => {
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-config-"));
temporaryDirectories.push(modulesDirectory);
await writeModule(modulesDirectory, "announcements", announcementsShapedDeclarations);
const discovered: readonly DiscoveredModule[] = await discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
});
// Every fixture entrypoint throws on import; reaching these assertions is direct evidence that
// discovery stayed inert, exactly as it was before configuration existed.
expect(discovered).toHaveLength(1);
expect(discovered[0]?.manifest.configuration.map((entry) => entry.key)).toEqual([
"slideSeconds",
"pollIntervalSeconds",
"bucket",
"storageCredential",
]);
const manifest = discovered[0]?.manifest;
if (!manifest) {
throw new Error("expected a discovered module");
}
const resolved = resolveModuleConfigurationFor(manifest, {
secrets: secretsFor({ announcements: { storageCredential: "s3cret" } }),
settings: settings(["announcements", "bucket", "announcements"]),
});
// The whole point, end to end: a real product's settings arrive validated and defaulted, and the
// module reached no database, no environment, and no platform internal to get them.
expect(resolved.issue).toBeUndefined();
expect(resolved.configuration?.getInteger("slideSeconds")).toBe(7);
expect(resolved.configuration?.getInteger("pollIntervalSeconds")).toBe(30);
expect(resolved.configuration?.getString("bucket")).toBe("announcements");
expect(resolved.configuration?.getSecret("storageCredential")).toBe("s3cret");
});
it("rejects a manifest whose configuration declaration is malformed, at discovery", async () => {
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-config-"));
temporaryDirectories.push(modulesDirectory);
await writeModule(modulesDirectory, "announcements", [
{ key: "apiToken", type: "secret", required: true },
// A duplicate key: the manifest is invalid, so the whole inventory is rejected before any
// configuration is ever resolved.
{ key: "apiToken", type: "string" },
]);
await expect(
discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
}),
).rejects.toMatchObject({ name: "ModuleDiscoveryError" });
});
});