Merge pull request #847 from tdurieux/codex/review-consent-ui

Add isolated artifact owner consent page
This commit is contained in:
Thomas Durieux authored and GitHub committed 2026-10-08 18:33:32 +02:00
commit 1d20b91024
10 files changed
+443 -3

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
# Artifact consent page
`/review-link` serves a separate page without the ordinary site's third-party widget or profile header. Its content security policy allows only first-party scripts and connections. The page rejects URL queries and noncanonical aliases, disables caching and referrers, and accepts only filename-shaped manifest entries.
The handoff fragment contains exactly `clientId`, `intentId` and `token`. The application removes it from the current history entry before starting application requests or the router. Intent credentials and signed preview tickets stay in a controller closure. They are not rendered or written to browser storage. Reloading loses the handoff; users must reopen a new link from their submission. Signed-out users sign in before reopening the link.
Owners enter the anonymous repository ID and request a preview. The backend supplies the repository name, access policy, retention deadline and expiry. Confirmation requires two separate unchecked approvals. A failed confirmation keeps the same signed ticket and random request ID for an explicit retry. Repository changes are disabled while that confirmation is uncertain. Account changes and page disposal cancel requests and discard late results. Success says that consent is recorded and that artifact linking remains incomplete.
The page does not mount the consent API or advertise available integration capabilities. Startup configuration, final binding exchange, provider activation and a review-side link launcher remain unfinished. The consent HTTP router still needs to be mounted after authentication and before body parsing.
Validation uses the compiled frontend bundles. Targeted tests cover fragment removal, storage, approval flags, replay identity, departure during CSRF, account changes during preview, invalid handoffs and policies, endpoint unavailability, expiry, sign-in and mismatched receipts. Server tests check canonical paths, headers and unsafe manifests. Chromium checks at 1280 and 320 pixels exercise the full synthetic form flow with CSP enabled and check overflow, external requests, script errors and WCAG A/AA rules. No live database or provider request is involved.
Run `npm run build:ui`, then `npx mocha test/review-consent-page.test.js test/review-consent-ui.test.js`. Set `TEST_REVIEW_PAGE_PERF_REPORT` to a private output path to measure 100 isolated HTML responses over loopback HTTP. This benchmark excludes browser rendering, database operations and provider latency.
+24
View File
@@ -0,0 +1,24 @@
<section class="container py-4" style="max-width: 48rem; overflow-wrap: anywhere">
<a href="/">Anonymous GitHub</a>
<h1 class="mt-3">Approve artifact access</h1>
<p>This records your approval as the repository owner or an authorized coauthor.</p>
<p v-if="!user?.username">Sign in first, then reopen the artifact link from your submission. <a href="/signin">Sign in</a></p>
<p v-if="error" role="alert">{{ error }}</p>
<p v-if="saved" role="status">Consent recorded. Artifact linking is not complete. Return to your submission to check its status.</p>
<form v-if="user?.username && !saved" @submit.prevent="loadPreview">
<label for="review-repository">Anonymous repository ID</label>
<input id="review-repository" class="form-control" v-model="repositoryId" maxlength="128" autocomplete="off" :disabled="busy || uncertain" aria-describedby="review-repository-help">
<p id="review-repository-help">Use the ID after /r/ in the anonymous repository URL.</p>
<button type="submit" class="btn btn-primary" :disabled="busy || uncertain">Preview access and retention</button>
</form>
<section v-if="preview && !saved" class="mt-4" aria-labelledby="review-policy-title">
<h2 id="review-policy-title">{{ preview.repositoryName }}</h2>
<p>Access: {{ preview.policy.access === 'anonymous-link' ? 'Anyone with the anonymous link' : 'Restricted review access' }}.</p>
<p>Retain until {{ preview.policy.retainUntil }}. Policy version {{ preview.policy.version }}.</p>
<p v-if="expired" role="alert">This preview expired. Open a new artifact link from your submission.</p>
<div><label><input type="checkbox" v-model="acceptAccess" :disabled="busy || uncertain || expired"> I approve the access policy above.</label></div>
<div><label><input type="checkbox" v-model="acceptRetention" :disabled="busy || uncertain || expired"> I approve retention until the date above.</label></div>
<button class="btn btn-primary" type="button" @click="confirmConsent" :disabled="busy || expired || !acceptAccess || !acceptRetention">{{ uncertain ? 'Retry confirmation' : 'Confirm consent' }}</button>
</section>
<p v-if="busy" role="status">Waiting for the server…</p>
</section>
+11 -3
View File
@@ -1,3 +1,4 @@
import { takeReviewFragment } from "./review-consent.js";
import { loadLibrary } from "./lazy-assets.js";
import { createApp, h, watch, provide, inject, onBeforeUnmount } from "vue";
import { createRouter, createWebHistory, RouterView } from "vue-router";
@@ -33,6 +34,9 @@ export function safeUrl(value) {
}
export function mountApplication(target = "#app", options = {}) {
const isolatedConsent = !!document.querySelector('meta[name="review-consent-page"][content="1"]');
let reviewHandoff = isolatedConsent && window.location.pathname === "/review-link" ? takeReviewFragment(window) : null;
const takeReviewHandoff = () => { const value = reviewHandoff; reviewHandoff = null; return value; };
// AngularJS links stored the repository route in the fragment. Normalize it
// before web history reads the URL, replacing the entry so Back still works.
if (!options.history && window.location.pathname === "/" && /^#!\/r\/[^/?#]+/.test(window.location.hash)) {
@@ -53,7 +57,7 @@ export function mountApplication(target = "#app", options = {}) {
const value = router.currentRoute.value.params[key];
return Array.isArray(value) ? value.join("/") : value;
} });
return { http, ...timers, location, params, window, promises, html: { trustAsHtml: value => value }, translate: (key, params) => Promise.resolve(translate(key, params)), quotaService: createQuotaService(http) };
return { takeReviewHandoff, http, ...timers, location, params, window, promises, html: { trustAsHtml: value => value }, translate: (key, params) => Promise.resolve(translate(key, params)), quotaService: createQuotaService(http) };
}
function pageComponent(definition) {
return {
@@ -97,7 +101,7 @@ export function mountApplication(target = "#app", options = {}) {
const href = anchor.getAttribute("href");
if (href.startsWith("#")) return;
const url = new URL(anchor.href, window.location.href);
if (url.origin !== window.location.origin || serverPaths.test(url.pathname)) return;
if (isolatedConsent || /^\/review-link\/?$/i.test(url.pathname) || url.origin !== window.location.origin || serverPaths.test(url.pathname)) return;
event.preventDefault();
router.push(url.pathname + url.search + url.hash);
$("#navbarSupportedContent.show").collapse("hide");
@@ -106,7 +110,7 @@ export function mountApplication(target = "#app", options = {}) {
onBeforeUnmount(() => document.removeEventListener("click", navigate));
return () => [
iconsTemplate(root, []),
h("header", { class: "app-header" }, templates["partials/header.htm"](root, headerCache)),
isolatedConsent ? null : h("header", { class: "app-header" }, templates["partials/header.htm"](root, headerCache)),
h("main", { class: "app-view align-items-stretch w-100" }, h(RouterView, null, {
default: ({ Component, route }) => Component ? h(Component, { key: route.meta.preserveExplorer ? route.matched[0]?.path : route.path }) : null,
})),
@@ -129,6 +133,10 @@ export function mountApplication(target = "#app", options = {}) {
app.directive("paper-scrollspy", paperScrollspy);
app.use(router);
router.beforeEach(async to => {
if ((to.path === "/review-link" && !isolatedConsent) || (isolatedConsent && to.path !== "/review-link")) {
window.location.assign(to.fullPath);
return false;
}
root?.emit("routeLeave");
if (/^\/(r|repository|anonymize|pull-request-anonymize|gist-anonymize|pr|gist)(\/|$)/.test(to.path)) {
await loadLibrary("markdown");
+72
View File
@@ -0,0 +1,72 @@
// Read once, before the router or application requests can observe the fragment.
export function takeReviewFragment(browser) {
const fragment = browser.location.hash.slice(1);
browser.history.replaceState(null, "", "/review-link");
if (fragment.length > 2048) return null;
const values = new URLSearchParams(fragment);
if ([...values.keys()].length !== 3 || [...values.keys()].some(key => !["clientId", "intentId", "token"].includes(key))) return null;
const clientId = values.get("clientId"), intentId = values.get("intentId"), token = values.get("token");
if (!/^[a-f0-9]{32}$/.test(clientId || "") || !/^[a-f0-9]{32}$/.test(intentId || "") || !/^[a-f0-9]{64}$/.test(token || "")) return null;
return { contract: "4open.artifacts/1", clientId, intentId, token };
}
export function reviewConsentController(state, services) {
const { http, promises, window: browser } = services;
let handoff = services.takeReviewHandoff();
let ticket, csrf, confirmId, consumeId, identity;
let active = true, expiryTimer;
const stop = promises.defer();
const requestId = () => [...browser.crypto.getRandomValues(new Uint8Array(16))].map(byte => byte.toString(16).padStart(2, "0")).join("");
Object.assign(state, { repositoryId: "", busy: false, preview: null, acceptAccess: false, acceptRetention: false, uncertain: false, saved: false, expired: false, error: handoff ? "" : "Open a new artifact link from your submission. This link is missing or invalid." });
const current = () => active && state.user?.username === identity;
function dispose() {
active = false; handoff = ticket = csrf = undefined;
stop.resolve(); browser.clearTimeout(expiryTimer);
}
state.on("dispose", dispose);
state.on("routeLeave", dispose);
state.watch(() => state.user?.username, username => {
if (!identity && username) identity = username;
else if (identity && username !== identity) {
dispose(); state.preview = null; state.busy = false;
state.error = "Your account changed. Open a new artifact link from your submission.";
}
});
const options = () => ({ timeout: stop.promise, headers: { "Content-Type": "application/json", "X-Review-CSRF": csrf } });
const message = error => error?.status === 404 ? "Artifact linking is not available on this server." : error?.status === 401 ? "Sign in, then open a new artifact link from your submission." : error?.status === 403 ? "This account cannot approve this repository or this link." : "The request could not be completed. You can retry from this page.";
state.loadPreview = async () => {
if (!active || !handoff || state.busy || state.uncertain || state.saved || !state.user?.username) return;
identity = state.user.username;
const repositoryId = state.repositoryId.trim();
if (!/^[A-Za-z0-9_-]{3,128}$/.test(repositoryId)) { state.error = "Enter the anonymous repository ID from its URL."; return; }
state.busy = true; state.error = ""; state.preview = null; ticket = undefined;
state.acceptAccess = state.acceptRetention = false; state.expired = false;
browser.clearTimeout(expiryTimer);
consumeId ||= requestId();
try {
csrf = (await http.get("/api/review-consent/csrf", { timeout: stop.promise })).data.csrf;
if (!current()) return;
if (!/^[a-f0-9]{64}$/.test(csrf || "")) throw new Error("invalid_csrf");
const result = (await http.post("/api/review-consent/preview", { repositoryId, intent: { ...handoff, requestId: consumeId } }, options())).data;
if (!current()) return;
if (!result || typeof result.ticket !== "string" || !result.ticket || result.ticket.length > 8192 || typeof result.repositoryName !== "string" || !["restricted-review", "anonymous-link"].includes(result.policy?.access) || !Number.isSafeInteger(result.policy.version) || !Number.isFinite(Date.parse(result.policy.retainUntil)) || !Number.isFinite(Date.parse(result.expiresAt)) || Date.parse(result.expiresAt) <= Date.now() || Date.parse(result.expiresAt) > Date.now() + 600000) throw new Error("invalid_preview");
ticket = result.ticket; confirmId = requestId();
state.preview = { repositoryName: result.repositoryName, policy: { ...result.policy }, expiresAt: result.expiresAt };
expiryTimer = browser.setTimeout(() => { if (active) state.expired = true; }, Math.max(0, Date.parse(result.expiresAt) - Date.now()));
} catch (error) { if (current()) state.error = message(error); }
finally { if (current()) state.busy = false; }
};
state.confirmConsent = async () => {
if (!current() || state.busy || state.saved || !ticket || !state.acceptAccess || !state.acceptRetention) return;
if (Date.parse(state.preview.expiresAt) <= Date.now()) { state.expired = true; return; }
state.busy = true; state.error = "";
try {
const result = (await http.post("/api/review-consent/confirm", { ticket, requestId: confirmId, acceptAccess: true, acceptRetention: true }, options())).data;
if (!current()) return;
if (result?.requestId !== confirmId || !Number.isFinite(Date.parse(result.confirmedAt))) throw new Error("invalid_receipt");
state.saved = true; state.uncertain = false; handoff = ticket = undefined;
} catch (error) {
if (current()) { state.uncertain = true; state.error = "The confirmation was not verified. Retry with the same approval, or return to your submission to check its status."; }
} finally { if (current()) state.busy = false; }
};
}
+2
View File
@@ -1,7 +1,9 @@
import { reviewConsentController } from "./review-consent.js";
import * as pages from "./app.js";
import * as admin from "./admin.js";
export const pageRoutes = [
{path: "/review-link", template: "partials/reviewConsent.htm", title: "Artifact consent – Anonymous GitHub", setup: (state, services) => reviewConsentController(state, services)},
{path: "/connections", template: "partials/connections.htm", title: "GitHub connections – Anonymous GitHub", preserveExplorer: false, setup: (state, services) => pages.connectionsController(state, services.http)},
{path: "/signin", template: "partials/signin.htm", title: "Sign in – Anonymous GitHub", preserveExplorer: false, setup: (state, services) => pages.signinController(state, services.http)},
{path: "/", template: "partials/home.htm", title: "Anonymous GitHub – Share the code, not the author", preserveExplorer: false, setup: (state, services) => pages.homeController(state, services.http, services.location, services.window, services.timeout)},
+2
View File
@@ -1,3 +1,4 @@
import { render as reviewConsentTemplate } from "../partials/reviewConsent.htm";
import { render as connectionsTemplate } from "../partials/connections.htm";
import { render as signinTemplate } from "../partials/signin.htm";
import { render as template0 } from "../partials/404.htm";
@@ -27,6 +28,7 @@ import { render as template23 } from "../partials/profile.htm";
import { render as template24 } from "../partials/pullRequest.htm";
import { render as template25 } from "../partials/status.htm";
export const templates = {
"partials/reviewConsent.htm": reviewConsentTemplate,
"partials/connections.htm": connectionsTemplate,
"partials/signin.htm": signinTemplate,
"partials/404.htm": template0,
+4
View File
@@ -43,6 +43,9 @@ import { getAnonymizationPoolStats } from "../core/anonymization-pool";
import { createReviewCapabilities } from "./service/review-capabilities";
import { createReviewConsentPage, isReviewConsentPagePath } from "./review-consent-page";
const reviewConsentPage = createReviewConsentPage();
const logger = createLogger("server");
// Lazily build the templated index.html on first request so the server
@@ -80,6 +83,7 @@ function getIndexHtml(): string {
}
function indexResponse(req: express.Request, res: express.Response) {
if (isReviewConsentPagePath(req.path)) return reviewConsentPage(req, res);
if (
req.path.startsWith("/script") ||
req.path.startsWith("/css") ||
+58
View File
@@ -0,0 +1,58 @@
import { Request, Response } from "express";
import { existsSync, readFileSync } from "fs";
import { resolve } from "path";
// Reserve aliases too: they must never fall through to the ordinary page,
// which loads third-party scripts. Only the canonical URL serves this page.
export function isReviewConsentPagePath(path: string): boolean {
try {
return (
decodeURIComponent(path).toLowerCase().replace(/\/+$/, "") ===
"/review-link"
);
} catch {
return false;
}
}
export function createReviewConsentPage(
manifestPath = resolve("public", "asset-manifest.json"),
) {
return (req: Request, res: Response): void => {
res.set({
"Cache-Control": "no-store",
"Referrer-Policy": "no-referrer",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Content-Security-Policy":
"default-src 'self'; script-src 'self'; connect-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'",
});
if (
req.originalUrl !== "/review-link" ||
(req.method !== "GET" && req.method !== "HEAD")
) {
res.status(404).json({ error: "not_found" });
return;
}
let manifest: Record<string, unknown> = {};
try {
if (existsSync(manifestPath)) {
const parsed: unknown = JSON.parse(readFileSync(manifestPath, "utf8"));
if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
manifest = parsed as Record<string, unknown>;
}
} catch {
// A missing or malformed manifest uses the ordinary unhashed assets.
}
const asset = (name: string): string => {
const value = manifest[name];
const extension = name.endsWith(".js") ? "js" : "css";
return typeof value === "string" &&
new RegExp(`^[A-Za-z0-9][A-Za-z0-9_.-]*\\.${extension}$`).test(value)
? value
: name;
};
res.type("html").send(`<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="referrer" content="no-referrer"><meta name="review-consent-page" content="1"><title>Artifact consent · Anonymous GitHub</title><link rel="stylesheet" href="/css/${asset("all.min.css")}"></head><body><div id="app"></div><script src="/script/${asset("core.min.js")}"></script><script defer src="/script/${asset("vendor.min.js")}"></script></body></html>`);
};
}
+127
View File
@@ -0,0 +1,127 @@
require("ts-node/register/transpile-only");
const { expect } = require("chai");
const express = require("express");
const http = require("http");
const fs = require("fs");
const os = require("os");
const path = require("path");
const {
createReviewConsentPage,
isReviewConsentPagePath,
} = require("../src/server/review-consent-page");
describe("isolated review consent page", function () {
let server, directory, manifest;
beforeEach(async function () {
directory = fs.mkdtempSync(path.join(os.tmpdir(), "review-page-"));
manifest = path.join(directory, "manifest.json");
const app = express();
const page = createReviewConsentPage(manifest);
app.use((req, res) =>
isReviewConsentPagePath(req.path)
? page(req, res)
: res.status(418).end(),
);
server = await new Promise((resolve) => {
const s = app.listen(0, "127.0.0.1", () => resolve(s));
});
});
afterEach(async function () {
await new Promise((resolve) => server.close(resolve));
fs.rmSync(directory, { recursive: true, force: true });
});
function get(url, method = "GET") {
return new Promise((resolve, reject) => {
const req = http.request(
{
hostname: "127.0.0.1",
port: server.address().port,
path: url,
method,
},
(res) => {
let body = "";
res.on("data", (chunk) => {
body += chunk;
});
res.on("end", () =>
resolve({ status: res.statusCode, headers: res.headers, body }),
);
},
);
req.on("error", reject);
req.end();
});
}
it("serves only first-party assets with no inline scripts or external frames", async function () {
const response = await get("/review-link");
expect(response.status).equal(200);
expect(response.headers["cache-control"]).equal("no-store");
expect(response.headers["referrer-policy"]).equal("no-referrer");
expect(response.headers["x-frame-options"]).equal("DENY");
expect(response.headers["content-security-policy"])
.include("script-src 'self'")
.include("frame-ancestors 'none'");
expect(response.body).include('name="review-consent-page"');
expect(response.body).not.match(/https?:|kofi|ko-fi|<iframe|<script\s*>/i);
expect(response.body.match(/<script /g)).length(2);
expect(response.body)
.include("/script/core.min.js")
.include("/script/vendor.min.js");
});
it("reserves encoded, case and slash aliases and rejects query strings and writes", async function () {
for (const url of [
"/review-link/",
"/REVIEW-LINK",
"/%72eview-link",
"/review-link?token=secret",
"/review-link///",
]) {
const response = await get(url);
expect(response.status, url).equal(404);
expect(response.body).not.include("<html");
expect(response.headers["referrer-policy"]).equal("no-referrer");
}
expect((await get("/review-link", "POST")).status).equal(404);
expect((await get("/review-link-other")).status).equal(418);
expect(isReviewConsentPagePath("/%")).equal(false);
});
it("accepts hashed filenames but rejects manifest URL and HTML injection", async function () {
fs.writeFileSync(
manifest,
JSON.stringify({
"core.min.js": "core.abc123.js",
"vendor.min.js": "//evil.test/x.js",
"all.min.css": 'x.css" onload="alert(1)',
}),
);
const response = await get("/review-link");
expect(response.body)
.include("/script/core.abc123.js")
.include("/script/vendor.min.js")
.include("/css/all.min.css");
expect(response.body).not.include("evil.test").not.include("onload");
fs.writeFileSync(manifest, "null");
expect((await get("/review-link")).status).equal(200);
fs.writeFileSync(manifest, "{");
expect((await get("/review-link")).status).equal(200);
});
it("serves HEAD without a body", async function () {
const response = await get("/review-link", "HEAD");
expect(response.status).equal(200);
expect(response.body).equal("");
});
it("measures 100 isolated page responses", async function () {
const times = [];
const { performance } = require("perf_hooks");
for (let i = 0; i < 100; i++) {
const start = performance.now();
expect((await get('/review-link')).status).equal(200);
times.push(performance.now() - start);
}
times.sort((a, b) => a - b);
const output = require("process").env.TEST_REVIEW_PAGE_PERF_REPORT;
if (output) fs.writeFileSync(output, JSON.stringify({ requests: 100, workload: 'isolated HTML shell, loopback HTTP, no browser or provider', medianMs: times[49], p95Ms: times[94] }, null, 2) + '\n');
});
});
+130
View File
@@ -0,0 +1,130 @@
const { expect } = require("chai");
const { JSDOM, VirtualConsole } = require("jsdom");
const fs = require("fs");
const { URL } = require("url");
const path = require("path");
const { setTimeout: delay } = require("timers/promises");
const bundles = ["core.min.js", "vendor.min.js"].map(name => fs.readFileSync(path.join(__dirname, "../public/script", name), "utf8"));
const token = "3".repeat(64);
const fragment = `clientId=${"1".repeat(32)}&intentId=${"2".repeat(32)}&token=${token}`;
const csrf = "a".repeat(64);
const preview = () => ({ ticket: 'private-ticket', repositoryName: 'Synthetic repository', policy: { version: 1, access: 'restricted-review', retainUntil: '2099-01-01T00:00:00Z' }, expiresAt: new Date(Date.now() + 60000).toISOString() });
async function browser(overrides = {}, hash = fragment) {
const errors = [], requests = [];
const vc = new VirtualConsole();
vc.on('jsdomError', error => errors.push(error.message));
vc.on('error', error => errors.push(String(error)));
const dom = new JSDOM('<!doctype html><html><head><meta name="review-consent-page" content="1"></head><body><div id="app"></div></body></html>', { url: 'https://anonymous.example.test/review-link#' + hash, runScripts: 'dangerously', pretendToBeVisual: true, virtualConsole: vc });
const w = dom.window;
w.matchMedia = () => ({ matches: false });
w.HTMLCanvasElement.prototype.getContext = () => null;
w.fetch = async (url, options = {}) => {
const pathname = new URL(url).pathname;
const request = { pathname, ...options, payload: options.body ? JSON.parse(options.body) : null };
requests.push(request);
let data;
if (overrides[pathname]) data = await overrides[pathname](request);
else if (pathname === '/api/user') data = { username: 'owner' };
else if (pathname === '/api/review-consent/csrf') data = { csrf };
else if (pathname === '/api/review-consent/preview') data = preview();
else if (pathname === '/api/review-consent/confirm') data = { requestId: request.payload.requestId, confirmedAt: new Date().toISOString() };
else data = [];
const status = data?.__status || 200;
return { ok: status < 400, status, headers: { get: () => 'application/json' }, text: async () => JSON.stringify(data?.__status ? data.body : data) };
};
bundles.forEach(bundle => w.eval(bundle));
const app = w.anonymousApp;
await app.router.isReady(); await delay(30);
const document = w.document;
const click = async selector => { document.querySelector(selector).click(); await delay(30); };
return { w, app, document, errors, requests, click,
async preview() { const input = document.querySelector('#review-repository'); input.value = 'synthetic-repo'; input.dispatchEvent(new w.Event('input', { bubbles: true })); await click('button[type=submit]'); },
async accept() { for (const input of document.querySelectorAll('input[type=checkbox]')) { input.click(); } await delay(5); },
close() { app.app.unmount(); w.close(); },
};
}
describe('review consent browser page', function () {
this.timeout(10000);
let b;
afterEach(() => b?.close());
it('scrubs the fragment before requests and keeps secrets out of rendered state and storage', async function () {
b = await browser();
expect(b.w.location.href).equal('https://anonymous.example.test/review-link');
expect(JSON.stringify(b.w.history.state)).not.include(token);
await b.preview();
expect(b.document.body.textContent).include('Synthetic repository');
expect(b.document.documentElement.outerHTML).not.include(token).not.include('private-ticket');
expect(Object.keys(b.w.localStorage)).deep.equal(['darkMode']);
expect(JSON.stringify(b.w.localStorage)).not.include(token).not.include('private-ticket');
expect(b.w.sessionStorage.length).equal(0);
expect(b.document.querySelector('.app-header')).equal(null);
expect(b.requests.find(r => r.pathname.endsWith('/preview')).payload.intent.token).equal(token);
expect(b.errors).deep.equal([]);
});
it('requires both approvals and preserves the confirmation identity after response loss', async function () {
let attempts = 0;
b = await browser({ '/api/review-consent/confirm': request => { if (++attempts === 1) throw new Error('lost response'); return { requestId: request.payload.requestId, confirmedAt: new Date().toISOString() }; } });
await b.preview();
expect(b.document.querySelector('button[type=button]').disabled).equal(true);
await b.accept(); await b.click('button[type=button]');
expect(b.document.body.textContent).include('Retry confirmation');
expect(b.document.querySelector('#review-repository').disabled).equal(true);
await b.click('button[type=button]');
const writes = b.requests.filter(r => r.pathname.endsWith('/confirm'));
expect(writes).length(2); expect(writes[0].payload).deep.equal(writes[1].payload);
expect(b.document.body.textContent).include('Consent recorded. Artifact linking is not complete.');
});
it('does not send a preview after departure during the CSRF request', async function () {
let resolve;
b = await browser({ '/api/review-consent/csrf': () => new Promise(yes => { resolve = yes; }) });
await b.preview(); b.app.app.unmount(); resolve({ csrf }); await delay(30);
expect(b.requests.filter(r => r.pathname.endsWith('/preview'))).length(0);
expect(b.requests.find(r => r.pathname.endsWith('/csrf')).signal.aborted).equal(true);
});
it('discards a late preview after account change', async function () {
let resolve;
b = await browser({ '/api/review-consent/preview': () => new Promise(yes => { resolve = yes; }) });
await b.preview(); b.app.state.user = { username: 'someone-else' }; await delay(5); resolve(preview()); await delay(30);
expect(b.document.body.textContent).include('Your account changed');
expect(b.document.body.textContent).not.include('Synthetic repository');
});
it('rejects duplicate or missing fragment fields without making consent requests', async function () {
b = await browser({}, fragment + '&token=' + token); await b.preview();
expect(b.document.body.textContent).include('missing or invalid');
expect(b.requests.some(r => r.pathname.startsWith('/api/review-consent'))).equal(false);
expect(b.w.location.hash).equal('');
});
it('shows unavailable endpoints and keeps the intent request ID on preview retry', async function () {
let attempts = 0;
b = await browser({ '/api/review-consent/preview': () => ++attempts === 1 ? { __status: 404, body: {} } : preview() });
await b.preview(); expect(b.document.body.textContent).include('not available'); await b.preview();
const requests = b.requests.filter(r => r.pathname.endsWith('/preview'));
expect(requests[0].payload.intent.requestId).equal(requests[1].payload.intent.requestId);
});
it('does not approve an expired preview', async function () {
b = await browser({ '/api/review-consent/preview': () => ({ ...preview(), expiresAt: new Date(Date.now() + 1000).toISOString() }) });
await b.preview(); await b.accept(); await delay(1050); await b.click('button[type=button]');
expect(b.requests.some(r => r.pathname.endsWith('/confirm'))).equal(false);
expect(b.document.body.textContent).include('preview expired');
});
it('requires sign-in without retaining the handoff in browser storage', async function () {
b = await browser({ '/api/user': () => null });
expect(b.document.querySelector('#review-repository')).equal(null);
expect(b.document.body.textContent).include('Sign in first');
expect(b.w.location.hash).equal('');
expect(b.w.sessionStorage.length).equal(0);
expect(b.requests.some(r => r.pathname.startsWith('/api/review-consent'))).equal(false);
});
it('rejects malformed policy previews and mismatched confirmation receipts', async function () {
let attempts = 0;
b = await browser({
'/api/review-consent/preview': () => ++attempts === 1 ? { ...preview(), policy: { ...preview().policy, access: 'unsupported' } } : preview(),
'/api/review-consent/confirm': () => ({ requestId: 'wrong', confirmedAt: new Date().toISOString() }),
});
await b.preview(); expect(b.document.querySelector('input[type=checkbox]')).equal(null);
await b.preview(); await b.accept(); await b.click('button[type=button]');
expect(b.document.body.textContent).include('confirmation was not verified');
expect(b.document.body.textContent).not.include('Consent recorded.');
});
});