From 38e3278429bd9758221f2e4a08d22f9d73dea8df Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 16 Sep 2026 16:34:39 -0700 Subject: [PATCH] oskiewar consent: tests for the wall, and the reason it is still shut The consent endpoint shipped in eb7713a485 and deployed, but REGARDE_GATEWAY_URL and REGARDE_SUBJECT_SALT were never provisioned, so every ask fail-closes at 503. Existing coverage stopped at readScope -- how a player's answer is read -- and said nothing about the hop across the gateway, which is where the wall either holds or quietly stops being one. Two files: oskiewar-consent-gateway.test.mjs, 16 offline tests against a local stub desk. Auth is mocked so the branches past the door are reachable without a network. Every refusal is asserted rather than assumed: unset gateway, unset salt, desk 500, desk unreachable, an answer with no outcome. The three that matter most are that no raw Auth0 subject crosses the wire, that the pseudonym changes with the salt so grants cannot be correlated, and that allow still returns capability: null -- the wall is proven, the worker behind it is not built. oskiewar-consent-e2e.test.mjs, 8 tests over real HTTP with a real bearer token from ~/.ac-token. It deliberately does not assert 503: that is only true while the desk is unset, and the wall has to be safe either way. What it asserts is the promise that holds in every configuration -- no request comes back holding a capability, no response echoes the subject -- and it prints which state it found, so a run doubles as a status check on whether the desk is reachable yet. Both pass: 26 offline, 8 live against production. npm run test:consent supplies --experimental-test-module-mocks so the flag is not something to remember. The offline file is run directly rather than under --test on purpose: the endpoint's console.log of a gateway status corrupts the test runner's serialized IPC channel and the run dies with "Unable to deserialize cloned data". Committed through a private GIT_INDEX_FILE because a concurrent session held .git/index.lock throughout. Co-Authored-By: Claude Opus 5 (1M context) --- package.json | 2 + system/tests/oskiewar-consent-e2e.test.mjs | 160 ++++++++++++ .../tests/oskiewar-consent-gateway.test.mjs | 235 ++++++++++++++++++ 3 files changed, 397 insertions(+) create mode 100644 system/tests/oskiewar-consent-e2e.test.mjs create mode 100644 system/tests/oskiewar-consent-gateway.test.mjs diff --git a/package.json b/package.json index daae7f5d81..d0d4e6be19 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,8 @@ "papers:publish": "node papers/cli.mjs publish", "mediascholar": "node papers/bin/mediascholar.mjs", "test:mediascholar": "node --test papers/test/mediascholar.test.mjs slab/test/prox-worker.test.mjs slab/test/mediascholar-credential-proxy.test.mjs slab/test/mediascholar-mcp-integration.test.mjs slab/test/mediascholar-status.test.mjs", + "test:consent": "node system/tests/oskiewar-consent.test.mjs && node --experimental-test-module-mocks --disable-warning=ExperimentalWarning system/tests/oskiewar-consent-gateway.test.mjs", + "test:consent:e2e": "node system/tests/oskiewar-consent-e2e.test.mjs", "user": "f() { echo -n \"https://cloud.digitalocean.com/spaces/user-aesthetic-computer?path=\"; curl -s \"https://aesthetic.computer/user?from=$1\" | jq -r '.sub'; }; f", "filter": "cd shared; node --experimental-repl-await -i -e \"import('./filter.mjs').then(m => global.filter = m.filter)\"", "chat": "echo '\nšŸ’¬ Chat\n';", diff --git a/system/tests/oskiewar-consent-e2e.test.mjs b/system/tests/oskiewar-consent-e2e.test.mjs new file mode 100644 index 0000000000..c426a6ba40 --- /dev/null +++ b/system/tests/oskiewar-consent-e2e.test.mjs @@ -0,0 +1,160 @@ +// The consent wall, over the wire, as a signed-in player meets it. +// +// `oskiewar-consent-gateway.test.mjs` stubs auth and the desk to prove the +// branches. This file proves the deployed thing: a real Auth0 bearer token +// against a real host, asserting the contract that must hold no matter how +// REGARDE is configured on the other side. +// +// The invariant under test is deliberately not "the wall returns 503". That is +// only true while REGARDE_GATEWAY_URL is unset, and the point of the wall is +// that it is safe in both states. What is asserted instead is the promise the +// endpoint actually makes: **no request, in any configuration, comes back +// holding a capability**, and no response echoes the player's Auth0 subject. +// The suite reports which state it observed so a run doubles as a status check. +// +// Auth: `~/.ac-token` (written by `node tezos/ac-login.mjs`), or AC_TOKEN. +// Target: AC_CONSENT_URL, default production. +// +// Run: npm run test:consent:e2e +import assert from "node:assert/strict"; +import test, { before } from "node:test"; +import { readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +const URL_UNDER_TEST = process.env.AC_CONSENT_URL + ?? "https://oskiewar.com/api/oskiewar-consent"; + +const minimal = { + source: ["appearance"], + outputs: ["portrait"], + distribution: ["private_preview"], + retention: "bound_to_purpose_scope", +}; + +let token; +let subject; + +// The token file is JSON written by ac-login; tolerate a bare token too, since +// AC_TOKEN is likely to be pasted rather than generated. +async function loadToken() { + if (process.env.AC_TOKEN) return process.env.AC_TOKEN.trim(); + try { + const raw = await readFile(join(homedir(), ".ac-token"), "utf8"); + const trimmed = raw.trim(); + if (!trimmed.startsWith("{")) return trimmed; + const parsed = JSON.parse(trimmed); + return parsed.access_token ?? parsed.accessToken ?? parsed.token ?? null; + } catch { return null; } +} + +before(async () => { + token = await loadToken(); + if (!token) { + console.log("… no ~/.ac-token or AC_TOKEN — signed-in cases will skip."); + console.log(" get one with: node tezos/ac-login.mjs"); + return; + } + // Resolve the subject so the "never echoed" assertion has something real to + // look for rather than a guess at the shape of it. + try { + const info = await fetch("https://hi.aesthetic.computer/userinfo", { + headers: { Authorization: `Bearer ${token}` }, + }); + if (info.ok) { + subject = (await info.json())?.sub; + console.log(`→ signed in as ${subject}`); + } else { + console.log(`… token did not resolve (userinfo ${info.status}); it may be stale.`); + console.log(" refresh with: node tezos/ac-login.mjs"); + token = null; + } + } catch (err) { + console.log(`… could not reach Auth0: ${err.message}`); + token = null; + } +}); + +const post = (body, headers = {}) => fetch(URL_UNDER_TEST, { + method: "POST", + headers: { "Content-Type": "application/json", ...headers }, + body: JSON.stringify(body), +}); + +const signed = (body) => post(body, { Authorization: `Bearer ${token}` }); + +test("the endpoint is deployed and answering", async () => { + const response = await post(minimal); + assert.notEqual(response.status, 404, + `${URL_UNDER_TEST} is not routed — the function did not deploy`); +}); + +test("a stranger is turned away", async () => { + const response = await post(minimal); + assert.equal(response.status, 401); + const answer = await response.json(); + assert.equal(answer.capability, null); +}); + +test("a garbled token is turned away", async () => { + const response = await post(minimal, { Authorization: "Bearer not-a-token" }); + assert.equal(response.status, 401); +}); + +test("a signed-in player's malformed ask is refused", async (t) => { + if (!token) return t.skip("no credentials"); + const response = await signed({ ...minimal, outputs: ["hologram"] }); + assert.equal(response.status, 400); + const answer = await response.json(); + assert.equal(answer.capability, null); + assert.match(answer.message, /thing to make/); +}); + +test("an empty ask is refused rather than read as blanket permission", async (t) => { + if (!token) return t.skip("no credentials"); + const response = await signed({}); + assert.equal(response.status, 400); + assert.equal((await response.json()).capability, null); +}); + +// The load-bearing one. Whatever REGARDE is doing, this must not hand back a +// capability — and it must not be a 500 either, because an unhandled throw is +// not a refusal, it is a wall with no one behind it. +test("a well-formed ask never yields a capability", async (t) => { + if (!token) return t.skip("no credentials"); + const response = await signed(minimal); + const answer = await response.json(); + + assert.equal(answer.capability ?? null, null, + "no configuration of REGARDE may return a generation capability yet"); + assert.ok(response.status < 500 || [502, 503, 504].includes(response.status), + `unexpected server error ${response.status}: ${JSON.stringify(answer)}`); + + if (response.status === 503) { + assert.equal(answer.outcome, "refuse"); + console.log(" state: fail-closed — REGARDE_GATEWAY_URL/SALT unset in this environment."); + } else if (response.status === 200) { + assert.ok(["allow", "deny", "edit", "refuse"].includes(answer.outcome), + `unregistered outcome ${answer.outcome}`); + console.log(` state: desk reachable — outcome "${answer.outcome}".`); + } else { + console.log(` state: desk configured but unhappy — ${response.status}.`); + } +}); + +test("the response never echoes the player's Auth0 subject", async (t) => { + if (!token) return t.skip("no credentials"); + if (!subject) return t.skip("subject unresolved"); + const body = await (await signed(minimal)).text(); + assert.ok(!body.includes(subject), + "the raw Auth0 subject must never appear in a response"); + assert.doesNotMatch(body, /auth0\|/); +}); + +test("the browser wall can reach it cross-origin", async (t) => { + if (!token) return t.skip("no credentials"); + // The wizard is served from the game's origin and posts here; a preflight + // that forgets the header turns the whole wall into a console error. + const response = await signed(minimal); + assert.equal(response.headers.get("access-control-allow-origin"), "*"); +}); diff --git a/system/tests/oskiewar-consent-gateway.test.mjs b/system/tests/oskiewar-consent-gateway.test.mjs new file mode 100644 index 0000000000..700bfb202a --- /dev/null +++ b/system/tests/oskiewar-consent-gateway.test.mjs @@ -0,0 +1,235 @@ +// The consent wall's dispatch to REGARDE, and every way it must refuse. +// +// `oskiewar-consent.test.mjs` covers how a player's answer is read. This file +// covers what happens after it is read: the hop across REGARDE_GATEWAY_URL, +// and the promise that no branch of it ever hands back a capability. The +// endpoint's own comment names the branch worth guarding hardest — an +// unconfigured gateway must refuse rather than become "generation works, the +// wall is decorative" — so that is asserted here rather than assumed. +// +// REGARDE is not in this repository and is not reached by these tests. The +// desk is replaced by a local stub whose whole job is to be observable: the +// interesting assertions are about the request we send it (no raw identity, +// a stable idempotency key) and about how we treat each answer it gives. +// +// Run: npm run test:consent (needs --experimental-test-module-mocks, which +// the script supplies; `authorize` otherwise reaches Auth0 over the network). +import assert from "node:assert/strict"; +import test, { mock, before, after } from "node:test"; +import http from "node:http"; + +const SUB = "auth0|test-player-0001"; + +// Auth is a seam here, not a subject. It is stubbed so every test below can +// get past the door and exercise the gateway branches offline; the real +// Auth0 path is covered live in `oskiewar-consent-e2e.test.mjs`. +mock.module("../backend/authorization.mjs", { + exports: { authorize: async ({ authorization }) => + authorization ? { sub: SUB } : undefined }, +}); + +const { handler } = await import("../netlify/functions/oskiewar-consent.mjs"); + +// The narrowest answer that survives `readScope`, so each test changes one +// thing and the failure is unambiguous. +const minimal = { + source: ["appearance"], + outputs: ["portrait"], + distribution: ["private_preview"], + retention: "bound_to_purpose_scope", +}; + +const ask = (body = minimal, headers = { authorization: "Bearer t" }) => + handler({ httpMethod: "POST", headers, body: JSON.stringify(body) }); + +const json = (response) => JSON.parse(response.body); + +// The stub desk. `reply` is swapped per test; `seen` keeps the last request so +// a test can assert on what actually crossed the wire. +let desk, deskURL, seen; +let reply = () => ({ status: 200, body: { outcome: "allow" } }); + +before(async () => { + desk = http.createServer((req, res) => { + let raw = ""; + req.on("data", (chunk) => { raw += chunk; }); + req.on("end", () => { + seen = JSON.parse(raw || "{}"); + const { status, body } = reply(seen); + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(JSON.stringify(body)); + }); + }); + await new Promise((resolve) => desk.listen(0, "127.0.0.1", resolve)); + deskURL = `http://127.0.0.1:${desk.address().port}/`; +}); + +after(() => desk?.close()); + +// Configured by default; the tests that care about absence clear these +// explicitly so the fail-closed branch is reached deliberately. +const configure = () => { + process.env.REGARDE_GATEWAY_URL = deskURL; + process.env.REGARDE_SUBJECT_SALT = "test-salt"; +}; + +test("an unsigned ask never reaches the desk", async () => { + configure(); + seen = undefined; + const response = await ask(minimal, {}); + assert.equal(response.statusCode, 401); + assert.equal(json(response).capability, null); + assert.equal(seen, undefined, "the desk must not be called for a stranger"); +}); + +test("an unreadable body is refused before the desk is troubled", async () => { + configure(); + seen = undefined; + const response = await handler({ + httpMethod: "POST", + headers: { authorization: "Bearer t" }, + body: "{not json", + }); + assert.equal(response.statusCode, 400); + assert.equal(seen, undefined); +}); + +test("an unregistered term is refused before the desk is troubled", async () => { + configure(); + seen = undefined; + const response = await ask({ ...minimal, outputs: ["hologram"] }); + assert.equal(response.statusCode, 400); + assert.match(json(response).message, /thing to make/); + assert.equal(seen, undefined); +}); + +// The branch the endpoint's own comment singles out. Both halves of the +// configuration are load-bearing: a gateway with no salt would send raw subs. +test("an unset gateway refuses instead of waving the ask through", async () => { + process.env.REGARDE_SUBJECT_SALT = "test-salt"; + delete process.env.REGARDE_GATEWAY_URL; + const response = await ask(); + assert.equal(response.statusCode, 503); + assert.equal(json(response).outcome, "refuse"); + assert.equal(json(response).capability, null); +}); + +test("an unset salt refuses too, rather than sending a raw subject", async () => { + process.env.REGARDE_GATEWAY_URL = deskURL; + delete process.env.REGARDE_SUBJECT_SALT; + seen = undefined; + const response = await ask(); + assert.equal(response.statusCode, 503); + assert.equal(seen, undefined, "nothing may leave without a salt to hash with"); +}); + +test("a desk that refuses the ask yields no capability", async () => { + configure(); + reply = () => ({ status: 500, body: { error: "nope" } }); + const response = await ask(); + assert.equal(response.statusCode, 502); + assert.equal(json(response).capability, null); +}); + +test("a desk that cannot be reached yields no capability", async () => { + // A closed port takes the same catch as an abort, without spending the + // full GATEWAY_TIMEOUT_MS waiting for one. + process.env.REGARDE_GATEWAY_URL = "http://127.0.0.1:1/"; + process.env.REGARDE_SUBJECT_SALT = "test-salt"; + const response = await ask(); + assert.equal(response.statusCode, 504); + assert.equal(json(response).capability, null); +}); + +test("allow is the only outcome that opens anything — and it still opens nothing yet", async () => { + configure(); + reply = () => ({ status: 200, body: { outcome: "allow", + receipt: { decision: "allow", hash: "abc123", iat: 1, exp: 2 } } }); + const response = await ask(); + assert.equal(response.statusCode, 200); + const answer = json(response); + assert.equal(answer.outcome, "allow"); + assert.deepEqual(answer.receipt, + { decision: "allow", hash: "abc123", issued_at: 1, expires_at: 2 }); + // Slice 1 proves the wall; the capability stays null until there is a + // worker whose reach this grant can bound. + assert.equal(answer.capability, null); +}); + +test("a receipt keyed by jti is read the same as one keyed by hash", async () => { + configure(); + reply = () => ({ status: 200, body: { outcome: "allow", + receipt: { decision: "allow", jti: "from-jti" } } }); + const answer = json(await ask()); + assert.equal(answer.receipt.hash, "from-jti"); + assert.equal(answer.receipt.issued_at, null); +}); + +test("deny yields no capability", async () => { + configure(); + reply = () => ({ status: 200, body: { outcome: "deny" } }); + const answer = json(await ask()); + assert.equal(answer.outcome, "deny"); + assert.equal(answer.capability, null); +}); + +test("edit surfaces the desk's counter rather than retrying it", async () => { + configure(); + reply = () => ({ status: 200, body: { outcome: "edit", + counter: { frozen_fields: { purpose_scope: { outputs: ["portrait"] } } } } }); + const answer = json(await ask()); + assert.equal(answer.outcome, "edit"); + assert.deepEqual(answer.counter, { outputs: ["portrait"] }); + assert.equal(answer.capability, null); +}); + +test("an answer with no outcome is read as a refusal", async () => { + configure(); + reply = () => ({ status: 200, body: {} }); + const answer = json(await ask()); + assert.equal(answer.outcome, "refuse"); + assert.equal(answer.capability, null); + assert.equal(answer.receipt, null); +}); + +test("no raw identity crosses the wire", async () => { + configure(); + reply = () => ({ status: 200, body: { outcome: "allow" } }); + await ask(); + const wire = JSON.stringify(seen); + assert.doesNotMatch(wire, /auth0\|/, "the Auth0 subject must not leave"); + assert.ok(!wire.includes(SUB)); + assert.match(seen.subject, /^sub_[0-9a-f]{32}$/); + assert.equal(seen.venue, "oskiewar"); + assert.equal(seen.operation_type, "DATA_OPERATION"); + assert.equal(seen.frozen_fields.operation_kind, "GRANT_CONSENT"); +}); + +test("the pseudonym is scoped to the salt, so grants cannot be correlated", async () => { + configure(); + await ask(); + const first = seen.subject; + process.env.REGARDE_SUBJECT_SALT = "a-different-purpose"; + await ask(); + assert.notEqual(seen.subject, first, + "the same person must be a different subject under a different salt"); +}); + +test("the same ask is idempotent; a changed ask is a new one", async () => { + configure(); + await ask(); + const first = seen.idempotency_key; + await ask(); + assert.equal(seen.idempotency_key, first, "asking twice must not litter the chain"); + await ask({ ...minimal, distribution: ["online_play"] }); + assert.notEqual(seen.idempotency_key, first, "a wider scope is a genuinely new ask"); +}); + +test("ticking appearance does not tick marketing, merchandise or training", async () => { + configure(); + await ask(); + const scope = seen.frozen_fields.purpose_scope; + assert.equal(scope.marketing, false); + assert.equal(scope.merchandise, false); + assert.equal(scope.model_training, false); +}); -- 2.51.2