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); +});