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
668 lines
26 KiB
TypeScript
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" });
|
|
});
|
|
});
|