One sign-in drive for every e2e, the public surface and visibility in the rights matrix, and the stack gates in validate

e2e-login-flow and e2e-revocation now share the matrix's driveLogin, which counts a sign-in only once the app is really reached and the app install is asserted; their own loops could report "shell reached" while still on the picker page. The rights matrix gains a public-surface group (allow-listed endpoints answer 200, non-public paths 404, status codes only) and a visibility group (an event with a meeting point and a participant switched public and private by its owner: event, meeting point, attendees collection and catalog listing follow). validate.ts probes the stack and runs test:e2e-rights and test:e2e-revocation as layers, reported SKIPPED when the stack is down and never counted as passed.
This commit is contained in:
Sylvain Duchesne
2026-10-13 18:11:34 +02:00
parent 88f23b7023
commit 9381c206df
5 changed files with 437 additions and 179 deletions
+139 -17
View File
@@ -2,8 +2,8 @@
/**
* validate.ts — the Festipod validation matrix.
*
* SCOPE (2026-10-09, after the ActivityPods-first switch). Two layers are in
* the gate:
* SCOPE (2026-10-09, after the ActivityPods-first switch; e2e probes added
* 2026-10-13). Four layers are in the gate:
*
* (a) Festipod @ui — the UI layer. The `@ui` tag does not exist in this repo;
* the layer is exactly the complement of the polyfill/broker tags, and its
@@ -23,6 +23,25 @@
* Requires the stack to be UP (the app server and Chromium are started
* here); an unreachable stack fails the layer rather than skipping it.
*
* (c) Festipod e2e rights matrix — `bun run test:e2e-rights`: three fresh
* users plus anonymous, every feature with positive AND negative access
* lines (public surface, public reads, refused writes, profile privacy,
* catalog and public intent, discovery, join/leave and counters,
* visibility toggles). Exit 0 only when every hard line holds.
*
* (d) Festipod e2e revocation — `bun run test:e2e-revocation`: the app
* notices its registration being revoked on the pod, logs the user out,
* and recovers through consent.
*
* (c) and (d) drive the browser through the pod's login and consent, so
* they need the WHOLE local stack, the app served at APP_URL included
* (the OIDC redirect_uri is registered for that origin only). They are
* STACK-GATED: the stack is probed first; when any part is unreachable
* both are SKIPPED — loudly, the unreachable URL named in the matrix and
* in the verdict, never silently — and when it is up they run, and a
* non-zero exit fails the layer. A fully-down stack still fails
* `validate` through (b), which never skips.
*
* SUSPENDED, NOT RUN HERE (documented skip, code preserved — nothing deleted):
* - `@data`, `@e2e`, `@multibrowser`, `@shared-wallet`, `@smoke` scenarios are
* polyfill/broker-bound: they sign the shared NextGraph wallet through the
@@ -34,7 +53,8 @@
* `rule_tests-validate-festipod-not-the-sdk`).
*
* A smoke layer that cannot reach its stack FAILS, loudly — it never silently
* skips. Exit code is non-zero if any layer fails.
* skips. A skipped e2e probe never counts as passed: it is reported as
* SKIPPED with its reason. Exit code is non-zero if any layer fails.
*/
import { spawnSync } from "child_process";
@@ -51,12 +71,34 @@ const APODS_TAGS = "@e2e-activitypods and not @suspendu";
const TIMEOUT_UI_MS = 15 * 60 * 1000; // 15 min
const TIMEOUT_APODS_MS = 10 * 60 * 1000; // 10 min
const TIMEOUT_RIGHTS_MS = 25 * 60 * 1000; // 25 min (the matrix bounds itself at 15 min of UI drives)
const TIMEOUT_REVOCATION_MS = 15 * 60 * 1000; // 15 min (the probe bounds itself at 10 min)
/** The local stack, with the same defaults and env overrides as the e2e scripts. */
const strip = (u: string): string => u.replace(/\/+$/, "");
const POD_PROVIDER = strip(process.env.POD_PROVIDER_URL || "http://localhost:3000");
const POD_FRONT = strip(process.env.POD_FRONTEND_URL || "http://localhost:5000");
const BACKEND = strip(process.env.BACKEND_URL || "http://localhost:3003");
const APP = strip(process.env.APP_URL || "http://localhost:4000");
const FUSEKI = strip(process.env.FUSEKI_URL || "http://localhost:3030");
/** What the browser-driven e2e probes need answering 200 — every piece of the local stack. */
const E2E_STACK: Array<[string, string]> = [
// The triplestore both servers store into: with it down they still answer their static routes
// (observed 2026-10-13: the pod's OpenID configuration 200 while every sign-up failed).
["triplestore (fuseki)", `${FUSEKI}/`],
["pod provider", `${POD_PROVIDER}/.well-known/openid-configuration`],
["pod frontend (login + consent)", `${POD_FRONT}/`],
["Festipod backend", `${BACKEND}/app`],
["Festipod app at APP_URL (the only registered OIDC redirect origin)", `${APP}/`],
];
// ─── Helpers ───────────────────────────────────────────────────────────────
interface StepResult {
label: string;
status: "passed" | "failed" | "error";
status: "passed" | "failed" | "error" | "skipped";
/** Failure lines; for a skipped layer, why it was skipped. */
failures: string[];
exitCode: number;
durationMs: number;
@@ -75,14 +117,20 @@ function cucumberArgs(config: string, tags: string): string[] {
];
}
function runStep(label: string, args: string[], timeoutMs: number): StepResult {
function runStep(
label: string,
args: string[],
timeoutMs: number,
command = "node",
extract: (output: string) => string[] = extractFailures,
): StepResult {
const t0 = Date.now();
console.log(`\n${"═".repeat(64)}`);
console.log(`▶ ${label}`);
console.log(` node ${args.join(" ")}`);
console.log(` ${command} ${args.join(" ")}`);
console.log(`${"═".repeat(64)}`);
const result = spawnSync("node", args, {
const result = spawnSync(command, args, {
cwd: REPO_DIR,
env: process.env,
encoding: "utf-8",
@@ -107,12 +155,68 @@ function runStep(label: string, args: string[], timeoutMs: number): StepResult {
return {
label,
status: (result.status ?? 1) === 0 ? "passed" : "failed",
failures: extractFailures(combined),
failures: extract(combined),
exitCode: result.status ?? 1,
durationMs,
};
}
/** A stack-gated layer that did not run: reported, never counted as passed. */
function skippedStep(label: string, reasons: string[]): StepResult {
console.log(`\n${"═".repeat(64)}`);
console.log(`⏭️ ${label} — SKIPPED: the local stack is down`);
for (const r of reasons) console.log(` ↳ ${r}`);
console.log(`${"═".repeat(64)}`);
return { label, status: "skipped", failures: reasons, exitCode: 0, durationMs: 0 };
}
/** Every part of the e2e stack that does not answer 200, as "name: url → status". Empty when the stack is up. */
async function e2eStackDown(): Promise<string[]> {
const down: string[] = [];
for (const [name, url] of E2E_STACK) {
let status: number | string;
try {
status = (await fetch(url, { signal: AbortSignal.timeout(5000) })).status;
} catch (e) {
status = `unreachable (${e instanceof Error ? e.message : String(e)})`;
}
if (status !== 200) down.push(`${name}: ${url} → ${typeof status === "number" ? `HTTP ${status}` : status}`);
}
return down;
}
/** The rights matrix's verdict lines: every failed hard line, any notice, and the verdict. */
function extractMatrixFailures(output: string): string[] {
return output
.split("\n")
.map((l) => l.trim())
.filter((l) => /^(FAILED: |VERDICT: FAIL|!!! (?!NOTICE:)\S)/.test(l) || l.includes("RIGHTS MATRIX — ABORTED"));
}
/**
* The revocation probe's JSON report (pretty-printed: its top-level braces are
* the only ones at column 0): the phases that did not hold, and unexpected ≥400s.
*/
function extractRevocationFailures(output: string): string[] {
const start = output.lastIndexOf("\n{\n") + 1;
const end = output.indexOf("\n}", start);
try {
const report = JSON.parse(output.slice(start, end + 2)) as Record<string, unknown>;
if (report["ok"] === true) return [];
const failures: string[] = [];
for (const phase of ["positive", "negative", "recovered"]) {
const p = report[phase] as { ok?: unknown } | undefined;
if (p && p.ok !== true) failures.push(`phase ${phase}: ok=${String(p.ok)}`);
}
const fatal = report["fatal4xx"];
if (Array.isArray(fatal) && fatal.length) failures.push(`${fatal.length} unexpected ≥400 response(s)`);
if (typeof report["fatal"] === "string") failures.push(`aborted: ${report["fatal"]}`);
return failures.length ? failures : ["ok=false"];
} catch {
return ["no JSON report found in the output"];
}
}
/** The failure lines worth surfacing: failed scenario names and the summary. */
function extractFailures(output: string): string[] {
const lines = output.split("\n");
@@ -139,31 +243,49 @@ function printMatrix(steps: StepResult[]): void {
console.log("╚══════════════════════════════════════════════════════════╝\n");
const maxLabel = Math.max(...steps.map((s) => s.label.length));
for (const step of steps) {
const icon = step.status === "passed" ? "✅" : step.status === "failed" ? "❌" : "⚠️ ";
console.log(` ${icon} ${step.label.padEnd(maxLabel + 2)} [${fmtDuration(step.durationMs)}]`);
const icon = step.status === "passed" ? "✅" : step.status === "failed" ? "❌" : step.status === "skipped" ? "⏭️ " : "⚠️ ";
const time = step.status === "skipped" ? "SKIPPED — local stack down" : fmtDuration(step.durationMs);
console.log(` ${icon} ${step.label.padEnd(maxLabel + 2)} [${time}]`);
for (const f of step.failures) console.log(` ↳ ${f}`);
}
console.log("");
console.log(" ℹ️ @data/@e2e/@multibrowser/@shared-wallet/@smoke : suspendus (workstream NextGraph).");
const failed = steps.filter((s) => s.status !== "passed");
console.log(failed.length === 0
? " 🟢 ALL LAYERS PASSED"
: ` 🔴 ${failed.length} LAYER(S) FAILED: ${failed.map((s) => s.label).join(", ")}`);
const failed = steps.filter((s) => s.status === "failed" || s.status === "error");
const skipped = steps.filter((s) => s.status === "skipped");
if (skipped.length) {
console.log(` 🟡 ${skipped.length} LAYER(S) SKIPPED (local stack down — NOT validated): ${skipped.map((s) => s.label).join(", ")}`);
}
console.log(failed.length > 0
? ` 🔴 ${failed.length} LAYER(S) FAILED: ${failed.map((s) => s.label).join(", ")}`
: skipped.length
? " 🟢 NO LAYER FAILED — but the skipped layers above were not run"
: " 🟢 ALL LAYERS PASSED");
console.log(` ⏱ Total: ${fmtDuration(steps.reduce((sum, s) => sum + s.durationMs, 0))}`);
console.log("");
}
// ─── Main ──────────────────────────────────────────────────────────────────
function main(): void {
async function main(): Promise<void> {
console.log(`🔍 Festipod — validation (repo: ${REPO_DIR})`);
const steps: StepResult[] = [];
steps.push(runStep("festipod:@ui", cucumberArgs("cucumber.json", UI_TAGS), TIMEOUT_UI_MS));
steps.push(runStep("festipod:@e2e-activitypods", cucumberArgs("cucumber.smoke.json", APODS_TAGS), TIMEOUT_APODS_MS));
// Stack-gated e2e probes: one stack probe, then both run or both are skipped (loudly).
const down = await e2eStackDown();
if (down.length) {
const reasons = [...down, "start the stack: concept `data-layer`, `cookbook_local-activitypods-platform.md`"];
steps.push(skippedStep("festipod:e2e-rights", reasons));
steps.push(skippedStep("festipod:e2e-revocation", reasons));
} else {
steps.push(runStep("festipod:e2e-rights", ["run", "test:e2e-rights"], TIMEOUT_RIGHTS_MS, "bun", extractMatrixFailures));
steps.push(runStep("festipod:e2e-revocation", ["run", "test:e2e-revocation"], TIMEOUT_REVOCATION_MS, "bun", extractRevocationFailures));
}
printMatrix(steps);
process.exit(steps.some((s) => s.status !== "passed") ? 1 : 0);
process.exit(steps.some((s) => s.status === "failed" || s.status === "error") ? 1 : 0);
}
main();
void main();