Heimdall/tests/consent-drift.test.ts
Heimdall 71ce9c16e4 Consent review fixes: ask-only cookie, fixed-expiry grants links, wording carried to the receipt, one revocation per grant
The ask sets its own cookie and no launch binding; Continue sets the binding
and carries the exact text shown into it; the receipt stores that text and its
hash. Grants-page form tokens keep the link's expiry. Revocations are unique
per grant and revoke reports only what it wrote. access_denied's attempt
denial code is pinned for Bifrost and Repixelizer.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 13:30:39 +00:00

65 lines
3.1 KiB
TypeScript

import { afterEach, expect, it, vi } from "vitest";
import { type FastifyInstance } from "fastify";
// The receipt records the wording the person was shown. If the wording changes
// between the ask and the callback (a deploy inside the state's lifetime), the
// receipt must still hold what they saw.
const drift = vi.hoisted(() => ({ on: false }));
vi.mock("../src/consent.js", async (importOriginal) => {
const real = await importOriginal<typeof import("../src/consent.js")>();
return { ...real, consentText: (ask: Parameters<typeof real.consentText>[0]) => real.consentText(ask) + (drift.on ? "\nREWORDED AFTER THE ASK" : "") };
});
import { buildApp } from "../src/app.js";
import { InMemoryStore } from "../src/store/index.js";
import { createTestConfig, setCookieOf, stateOf } from "./oauth-flow.js";
const apps: FastifyInstance[] = [];
afterEach(async () => {
drift.on = false;
while (apps.length) await apps.pop()?.close();
});
it("a receipt holds the wording of the ask, not wording rebuilt at the callback", async () => {
const store = new InMemoryStore();
const app = await buildApp({
config: createTestConfig(),
store,
oauthRuntimes: {
joystick: {
async exchangeAuthorizationCode() {
return { accessToken: "joy-access-code-a", refreshToken: "r", tokenType: "Bearer", scope: ["identity:read"], expiresAt: "2099-01-01T00:00:00.000Z", raw: {} };
},
async resolveIdentity() {
return { provider: "joystick", providerUserId: "joy-a", username: "a", profile: {} };
},
async evaluateEntitlements() {
return { facts: [], snapshots: [] };
},
},
},
});
apps.push(app);
const start = await app.inject({
method: "POST",
url: "/v1/oauth/joystick/start",
headers: { "x-heimdall-app-secret": "streampixels-secret" },
payload: { appSlug: "streampixels", mode: "connect", connection: { kind: "creator", targetId: "c" }, returnTo: "https://streampixels.gamecult.org/s", handoff: { kind: "browser_completion" } },
});
const state = stateOf(start.json());
const ask = await app.inject({ method: "GET", url: `/v1/oauth/joystick/launch?state=${encodeURIComponent(state)}`, headers: { accept: "text/html" } });
const askCookie = setCookieOf(ask).find((c) => c.startsWith("__Host-heimdall-ask-"))!.split(";")[0]!;
const go = await app.inject({
method: "POST",
url: "/v1/oauth/joystick/consent",
headers: { "content-type": "application/x-www-form-urlencoded", cookie: askCookie },
payload: new URLSearchParams({ state, decision: "continue" }).toString(),
});
const binding = setCookieOf(go).find((c) => c.startsWith("__Host-heimdall-oauth-"))!.split(";")[0]!;
drift.on = true; // the wording changes after the person has agreed
const done = await app.inject({ method: "GET", url: `/v1/oauth/joystick/callback?state=${encodeURIComponent(state)}&code=code-a`, headers: { cookie: binding, accept: "application/json" } });
expect(done.statusCode).toBe(201);
const [receipt] = await store.listConsentReceipts(done.json().account.id);
expect(receipt?.kind === "grant" && receipt.consentText).not.toContain("REWORDED");
});