CI / verify (pull_request) Successful in 1m2s
CI / infrastructure-smoke (pull_request) Successful in 37s
CI / migration-smoke (pull_request) Successful in 1m52s
CI / standalone-module (pull_request) Successful in 26s
CI / image-build (pull_request) Successful in 28s
CI / deploy-smoke (same-origin) (pull_request) Successful in 43s
CI / deploy-smoke (split) (pull_request) Successful in 1m30s
@ekklesia/contracts still emitted a type-only import of drizzle-orm/node-postgres for the legacy ModuleDatabase facade, so a standalone module type-checked only because skipLibCheck hid it. - ModuleDatabase, LegacyModuleRuntimeServices and AnyModuleRuntimeServices move to core/src/modules/database.ts, beside the facade Core builds; the facade itself is unchanged. - contracts' runtime, route, and services generics no longer name an ORM: the database type is a plain parameter defaulting to the ABI v1 executor, and the legacy definition's database is opaque until Core specializes it. Core and apps/api name the legacy types from Core. - contracts drops its drizzle-orm optional peer and devDependency; Drizzle stays public only through @ekklesia/module-database/drizzle. - The facade's type-level test moves from contracts to Core. - Regression proof: the standalone fixture type-checks with skipLibCheck: false against the packed tarballs, with no drizzle-orm installed (the smoke asserts both). Against the previous contracts tarball the same check fails with TS2307 on drizzle-orm/node-postgres. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1099 lines
44 KiB
TypeScript
1099 lines
44 KiB
TypeScript
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { ANONYMOUS_PRINCIPAL } from "@ekklesia/contracts";
|
|
import type { AuthorizationEvaluator, AuthorizationStore } from "@ekklesia/core/authorization";
|
|
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
|
import {
|
|
composePlatformPermissionRegistry,
|
|
createModuleUiPublicationRegistry,
|
|
type DiscoveredModule,
|
|
discoverModules,
|
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
|
type LegacyModuleRuntimeServices,
|
|
type ModuleDatabase,
|
|
} from "@ekklesia/core/modules";
|
|
import { loadModuleUi } from "@ekklesia/module-ui-host";
|
|
import { MODULE_UI_ABI_VERSION, NO_MODULE_UI_CONTRIBUTIONS } from "@ekklesia/module-ui-sdk";
|
|
import { Window } from "happy-dom";
|
|
import type { Hono } from "hono";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
|
import { readyHealth } from "./support/ready-health.js";
|
|
import { createApiApp } from "../src/app.js";
|
|
import { createDisplayHostAssets } from "../src/display-host-assets.js";
|
|
import {
|
|
MODULE_UI_ASSET_PREFIX,
|
|
MODULE_UI_CACHE_CONTROL,
|
|
MODULE_UI_CONTENT_TYPE,
|
|
moduleContributionArtifactUrl,
|
|
moduleDisplayUiArtifactUrl,
|
|
moduleUiArtifactUrl,
|
|
} from "../src/module-ui-routes.js";
|
|
import {
|
|
createInMemoryModuleLifecycleStore,
|
|
moduleLifecycleSnapshotFor,
|
|
} from "./support/in-memory-module-lifecycle.js";
|
|
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
|
|
import { moduleStorageOptionFor } from "./support/in-memory-module-storage.js";
|
|
|
|
const trustedOrigin = "https://dashboard.example.test";
|
|
|
|
const temporaryDirectories: string[] = [];
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(
|
|
temporaryDirectories
|
|
.splice(0)
|
|
.map((directory) => rm(directory, { force: true, recursive: true })),
|
|
);
|
|
});
|
|
|
|
async function createModulesDirectory(): Promise<string> {
|
|
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-api-ui-publication-"));
|
|
temporaryDirectories.push(directory);
|
|
return directory;
|
|
}
|
|
|
|
/** A real, loadable module UI artifact: a valid `ModuleUiDefinition` default export, written as
|
|
* plain ESM. `@ekklesia/module-ui-build` already proves the esbuild pipeline emits this shape
|
|
* (`packages/module-ui-host/tests/real-artifact.test.ts`); what these tests need is a byte
|
|
* sequence whose exact publication and loader-compatibility can be asserted. */
|
|
function artifactSource(id: string, version: string): string {
|
|
return `export default {
|
|
abi: ${MODULE_UI_ABI_VERSION},
|
|
id: ${JSON.stringify(id)},
|
|
version: ${JSON.stringify(version)},
|
|
mount(container, context) {
|
|
container.textContent = context.moduleId + ":mounted";
|
|
return () => {
|
|
container.textContent = "";
|
|
};
|
|
},
|
|
};
|
|
`;
|
|
}
|
|
|
|
interface ModuleFixture {
|
|
readonly id: string;
|
|
readonly version?: string;
|
|
/** Written to `dist/ui/index.js` and declared as `entrypoints.ui`. Omit for a backend-only
|
|
* module, whose manifest then declares no UI entrypoint at all. */
|
|
readonly ui?: string;
|
|
/** Written to a distinct artifact and declared as the protected display entrypoint. */
|
|
readonly display?: string;
|
|
/** Extra package-relative files written alongside the artifact — a source map, a sibling bundle
|
|
* chunk, an operator secret — so "only the declared artifact is reachable" is asserted against
|
|
* files that genuinely exist on disk rather than against hypothetical ones. */
|
|
readonly extraFiles?: Readonly<Record<string, string>>;
|
|
/** Capabilities this module provides — what another module's contributions target. */
|
|
readonly capabilities?: readonly { readonly id: string; readonly version: string }[];
|
|
/** Browser renderers this module contributes to another module's capability. */
|
|
readonly contributions?: readonly {
|
|
readonly localName: string;
|
|
readonly capability: string;
|
|
readonly capabilityRange: string;
|
|
readonly source: string;
|
|
}[];
|
|
}
|
|
|
|
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
|
|
const version = fixture.version ?? "0.1.0";
|
|
const displayPermission = `${fixture.id}.display.view`;
|
|
const moduleDirectory = path.join(modulesDirectory, fixture.id);
|
|
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
|
await writeFile(
|
|
path.join(moduleDirectory, "package.json"),
|
|
JSON.stringify({
|
|
ekklesia: { layer: "module" },
|
|
name: `@test/module-${fixture.id}`,
|
|
type: "module",
|
|
version,
|
|
}),
|
|
);
|
|
await writeFile(
|
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
|
JSON.stringify({
|
|
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
|
entrypoints: {
|
|
server: "./dist/server/index.js",
|
|
...(fixture.ui === undefined ? {} : { ui: "./dist/ui/index.js" }),
|
|
...(fixture.display === undefined
|
|
? {}
|
|
: {
|
|
display: {
|
|
artifact: "./dist/display/index.js",
|
|
permission: displayPermission,
|
|
},
|
|
}),
|
|
},
|
|
capabilities: fixture.capabilities ?? [],
|
|
contributions: (fixture.contributions ?? []).map((contribution) => ({
|
|
artifact: `./dist/contributions/${contribution.localName}/index.js`,
|
|
capability: contribution.capability,
|
|
capabilityRange: contribution.capabilityRange,
|
|
id: `${fixture.id}.${contribution.localName}`,
|
|
version: "1.0.0",
|
|
})),
|
|
id: fixture.id,
|
|
manifestVersion: 8,
|
|
name: `${fixture.id} module`,
|
|
permissions:
|
|
fixture.display === undefined
|
|
? []
|
|
: [
|
|
{
|
|
baseline: "restricted",
|
|
description: `View ${fixture.id}'s display`,
|
|
id: displayPermission,
|
|
},
|
|
],
|
|
version,
|
|
}),
|
|
);
|
|
await writeFile(
|
|
path.join(moduleDirectory, "dist/server/index.js"),
|
|
`export default { id: ${JSON.stringify(fixture.id)}, version: ${JSON.stringify(version)} };`,
|
|
);
|
|
if (fixture.ui !== undefined) {
|
|
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
|
|
await writeFile(path.join(moduleDirectory, "dist/ui/index.js"), fixture.ui);
|
|
}
|
|
for (const contribution of fixture.contributions ?? []) {
|
|
const contributionDirectory = path.join(
|
|
moduleDirectory,
|
|
"dist/contributions",
|
|
contribution.localName,
|
|
);
|
|
await mkdir(contributionDirectory, { recursive: true });
|
|
await writeFile(path.join(contributionDirectory, "index.js"), contribution.source);
|
|
await writeFile(path.join(contributionDirectory, "index.js.map"), '{"sources":["secret"]}');
|
|
}
|
|
if (fixture.display !== undefined) {
|
|
await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
|
|
await writeFile(path.join(moduleDirectory, "dist/display/index.js"), fixture.display);
|
|
}
|
|
for (const [relativePath, contents] of Object.entries(fixture.extraFiles ?? {})) {
|
|
const target = path.join(moduleDirectory, relativePath);
|
|
await mkdir(path.dirname(target), { recursive: true });
|
|
await writeFile(target, contents);
|
|
}
|
|
}
|
|
|
|
interface Installation {
|
|
readonly app: Hono;
|
|
readonly modules: readonly DiscoveredModule[];
|
|
readonly modulesDirectory: string;
|
|
}
|
|
|
|
/**
|
|
* A real installation: fixtures on disk, Core's real `discoverModules` (manifest validation,
|
|
* compiled-artifact validation, package containment, symlink canonicalization), Core's real
|
|
* publication registry, and the real `createApiApp`. Nothing about the publication policy or the
|
|
* artifact path is hand-built, so these tests exercise the actual security boundary rather than a
|
|
* reconstruction of it.
|
|
*/
|
|
async function install(
|
|
fixtures: readonly ModuleFixture[],
|
|
publicationModuleIds?: readonly string[],
|
|
): Promise<Installation> {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
for (const fixture of fixtures) {
|
|
await createModule(modulesDirectory, fixture);
|
|
}
|
|
const modules = await discoverModules({
|
|
moduleSdkVersion: "0.1.0",
|
|
modulesDirectory,
|
|
platformVersion: "0.1.0",
|
|
});
|
|
const publicationModules =
|
|
publicationModuleIds === undefined
|
|
? modules
|
|
: modules.filter((module) => publicationModuleIds.includes(module.manifest.id));
|
|
|
|
const database = Object.freeze({}) as unknown as ModuleDatabase;
|
|
const services: LegacyModuleRuntimeServices = Object.freeze({ database });
|
|
const runtime: EkklesiaIdentityRuntime = {
|
|
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
|
|
// Anonymous, and every authorization decision denied: whatever this route does, it does it
|
|
// without a session and without a single permission.
|
|
resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL),
|
|
};
|
|
const evaluator: AuthorizationEvaluator = {
|
|
authorize: vi.fn().mockResolvedValue({ allowed: false }),
|
|
};
|
|
const store = {
|
|
createGrant: vi.fn(),
|
|
grantPlatformOwner: vi.fn(),
|
|
hasActiveGrant: vi.fn().mockResolvedValue(false),
|
|
isPlatformOwner: vi.fn().mockResolvedValue(false),
|
|
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
|
|
revokeGrant: vi.fn(),
|
|
revokePlatformOwner: vi.fn(),
|
|
} as unknown as AuthorizationStore;
|
|
|
|
const app = createApiApp({
|
|
authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
|
|
...(publicationModules.some((module) => module.entrypoints.display !== undefined)
|
|
? {
|
|
displayHostAssets: createDisplayHostAssets(
|
|
'<!doctype html><div id="display-root"></div>',
|
|
"export {};\n",
|
|
"export {};\n",
|
|
),
|
|
}
|
|
: {}),
|
|
moduleRoutes: Object.freeze([]),
|
|
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
|
moduleServices: services,
|
|
moduleUi: createModuleUiPublicationRegistry(publicationModules),
|
|
moduleLifecycle: {
|
|
snapshot: moduleLifecycleSnapshotFor(modules),
|
|
store: createInMemoryModuleLifecycleStore(),
|
|
},
|
|
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
|
moduleStorage: moduleStorageOptionFor(modules),
|
|
dashboard: createInMemoryDashboardStore(),
|
|
dashboardHostAssets: testDashboardHostAssets(),
|
|
health: readyHealth(),
|
|
runtime,
|
|
trustedOrigins: [trustedOrigin],
|
|
});
|
|
|
|
return { app, modules, modulesDirectory };
|
|
}
|
|
|
|
describe("module UI artifact publication — happy path", () => {
|
|
it("serves the declared artifact byte-for-byte at the canonical URL", async () => {
|
|
const source = artifactSource("announcements", "1.4.2");
|
|
const { app } = await install([{ id: "announcements", ui: source, version: "1.4.2" }]);
|
|
|
|
const response = await app.request(
|
|
moduleUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" }),
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(await response.text()).toBe(source);
|
|
});
|
|
|
|
it("serves it as ES module JavaScript, with sniffing disabled", async () => {
|
|
const { app } = await install([
|
|
{ id: "announcements", ui: artifactSource("announcements", "0.1.0") },
|
|
]);
|
|
|
|
const response = await app.request(
|
|
moduleUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" }),
|
|
);
|
|
|
|
expect(response.headers.get("content-type")).toBe(MODULE_UI_CONTENT_TYPE);
|
|
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
|
});
|
|
|
|
it("revalidates rather than promising immutability the platform cannot enforce", async () => {
|
|
const { app } = await install([
|
|
{ id: "announcements", ui: artifactSource("announcements", "0.1.0") },
|
|
]);
|
|
|
|
const response = await app.request(
|
|
moduleUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" }),
|
|
);
|
|
|
|
expect(response.headers.get("cache-control")).toBe(MODULE_UI_CACHE_CONTROL);
|
|
// A reused version can be rebuilt with different bytes, so neither of these may ever appear.
|
|
expect(response.headers.get("cache-control")).not.toContain("immutable");
|
|
expect(response.headers.get("cache-control")).not.toContain("max-age");
|
|
// A strong validator (no `W/` prefix), so the revalidation above is actually decidable.
|
|
expect(response.headers.get("etag")).toMatch(/^"[A-Za-z0-9_-]+"$/u);
|
|
});
|
|
|
|
it("varies on Origin, so a shared cache cannot reuse one origin's CORS response for another", async () => {
|
|
const { app } = await install([
|
|
{ id: "announcements", ui: artifactSource("announcements", "0.1.0") },
|
|
]);
|
|
|
|
const response = await app.request(
|
|
moduleUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" }),
|
|
{ headers: { origin: trustedOrigin } },
|
|
);
|
|
|
|
expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin);
|
|
expect(response.headers.get("vary")?.toLowerCase()).toContain("origin");
|
|
});
|
|
|
|
it("answers a matching conditional request with 304 and no body", async () => {
|
|
const { app } = await install([
|
|
{ id: "announcements", ui: artifactSource("announcements", "0.1.0") },
|
|
]);
|
|
const url = moduleUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" });
|
|
const etag = (await app.request(url)).headers.get("etag") as string;
|
|
|
|
const revalidated = await app.request(url, { headers: { "if-none-match": etag } });
|
|
|
|
expect(revalidated.status).toBe(304);
|
|
expect(await revalidated.text()).toBe("");
|
|
expect(revalidated.headers.get("etag")).toBe(etag);
|
|
expect(revalidated.headers.get("cache-control")).toBe(MODULE_UI_CACHE_CONTROL);
|
|
});
|
|
|
|
it("applies RFC 9110 weak comparison and the wildcard, and refuses a stale validator", async () => {
|
|
const { app } = await install([
|
|
{ id: "announcements", ui: artifactSource("announcements", "0.1.0") },
|
|
]);
|
|
const url = moduleUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" });
|
|
const etag = (await app.request(url)).headers.get("etag") as string;
|
|
|
|
const cases: ReadonlyArray<readonly [string, number]> = [
|
|
[`W/${etag}`, 304],
|
|
["*", 304],
|
|
[`"someone-elses-tag", ${etag}`, 304],
|
|
['"stale-tag"', 200],
|
|
["not a valid header at all", 200],
|
|
];
|
|
for (const [header, expected] of cases) {
|
|
const response = await app.request(url, { headers: { "if-none-match": header } });
|
|
expect(response.status, header).toBe(expected);
|
|
}
|
|
});
|
|
|
|
it("recomputes the validator from the bytes actually on disk after a same-version rebuild", async () => {
|
|
const { app, modulesDirectory } = await install([
|
|
{ id: "announcements", ui: artifactSource("announcements", "0.1.0") },
|
|
]);
|
|
const url = moduleUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" });
|
|
const first = await app.request(url);
|
|
const firstEtag = first.headers.get("etag");
|
|
|
|
// Exactly the develop-in-place case an `immutable` policy would have made unrecoverable.
|
|
const rebuilt = `${artifactSource("announcements", "0.1.0")}// rebuilt\n`;
|
|
await writeFile(path.join(modulesDirectory, "announcements/dist/ui/index.js"), rebuilt);
|
|
|
|
const second = await app.request(url, { headers: { "if-none-match": firstEtag as string } });
|
|
|
|
expect(second.status).toBe(200);
|
|
expect(await second.text()).toBe(rebuilt);
|
|
expect(second.headers.get("etag")).not.toBe(firstEtag);
|
|
});
|
|
|
|
it("serves the artifact without executing it, even when its top level throws", async () => {
|
|
const exploding = 'throw new Error("module UI must never be evaluated on the server");\n';
|
|
const { app } = await install([{ id: "announcements", ui: exploding }]);
|
|
|
|
const response = await app.request(
|
|
moduleUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" }),
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(await response.text()).toBe(exploding);
|
|
});
|
|
|
|
it("requires no authentication, by deliberate decision", async () => {
|
|
// The installation's identity runtime resolves an anonymous principal and its evaluator denies
|
|
// every permission. If this route ever grows an authentication or authorization requirement,
|
|
// this test is the one that must be consciously changed.
|
|
const { app } = await install([
|
|
{ id: "announcements", ui: artifactSource("announcements", "0.1.0") },
|
|
]);
|
|
|
|
const response = await app.request(
|
|
moduleUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" }),
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
});
|
|
|
|
it("still refuses a protected platform API to the same anonymous caller", async () => {
|
|
// Publication grants no authority: the very same caller that just downloaded the bundle is
|
|
// still denied every permission-protected route.
|
|
const { app } = await install([
|
|
{ id: "announcements", ui: artifactSource("announcements", "0.1.0") },
|
|
]);
|
|
|
|
expect(
|
|
(await app.request(moduleUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" })))
|
|
.status,
|
|
).toBe(200);
|
|
const grants = await app.request("/api/v1/grants?userId=someone");
|
|
expect(grants.status).toBe(403);
|
|
});
|
|
});
|
|
|
|
describe("module display UI artifact publication", () => {
|
|
it("serves the declared display bytes anonymously at the exact role-specific URL", async () => {
|
|
const source = `${artifactSource("announcements", "1.4.2")}// display\n`;
|
|
const { app } = await install([{ display: source, id: "announcements", version: "1.4.2" }]);
|
|
const url = moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" });
|
|
|
|
expect(url).toBe(`${MODULE_UI_ASSET_PREFIX}/announcements/1.4.2/display`);
|
|
const response = await app.request(url);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(await response.text()).toBe(source);
|
|
expect(response.headers.get("content-type")).toBe(MODULE_UI_CONTENT_TYPE);
|
|
expect(response.headers.get("cache-control")).toBe(MODULE_UI_CACHE_CONTROL);
|
|
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
|
const etag = response.headers.get("etag") as string;
|
|
expect(etag).toMatch(/^"[A-Za-z0-9_-]+"$/u);
|
|
|
|
const revalidated = await app.request(url, { headers: { "if-none-match": etag } });
|
|
expect(revalidated.status).toBe(304);
|
|
expect(await revalidated.text()).toBe("");
|
|
expect(revalidated.headers.get("cache-control")).toBe(MODULE_UI_CACHE_CONTROL);
|
|
expect(revalidated.headers.get("etag")).toBe(etag);
|
|
});
|
|
|
|
it("keeps display-only, primary-only, both, and neither roles strictly separate", async () => {
|
|
const primary = `${artifactSource("signage", "0.1.0")}// primary\n`;
|
|
const display = `${artifactSource("signage", "0.1.0")}// display\n`;
|
|
const { app } = await install([
|
|
{ display: `${display}// announcements\n`, id: "announcements" },
|
|
{ id: "calendar", ui: `${primary}// calendar\n` },
|
|
{ display, id: "signage", ui: primary },
|
|
{ id: "backend" },
|
|
]);
|
|
|
|
const primaryUrl = (moduleId: string) => moduleUiArtifactUrl({ moduleId, version: "0.1.0" });
|
|
const displayUrl = (moduleId: string) =>
|
|
moduleDisplayUiArtifactUrl({ moduleId, version: "0.1.0" });
|
|
|
|
expect((await app.request(primaryUrl("announcements"))).status).toBe(404);
|
|
expect((await app.request(displayUrl("announcements"))).status).toBe(200);
|
|
expect((await app.request(primaryUrl("calendar"))).status).toBe(200);
|
|
expect((await app.request(displayUrl("calendar"))).status).toBe(404);
|
|
expect(await (await app.request(primaryUrl("signage"))).text()).toBe(primary);
|
|
expect(await (await app.request(displayUrl("signage"))).text()).toBe(display);
|
|
expect((await app.request(primaryUrl("backend"))).status).toBe(404);
|
|
expect((await app.request(displayUrl("backend"))).status).toBe(404);
|
|
});
|
|
|
|
it("refuses wrong versions with the same sanitized response as an undeclared display", async () => {
|
|
const { app } = await install([
|
|
{
|
|
display: `${artifactSource("announcements", "1.4.2")}// display\n`,
|
|
id: "announcements",
|
|
version: "1.4.2",
|
|
},
|
|
{ id: "calendar", ui: artifactSource("calendar", "0.1.0") },
|
|
]);
|
|
|
|
const responses = await Promise.all(
|
|
[
|
|
moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "latest" }),
|
|
moduleDisplayUiArtifactUrl({ moduleId: "calendar", version: "0.1.0" }),
|
|
moduleDisplayUiArtifactUrl({ moduleId: "missing", version: "1.4.2" }),
|
|
].map(async (url) => {
|
|
const response = await app.request(url);
|
|
return { body: await response.text(), status: response.status };
|
|
}),
|
|
);
|
|
|
|
expect(responses).toEqual([
|
|
{ body: '{"code":"MODULE_UI_NOT_PUBLISHED"}', status: 404 },
|
|
{ body: '{"code":"MODULE_UI_NOT_PUBLISHED"}', status: 404 },
|
|
{ body: '{"code":"MODULE_UI_NOT_PUBLISHED"}', status: 404 },
|
|
]);
|
|
});
|
|
|
|
it("never turns the display URL into a package directory or generic role selector", async () => {
|
|
const display = `${artifactSource("announcements", "0.1.0")}// display\n`;
|
|
const { app } = await install([
|
|
{
|
|
display,
|
|
extraFiles: {
|
|
".env": "DISPLAY_SECRET=do-not-serve\n",
|
|
"dist/display/index.js.map": "display source map secret",
|
|
"dist/display/internal-chunk.js": "export const internal = 'secret';\n",
|
|
},
|
|
id: "announcements",
|
|
},
|
|
]);
|
|
const exact = moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" });
|
|
|
|
for (const url of [
|
|
`${exact}.map`,
|
|
`${exact}/index.js.map`,
|
|
`${exact}/internal-chunk.js`,
|
|
`${exact}/../../.env`,
|
|
`${MODULE_UI_ASSET_PREFIX}/announcements/0.1.0/dashboard`,
|
|
`${MODULE_UI_ASSET_PREFIX}/announcements/0.1.0/arbitrary-role`,
|
|
]) {
|
|
const response = await app.request(url);
|
|
expect(response.status, url).toBe(404);
|
|
const body = await response.text();
|
|
expect(body, url).not.toContain("secret");
|
|
expect(body, url).not.toContain("mount");
|
|
}
|
|
});
|
|
|
|
it("cannot publish a blocked module omitted from Core's eligible registry input", async () => {
|
|
const display = `${artifactSource("announcements", "0.1.0")}// display\n`;
|
|
const { app } = await install(
|
|
[
|
|
{ display, id: "announcements" },
|
|
{ display: `${display}// signage\n`, id: "signage" },
|
|
],
|
|
["signage"],
|
|
);
|
|
|
|
expect(
|
|
(
|
|
await app.request(
|
|
moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" }),
|
|
)
|
|
).status,
|
|
).toBe(404);
|
|
expect(
|
|
(await app.request(moduleDisplayUiArtifactUrl({ moduleId: "signage", version: "0.1.0" })))
|
|
.status,
|
|
).toBe(200);
|
|
});
|
|
|
|
it("reports the same fixed 503 when the validated display artifact disappears", async () => {
|
|
const { app, modulesDirectory } = await install([
|
|
{
|
|
display: `${artifactSource("announcements", "0.1.0")}// display\n`,
|
|
id: "announcements",
|
|
},
|
|
]);
|
|
await rm(path.join(modulesDirectory, "announcements/dist/display/index.js"));
|
|
|
|
const response = await app.request(
|
|
moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" }),
|
|
);
|
|
|
|
expect(response.status).toBe(503);
|
|
expect(await response.json()).toEqual({ code: "MODULE_UI_ARTIFACT_UNAVAILABLE" });
|
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
|
});
|
|
});
|
|
|
|
describe("module UI artifact publication — module identity", () => {
|
|
async function expectNotPublished(app: Hono, url: string): Promise<void> {
|
|
const response = await app.request(url);
|
|
expect(response.status).toBe(404);
|
|
expect(await response.json()).toEqual({ code: "MODULE_UI_NOT_PUBLISHED" });
|
|
}
|
|
|
|
it("refuses a module that is not installed", async () => {
|
|
const { app } = await install([
|
|
{ id: "announcements", ui: artifactSource("announcements", "0.1.0") },
|
|
]);
|
|
|
|
await expectNotPublished(app, moduleUiArtifactUrl({ moduleId: "calendar", version: "0.1.0" }));
|
|
});
|
|
|
|
it("refuses an installed module that declares no UI entrypoint", async () => {
|
|
const { app } = await install([{ id: "calendar" }]);
|
|
|
|
await expectNotPublished(app, moduleUiArtifactUrl({ moduleId: "calendar", version: "0.1.0" }));
|
|
});
|
|
|
|
it("refuses a version other than the installed one rather than serving what exists", async () => {
|
|
const { app } = await install([
|
|
{ id: "announcements", ui: artifactSource("announcements", "1.4.2"), version: "1.4.2" },
|
|
]);
|
|
|
|
for (const version of ["1.4.1", "1.4.3", "1.4", "latest", "^1.4.2"]) {
|
|
await expectNotPublished(app, moduleUiArtifactUrl({ moduleId: "announcements", version }));
|
|
}
|
|
expect(
|
|
(await app.request(moduleUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" })))
|
|
.status,
|
|
).toBe(200);
|
|
});
|
|
|
|
it("makes unknown module, no UI, and wrong version indistinguishable", async () => {
|
|
const { app } = await install([
|
|
{ id: "announcements", ui: artifactSource("announcements", "1.4.2"), version: "1.4.2" },
|
|
{ id: "calendar" },
|
|
]);
|
|
|
|
const responses = await Promise.all(
|
|
[
|
|
moduleUiArtifactUrl({ moduleId: "signage", version: "1.4.2" }),
|
|
moduleUiArtifactUrl({ moduleId: "calendar", version: "0.1.0" }),
|
|
moduleUiArtifactUrl({ moduleId: "announcements", version: "9.9.9" }),
|
|
].map(async (url) => {
|
|
const response = await app.request(url);
|
|
return { body: await response.text(), status: response.status };
|
|
}),
|
|
);
|
|
|
|
// An anonymous caller must not be able to use the distinction to enumerate an installation.
|
|
expect(new Set(responses.map((entry) => `${entry.status}:${entry.body}`)).size).toBe(1);
|
|
});
|
|
|
|
it("never serves one module's artifact under another module's identity", async () => {
|
|
const { app } = await install([
|
|
{ id: "announcements", ui: artifactSource("announcements", "0.1.0") },
|
|
{ id: "signage", ui: artifactSource("signage", "0.1.0") },
|
|
]);
|
|
|
|
const announcements = await app.request(
|
|
moduleUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" }),
|
|
);
|
|
const signage = await app.request(
|
|
moduleUiArtifactUrl({ moduleId: "signage", version: "0.1.0" }),
|
|
);
|
|
|
|
expect(await announcements.text()).toContain('"announcements"');
|
|
expect(await signage.text()).toContain('"signage"');
|
|
});
|
|
});
|
|
|
|
describe("module UI artifact publication — filesystem exposure", () => {
|
|
const sourceMap =
|
|
'{"version":3,"sources":["../../src/ui/index.tsx"],"sourcesContent":["secret"]}';
|
|
|
|
/** One installation carrying every kind of file a wildcard route would have leaked. */
|
|
async function installWithNeighbours(): Promise<Installation> {
|
|
return install([
|
|
{
|
|
extraFiles: {
|
|
".env": "EKKLESIA_IDENTITY_SECRET=super-secret\n",
|
|
"dist/server/index.js.map": sourceMap,
|
|
"dist/ui/index.js.map": sourceMap,
|
|
"dist/ui/internal-chunk.js": "export const internal = 1;\n",
|
|
},
|
|
id: "announcements",
|
|
ui: artifactSource("announcements", "0.1.0"),
|
|
},
|
|
]);
|
|
}
|
|
|
|
it("exposes no path shape beyond the two identity segments", async () => {
|
|
const { app } = await installWithNeighbours();
|
|
const base = `${MODULE_UI_ASSET_PREFIX}/announcements`;
|
|
|
|
const unreachable = [
|
|
// Source maps: real files, sitting right next to the published artifact.
|
|
`${base}/0.1.0.map`,
|
|
`${base}/0.1.0/index.js.map`,
|
|
`${base}/0.1.0/dist/ui/index.js.map`,
|
|
// Sibling bundle output and package-root files.
|
|
`${base}/0.1.0/internal-chunk.js`,
|
|
`${base}/0.1.0/package.json`,
|
|
`${base}/0.1.0/ekklesia.module.json`,
|
|
`${base}/0.1.0/.env`,
|
|
// A trailing segment of any kind, including an empty one.
|
|
`${base}/0.1.0/`,
|
|
`${base}/0.1.0/anything`,
|
|
// Fewer segments than the route declares. The collection root itself is deliberately absent
|
|
// from this list: it is the authenticated inventory (ADR 0026) rather than a 404, and is
|
|
// asserted separately below.
|
|
base,
|
|
];
|
|
|
|
for (const url of unreachable) {
|
|
const response = await app.request(url);
|
|
expect(response.status, url).toBe(404);
|
|
// Nothing leaks: not the artifact, not a neighbour, not a path.
|
|
const body = await response.text();
|
|
expect(body, url).not.toContain("secret");
|
|
expect(body, url).not.toContain("internal");
|
|
expect(body, url).not.toContain("mount");
|
|
}
|
|
});
|
|
|
|
it("serves no artifact bytes from the collection root, which is the authenticated inventory", async () => {
|
|
const { app } = await installWithNeighbours();
|
|
|
|
// `/api/v1/module-ui` lists what a host may mount (ADR 0026) and requires
|
|
// `core.modules.ui.read`; this installation's caller is anonymous, so it is refused. Either way
|
|
// it never yields a byte of any artifact or neighbouring file.
|
|
const response = await app.request(MODULE_UI_ASSET_PREFIX);
|
|
const body = await response.text();
|
|
|
|
expect(response.status).toBe(403);
|
|
expect(body).not.toContain("mount");
|
|
expect(body).not.toContain("secret");
|
|
expect(body).not.toContain("internal");
|
|
});
|
|
|
|
it("treats a traversal attempt as an ordinary failed lookup", async () => {
|
|
const { app } = await installWithNeighbours();
|
|
|
|
const attempts = [
|
|
`${MODULE_UI_ASSET_PREFIX}/..%2F..%2F..%2Fetc%2Fpasswd/0.1.0`,
|
|
`${MODULE_UI_ASSET_PREFIX}/announcements/..%2F..%2Fpackage.json`,
|
|
`${MODULE_UI_ASSET_PREFIX}/announcements%2Fdist%2Fui%2Findex.js/0.1.0`,
|
|
`${MODULE_UI_ASSET_PREFIX}/${encodeURIComponent("../announcements")}/0.1.0`,
|
|
`${MODULE_UI_ASSET_PREFIX}/announcements/${encodeURIComponent("0.1.0/../../.env")}`,
|
|
`${MODULE_UI_ASSET_PREFIX}/announcements/${encodeURIComponent("0.1.0 .map")}`,
|
|
];
|
|
|
|
for (const url of attempts) {
|
|
const response = await app.request(url);
|
|
expect(response.status, url).toBe(404);
|
|
expect(await response.text(), url).not.toContain("secret");
|
|
}
|
|
});
|
|
|
|
it("keeps an absolute filesystem path from ever becoming a request parameter that works", async () => {
|
|
const { app, modules } = await install([
|
|
{ id: "announcements", ui: artifactSource("announcements", "0.1.0") },
|
|
]);
|
|
const artifactPath = (modules[0] as DiscoveredModule).entrypoints.ui as string;
|
|
|
|
const response = await app.request(
|
|
`${MODULE_UI_ASSET_PREFIX}/${encodeURIComponent(artifactPath)}/0.1.0`,
|
|
);
|
|
|
|
expect(response.status).toBe(404);
|
|
});
|
|
});
|
|
|
|
describe("module UI artifact publication — request surface", () => {
|
|
it("is read-only: no other method is routed at all", async () => {
|
|
const { app } = await install([
|
|
{ id: "announcements", ui: artifactSource("announcements", "0.1.0") },
|
|
]);
|
|
const url = moduleUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" });
|
|
|
|
for (const method of ["POST", "PUT", "PATCH", "DELETE"]) {
|
|
const response = await app.request(url, { method });
|
|
expect(response.status, method).toBe(404);
|
|
expect(await response.json(), method).toEqual({ code: "NOT_FOUND" });
|
|
}
|
|
});
|
|
|
|
it("answers HEAD with the full header contract and no body", async () => {
|
|
const { app } = await install([
|
|
{ id: "announcements", ui: artifactSource("announcements", "0.1.0") },
|
|
]);
|
|
const url = moduleUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" });
|
|
|
|
const response = await app.request(url, { method: "HEAD" });
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers.get("content-type")).toBe(MODULE_UI_CONTENT_TYPE);
|
|
expect(response.headers.get("etag")).toBe((await app.request(url)).headers.get("etag"));
|
|
expect(await response.text()).toBe("");
|
|
});
|
|
|
|
it("fails closed on malformed percent-encoding instead of throwing", async () => {
|
|
const { app } = await install([
|
|
{ id: "announcements", ui: artifactSource("announcements", "0.1.0") },
|
|
]);
|
|
|
|
for (const url of [
|
|
`${MODULE_UI_ASSET_PREFIX}/%zz/0.1.0`,
|
|
`${MODULE_UI_ASSET_PREFIX}/announcements/%E0%A4%A`,
|
|
`${MODULE_UI_ASSET_PREFIX}/a%00b/0.1.0`,
|
|
]) {
|
|
const response = await app.request(url);
|
|
// An ordinary refused lookup, never a 500 that would reveal a decoding failure.
|
|
expect(response.status, url).toBe(404);
|
|
expect(await response.json(), url).toEqual({ code: "MODULE_UI_NOT_PUBLISHED" });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("module UI artifact publication — artifact changed after discovery", () => {
|
|
it("reports a sanitized 503 when the validated artifact has been deleted", async () => {
|
|
const { app, modulesDirectory } = await install([
|
|
{ id: "announcements", ui: artifactSource("announcements", "0.1.0") },
|
|
]);
|
|
const url = moduleUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" });
|
|
expect((await app.request(url)).status).toBe(200);
|
|
|
|
await rm(path.join(modulesDirectory, "announcements/dist/ui/index.js"));
|
|
|
|
const response = await app.request(url);
|
|
|
|
expect(response.status).toBe(503);
|
|
expect(await response.json()).toEqual({ code: "MODULE_UI_ARTIFACT_UNAVAILABLE" });
|
|
});
|
|
|
|
it("leaks no filesystem detail when the artifact is unreadable", async () => {
|
|
const { app, modulesDirectory } = await install([
|
|
{ id: "announcements", ui: artifactSource("announcements", "0.1.0") },
|
|
]);
|
|
const artifact = path.join(modulesDirectory, "announcements/dist/ui/index.js");
|
|
await rm(artifact);
|
|
// Replaced by a directory: a different `errno` (EISDIR) on the same validated path.
|
|
await mkdir(artifact);
|
|
|
|
const response = await app.request(
|
|
moduleUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" }),
|
|
);
|
|
const body = await response.text();
|
|
|
|
expect(response.status).toBe(503);
|
|
expect(body).not.toContain(modulesDirectory);
|
|
expect(body).not.toContain(tmpdir());
|
|
expect(body).not.toContain("dist");
|
|
expect(body).not.toContain("EISDIR");
|
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
|
});
|
|
|
|
it("never falls back to a reconstructed path when the exact artifact is gone", async () => {
|
|
const { app, modulesDirectory } = await install([
|
|
{ id: "announcements", ui: artifactSource("announcements", "0.1.0") },
|
|
]);
|
|
const moduleDirectory = path.join(modulesDirectory, "announcements");
|
|
await rm(path.join(moduleDirectory, "dist/ui/index.js"));
|
|
// Plausible neighbours a path-guessing implementation might land on instead.
|
|
await writeFile(path.join(moduleDirectory, "dist/ui/index.mjs"), "export default {};\n");
|
|
await writeFile(path.join(moduleDirectory, "dist/index.js"), "export default {};\n");
|
|
|
|
const response = await app.request(
|
|
moduleUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" }),
|
|
);
|
|
|
|
expect(response.status).toBe(503);
|
|
});
|
|
});
|
|
|
|
describe("module UI artifact URL generation", () => {
|
|
it("agrees with the route it is generated for", async () => {
|
|
const { app } = await install([
|
|
{ id: "announcements", ui: artifactSource("announcements", "1.4.2"), version: "1.4.2" },
|
|
]);
|
|
|
|
const url = moduleUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" });
|
|
|
|
expect(url).toBe(`${MODULE_UI_ASSET_PREFIX}/announcements/1.4.2`);
|
|
expect((await app.request(url)).status).toBe(200);
|
|
});
|
|
|
|
it("is root-relative, so a host resolves it against whichever API origin it is configured for", () => {
|
|
const url = moduleUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" });
|
|
|
|
expect(url.startsWith("/")).toBe(true);
|
|
expect(new URL(url, "https://ekklesia.example.test").href).toBe(
|
|
`https://ekklesia.example.test${MODULE_UI_ASSET_PREFIX}/announcements/1.4.2`,
|
|
);
|
|
// A split-origin host resolves the identical value against its own API base.
|
|
expect(new URL(url, "http://127.0.0.1:3000").href).toBe(
|
|
`http://127.0.0.1:3000${MODULE_UI_ASSET_PREFIX}/announcements/1.4.2`,
|
|
);
|
|
});
|
|
|
|
it("encodes hostile input instead of letting it restructure the URL", () => {
|
|
const hostile = [
|
|
"../../etc/passwd",
|
|
"announcements/dist/ui/index.js",
|
|
"announcements?x=1",
|
|
"announcements#fragment",
|
|
"announcements ",
|
|
"a b",
|
|
];
|
|
|
|
for (const value of hostile) {
|
|
// Still exactly the prefix's own segments plus one for the module and one for the version.
|
|
const expectedSegments = MODULE_UI_ASSET_PREFIX.split("/").length + 2;
|
|
const moduleUrl = moduleUiArtifactUrl({ moduleId: value, version: "0.1.0" });
|
|
expect(moduleUrl.split("/"), value).toHaveLength(expectedSegments);
|
|
expect(moduleUrl.includes("?"), value).toBe(false);
|
|
expect(moduleUrl.includes("#"), value).toBe(false);
|
|
|
|
const versionUrl = moduleUiArtifactUrl({ moduleId: "announcements", version: value });
|
|
expect(versionUrl.split("/"), value).toHaveLength(expectedSegments);
|
|
expect(versionUrl.includes("?"), value).toBe(false);
|
|
expect(versionUrl.includes("#"), value).toBe(false);
|
|
}
|
|
});
|
|
});
|
|
|
|
interface HostContainer {
|
|
readonly children: { readonly length: number };
|
|
querySelector(selectors: string): HostContainer | null;
|
|
readonly shadowRoot: { querySelector(selectors: string): { textContent: string } | null } | null;
|
|
}
|
|
|
|
describe("module UI artifact publication — the generic loader consumes what is published", () => {
|
|
/**
|
|
* Node cannot `import()` an `http:` URL, so the two halves of the chain are proven separately and
|
|
* honestly: the tests above prove the route publishes the exact artifact bytes with ES module
|
|
* JavaScript semantics over real HTTP routing, and this one takes **those very bytes, as returned
|
|
* by the route**, and puts them through the real `@ekklesia/module-ui-host` loader as a `data:`
|
|
* URL — a genuine module URL, the same technique ADR 0024's own loader tests use. Nothing here
|
|
* reads the artifact from the filesystem.
|
|
*/
|
|
async function publishedModuleUrl(app: Hono, moduleId: string, version: string): Promise<string> {
|
|
const response = await app.request(moduleUiArtifactUrl({ moduleId, version }));
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers.get("content-type")).toBe(MODULE_UI_CONTENT_TYPE);
|
|
const bytes = Buffer.from(await response.arrayBuffer());
|
|
return `data:text/javascript;base64,${bytes.toString("base64")}`;
|
|
}
|
|
|
|
/**
|
|
* A real host-owned container, because since ADR 0040 the loader builds an isolated Shadow DOM
|
|
* root inside whatever it is given. The container's type is read back off `loadModuleUi` itself
|
|
* rather than written as `Element`, because `apps/api` compiles against Node libs only — naming a
|
|
* DOM type here would be the same browser/server confusion this route exists to keep apart.
|
|
*/
|
|
type LoaderContainer = Parameters<typeof loadModuleUi>[0]["container"];
|
|
|
|
/**
|
|
* `happy-dom` used as a library rather than as this suite's environment: the loader now builds a
|
|
* real isolated root inside the container, and installing a DOM environment here would replace
|
|
* the module runner's resolution for a server-side suite that dynamically imports real module
|
|
* entrypoints.
|
|
*/
|
|
function containerSink(): HostContainer {
|
|
return new Window().document.createElement("div") as unknown as HostContainer;
|
|
}
|
|
|
|
/** What the mounted artifact rendered, read through its open isolated root. */
|
|
function mountedText(container: HostContainer): string {
|
|
const shadowHost = container.querySelector("[data-ekklesia-module-ui]");
|
|
return (
|
|
shadowHost?.shadowRoot?.querySelector("[data-ekklesia-module-ui-root]")?.textContent ?? ""
|
|
);
|
|
}
|
|
|
|
function hostContext() {
|
|
return {
|
|
api: { request: async () => new Response(null, { status: 200 }) },
|
|
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
|
display: undefined,
|
|
moduleId: "announcements",
|
|
permissions: { has: () => false },
|
|
principal: { kind: "anonymous" as const },
|
|
realtime: { subscribe: () => ({ close: () => undefined }) },
|
|
};
|
|
}
|
|
|
|
it("mounts and unmounts an artifact obtained from the publication route", async () => {
|
|
const { app } = await install([
|
|
{ id: "announcements", ui: artifactSource("announcements", "1.4.2"), version: "1.4.2" },
|
|
]);
|
|
const url = await publishedModuleUrl(app, "announcements", "1.4.2");
|
|
const container = containerSink();
|
|
|
|
const mounted = await loadModuleUi({
|
|
container: container as unknown as LoaderContainer,
|
|
context: hostContext(),
|
|
source: { moduleId: "announcements", url, version: "1.4.2" },
|
|
});
|
|
|
|
expect(mountedText(container)).toBe("announcements:mounted");
|
|
await mounted.unmount();
|
|
expect(container.children).toHaveLength(0);
|
|
});
|
|
|
|
it("still rejects the published artifact when the host expects a different identity", async () => {
|
|
const { app } = await install([
|
|
{ id: "announcements", ui: artifactSource("announcements", "1.4.2"), version: "1.4.2" },
|
|
]);
|
|
const url = await publishedModuleUrl(app, "announcements", "1.4.2");
|
|
|
|
await expect(
|
|
loadModuleUi({
|
|
container: containerSink() as unknown as LoaderContainer,
|
|
context: hostContext(),
|
|
source: { moduleId: "announcements", url, version: "9.9.9" },
|
|
}),
|
|
).rejects.toMatchObject({ code: "MODULE_UI_IDENTITY_MISMATCH" });
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Contribution artifact publication (ADR 0041), on exactly the same terms as every other declared
|
|
* artifact: three lookup keys, one exact registry match, one already-validated canonical path.
|
|
*/
|
|
describe("module contribution artifact publication", () => {
|
|
const signage = {
|
|
capabilities: [{ id: "signage.slide-renderer", version: "1.2.0" }],
|
|
id: "signage",
|
|
};
|
|
const contributionSource = "export default { contributed: true };\n";
|
|
const announcements = (capabilityRange = "^1.0.0") => ({
|
|
contributions: [
|
|
{
|
|
capability: "signage.slide-renderer",
|
|
capabilityRange,
|
|
localName: "signage-slide",
|
|
source: contributionSource,
|
|
},
|
|
],
|
|
id: "announcements",
|
|
});
|
|
const contributionUrl = moduleContributionArtifactUrl(
|
|
{ moduleId: "announcements", version: "0.1.0" },
|
|
"announcements.signage-slide",
|
|
);
|
|
|
|
it("serves the declared contribution artifact byte for byte", async () => {
|
|
const { app } = await install([signage, announcements()]);
|
|
|
|
const response = await app.request(contributionUrl);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers.get("content-type")).toBe(MODULE_UI_CONTENT_TYPE);
|
|
expect(response.headers.get("cache-control")).toBe(MODULE_UI_CACHE_CONTROL);
|
|
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
|
expect(await response.text()).toBe(contributionSource);
|
|
});
|
|
|
|
it("builds a URL that cannot select a neighbouring file", () => {
|
|
expect(contributionUrl).toBe(
|
|
"/api/v1/module-ui/announcements/0.1.0/contributions/announcements.signage-slide",
|
|
);
|
|
// The contribution id is one segment's worth of text, always — a value that needed encoding
|
|
// simply fails to resolve.
|
|
expect(
|
|
moduleContributionArtifactUrl(
|
|
{ moduleId: "announcements", version: "0.1.0" },
|
|
"../../../etc/passwd",
|
|
),
|
|
).toBe("/api/v1/module-ui/announcements/0.1.0/contributions/..%2F..%2F..%2Fetc%2Fpasswd");
|
|
});
|
|
|
|
it.each([
|
|
["an unknown contribution id", "announcements.nope"],
|
|
["another module's contribution id", "signage.slide-renderer"],
|
|
["a path traversal", "../../../etc/passwd"],
|
|
["the artifact's source map", "announcements.signage-slide.map"],
|
|
])("refuses %s with the same indistinguishable 404", async (_label, contributionId) => {
|
|
const { app } = await install([signage, announcements()]);
|
|
|
|
const response = await app.request(
|
|
moduleContributionArtifactUrl(
|
|
{ moduleId: "announcements", version: "0.1.0" },
|
|
contributionId,
|
|
),
|
|
);
|
|
|
|
expect(response.status).toBe(404);
|
|
expect(await response.json()).toEqual({ code: "MODULE_UI_NOT_PUBLISHED" });
|
|
});
|
|
|
|
it("refuses a version the platform does not have installed", async () => {
|
|
const { app } = await install([signage, announcements()]);
|
|
|
|
const response = await app.request(
|
|
moduleContributionArtifactUrl(
|
|
{ moduleId: "announcements", version: "9.9.9" },
|
|
"announcements.signage-slide",
|
|
),
|
|
);
|
|
|
|
expect(response.status).toBe(404);
|
|
});
|
|
|
|
it("publishes nothing when no installed module provides the target capability", async () => {
|
|
const { app } = await install([announcements()]);
|
|
|
|
expect((await app.request(contributionUrl)).status).toBe(404);
|
|
});
|
|
|
|
it("publishes nothing when the provider is outside the declared compatible range", async () => {
|
|
const { app } = await install([signage, announcements("^2.0.0")]);
|
|
|
|
expect((await app.request(contributionUrl)).status).toBe(404);
|
|
});
|
|
|
|
it("keeps the sibling source map and the module's own directory unreachable", async () => {
|
|
const { app } = await install([signage, announcements()]);
|
|
|
|
for (const suffix of [".map", "/index.js.map", "/../ui/index.js"]) {
|
|
const response = await app.request(`${contributionUrl}${suffix}`);
|
|
expect(response.status).toBe(404);
|
|
}
|
|
});
|
|
});
|