diff --git a/.github/extensions/og-preview/README.md b/.github/extensions/og-preview/README.md index fac3d789d..9cfc22913 100644 --- a/.github/extensions/og-preview/README.md +++ b/.github/extensions/og-preview/README.md @@ -40,6 +40,36 @@ port) that serves the static UI from `ui/` and a JSON API: The target page is fetched and parsed server-side (no external dependencies), which sidesteps browser CORS and lets it reach `localhost`. +## Network access and isolation + +Select a localhost URL in the address form or through a canvas action to authorize +that exact origin (scheme, hostname, and port). Links and resources discovered in +the page cannot authorize another local origin. Public-to-local redirects are +blocked, even if the local origin was previously selected. + +Private-network destinations beyond loopback require `OG_ALLOW_PRIVATE_NETWORK=1` +in addition to explicit selection. This setting does not grant arbitrary private +access to fetched pages. Selecting a different origin invalidates the previous +browse capability; resources on additional local ports must be previewed separately. + +Every connection validates all DNS answers and pins the validated addresses to +the request, including redirect hops. IPv4-mapped IPv6 addresses are checked +against the same policy as IPv4. The original hostname remains in use for HTTP +Host and TLS certificate verification. + +The host-provided canvas URL contains a private UI capability in its fragment. +The renderer uses it for API calls and events; do not share that URL. Sandboxed +pages receive a separate, revocable resource-only capability and cannot select +origins or invoke session/issue actions. Public errors omit exception details. +The browse frame continues to run scripts without `allow-same-origin`; module +import rewriting is a preview transform, not an HTML sanitizer. + +Run the dependency-free regression suite with Node.js 24: + +```powershell +node --test .github\extensions\og-preview\tests\*.test.mjs +``` + ## Agent actions & tools - **`open_og_preview`** `{ url?, instanceId? }` *(tool)* — open or focus the diff --git a/.github/extensions/og-preview/extension.mjs b/.github/extensions/og-preview/extension.mjs index 01083e909..077b10d31 100644 --- a/.github/extensions/og-preview/extension.mjs +++ b/.github/extensions/og-preview/extension.mjs @@ -15,7 +15,8 @@ import { extname } from "node:path"; import { joinSession, createCanvas, CanvasError } from "@github/copilot-sdk/extension"; -import { fetchUrl, normalizeUrl } from "./lib/http-fetch.mjs"; +import { fetchUrl, normalizeUrl, authorizePreview } from "./lib/http-fetch.mjs"; +import { createAccess, requestAccess } from "./lib/request-access.mjs"; import { parseMetadata } from "./lib/parse-og.mjs"; import { checkAgentReadiness } from "./lib/agent-readiness.mjs"; @@ -74,9 +75,9 @@ async function serveAsset(res, name) { } /** Fetch a target URL and parse its OpenGraph metadata. */ -async function loadMetadata(rawUrl) { +async function loadMetadata(rawUrl, policy) { const target = normalizeUrl(rawUrl); - const result = await fetchUrl(target); + const result = await fetchUrl(target, policy); if (result.status >= 400) { throw new Error(`Target responded with HTTP ${result.status}.`); } @@ -90,6 +91,14 @@ async function loadMetadata(rawUrl) { return data; } +async function selectPreview(entry, url) { + const policy = await authorizePreview(url); + entry.policy = policy; + // Previously rendered content must not inherit a later local selection. + entry.browseToken = createAccess().browseToken; + return policy; +} + function sendJson(res, status, obj) { res.statusCode = status; res.setHeader("Content-Type", "application/json; charset=utf-8"); @@ -353,7 +362,7 @@ function rewriteBrowseDoc(html, finalUrl, appOrigin) { ); // Inline (no src) → rewrite import specifiers - out = out.replace(/]*)>([\s\S]*?)<\/script>/gi, (m, attrs, body) => { + out = out.replace(/])((?:[^"'<>]|"[^"]*"|'[^']*')*)>([\s\S]*?)<\/script(?=[\t\n\f\r />])(?:[^"'<>]|"[^"]*"|'[^']*')*>/gi, (m, attrs, body) => { if (/\ssrc\s*=/i.test(attrs)) return m; // external handled above if (!/type\s*=\s*["']?module/i.test(attrs)) return m; // classic scripts unaffected return `${rewriteJs(body, finalUrl, appOrigin)}`; @@ -439,7 +448,8 @@ function broadcast(entry, payload) { async function handleRequest(entry, req, res) { const reqUrl = new URL(req.url, "http://127.0.0.1"); - const path = reqUrl.pathname; + let path = reqUrl.pathname; + res.setHeader("Referrer-Policy", "no-referrer"); if (path === "/" || path === "/index.html") { return serveAsset(res, "index.html"); @@ -448,6 +458,17 @@ async function handleRequest(entry, req, res) { return serveAsset(res, path.slice(1)); } + const access = requestAccess(entry, req, reqUrl); + if (!access) return sendJson(res, 403, { error: "Preview access denied." }); + path = access.path; + if (path !== "/api/open-session" && path !== "/api/create-issue" && req.method !== "GET") { + return sendJson(res, 405, { error: "Use GET." }); + } + // A request keeps its authorization snapshot even when another selection + // replaces the active origin while a fetch/redirect is in flight. + let policy = entry.policy; + const proxyOrigin = new URL(entry.url).origin + `/browse/${entry.browseToken}`; + if (path === "/events") { res.writeHead(200, { "Content-Type": "text/event-stream", @@ -469,15 +490,19 @@ async function handleRequest(entry, req, res) { // user out of the Browse tab on every in-page navigation. const silent = reqUrl.searchParams.get("silent") === "1"; try { - const data = await loadMetadata(u); + if (reqUrl.searchParams.get("select") === "1") { + policy = await selectPreview(entry, u); + } + const data = await loadMetadata(u, policy); entry.currentUrl = data.requestedUrl; sendJson(res, 200, data); // Refresh the host panel title to the resolved URL (fire-and-forget; // guarded against loops by syncTitle). if (!silent) syncTitle(entry, data.requestedUrl).catch(() => {}); return; - } catch (err) { - return sendJson(res, 200, { error: err.message }); + } catch { + log("OG Viewer: metadata request failed.", "warning"); + return sendJson(res, 200, { error: "Couldn't load metadata. Check the URL and selected-origin access." }); } } @@ -486,10 +511,11 @@ async function handleRequest(entry, req, res) { if (!u) return sendJson(res, 400, { error: "Missing 'u' query parameter." }); try { const target = normalizeUrl(u); - const report = await checkAgentReadiness(target); + const report = await checkAgentReadiness(target, policy); return sendJson(res, 200, report); - } catch (err) { - return sendJson(res, 200, { error: err.message }); + } catch { + log("OG Viewer: agent-readiness request failed.", "warning"); + return sendJson(res, 200, { error: "Couldn't check agent readiness." }); } } @@ -500,7 +526,7 @@ async function handleRequest(entry, req, res) { return res.end("Missing 'u'"); } try { - const img = await fetchUrl(u, { accept: "image/*,*/*;q=0.8", timeoutMs: 12000 }); + const img = await fetchUrl(u, { ...policy, accept: "image/*,*/*;q=0.8", timeoutMs: 12000 }); res.statusCode = img.status >= 400 ? img.status : 200; res.setHeader("Content-Type", img.contentType || "application/octet-stream"); res.setHeader("Cache-Control", "public, max-age=300"); @@ -517,6 +543,7 @@ async function handleRequest(entry, req, res) { try { const target = githubBlobToRaw(u); const r = await fetchUrl(target, { + ...policy, accept: "text/plain,text/markdown,application/json,text/*;q=0.9,*/*;q=0.5", timeoutMs: 12000, maxBytes: 1024 * 1024, @@ -564,8 +591,9 @@ async function handleRequest(entry, req, res) { truncated, text, }); - } catch (err) { - return sendJson(res, 200, { error: err.message || "Couldn't load file preview." }); + } catch { + log("OG Viewer: file preview failed.", "warning"); + return sendJson(res, 200, { error: "Couldn't load file preview." }); } } @@ -575,17 +603,19 @@ async function handleRequest(entry, req, res) { // them, and rewrites JS imports / CSS urls so the dependency graph stays inside // the proxy. The path layout makes relative module imports resolve correctly. if (path.startsWith("/api/proxy/")) { - const target = proxyDecodePath(path, reqUrl.search); + const search = new URLSearchParams(reqUrl.search); + if (access.privileged) search.delete("key"); + const target = proxyDecodePath(path, search.size ? `?${search}` : ""); if (!target) { res.statusCode = 400; return res.end("Bad proxy path"); } - const appOrigin = "http://" + (req.headers.host || "127.0.0.1"); + const appOrigin = proxyOrigin; try { - const r = await fetchUrl(target, { accept: "*/*", timeoutMs: 15000 }); + const r = await fetchUrl(target, { ...policy, accept: "*/*", timeoutMs: 15000 }); const ct = r.contentType || ""; res.setHeader("Access-Control-Allow-Origin", "*"); - res.setHeader("Cache-Control", "public, max-age=300"); + res.setHeader("Cache-Control", "no-store"); const realPath = (() => { try { return new URL(r.url).pathname; @@ -617,10 +647,11 @@ async function handleRequest(entry, req, res) { res.statusCode = r.status >= 400 ? r.status : 200; res.setHeader("Content-Type", ct || "application/octet-stream"); return res.end(r.body); - } catch (err) { + } catch { + log("OG Viewer: proxy resource request failed.", "warning"); res.statusCode = 502; res.setHeader("Access-Control-Allow-Origin", "*"); - return res.end(String(err && err.message ? err.message : err)); + return res.end("Couldn't load preview resource."); } } @@ -632,6 +663,7 @@ async function handleRequest(entry, req, res) { } try { const r = await fetchUrl(normalizeUrl(u), { + ...policy, accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", timeoutMs: 15000, }); @@ -639,7 +671,7 @@ async function handleRequest(entry, req, res) { const isHtml = !ct || /text\/html|application\/xhtml\+xml|\/xml|text\/plain/i.test(ct); res.setHeader("Cache-Control", "no-store"); res.setHeader("Access-Control-Allow-Origin", "*"); - const appOrigin = "http://" + (req.headers.host || "127.0.0.1"); + const appOrigin = proxyOrigin; if (!isHtml) { // Serve non-HTML targets (images, PDFs, …) verbatim so links to // them still render inside the browse frame. @@ -652,11 +684,12 @@ async function handleRequest(entry, req, res) { res.statusCode = 200; res.setHeader("Content-Type", "text/html; charset=utf-8"); return res.end(rewriteBrowseDoc(r.body.toString("utf8"), r.url, appOrigin)); - } catch (err) { + } catch { + log("OG Viewer: browse request failed.", "warning"); res.statusCode = 200; res.setHeader("Content-Type", "text/html; charset=utf-8"); res.setHeader("Cache-Control", "no-store"); - return res.end(browseErrorPage(u, err && err.message ? err.message : String(err))); + return res.end(browseErrorPage(u, "Check the URL and selected-origin access.")); } } @@ -667,8 +700,8 @@ async function handleRequest(entry, req, res) { let body; try { body = await readJsonBody(req); - } catch (err) { - return sendJson(res, 400, { error: err.message }); + } catch { + return sendJson(res, 400, { error: "Invalid JSON request body." }); } const repo = typeof body.repo === "string" ? body.repo.trim() : ""; const pageUrl = typeof body.url === "string" ? body.url.trim() : ""; @@ -690,13 +723,14 @@ async function handleRequest(entry, req, res) { return sendJson(res, 200, { ok: false, error: "Session bridge unavailable." }); } // Fire the request into the host chat session; the agent acts on it. - sessionRef.send(message).catch(() => {}); + await sessionRef.send(message); log(`OG Viewer: requested ${kind} for ${repo}.`); return sendJson(res, 200, { ok: true }); - } catch (err) { + } catch { + log("OG Viewer: session action failed.", "warning"); return sendJson(res, 200, { ok: false, - error: err && err.message ? err.message : String(err), + error: "Couldn't send the session action.", }); } } @@ -707,6 +741,7 @@ async function handleRequest(entry, req, res) { async function startServer(instanceId, currentUrl) { const entry = { + ...createAccess(), instanceId, server: null, url: "", @@ -714,10 +749,12 @@ async function startServer(instanceId, currentUrl) { titleKey: currentUrl ? titleKey(currentUrl) : "", clients: new Set(), }; + if (currentUrl) entry.policy = await authorizePreview(currentUrl); const server = createServer((req, res) => { - Promise.resolve(handleRequest(entry, req, res)).catch((err) => { + Promise.resolve(handleRequest(entry, req, res)).catch(() => { + log("OG Viewer: request failed.", "warning"); if (!res.headersSent) res.statusCode = 500; - res.end(String(err && err.message ? err.message : err)); + res.end("Preview request failed."); }); }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); @@ -731,7 +768,8 @@ async function startServer(instanceId, currentUrl) { function instanceUrl(entry) { const base = entry.url; - return entry.currentUrl ? `${base}?u=${encodeURIComponent(entry.currentUrl)}` : base; + const url = entry.currentUrl ? `${base}?u=${encodeURIComponent(entry.currentUrl)}` : base; + return `${url}#key=${entry.uiToken}`; } const ogCanvas = createCanvas({ @@ -767,7 +805,8 @@ const ogCanvas = createCanvas({ } const url = ctx.input?.url; if (!url) throw new CanvasError("invalid_input", "An 'url' value is required."); - const data = await loadMetadata(url); + const policy = await selectPreview(entry, url); + const data = await loadMetadata(url, policy); entry.currentUrl = data.requestedUrl; broadcast(entry, { type: "load", url: data.requestedUrl }); syncTitle(entry, data.requestedUrl).catch(() => {}); @@ -787,7 +826,7 @@ const ogCanvas = createCanvas({ handler: async (ctx) => { const url = ctx.input?.url; if (!url) throw new CanvasError("invalid_input", "An 'url' value is required."); - const data = await loadMetadata(url); + const data = await loadMetadata(url, await authorizePreview(url)); return { requestedUrl: data.requestedUrl, resolved: data.resolved, @@ -803,6 +842,9 @@ const ogCanvas = createCanvas({ if (!entry) { entry = await startServer(ctx.instanceId, inputUrl); } else if (inputUrl) { + // Title refreshes use currentUrl and must not grant permissions to + // a redirect or a route reported by the sandboxed page. + if (inputUrl !== entry.currentUrl) await selectPreview(entry, inputUrl); entry.currentUrl = inputUrl; broadcast(entry, { type: "load", url: inputUrl }); } diff --git a/.github/extensions/og-preview/lib/agent-readiness.mjs b/.github/extensions/og-preview/lib/agent-readiness.mjs index 188a7e8e5..9f9f159ba 100644 --- a/.github/extensions/og-preview/lib/agent-readiness.mjs +++ b/.github/extensions/og-preview/lib/agent-readiness.mjs @@ -44,8 +44,8 @@ async function probe(url, opts = {}) { bytes: r.body ? r.body.length : 0, finalUrl: r.url, }; - } catch (err) { - return { ok: false, status: 0, error: String((err && err.message) || err) }; + } catch { + return { ok: false, status: 0, error: "Probe unavailable or outside the authorized preview origin." }; } } @@ -94,33 +94,34 @@ async function dnsAid(host) { return { ok: false }; } -export async function checkAgentReadiness(rawUrl) { +export async function checkAgentReadiness(rawUrl, policy = {}) { const u = new URL(rawUrl); const origin = u.origin; const host = u.hostname; const W = (p) => origin + p; + const check = (url, opts) => probe(url, { ...policy, ...opts }); const [ robots, sitemapXml, llms, llmsFull, mdNeg, page, mcp1, mcp2, a2a1, a2a2, aiPlugin, skills1, skills2, oauthPr, oauthAs, apiCatalog, aid, ] = await Promise.all([ - probe(W("/robots.txt"), { accept: "text/plain,*/*;q=0.8" }), - probe(W("/sitemap.xml"), { accept: "application/xml,text/xml,*/*;q=0.8" }), - probe(W("/llms.txt"), { accept: "text/markdown,text/plain,*/*;q=0.8" }), - probe(W("/llms-full.txt"), { accept: "text/markdown,text/plain,*/*;q=0.8" }), - probe(rawUrl, { accept: "text/markdown; q=1.0, text/x-markdown; q=0.9, text/plain; q=0.5" }), - probe(rawUrl, { accept: "text/html,application/xhtml+xml" }), - probe(W("/.well-known/mcp"), { accept: "application/json,*/*;q=0.8" }), - probe(W("/.well-known/mcp.json"), { accept: "application/json,*/*;q=0.8" }), - probe(W("/.well-known/agent.json"), { accept: "application/json,*/*;q=0.8" }), - probe(W("/.well-known/agent-card.json"), { accept: "application/json,*/*;q=0.8" }), - probe(W("/.well-known/ai-plugin.json"), { accept: "application/json,*/*;q=0.8" }), - probe(W("/.well-known/agent-skills.json"), { accept: "application/json,*/*;q=0.8" }), - probe(W("/.well-known/skills.json"), { accept: "application/json,*/*;q=0.8" }), - probe(W("/.well-known/oauth-protected-resource"), { accept: "application/json,*/*;q=0.8" }), - probe(W("/.well-known/oauth-authorization-server"), { accept: "application/json,*/*;q=0.8" }), - probe(W("/.well-known/api-catalog"), { accept: "application/linkset+json,application/json,*/*;q=0.8" }), + check(W("/robots.txt"), { accept: "text/plain,*/*;q=0.8" }), + check(W("/sitemap.xml"), { accept: "application/xml,text/xml,*/*;q=0.8" }), + check(W("/llms.txt"), { accept: "text/markdown,text/plain,*/*;q=0.8" }), + check(W("/llms-full.txt"), { accept: "text/markdown,text/plain,*/*;q=0.8" }), + check(rawUrl, { accept: "text/markdown; q=1.0, text/x-markdown; q=0.9, text/plain; q=0.5" }), + check(rawUrl, { accept: "text/html,application/xhtml+xml" }), + check(W("/.well-known/mcp"), { accept: "application/json,*/*;q=0.8" }), + check(W("/.well-known/mcp.json"), { accept: "application/json,*/*;q=0.8" }), + check(W("/.well-known/agent.json"), { accept: "application/json,*/*;q=0.8" }), + check(W("/.well-known/agent-card.json"), { accept: "application/json,*/*;q=0.8" }), + check(W("/.well-known/ai-plugin.json"), { accept: "application/json,*/*;q=0.8" }), + check(W("/.well-known/agent-skills.json"), { accept: "application/json,*/*;q=0.8" }), + check(W("/.well-known/skills.json"), { accept: "application/json,*/*;q=0.8" }), + check(W("/.well-known/oauth-protected-resource"), { accept: "application/json,*/*;q=0.8" }), + check(W("/.well-known/oauth-authorization-server"), { accept: "application/json,*/*;q=0.8" }), + check(W("/.well-known/api-catalog"), { accept: "application/linkset+json,application/json,*/*;q=0.8" }), dnsAid(host), ]); diff --git a/.github/extensions/og-preview/lib/http-fetch.mjs b/.github/extensions/og-preview/lib/http-fetch.mjs index d24a563a1..5370349eb 100644 --- a/.github/extensions/og-preview/lib/http-fetch.mjs +++ b/.github/extensions/og-preview/lib/http-fetch.mjs @@ -13,74 +13,81 @@ const USER_AGENT = const LOCAL_HOST_RE = /^(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|::1|.*\.localhost)(:|\/|$)/i; -// SSRF guard. The tool intentionally supports localhost, but every other -// private / link-local / unique-local / carrier-grade-NAT range is denied by -// default so the agent-callable actions and the loopback proxy can't be turned -// into a request-forgery primitive against the developer's machine/network -// (e.g. the 169.254.169.254 cloud metadata endpoint). Set -// OG_ALLOW_PRIVATE_NETWORK=1 to opt in to private destinations beyond localhost. +// Local destinations require explicit origin authorization. The environment +// opt-in permits selecting private origins; it never authorizes discovered URLs. const ALLOW_PRIVATE_NETWORK = /^(1|true|yes|on)$/i.test( String(process.env.OG_ALLOW_PRIVATE_NETWORK || ""), ); -function ipv4Allowed(ip, allowPrivate) { - const o = ip.split(".").map((n) => Number(n)); - if (o.length !== 4 || o.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) { - return false; +function addressKind(ip) { + if (net.isIP(ip) === 6) { + const canonical = new URL(`http://[${ip}]/`).hostname.slice(1, -1); + const mapped = canonical.match(/^::ffff:([a-f0-9]+):([a-f0-9]+)$/); + if (mapped) { + const high = parseInt(mapped[1], 16); + const low = parseInt(mapped[2], 16); + return addressKind(`${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`); + } + if (canonical === "::1") return "loopback"; + // Global unicast only; exclude transition and documentation ranges. + const [first, second = "0"] = canonical.split(":"); + if (/^[23]/.test(canonical) && + !(first === "2001" && (parseInt(second || "0", 16) < 512 || second === "db8")) && + first !== "2002" && first !== "3fff") return "public"; + return "private"; } - const [a, b] = o; - if (a === 127) return true; // loopback (localhost) — always allowed - if (allowPrivate) return true; - if (a === 0) return false; // "this" network - if (a === 10) return false; // private - if (a === 172 && b >= 16 && b <= 31) return false; // private - if (a === 192 && b === 168) return false; // private - if (a === 169 && b === 254) return false; // link-local + cloud metadata - if (a === 100 && b >= 64 && b <= 127) return false; // carrier-grade NAT - return true; -} - -function ipv6Allowed(ip, allowPrivate) { - const s = ip.toLowerCase(); - const mapped = s.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); - if (mapped) return ipv4Allowed(mapped[1], allowPrivate); - if (s === "::1") return true; // loopback - if (allowPrivate) return true; - if (s === "::") return false; // unspecified - if (/^fe[89ab]/.test(s)) return false; // fe80::/10 link-local - if (/^f[cd]/.test(s)) return false; // fc00::/7 unique-local - return true; + if (net.isIP(ip) !== 4) throw new Error("Invalid resolved address."); + const o = ip.split(".").map((n) => Number(n)); + const [a, b, c] = o; + if (a === 127) return "loopback"; + if (a === 0 || a === 10 || a >= 224 || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && (b === 168 || (b === 0 && (c === 0 || c === 2)))) || + (a === 169 && b === 254) || + (a === 100 && b >= 64 && b <= 127) || + (a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) || + (a === 203 && b === 0 && c === 113)) return "private"; + return "public"; } -function addressAllowed(ip, allowPrivate) { - const v = net.isIP(ip); - if (v === 4) return ipv4Allowed(ip, allowPrivate); - if (v === 6) return ipv6Allowed(ip, allowPrivate); - return false; +function parseTarget(rawUrl) { + const parsed = new URL(rawUrl); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error("Only HTTP and HTTPS URLs are supported."); + } + if (parsed.username || parsed.password) throw new Error("URL credentials are not supported."); + return parsed; } -// Resolve a hostname to its addresses and confirm none land in a denied range. -// Literal IPs are checked directly; explicit localhost names are always allowed. -async function assertHostAllowed(hostname, allowPrivate) { +async function resolveAddresses(hostname) { const host = hostname.startsWith("[") ? hostname.slice(1, -1) : hostname; - if (LOCAL_HOST_RE.test(host)) return; // localhost / *.localhost / 127.* / ::1 if (net.isIP(host)) { - if (!addressAllowed(host, allowPrivate)) { - throw new Error(`Blocked non-public address: ${host}`); - } - return; - } - let addrs; - try { - addrs = await dns.lookup(host, { all: true }); - } catch { - throw new Error(`Could not resolve host: ${host}`); + return [{ address: host, family: net.isIP(host) }]; } - for (const a of addrs) { - if (!addressAllowed(a.address, allowPrivate)) { - throw new Error(`Blocked non-public address for ${host}: ${a.address}`); + const addresses = await dns.lookup(host, { all: true }); + if (!addresses.length) throw new Error("Host resolved to no addresses."); + for (const record of addresses) { + if (!net.isIP(record.address) || net.isIP(record.address) !== record.family) { + throw new Error("Invalid DNS result."); } } + return addresses; +} + +/** Only trusted user/agent selection may call this, never discovered content. */ +export async function authorizePreview(rawUrl, allowPrivateNetwork = ALLOW_PRIVATE_NETWORK) { + const parsed = parseTarget(normalizeUrl(rawUrl)); + const addresses = await resolveAddresses(parsed.hostname); + const kinds = new Set(addresses.map((a) => addressKind(a.address))); + if (kinds.size !== 1) throw new Error("Host resolves to mixed network scopes."); + const kind = kinds.values().next().value; + if (kind === "private" && !allowPrivateNetwork) { + throw new Error("Private-network previews require OG_ALLOW_PRIVATE_NETWORK."); + } + return { + authorizedOrigin: kind === "public" ? null : parsed.origin, + allowPrivateNetwork, + }; } /** @@ -90,9 +97,12 @@ async function assertHostAllowed(hostname, allowPrivate) { export function normalizeUrl(input) { const trimmed = String(input ?? "").trim(); if (!trimmed) throw new Error("No URL provided."); - if (/^https?:\/\//i.test(trimmed)) return trimmed; + if (/^https?:\/\//i.test(trimmed)) return parseTarget(trimmed).href; + if (/^[a-z][a-z\d+.-]*:/i.test(trimmed) && !/^[^:/]+:\d+(?:[/?#]|$)/.test(trimmed)) { + throw new Error("Only HTTP and HTTPS URLs are supported."); + } const scheme = LOCAL_HOST_RE.test(trimmed) ? "http://" : "https://"; - return scheme + trimmed; + return parseTarget(scheme + trimmed).href; } const MAX_BYTES = 6 * 1024 * 1024; // 6 MB safety cap @@ -108,23 +118,32 @@ export function fetchUrl(rawUrl, options = {}) { accept = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", maxBytes = MAX_BYTES, allowPrivateNetwork = ALLOW_PRIVATE_NETWORK, + authorizedOrigin = null, } = options; return new Promise((resolve, reject) => { let redirects = 0; + let localOrigin = authorizedOrigin; const visit = async (urlStr) => { let parsed; try { - parsed = new URL(urlStr); + parsed = parseTarget(urlStr); } catch { - return reject(new Error(`Invalid URL: ${urlStr}`)); - } - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - return reject(new Error(`Unsupported protocol: ${parsed.protocol}`)); + return reject(new Error("Invalid or unsupported URL.")); } + let addresses; try { - await assertHostAllowed(parsed.hostname, allowPrivateNetwork); + if (parsed.origin !== localOrigin) localOrigin = null; + addresses = await resolveAddresses(parsed.hostname); + for (const { address } of addresses) { + const kind = addressKind(address); + if (kind !== "public" && + (parsed.origin !== localOrigin || + (kind === "private" && !allowPrivateNetwork))) { + throw new Error("Destination is outside the authorized preview origin."); + } + } } catch (err) { return reject(err); } @@ -134,6 +153,17 @@ export function fetchUrl(rawUrl, options = {}) { parsed, { method: "GET", + // Do not resolve again or reuse a connection validated for a + // different request. Keep the URL hostname for Host and TLS. + agent: false, + lookup: (_hostname, opts, callback) => { + const candidates = opts.family + ? addresses.filter((a) => a.family === opts.family) + : addresses; + if (!candidates.length) return callback(new Error("No validated address.")); + if (opts.all) return callback(null, candidates); + callback(null, candidates[0].address, candidates[0].family); + }, headers: { "User-Agent": USER_AGENT, Accept: accept, diff --git a/.github/extensions/og-preview/lib/request-access.mjs b/.github/extensions/og-preview/lib/request-access.mjs new file mode 100644 index 000000000..8df6398ad --- /dev/null +++ b/.github/extensions/og-preview/lib/request-access.mjs @@ -0,0 +1,25 @@ +import { randomBytes } from "node:crypto"; + +export function createAccess() { + return { + uiToken: randomBytes(32).toString("hex"), + browseToken: randomBytes(32).toString("hex"), + policy: {}, + }; +} + +/** Browse content can fetch resources, but cannot select origins or use tools. */ +export function requestAccess(entry, req, url) { + if (req.headers.host !== new URL(entry.url).host) return null; + const prefix = `/browse/${entry.browseToken}`; + if (url.pathname.startsWith(prefix + "/api/proxy")) { + const path = url.pathname.slice(prefix.length); + if (path === "/api/proxy" || path.startsWith("/api/proxy/")) { + return req.method === "GET" ? { path, privileged: false } : null; + } + } + if (url.searchParams.get("key") === entry.uiToken) { + return { path: url.pathname, privileged: true }; + } + return null; +} diff --git a/.github/extensions/og-preview/tests/fixtures/sdk.mjs b/.github/extensions/og-preview/tests/fixtures/sdk.mjs new file mode 100644 index 000000000..5e4fe06bf --- /dev/null +++ b/.github/extensions/og-preview/tests/fixtures/sdk.mjs @@ -0,0 +1,12 @@ +export let registration; +export const messages = []; +export const createCanvas = (options) => options; +export class CanvasError extends Error {} +export async function joinSession(options) { + registration = options; + return { + log() {}, + async send(message) { messages.push(message); }, + rpc: { canvas: { async open() {} } }, + }; +} diff --git a/.github/extensions/og-preview/tests/http-fetch.test.mjs b/.github/extensions/og-preview/tests/http-fetch.test.mjs new file mode 100644 index 000000000..3bb360b27 --- /dev/null +++ b/.github/extensions/og-preview/tests/http-fetch.test.mjs @@ -0,0 +1,155 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { EventEmitter } from "node:events"; +import http from "node:http"; +import https from "node:https"; +import dns from "node:dns/promises"; +import { authorizePreview, fetchUrl, normalizeUrl } from "../lib/http-fetch.mjs"; + +function transport(t, library = http, responses = [{}]) { + const requests = []; + t.mock.method(library, "request", (url, options, receive) => { + const req = new EventEmitter(); + req.setTimeout = () => {}; + req.destroy = () => {}; + req.end = () => { + const next = responses[requests.length - 1] || {}; + const res = new EventEmitter(); + res.statusCode = next.status || 200; + res.headers = next.headers || {}; + res.resume = () => {}; + receive(res); + queueMicrotask(() => { + res.emit("data", Buffer.from("ok")); + res.emit("end"); + }); + }; + requests.push({ url, options }); + return req; + }); + return requests; +} + +test("blocks private, mapped IPv6, transition, and loopback addresses without selection", async (t) => { + const requests = transport(t); + for (const host of [ + "169.254.169.254", "10.0.0.1", "127.0.0.1", "0.0.0.0", "100.64.0.1", + "[::1]", "[fc00::1]", "[fe80::1]", "[::ffff:169.254.169.254]", + "[0:0:0:0:0:ffff:a00:1]", "[::ffff:7f00:1]", "[2002:a00:1::]", + "[2001::1]", "[64:ff9b::a00:1]", + ]) { + await assert.rejects(fetchUrl(`http://${host}/`, { allowPrivateNetwork: false }), /authorized/); + } + assert.equal(requests.length, 0); +}); + +test("environment/private opt-in alone never permits discovered private destinations", async (t) => { + const requests = transport(t); + await assert.rejects(fetchUrl("http://10.0.0.1/", { allowPrivateNetwork: true }), /authorized/); + assert.equal(requests.length, 0); +}); + +test("explicit loopback authorization is exact host, scheme and port", async (t) => { + const requests = transport(t); + const policy = await authorizePreview("http://127.0.0.1:4321/", false); + assert.equal((await fetchUrl("http://127.0.0.1:4321/page", policy)).status, 200); + for (const url of ["http://127.0.0.1:4322/", "http://[::1]:4321/", "https://127.0.0.1:4321/"]) { + await assert.rejects(fetchUrl(url, policy), /authorized/); + } + assert.equal(requests.length, 1); +}); + +test("private selection needs opt-in and authorizes only that origin", async (t) => { + const requests = transport(t); + await assert.rejects(authorizePreview("http://10.0.0.1/", false), /OG_ALLOW_PRIVATE_NETWORK/); + const policy = await authorizePreview("http://10.0.0.1/", true); + await fetchUrl("http://10.0.0.1/page", policy); + await assert.rejects(fetchUrl("http://10.0.0.2/", policy), /authorized/); + assert.equal(requests.length, 1); +}); + +test("public selection does not pre-authorize later DNS rebinding to loopback", async (t) => { + const requests = transport(t); + t.mock.method(dns, "lookup", async () => [{ address: "8.8.8.8", family: 4 }]); + const policy = await authorizePreview("http://preview.example/", false); + t.mock.method(dns, "lookup", async () => [{ address: "127.0.0.1", family: 4 }]); + await assert.rejects(fetchUrl("http://preview.example/", policy), /authorized/); + assert.equal(requests.length, 0); +}); + +test("pins validated DNS addresses while preserving the hostname for Host and TLS", async (t) => { + const lookups = t.mock.method(dns, "lookup", async () => [{ address: "8.8.8.8", family: 4 }]); + const requests = transport(t, https); + await fetchUrl("https://preview.example:443/path"); + const { url, options } = requests[0]; + assert.equal(url.hostname, "preview.example"); + assert.equal(url.protocol, "https:"); + assert.equal(options.agent, false); + assert.equal(options.rejectUnauthorized, undefined); + t.mock.method(dns, "lookup", async () => { throw new Error("Must not resolve again"); }); + options.lookup("preview.example", { all: true }, (err, addresses) => { + assert.ifError(err); + assert.deepEqual(addresses, [{ address: "8.8.8.8", family: 4 }]); + }); + options.lookup("preview.example", {}, (err, address, family) => { + assert.ifError(err); + assert.equal(address, "8.8.8.8"); + assert.equal(family, 4); + }); + assert.equal(lookups.mock.callCount(), 1); +}); + +test("checks every DNS result including localhost names and rejects empty answers", async (t) => { + const requests = transport(t); + for (const answers of [ + [], + [{ address: "8.8.8.8", family: 4 }, { address: "10.0.0.1", family: 4 }], + [{ address: "10.0.0.1", family: 4 }], + [{ address: "not-an-ip", family: 4 }], + ]) { + t.mock.method(dns, "lookup", async () => answers); + await assert.rejects(fetchUrl("http://untrusted.localhost/")); + } + assert.equal(requests.length, 0); +}); + +test("public IPv4 and IPv6 mapped addresses remain usable", async (t) => { + const requests = transport(t); + for (const host of ["8.8.8.8", "[2606:4700:4700::1111]", "[::ffff:808:808]"]) { + await fetchUrl(`http://${host}/`); + } + assert.equal(requests.length, 3); +}); + +test("revalidates redirect targets and never follows public-to-local redirects", async (t) => { + const requests = transport(t, http, [{ status: 302, headers: { location: "http://127.0.0.1/" } }]); + await assert.rejects(fetchUrl("http://8.8.8.8/"), /authorized/); + assert.equal(requests.length, 1); +}); + +test("local redirects can remain on the selected origin but not switch ports", async (t) => { + const requests = transport(t, http, [ + { status: 302, headers: { location: "/page" } }, + { status: 302, headers: { location: "http://127.0.0.1:2/" } }, + ]); + await assert.rejects(fetchUrl("http://127.0.0.1:1/", await authorizePreview("http://127.0.0.1:1/")), /authorized/); + assert.equal(requests.length, 2); +}); + +test("rejects unsupported schemes and embedded credentials before transport", async (t) => { + const requests = transport(t); + for (const url of ["file:///secret", "javascript:alert(1)", "ftp://8.8.8.8", "https://user:pass@8.8.8.8/"]) { + await assert.rejects(fetchUrl(url), /Invalid or unsupported/); + } + assert.equal(requests.length, 0); + assert.equal(normalizeUrl("localhost:4321"), "http://localhost:4321/"); + assert.equal(normalizeUrl("aspire.dev"), "https://aspire.dev/"); + assert.throws(() => normalizeUrl("file:///secret"), /Only HTTP/); + assert.throws(() => normalizeUrl("javascript:alert(1)"), /Only HTTP/); +}); + +test("a public resource cannot redirect back to an otherwise authorized local origin", async (t) => { + const requests = transport(t, http, [{ status: 302, headers: { location: "http://127.0.0.1/" } }]); + await assert.rejects(fetchUrl("http://8.8.8.8/", await authorizePreview("http://127.0.0.1/")), /authorized/); + assert.equal(requests.length, 1); +}); diff --git a/.github/extensions/og-preview/tests/request-access.test.mjs b/.github/extensions/og-preview/tests/request-access.test.mjs new file mode 100644 index 000000000..3d704ab17 --- /dev/null +++ b/.github/extensions/og-preview/tests/request-access.test.mjs @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { createAccess, requestAccess } from "../lib/request-access.mjs"; + +test("requires the UI key and literal server host for privileged routes", () => { + const entry = { ...createAccess(), url: "http://127.0.0.1:4321/" }; + const req = { method: "GET", headers: { host: "127.0.0.1:4321" } }; + const url = new URL(`/api/fetch?key=${entry.uiToken}`, entry.url); + assert.deepEqual(requestAccess(entry, req, url), { path: "/api/fetch", privileged: true }); + assert.equal(requestAccess(entry, { ...req, headers: { host: "rebound.example:4321" } }, url), null); + assert.equal(requestAccess(entry, req, new URL("/api/fetch", entry.url)), null); +}); + +test("browse keys only grant GET access to exact proxy routes", () => { + const entry = { ...createAccess(), url: "http://127.0.0.1:4321/" }; + const req = { method: "GET", headers: { host: "127.0.0.1:4321" } }; + const prefix = `/browse/${entry.browseToken}`; + for (const path of ["/api/proxy", "/api/proxy/http/example.com/path"]) { + const url = new URL(prefix + path, entry.url); + assert.deepEqual(requestAccess(entry, req, url), { path, privileged: false }); + assert.equal(requestAccess(entry, { ...req, method: "POST" }, url), null); + } + for (const path of ["/api/proxyevil", "/api/fetch", "/events", "/api/create-issue"]) { + assert.equal(requestAccess(entry, req, new URL(prefix + path, entry.url)), null); + } +}); + +test("capabilities are distinct across roles and canvas instances", () => { + const first = createAccess(); + const second = createAccess(); + assert.equal(new Set([first.uiToken, first.browseToken, second.uiToken, second.browseToken]).size, 4); + assert.equal(first.uiToken.length, 64); + const entry = { ...first, url: "http://127.0.0.1:4321/" }; + const req = { method: "GET", headers: { host: "127.0.0.1:4321" } }; + assert.equal(requestAccess(entry, req, new URL(`/api/fetch?key=${first.browseToken}`, entry.url)), null); + assert.equal(requestAccess(entry, req, new URL(`/api/fetch?key=${second.uiToken}`, entry.url)), null); +}); diff --git a/.github/extensions/og-preview/tests/server.test.mjs b/.github/extensions/og-preview/tests/server.test.mjs new file mode 100644 index 000000000..601a39175 --- /dev/null +++ b/.github/extensions/og-preview/tests/server.test.mjs @@ -0,0 +1,209 @@ +import assert from "node:assert/strict"; +import { test, before, after } from "node:test"; +import { registerHooks } from "node:module"; +import { createServer } from "node:http"; +import { once } from "node:events"; +import { registration, messages } from "./fixtures/sdk.mjs"; + +const hooks = registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === "@github/copilot-sdk/extension") { + return { url: new URL("./fixtures/sdk.mjs", import.meta.url).href, shortCircuit: true }; + } + return nextResolve(specifier, context); + }, +}); +await import("../extension.mjs"); +hooks.deregister(); + +let target; +let targetUrl; +let canvas; +let canvasUrl; +let key; +let hits = 0; +before(async () => { + target = createServer((req, res) => { + hits++; + res.setHeader("Content-Type", "text/html"); + if (req.url === "/bad-type") { + res.setHeader("Content-Type", "application/private-path-sentinel"); + } + if (req.url === "/redirect") { + res.writeHead(302, { Location: "http://127.0.0.1:1/" }); + res.end(); + return; + } + res.end(`Local preview + + + +Hello`); + }); + target.listen(0, "127.0.0.1"); + await once(target, "listening"); + targetUrl = `http://127.0.0.1:${target.address().port}`; + canvas = registration.canvases[0]; + const opened = await canvas.open({ instanceId: "test", input: { url: targetUrl } }); + canvasUrl = new URL(opened.url); + key = new URLSearchParams(canvasUrl.hash.slice(1)).get("key"); +}); +after(async () => { + await canvas.onClose({ instanceId: "test" }); + await new Promise((resolve) => target.close(resolve)); +}); +function api(path) { + const url = new URL(path, canvasUrl); + url.searchParams.set("key", key); + return url; +} +async function browse() { + const res = await fetch(api("/api/proxy?u=" + encodeURIComponent(targetUrl))); + return res.text(); +} +function resourceUrl(html) { + const value = html.match(/http:\/\/127\.0\.0\.1:\d+\/browse\/[a-f0-9]+\/api\/proxy\/http\/[^" ]+\/external\.js/); + assert.ok(value, "resource URL has a browse-only capability"); + return new URL(value[0]); +} + +test("requires the private outer-UI capability on API routes and SSE", async () => { + const beforeHits = hits; + for (const path of ["/api/fetch?select=1&u=" + encodeURIComponent(targetUrl), "/events", "/api/proxy?u=" + encodeURIComponent(targetUrl)]) { + const response = await fetch(new URL(path, canvasUrl)); + assert.equal(response.status, 403); + } + assert.equal(hits, beforeHits); +}); + +test("allows explicitly selected localhost metadata and same-origin resources", async () => { + const res = await fetch(api("/api/fetch?u=" + encodeURIComponent(targetUrl))); + assert.equal(res.status, 200); + assert.equal((await res.json()).resolved.title, "Local preview"); + const html = await browse(); + const resource = resourceUrl(html); + assert.equal((await fetch(resource)).status, 200); + assert.ok(!html.includes(key), "privileged token never enters fetched HTML"); +}); + +test("rewrites mixed-case inline modules with quoted delimiters and closing whitespace", async () => { + const html = await browse(); + assert.match(html, /import "http:\/\/127\.0\.0\.1:\d+\/browse\/[a-f0-9]+\/api\/proxy\/http\/127\.0\.0\.1:\d+\/module\.js"/); + assert.match(html, /const classic = "\/plain\.js"/); + assert.match(html, /import "http:\/\/127\.0\.0\.1:\d+\/browse\/[a-f0-9]+\/api\/proxy\/http\/127\.0\.0\.1:\d+\/end-attributes\.js"/); + assert.match(html, /import "http:\/\/127\.0\.0\.1:\d+\/browse\/[a-f0-9]+\/api\/proxy\/http\/127\.0\.0\.1:\d+\/end-slash\.js"/); +}); + +test("browse capability cannot authorize origins, send actions, or read events", async () => { + const resource = resourceUrl(await browse()); + const prefix = resource.pathname.slice(0, resource.pathname.indexOf("/api/proxy")); + const beforeMessages = messages.length; + for (const path of ["/api/fetch?select=1&u=http://127.0.0.1:1", "/api/open-session", "/events"]) { + const res = await fetch(new URL(prefix + path, canvasUrl)); + assert.equal(res.status, 403); + } + const res = await fetch(api("/api/create-issue"), { + method: "POST", + body: '{"repo":"microsoft/aspire.dev","prompt":"test"}', + }); + assert.equal(res.status, 200); + assert.equal(messages.length, beforeMessages + 1); +}); + +test("revokes old browse capabilities when the outer UI explicitly selects a target", async () => { + const oldResource = resourceUrl(await browse()); + const res = await fetch(api("/api/fetch?select=1&silent=1&u=" + encodeURIComponent(targetUrl))); + assert.equal((await res.json()).resolved.title, "Local preview"); + assert.equal((await fetch(oldResource)).status, 403); + assert.equal((await fetch(resourceUrl(await browse()))).status, 200); +}); + +for (const route of ["document", "resource"]) { + test(`in-flight ${route} responses retain their revoked browse capability`, async () => { + const html = 'Delayed preview'; + const started = Promise.withResolvers(); + let heldResponse; + let pending; + const delayedTarget = createServer((req, res) => { + res.setHeader("Content-Type", "text/html"); + if (req.url === "/delayed") { + heldResponse = res; + started.resolve(); + return; + } + res.end(html); + }); + delayedTarget.listen(0, "127.0.0.1"); + await once(delayedTarget, "listening"); + const origin = `http://127.0.0.1:${delayedTarget.address().port}`; + try { + const selection = await fetch(api("/api/fetch?select=1&silent=1&u=" + encodeURIComponent(origin))); + assert.equal((await selection.json()).resolved.title, "Delayed preview"); + const initial = await fetch(api("/api/proxy?u=" + encodeURIComponent(origin))); + const oldResource = resourceUrl(await initial.text()); + const delayedUrl = route === "document" + ? api("/api/proxy?u=" + encodeURIComponent(origin + "/delayed")) + : new URL(oldResource.href.replace("/external.js", "/delayed")); + pending = fetch(delayedUrl, { signal: AbortSignal.timeout(5000) }).then((res) => res.text()); + await Promise.race([ + started.promise, + pending.then(() => assert.fail("The upstream response must remain in flight.")), + ]); + + const reselection = await fetch(api("/api/fetch?select=1&silent=1&u=" + encodeURIComponent(targetUrl))); + assert.equal((await reselection.json()).resolved.title, "Local preview"); + const currentResource = resourceUrl(await browse()); + heldResponse.end(html); + const delayedHtml = await pending; + assert.equal(resourceUrl(delayedHtml).href, oldResource.href); + const currentPrefix = currentResource.pathname.split("/api/proxy")[0]; + assert.ok(!delayedHtml.includes(currentPrefix), "old content never receives the new capability"); + assert.equal((await fetch(oldResource)).status, 403); + assert.equal((await fetch(currentResource)).status, 200); + } finally { + heldResponse?.end(); + if (pending) await Promise.allSettled([pending]); + delayedTarget.closeAllConnections(); + await new Promise((resolve) => delayedTarget.close(resolve)); + } + }); +} + +test("returns fixed errors without paths, exception messages, or stack details", async () => { + const secret = "stack-path-sentinel"; + for (const path of ["/api/fetch", "/api/raw", "/api/agent-readiness"]) { + const res = await fetch(api(`${path}?u=${encodeURIComponent("file:///" + secret)}`)); + const body = await res.text(); + assert.ok(!body.includes(secret)); + assert.match(body, /error/); + } + const res = await fetch(api("/api/proxy?u=" + encodeURIComponent(targetUrl + "/redirect"))); + const html = await res.text(); + assert.match(html, /selected-origin access/); + assert.ok(!html.includes("Error:") && !html.includes(" at ")); + const badType = await fetch(api("/api/fetch?u=" + encodeURIComponent(targetUrl + "/bad-type"))); + const error = await badType.json(); + assert.ok(error.error); + assert.ok(!error.error.includes("private-path-sentinel")); +}); + +test("rejects unrelated loopback origins for all fetch surfaces", async () => { + const url = encodeURIComponent("http://127.0.0.1:1/"); + for (const path of ["/api/fetch", "/api/raw", "/api/img"]) { + const res = await fetch(api(`${path}?u=${url}`)); + if (path === "/api/img") assert.equal(res.status, 502); + else assert.match(await res.text(), /error/); + } + const resource = resourceUrl(await browse()); + resource.pathname = resource.pathname.replace(/\/http\/.*$/, "/http/127.0.0.1:1/"); + assert.equal((await fetch(resource)).status, 502); +}); + +test("standalone get_metadata action explicitly authorizes localhost", async () => { + const action = canvas.actions.find((action) => action.name === "get_metadata"); + const result = await action.handler({ input: { url: targetUrl } }); + assert.equal(result.resolved.title, "Local preview"); + const named = await action.handler({ input: { url: targetUrl.replace("127.0.0.1", "localhost") } }); + assert.equal(named.resolved.title, "Local preview"); +}); diff --git a/.github/extensions/og-preview/ui/app.js b/.github/extensions/og-preview/ui/app.js index a50e935c0..48b603d61 100644 --- a/.github/extensions/og-preview/ui/app.js +++ b/.github/extensions/og-preview/ui/app.js @@ -1,5 +1,13 @@ "use strict"; +const previewKey = new URLSearchParams(location.hash.slice(1)).get("key") || ""; +function apiUrl(path) { + const url = new URL(path, location.origin); + if (url.origin !== location.origin) throw new Error("Invalid preview API origin."); + url.searchParams.set("key", previewKey); + return url.pathname + url.search; +} + const TRANSPARENT = "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="; @@ -228,6 +236,9 @@ function withScheme(raw) { let v = (raw || "").trim(); if (!v || v === "https://" || v === "http://") return ""; if (/^https?:\/\//i.test(v)) return v; + // Preserve explicit schemes so the fetch/navigation boundary can reject + // them, rather than disguising file: or data: input as an HTTPS hostname. + if (/^[a-z][a-z\d+.-]*:/i.test(v) && !/^[^:/]+:\d+(?:[/?#]|$)/.test(v)) return v; v = v.replace(/^\/+/, ""); const isLocal = /^(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|[^/]+\.local)(:|\/|$)/i.test(v); return (isLocal ? "http://" : "https://") + v; @@ -278,7 +289,7 @@ function makeImage(url, className) { img.addEventListener("error", () => { if (img.dataset.stage === "direct") { img.dataset.stage = "proxy"; - img.src = "/api/img?u=" + encodeURIComponent(url); + img.src = apiUrl("/api/img?u=" + encodeURIComponent(url)); } else if (img.dataset.stage === "proxy") { img.dataset.stage = "placeholder"; img.src = TRANSPARENT; @@ -330,7 +341,7 @@ function showImgTip(url, x, y) { imgTipImg.onerror = () => { if (imgTipImg.dataset.stage === "direct") { imgTipImg.dataset.stage = "proxy"; - imgTipImg.src = "/api/img?u=" + encodeURIComponent(real); + imgTipImg.src = apiUrl("/api/img?u=" + encodeURIComponent(real)); } else { imgTipMeta.textContent = "Preview unavailable"; } @@ -899,7 +910,7 @@ async function showCodeCard(url, node) { let payload = codeCache.get(raw); if (!payload) { try { - const res = await fetch("/api/raw?u=" + encodeURIComponent(raw)); + const res = await fetch(apiUrl("/api/raw?u=" + encodeURIComponent(raw))); payload = await res.json(); } catch { payload = { error: "Couldn't load file." }; @@ -1621,7 +1632,7 @@ async function postAction(path, payload, btn, busyLabel, doneLabel) { btn.classList.add("busy"); if (labelEl && busyLabel) labelEl.textContent = busyLabel; try { - const res = await fetch(path, { + const res = await fetch(apiUrl(path), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), @@ -1995,7 +2006,7 @@ async function refreshAgentReadiness(data) { return; } try { - const res = await fetch("/api/agent-readiness?u=" + encodeURIComponent(targetUrl)); + const res = await fetch(apiUrl("/api/agent-readiness?u=" + encodeURIComponent(targetUrl))); const ar = await res.json(); if (seq !== arSeq) return; // a newer load superseded this probe if (!res.ok || ar.error) throw new Error(ar.error || `Request failed (${res.status})`); @@ -2159,7 +2170,8 @@ async function load(rawUrl, opts) { renderSkeleton(); try { const res = await fetch( - "/api/fetch?u=" + encodeURIComponent(url) + (silent ? "&silent=1" : ""), + apiUrl("/api/fetch?u=" + encodeURIComponent(url) + + (silent ? "&silent=1" : "") + (opts && opts.select ? "&select=1" : "")), ); const data = await res.json(); if (!res.ok || data.error) { @@ -2170,6 +2182,7 @@ async function load(rawUrl, opts) { input.value = data.requestedUrl || url; } pendingBrowseUrl = data.requestedUrl || url; + if (opts && opts.select) browseFrameUrl = ""; if (!(opts && opts.skipBrowse)) syncBrowseFrame(pendingBrowseUrl); document.title = data.requestedUrl ? `OG · ${(data.resolved && data.resolved.hostname) || data.requestedUrl}` @@ -2195,7 +2208,7 @@ async function load(rawUrl, opts) { $("#url-form").addEventListener("submit", (e) => { e.preventDefault(); - load(input.value); + load(input.value, { select: true }); }); $("#refresh").addEventListener("click", () => { if (browseActive()) { @@ -2266,7 +2279,7 @@ const luckyGo = $("#lucky-go"); if (luckyGo) { luckyGo.addEventListener("click", () => { input.value = luckyTarget; - load(luckyTarget); + load(luckyTarget, { select: true }); }); } @@ -2287,7 +2300,7 @@ if (luckyEmpty) { luckyEmpty.addEventListener("click", () => { const url = pickLuckySite(); input.value = url; - load(url); + load(url, { select: true }); }); } @@ -2430,7 +2443,7 @@ async function navBrowseFrame(rawUrl) { browsePanel.classList.add("has-browse"); const token = ++browseNavToken; try { - const res = await fetch("/api/proxy?u=" + encodeURIComponent(u)); + const res = await fetch(apiUrl("/api/proxy?u=" + encodeURIComponent(u))); const html = await res.text(); if (token !== browseNavToken) return; // a newer navigation superseded us browseFrame.srcdoc = html; @@ -2466,9 +2479,14 @@ $("#browse-open").addEventListener("click", () => { const u = withScheme(input.value || pendingBrowseUrl); if (!u) return; try { - window.open(u, "_blank", "noopener"); + const target = new URL(u); + if (target.protocol !== "http:" && target.protocol !== "https:") { + setStatus("error", "Only HTTP and HTTPS pages can be opened."); + return; + } + window.open(target.href, "_blank", "noopener"); } catch { - /* host may block popups */ + setStatus("error", "Couldn't open this URL."); } }); @@ -2477,6 +2495,7 @@ $("#browse-open").addEventListener("click", () => { // top-level URL input, advance the embedded frame to the new page, and refresh // every preview from it. window.addEventListener("message", (e) => { + if (e.source !== browseFrame.contentWindow) return; const m = e && e.data; if (!m || m.source !== "og-browse" || m.type !== "nav" || !m.url) return; if (!/^https?:\/\//i.test(m.url)) return; // ignore non-http targets (e.g. about:srcdoc) @@ -2542,12 +2561,13 @@ try { // Server-pushed loads (agent invoking the preview_url action). try { - const es = new EventSource("/events"); + const es = new EventSource(apiUrl("/events")); es.addEventListener("message", (e) => { try { const msg = JSON.parse(e.data); if (msg && msg.type === "load" && msg.url) { input.value = msg.url; + browseFrameUrl = ""; load(msg.url); } } catch { diff --git a/.github/scripts/merge-main-into-release.sh b/.github/scripts/merge-main-into-release.sh new file mode 100644 index 000000000..dca716029 --- /dev/null +++ b/.github/scripts/merge-main-into-release.sh @@ -0,0 +1,162 @@ +#!/usr/bin/env bash +# Usage: merge-main-into-release.sh +# Run from a clean release checkout. The caller owns fetching and pushing. +set -euo pipefail + +MAIN_SHA="$(git rev-parse --verify "${1:?main ref required}^{commit}")" +BRANCH="${2:?release branch required}" +RELEASE_SHA="$(git rev-parse HEAD)" +MERGE_BASE="$(git merge-base HEAD "$MAIN_SHA")" + +fail() { + echo "::error::$*" >&2 + exit 1 +} + +[[ -z "$(git status --porcelain)" ]] || fail "Release checkout must be clean." +if git rev-parse -q --verify MERGE_HEAD >/dev/null; then + fail "A merge is already in progress." +fi + +report() { + printf '%s\n' "$@" + if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then + printf '%s\n' "$@" >> "$GITHUB_STEP_SUMMARY" + fi +} + +report "## Release sync: $BRANCH" \ + "- Release SHA: \`$RELEASE_SHA\`" \ + "- Main SHA: \`$MAIN_SHA\`" \ + "- Merge base: \`$MERGE_BASE\`" + +ancestor_status=0 +git merge-base --is-ancestor "$MAIN_SHA" HEAD || ancestor_status=$? +case "$ancestor_status" in + 0) + report "Main is already an ancestor of release; nothing to merge." + exit 0 + ;; + 1) ;; + *) fail "Could not check main ancestry (exit $ancestor_status)." ;; +esac + +generated_files=( + src/frontend/src/data/aspire-integrations.json + src/frontend/src/data/github-stats.json + src/frontend/src/data/samples.json + src/frontend/src/data/twoslash/aspire.d.ts +) +generated_dirs=( + src/frontend/src/data/pkgs + src/frontend/src/data/ts-modules + src/frontend/src/assets/samples +) + +is_generated() { + local path + for path in "${generated_files[@]}"; do + [[ "$1" != "$path" ]] || return 0 + done + for path in "${generated_dirs[@]}"; do + case "$1" in "$path"|"$path"/*) return 0 ;; esac + done + return 1 +} + +is_curated() { + case "$1" in + src/frontend/src/assets/samples/*) return 1 ;; + src/frontend/src/content/*|src/frontend/src/assets/*) return 0 ;; + *) return 1 ;; + esac +} + +keep_release() { + if git cat-file -e "$RELEASE_SHA:$1" 2>/dev/null; then + git checkout "$RELEASE_SHA" -- "$1" + else + git rm -q --force --ignore-unmatch -- "$1" + fi +} + +conflicts_file="$(mktemp)" +cleanup() { + local status=$? + if [[ "$status" -ne 0 ]] && git rev-parse -q --verify MERGE_HEAD >/dev/null; then + if ! git merge --abort; then + echo "::error::Could not abort the failed release merge." >&2 + status=1 + fi + fi + rm -f -- "$conflicts_file" + exit "$status" +} +trap cleanup EXIT + +merge_status=0 +git merge --no-commit --no-ff "$MAIN_SHA" || merge_status=$? +if [[ "$merge_status" -gt 1 ]] || ! git rev-parse -q --verify MERGE_HEAD >/dev/null; then + fail "Git merge failed (exit $merge_status); main is not already integrated." +fi + +git diff --name-only --diff-filter=U -z > "$conflicts_file" +mapfile -d '' -t conflicts < "$conflicts_file" +if [[ "$merge_status" -ne 0 && "${#conflicts[@]}" -eq 0 ]]; then + fail "Git merge failed without resolvable conflict entries (exit $merge_status)." +fi + +unresolved=() +for path in "${conflicts[@]}"; do + if is_generated "$path"; then + continue + elif is_curated "$path"; then + keep_release "$path" + else + unresolved+=("$path") + fi +done + +if [[ "${#unresolved[@]}" -ne 0 ]]; then + report "" "### Conflicts requiring human review" + for path in "${unresolved[@]}"; do + report "- \`$path\`" + done + report "" \ + "Create a repair branch from \`$BRANCH\`, merge main into it, and resolve these conflicts without discarding release-only work." \ + "Land the repair PR with **Create a merge commit**, never squash or rebase. Copying content alone does not repair ancestry." \ + "After landing, verify \`git merge-base --is-ancestor $MAIN_SHA origin/$BRANCH\`, then dispatch a fresh workflow run from main." + fail "Automated merge aborted: ${#unresolved[@]} conflicts require human review." +fi + +# Only these producer-owned paths are mirrored. Other data is hand-authored. +for path in "${generated_files[@]}" "${generated_dirs[@]}"; do + diff_status=0 + git diff --quiet "$MAIN_SHA" -- "$path" || diff_status=$? + [[ "$diff_status" -le 1 ]] || fail "Could not compare generated path $path." + if [[ "$diff_status" -eq 0 && -z "$(git ls-files -u -- "$path")" ]]; then + continue + fi + git rm -r -q --force --ignore-unmatch -- "$path" + if git cat-file -e "$MAIN_SHA:$path" 2>/dev/null; then + git checkout "$MAIN_SHA" -- "$path" + fi +done + +[[ -z "$(git ls-files -u)" ]] || fail "Unexpected unmerged entries remain." +if ! markers="$(git diff --cached --check)"; then + [[ -n "$markers" ]] || fail "Could not check the merged diff." + if grep -q 'conflict marker' <<< "$markers"; then + fail "Conflict markers detected after auto-resolution." + fi + echo "::warning::Merged diff has whitespace warnings:" + printf '%s\n' "$markers" +fi + +git commit --no-edit --trailer "Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>" +git merge-base --is-ancestor "$MAIN_SHA" HEAD || fail "Merge did not preserve main ancestry; do not push." +read -r commit first_parent second_parent extra <<< "$(git rev-list --parents -n 1 HEAD)" +if [[ "$first_parent" != "$RELEASE_SHA" || "$second_parent" != "$MAIN_SHA" || -n "$extra" ]]; then + fail "Expected a two-parent release/main merge commit; do not push." +fi +report "" "Merged main into release with both parents preserved: \`$commit\`." diff --git a/.github/scripts/test-merge-main-into-release.sh b/.github/scripts/test-merge-main-into-release.sh new file mode 100644 index 000000000..cce4b78e0 --- /dev/null +++ b/.github/scripts/test-merge-main-into-release.sh @@ -0,0 +1,210 @@ +#!/usr/bin/env bash +set -euo pipefail + +export GIT_CONFIG_NOSYSTEM=1 +export GIT_CONFIG_GLOBAL=/dev/null +unset GITHUB_STEP_SUMMARY + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +HELPER="$SCRIPT_DIR/merge-main-into-release.sh" +TEST_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/release-sync-tests.XXXXXX")" +trap 'cd "$SCRIPT_DIR"; rm -rf -- "$TEST_ROOT"' EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +write_file() { + mkdir -p -- "$(dirname -- "$1")" + printf '%s\n' "$2" > "$1" +} + +commit_all() { + git add -A + git commit -qm "$1" +} + +new_repo() { + mkdir "$TEST_ROOT/$1" + cd "$TEST_ROOT/$1" + git init -q --initial-branch=main + git config user.name "Release sync test" + git config user.email "release-sync@example.invalid" + git config commit.gpgsign false + git config core.autocrlf false + write_file shared.txt base + commit_all base + git switch -qc release/test +} + +run_helper() { + if bash "$HELPER" main release/test > "$TEST_ROOT/output" 2>&1; then + return 0 + else + cat "$TEST_ROOT/output" >&2 + return 1 + fi +} + +expect_failure() { + if bash "$HELPER" main release/test > "$TEST_ROOT/output" 2>&1; then + fail "Expected merge failure." + fi + grep -q "$1" "$TEST_ROOT/output" || { cat "$TEST_ROOT/output"; fail "Missing expected diagnostic: $1"; } + [[ "$(git rev-parse HEAD)" == "$release_sha" ]] || fail "Failed merge changed release HEAD." + [[ -z "$(git status --porcelain)" ]] || fail "Failed merge left a dirty checkout." + if git rev-parse -q --verify MERGE_HEAD >/dev/null; then + fail "Failed merge was not aborted." + fi +} + +assert_parents() { + [[ "$(git show -s --format=%P HEAD)" == "$release_sha $(git rev-parse main)" ]] || fail "Merge parents differ." + git merge-base --is-ancestor main HEAD || fail "Main ancestry was lost." +} + +new_repo no-op +release_sha="$(git rev-parse HEAD)" +run_helper +[[ "$(git rev-parse HEAD)" == "$release_sha" ]] || fail "No-op created a commit." +grep -q "already an ancestor" "$TEST_ROOT/output" || fail "Missing no-op diagnostic." +echo "PASS: ancestor no-op" + +new_repo clean-merge +write_file release.txt release +commit_all release +release_sha="$(git rev-parse HEAD)" +git switch -q main +write_file main.txt main +commit_all main +git switch -q release/test +run_helper +assert_parents +[[ -f main.txt && -f release.txt ]] || fail "Clean merge lost files." +echo "PASS: clean merge preserves both parents" + +new_repo path-policies +generated_files=( + src/frontend/src/data/aspire-integrations.json + src/frontend/src/data/github-stats.json + src/frontend/src/data/samples.json + src/frontend/src/data/twoslash/aspire.d.ts +) +generated_dirs=( + src/frontend/src/data/pkgs + src/frontend/src/data/ts-modules + src/frontend/src/assets/samples +) +curated=src/frontend/src/content/docs/curated.mdx +deleted=src/frontend/src/content/docs/deleted.mdx +asset=src/frontend/src/assets/dashboard.txt +for path in "${generated_files[@]}" "$curated" "$deleted" "$asset"; do + write_file "$path" base +done +for path in "${generated_dirs[@]}"; do + write_file "$path/base-version.txt" base +done +commit_all shared-paths +git switch -q main +git merge -q --ff-only release/test +for path in "${generated_files[@]}" "$curated" "$deleted" "$asset"; do + write_file "$path" main +done +git rm -q -f -- src/frontend/src/data/samples.json +for path in "${generated_dirs[@]}"; do + git mv "$path/base-version.txt" "$path/main-version.txt" +done +write_file src/frontend/src/content/docs/new.mdx "nonconflicting main content" +commit_all main-paths +git switch -q release/test +for path in "${generated_files[@]}" "$curated" "$asset"; do + write_file "$path" release +done +git rm -q -- "$deleted" +for path in "${generated_dirs[@]}"; do + git mv "$path/base-version.txt" "$path/release-version.txt" +done +write_file src/frontend/src/data/hand-authored.json release +commit_all release-paths +release_sha="$(git rev-parse HEAD)" +run_helper +assert_parents +git diff --exit-code main HEAD -- "${generated_files[@]}" "${generated_dirs[@]}" +[[ "$(cat "$curated")" == release && "$(cat "$asset")" == release ]] || fail "Curated content was overwritten." +[[ ! -e "$deleted" ]] || fail "Release-side deletion was lost." +[[ -f src/frontend/src/content/docs/new.mdx ]] || fail "Nonconflicting content did not merge." +[[ "$(cat src/frontend/src/data/hand-authored.json)" == release ]] || fail "Hand-authored data was overwritten." +echo "PASS: generated mirroring, curated conflicts/deletions, nonconflicting content" + +new_repo unknown-conflict +write_file shared.txt release +commit_all release +release_sha="$(git rev-parse HEAD)" +git switch -q main +write_file shared.txt main +commit_all main +git switch -q release/test +export GITHUB_STEP_SUMMARY="$TEST_ROOT/summary" +expect_failure "conflicts require human review" +grep -q 'shared.txt' "$GITHUB_STEP_SUMMARY" || fail "Summary omitted conflict path." +grep -q 'never squash or rebase' "$GITHUB_STEP_SUMMARY" || fail "Summary omitted ancestry guidance." +unset GITHUB_STEP_SUMMARY +echo "PASS: unknown conflict aborts with recovery instructions" + +new_repo fatal-merge +release_sha="$(git rev-parse HEAD)" +git switch -q main +write_file main.txt main +commit_all main +git switch -q release/test +# Refuse Git's index lock without introducing tracked or untracked changes. +write_file .git/index.lock locked +expect_failure "Git merge failed" +[[ ! "$(cat "$TEST_ROOT/output")" == *"nothing to merge"* ]] || fail "Fatal error was reported as a no-op." +rm -- .git/index.lock +echo "PASS: fatal merge without MERGE_HEAD is not a no-op" + +new_repo conflict-markers +release_sha="$(git rev-parse HEAD)" +git switch -q main +write_file marker.txt '<<<<<<< HEAD' +commit_all markers +git switch -q release/test +expect_failure "Conflict markers detected" +echo "PASS: conflict marker rejection" + +new_repo squash-ancestry +git switch -q main +write_file shared.txt first-main +commit_all first-main +git switch -q release/test +git merge -q --squash main +git commit -qm squash-repair +if git merge-base --is-ancestor main HEAD; then + fail "Squash fixture unexpectedly preserved ancestry." +fi +write_file shared.txt release-after-squash +commit_all release-after-squash +release_sha="$(git rev-parse HEAD)" +git switch -q main +write_file shared.txt second-main +commit_all second-main +git switch -q release/test +expect_failure "conflicts require human review" +# Reconcile deliberately as a human would, retaining both parent histories. +merge_status=0 +git merge --no-commit --no-ff main > "$TEST_ROOT/manual-merge" 2>&1 || merge_status=$? +[[ "$merge_status" == 1 ]] || fail "Expected a manual repair conflict." +write_file shared.txt reconciled +commit_all real-repair +git merge-base --is-ancestor main HEAD || fail "Real repair did not establish ancestry." +git switch -q main +write_file next.txt next +commit_all next-main +git switch -q release/test +release_sha="$(git rev-parse HEAD)" +run_helper +assert_parents +[[ "$(cat shared.txt)" == reconciled && -f next.txt ]] || fail "Next sync revisited repaired content." +echo "PASS: real repair avoids repeating squash-era conflicts" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b80614e2..070a50454 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,8 +102,19 @@ jobs: if: ${{ needs.changes.outputs.apphost == 'true' }} uses: ./.github/workflows/apphost-build.yml + release-sync-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Test release merge policy and ancestry + shell: bash + run: | + bash -n .github/scripts/merge-main-into-release.sh + bash -n .github/scripts/test-merge-main-into-release.sh + bash .github/scripts/test-merge-main-into-release.sh + ci-gate: - needs: [changes, frontend-build, apphost-build] + needs: [changes, frontend-build, apphost-build, release-sync-tests] if: ${{ always() && !cancelled() }} runs-on: ubuntu-latest steps: @@ -115,12 +126,19 @@ jobs: APPHOST_CHANGED: ${{ needs.changes.outputs.apphost }} FRONTEND_RESULT: ${{ needs['frontend-build'].result }} APPHOST_RESULT: ${{ needs['apphost-build'].result }} + RELEASE_SYNC_RESULT: ${{ needs['release-sync-tests'].result }} run: | echo "changes result: $CHANGES_RESULT" echo "frontend changed: $FRONTEND_CHANGED" echo "frontend-build result: $FRONTEND_RESULT" echo "apphost changed: $APPHOST_CHANGED" echo "apphost-build result: $APPHOST_RESULT" + echo "release-sync-tests result: $RELEASE_SYNC_RESULT" + + if [[ "$RELEASE_SYNC_RESULT" != "success" ]]; then + echo "The release sync tests must succeed." + exit 1 + fi if [[ "$CHANGES_RESULT" != "success" ]]; then echo "The changes job must succeed." diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000..0d5c0a0c9 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,54 @@ +name: CodeQL + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: "23 7 * * 3" + workflow_dispatch: + +permissions: + actions: read + contents: read + packages: read + security-events: write + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - language: javascript-typescript + build-mode: none + - language: csharp + build-mode: manual + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup .NET + if: matrix.language == 'csharp' + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + global-json-file: global.json + + - name: Initialize CodeQL + uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + + - name: Build C# solution + if: matrix.language == 'csharp' + # CodeQL needs compiler extraction, not the Aspire orchestration bundle. + run: dotnet build Aspire.Dev.slnx --configuration Release -p:AspireUseCliBundle=false + + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/og-preview-tests.yml b/.github/workflows/og-preview-tests.yml new file mode 100644 index 000000000..19431e5e7 --- /dev/null +++ b/.github/workflows/og-preview-tests.yml @@ -0,0 +1,25 @@ +name: OG preview tests + +on: + pull_request: + paths: + - ".github/extensions/og-preview/**" + - ".github/workflows/og-preview-tests.yml" + push: + branches: [main] + paths: + - ".github/extensions/og-preview/**" + - ".github/workflows/og-preview-tests.yml" + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24.x" + - run: node --test .github/extensions/og-preview/tests/*.test.mjs diff --git a/.github/workflows/update-release-branch.yml b/.github/workflows/update-release-branch.yml index 7ec4b09be..5c6dcfcf3 100644 --- a/.github/workflows/update-release-branch.yml +++ b/.github/workflows/update-release-branch.yml @@ -84,130 +84,8 @@ jobs: git fetch origin main MAIN_SHA="$(git rev-parse origin/main)" - # The release branch is a curated snapshot of the shipped site. Merging - # main into it conflicts in three, and only three, well-understood ways: - # 1. Generated data -> main is authoritative (mirror it). - # 2. Curated content/imagery -> the release branch is authoritative. - # 3. Anything else -> a human must decide; abort cleanly. - # - # (1) is the exact output set of the update-integration-data workflow - # (its `allowed-files`); those paths are 100% machine-generated, so main - # always wins and mirroring also clears the version-stamped rename/rename - # orphans they routinely produce. (2) is site content plus documentation - # imagery, which the release branch pins to release-specific versions (the - # "Aspire 13.5 is available" banner, 13.5 dashboard screenshots, ...) and - # must never regress. (3) is a genuine divergence we refuse to guess at. - - GEN_FILES=" - src/frontend/src/data/aspire-integrations.json - src/frontend/src/data/github-stats.json - src/frontend/src/data/samples.json - src/frontend/src/data/twoslash/aspire.d.ts - " - GEN_DIRS=" - src/frontend/src/data/pkgs - src/frontend/src/data/ts-modules - src/frontend/src/assets/samples - " - - is_generated() { - case "$1" in - src/frontend/src/data/aspire-integrations.json|\ - src/frontend/src/data/github-stats.json|\ - src/frontend/src/data/samples.json|\ - src/frontend/src/data/twoslash/aspire.d.ts|\ - src/frontend/src/data/pkgs/*|\ - src/frontend/src/data/ts-modules/*|\ - src/frontend/src/assets/samples/*) return 0 ;; - *) return 1 ;; - esac - } - - # assets/samples/** is generated and is matched by is_generated first; - # it is excluded here defensively in case the checks are reordered. - is_curated() { - case "$1" in - src/frontend/src/assets/samples/*) return 1 ;; - src/frontend/src/content/*) return 0 ;; - src/frontend/src/assets/*) return 0 ;; - *) return 1 ;; - esac - } - - # Keep the release branch's version, honoring a release-side deletion. - keep_release() { - if git cat-file -e "HEAD:$1" 2>/dev/null; then - git checkout HEAD -- "$1" - git add -- "$1" - else - git rm -q --force --ignore-unmatch -- "$1" >/dev/null - fi - } - - # A generated path needs syncing when it has a conflict stage or its - # merged content differs from main. - needs_mirror() { - [ -n "$(git ls-files -u -- "$1")" ] && return 0 - git diff --quiet "$MAIN_SHA" -- "$1" 2>/dev/null || return 0 - return 1 - } - - git merge --no-commit --no-ff origin/main || true - - if ! git rev-parse -q --verify MERGE_HEAD >/dev/null; then - echo "Already up to date with origin/main; nothing to merge." - exit 0 - fi - - # Triage conflicts: generated ones are handled by the mirror step below; - # keep-release for curated paths; flag anything else as unresolvable. - unresolved=0 - while IFS= read -r f; do - [ -z "$f" ] && continue - if is_generated "$f"; then - continue - elif is_curated "$f"; then - keep_release "$f" - else - echo "::error::Unresolvable merge conflict in ${f} (not a generated or curated path)." - unresolved=1 - fi - done < <(git diff --name-only --diff-filter=U) - - if [ "$unresolved" -ne 0 ]; then - git merge --abort - echo "::error::Automated merge of main into ${BRANCH} aborted; a human must resolve the conflicts above." - exit 1 - fi - - # Force every generated producer path to match main exactly. This clears - # remaining generated conflicts and drops stale version-stamped orphans, - # without ever touching hand-authored files under data/. - for p in $GEN_FILES $GEN_DIRS; do - if git cat-file -e "$MAIN_SHA:$p" 2>/dev/null; then - if needs_mirror "$p"; then - git rm -r -q --force --ignore-unmatch -- "$p" >/dev/null 2>&1 || true - git checkout "$MAIN_SHA" -- "$p" - git add -A -- "$p" - fi - else - git rm -r -q --force --ignore-unmatch -- "$p" >/dev/null 2>&1 || true - fi - done - - if [ -n "$(git ls-files -u)" ]; then - git merge --abort - echo "::error::Unexpected unmerged entries remain after auto-resolution; aborting." - exit 1 - fi - - markers="$(git diff --cached --check || true)" - if printf '%s' "$markers" | grep -q 'conflict marker'; then - git merge --abort - echo "::error::Conflict markers detected after auto-resolution; aborting." - exit 1 - fi - - git commit --no-edit - echo "Merged origin/main into ${BRANCH} with automated conflict resolution." - git push + # Checkout is on release; always run the helper from the pinned main + # commit, including when release has not received the helper yet. + git show "$MAIN_SHA:.github/scripts/merge-main-into-release.sh" > "$RUNNER_TEMP/merge-main-into-release.sh" + bash "$RUNNER_TEMP/merge-main-into-release.sh" "$MAIN_SHA" "$BRANCH" + git push origin "HEAD:refs/heads/$BRANCH" diff --git a/src/frontend/astro.config.mjs b/src/frontend/astro.config.mjs index 73d95a3ff..4d9523fa8 100644 --- a/src/frontend/astro.config.mjs +++ b/src/frontend/astro.config.mjs @@ -3,7 +3,6 @@ import { defineConfig } from 'astro/config'; import { unified } from '@astrojs/markdown-remark'; import { sidebarTopics } from './config/sidebar/sidebar.topics.ts'; import { redirects } from './config/redirects.mjs'; -import { iconPacks } from './config/icon-packs.mjs'; import { locales } from './config/locales.ts'; import { headAttrs } from './config/head.attrs.ts'; import { socialConfig } from './config/socials.config.ts'; @@ -11,16 +10,15 @@ import { aspireProject } from './src/data/aspire-project.ts'; import { aspireVersionPlaceholdersIntegration } from './config/aspire-version-placeholders-integration.mjs'; import { remarkAspireVersionPlaceholders } from './config/remark-aspire-version-placeholders.mjs'; import { remarkTypeScriptFirstAppHostTabs } from './config/remark-typescript-first-apphost-tabs.mjs'; +import { remarkMermaid } from './config/remark-mermaid.mjs'; import catppuccin from '@catppuccin/starlight'; import lunaria from './config/lunaria-starlight.mjs'; -import mermaid from 'astro-mermaid'; import mdx from '@astrojs/mdx'; import starlightGitHubAlerts from 'starlight-github-alerts'; import starlightImageZoom from 'starlight-image-zoom'; import starlightKbd from 'starlight-kbd'; import starlightLinksValidator from 'starlight-links-validator'; import starlightLlmsTxt from 'starlight-llms-txt'; -import starlightScrollToTop from 'starlight-scroll-to-top'; import starlightSidebarTopics from 'starlight-sidebar-topics'; import starlightPageActions from 'starlight-page-actions'; import buildTiming from './config/build-timing.mjs'; @@ -62,13 +60,27 @@ export default defineConfig({ // Resolve filesystem-backed redirects before prerender modules are bundled. __ASPIRE_REDIRECT_PATHS__: JSON.stringify(Object.keys(redirects)), }, + ...(staticHostUrl + ? { + server: { + proxy: { + // Bypass Astro's trailing-slash routing for JSON and SSE. + '^/api/live(?:/.*)?$': { + target: staticHostUrl, + changeOrigin: true, + secure: false, + }, + }, + }, + } + : {}), }, prefetch: true, site: 'https://aspire.dev', trailingSlash: 'always', markdown: { processor: unified({ - remarkPlugins: [remarkTypeScriptFirstAppHostTabs, remarkAspireVersionPlaceholders], + remarkPlugins: [remarkTypeScriptFirstAppHostTabs, remarkAspireVersionPlaceholders, remarkMermaid], }), }, redirects: redirects, @@ -144,31 +156,6 @@ export default defineConfig({ '/hub/', '/hub/glossary/ats/', '/get-started/glossary/#polyglot', ], }), - starlightScrollToTop({ - // https://frostybee.github.io/starlight-scroll-to-top/svg-paths/ - svgPath: 'M4 16L12 8L20 16', - showTooltip: true, - threshold: 10, - showOnHomepage: true, - svgStrokeWidth: 4, - tooltipText: { - da: 'Rul op', - de: 'Nach oben scrollen', - en: 'Scroll to top', - es: 'Ir arriba', - fr: 'Retour en haut', - hi: 'ऊपर स्क्रॉल करें', - id: 'Gulir ke atas', - it: 'Torna su', - ja: 'トップへ戻る', - ko: '맨 위로', - 'pt-br': 'Voltar ao topo', - ru: 'Наверх', - tr: 'Başa dön', - uk: 'Прокрутити вгору', - 'zh-cn': '回到顶部', - }, - }), starlightGitHubAlerts(), starlightLlmsTxt({ projectName: 'Aspire', @@ -240,11 +227,6 @@ export default defineConfig({ ], }, }), - mermaid({ - theme: 'forest', - autoTheme: true, - iconPacks, - }), mdx({ optimize: true, gfm: true, @@ -255,21 +237,4 @@ export default defineConfig({ build: { concurrency: buildConcurrency, }, - ...(staticHostUrl - ? { - vite: { - server: { - proxy: { - // A regular-expression context bypasses Astro's trailing-slash - // routing for both the JSON snapshot and SSE stream. - '^/api/live(?:/.*)?$': { - target: staticHostUrl, - changeOrigin: true, - secure: false, - }, - }, - }, - }, - } - : {}), }); diff --git a/src/frontend/config/icon-packs.mjs b/src/frontend/config/icon-packs.mjs index 3e852800a..10df0c11e 100644 --- a/src/frontend/config/icon-packs.mjs +++ b/src/frontend/config/icon-packs.mjs @@ -1,6 +1,5 @@ // Icon packs configuration for Mermaid diagrams. -// astro-mermaid now serializes icon packs as name/url pairs, so custom packs -// must be served from a static JSON endpoint instead of an inline loader. +// Loaded lazily by src/scripts/mermaid.ts. export const iconPacks = [ { diff --git a/src/frontend/config/remark-mermaid.mjs b/src/frontend/config/remark-mermaid.mjs new file mode 100644 index 000000000..77efff657 --- /dev/null +++ b/src/frontend/config/remark-mermaid.mjs @@ -0,0 +1,24 @@ +/** Turn opted-in fences into plain pre elements before code highlighting. */ +export function remarkMermaid() { + return (tree) => { + function visit(parent) { + for (const [index, node] of parent.children.entries()) { + if (node.type === 'code' && node.lang === 'mermaid') { + parent.children[index] = { + type: 'paragraph', + children: [], + data: { + hName: 'pre', + hProperties: { className: ['mermaid'] }, + hChildren: [{ type: 'text', value: node.value }], + }, + position: node.position, + }; + } else if (Array.isArray(node.children)) { + visit(node); + } + } + } + visit(tree); + }; +} diff --git a/src/frontend/package.json b/src/frontend/package.json index 981891ff3..f22d80ea0 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -82,7 +82,6 @@ "astro": "^7.2.8", "astro-contributors": "^0.9.0", "astro-expressive-code": "^0.44.1", - "astro-mermaid": "^2.1.0", "astro-tooltips": "^0.6.2", "hast-util-to-html": "^9.0.5", "mdast-util-to-hast": "^13.2.1", @@ -98,7 +97,6 @@ "starlight-llms-txt": "^0.11.0", "starlight-page-actions": "^0.7.0", "starlight-plugin-icons": "^1.1.6", - "starlight-scroll-to-top": "^1.0.1", "starlight-sidebar-topics": "^0.8.0" }, "devDependencies": { diff --git a/src/frontend/pnpm-lock.yaml b/src/frontend/pnpm-lock.yaml index 278471734..d88ad5e24 100644 --- a/src/frontend/pnpm-lock.yaml +++ b/src/frontend/pnpm-lock.yaml @@ -107,9 +107,6 @@ importers: astro-expressive-code: specifier: ^0.44.1 version: 0.44.1(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) - astro-mermaid: - specifier: ^2.1.0 - version: 2.1.0(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(mermaid@11.16.1) astro-tooltips: specifier: ^0.6.2 version: 0.6.2 @@ -155,9 +152,6 @@ importers: starlight-plugin-icons: specifier: ^1.1.6 version: 1.1.6(patch_hash=ecdc00afa0722d3cdc77871f9debee551f67d1c02d82fea11c5aa41bf0f48a54)(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3))(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3)(unocss@66.7.5(@unocss/astro@66.7.5(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)))(zod@4.4.3) - starlight-scroll-to-top: - specifier: ^1.0.1 - version: 1.0.1(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3)) starlight-sidebar-topics: specifier: ^0.8.0 version: 0.8.0(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3)) @@ -1995,16 +1989,6 @@ packages: peerDependencies: astro: ^4.0.0-beta || ^5.0.0-beta || ^3.3.0 || ^6.0.0-beta || ^7.0.0 - astro-mermaid@2.1.0: - resolution: {integrity: sha512-fFRUN0BTZh+DZhDiLyblXoO26XqJ1Rr+qK3JGgSu7OBspKHDm59jkztg/aHsrdo1vO/tIq/+xhP/vgT8Mp92XA==} - peerDependencies: - '@mermaid-js/layout-elk': ^0.2.0 - astro: '>=4' - mermaid: ^10.0.0 || ^11.0.0 - peerDependenciesMeta: - '@mermaid-js/layout-elk': - optional: true - astro-tooltips@0.6.2: resolution: {integrity: sha512-I9uXbchctnRqbc0mnxKcBRfweMuql/U+619+MzNvq3kANc7xthOXj6cMNgAkTaXoHJLdFMKL3Fx6vB5cyiiRXg==} @@ -3809,12 +3793,6 @@ packages: unocss: '>=0.58.0' zod: '>=3.22.0 || >=4.0.0' - starlight-scroll-to-top@1.0.1: - resolution: {integrity: sha512-OkSVwEQBzHkYPgBYL/O9xSiFn75j6HvrmjvdL8+3Kk0oxgt4sNmkOwhB0bGjB/T7M7SAJ4AFk1Ojza1HVxOeHQ==} - engines: {node: '>=22.12.0'} - peerDependencies: - '@astrojs/starlight': '>=0.38.0' - starlight-sidebar-topics@0.8.0: resolution: {integrity: sha512-hE8+w2vNL13h6cq3UJGl05milJQ3+1BSlhaV5V4a993YzYczU6uLAj6jfUDKQ2mmgkdVWp8/UNDF4Je/rQXNQw==} engines: {node: '>=22.12.0'} @@ -6102,14 +6080,6 @@ snapshots: rehype-expressive-code: 0.44.1 url-extras: 0.1.0 - astro-mermaid@2.1.0(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(mermaid@11.16.1): - dependencies: - astro: 7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) - import-meta-resolve: 4.2.0 - mdast-util-to-string: 4.0.0 - mermaid: 11.16.1 - unist-util-visit: 5.1.0 - astro-tooltips@0.6.2: dependencies: tippy.js: 6.3.7 @@ -8641,10 +8611,6 @@ snapshots: unocss: 66.7.5(@unocss/astro@66.7.5(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) zod: 4.4.3 - starlight-scroll-to-top@1.0.1(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3)): - dependencies: - '@astrojs/starlight': 0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3) - starlight-sidebar-topics@0.8.0(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3)): dependencies: '@astrojs/starlight': 0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3) diff --git a/src/frontend/public/scripts/analytics/1ds.js b/src/frontend/public/scripts/analytics/1ds.js index 6c1ebc9fc..7829b16f7 100644 --- a/src/frontend/public/scripts/analytics/1ds.js +++ b/src/frontend/public/scripts/analytics/1ds.js @@ -2,7 +2,6 @@ (function () { if (typeof location !== 'undefined' && location.origin !== 'https://aspire.dev') { - console.debug('[1ds] Skipping load for origin:', location.origin); return; } @@ -11,7 +10,6 @@ } if (window.analytics && window.analytics.__initialized) { - console.debug('[1ds] Already initialized, skipping.'); return; } @@ -21,6 +19,7 @@ { instrumentationKey: '1c6ad99c3e274af7881b9c3c78eed459-573e6b44-ab25-4e60-97ad-7b7f38f0243a-6923', + disablePageUnloadEvents: ['unload'], channelConfiguration: { eventsLimitInMem: 50 }, propertyConfiguration: { env: 'PROD' }, webAnalyticsConfiguration: { @@ -42,6 +41,6 @@ analytics.__initialized = true; window.analytics = analytics; } catch (err) { - console.debug('[1ds] Failed to initialize Application Insights:', err); + console.warn('[1ds] Failed to initialize Application Insights:', err); } })(); diff --git a/src/frontend/public/scripts/analytics/track.js b/src/frontend/public/scripts/analytics/track.js index 2f570a065..ade61441c 100644 --- a/src/frontend/public/scripts/analytics/track.js +++ b/src/frontend/public/scripts/analytics/track.js @@ -2,12 +2,10 @@ (function () { if (!window.analytics || !window.analytics.__initialized) { - console.debug('[track] Analytics not initialized, skipping event tracking setup.'); return; } if (window.analytics.__trackingBound) { - console.debug('[track] Event tracking already bound, skipping.'); return; } @@ -42,11 +40,9 @@ try { window.analytics.capturePageAction(target, overrides); - console.debug('[track] Event tracked:', eventName, overrides); } catch (err) { - console.debug('[track] Failed to track event:', err); + console.warn('[track] Failed to track event:', err); } }); - console.debug('[track] Event tracking bound.'); })(); diff --git a/src/frontend/src/components/InstallCliModal.astro b/src/frontend/src/components/InstallCliModal.astro index b69be4701..9e7622674 100644 --- a/src/frontend/src/components/InstallCliModal.astro +++ b/src/frontend/src/components/InstallCliModal.astro @@ -175,9 +175,15 @@ const channelOptions: CustomSelectOption[] = [ return 'release'; } + let modalListeners: AbortController | undefined; + function initializeModal() { + modalListeners?.abort(); + modalListeners = undefined; const modal = document.getElementById('install-cli-modal') as HTMLDialogElement; if (!modal) return; + modalListeners = new AbortController(); + const { signal } = modalListeners; const versionSelect = modal.querySelector('#version-select') as HTMLSelectElement; const openBtns = document.querySelectorAll('[data-open-install-modal]'); @@ -227,53 +233,44 @@ const channelOptions: CustomSelectOption[] = [ versionSelect.addEventListener('change', (e) => { const quality = (e.target as HTMLSelectElement).value as 'release' | 'staging' | 'dev'; updateVersionDisplay(quality); - }); + }, { signal }); } // Open modal and position it openBtns.forEach((openBtn) => { - if (openBtn.dataset.installModalBound === 'true') { - return; - } - - openBtn.dataset.installModalBound = 'true'; openBtn.addEventListener('click', (e) => { e.preventDefault(); openModal(openBtn); - }); + }, { signal }); }); // Close modal handlers - closeBtn?.addEventListener('click', () => modal.close()); + closeBtn?.addEventListener('click', () => modal.close(), { signal }); // Close on backdrop click modal.addEventListener('click', (e) => { if (e.target === modal) modal.close(); - }); + }, { signal }); // Close on Escape key modal.addEventListener('keydown', (e) => { if (e.key === 'Escape') modal.close(); - }); + }, { signal }); // Close modal if window is resized below mobile breakpoint window.addEventListener('resize', () => { if (window.innerWidth < 800 && modal.open) modal.close(); - }); + }, { signal }); } - // Handle install button clicks via event delegation (works across page navigations) - document.addEventListener('click', (e) => { - const target = e.target as HTMLElement; - const openBtn = target.closest('[data-open-install-modal]'); - if (!openBtn) return; - - e.preventDefault(); + document.addEventListener('astro:before-swap', () => { + modalListeners?.abort(); + modalListeners = undefined; }); // Initialize on page load if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initializeModal); + document.addEventListener('DOMContentLoaded', initializeModal, { once: true }); } else { initializeModal(); } diff --git a/src/frontend/src/components/PivotSelector.astro b/src/frontend/src/components/PivotSelector.astro index 4c88adf41..68c50de48 100644 --- a/src/frontend/src/components/PivotSelector.astro +++ b/src/frontend/src/components/PivotSelector.astro @@ -23,8 +23,9 @@ const renderedOptions = : options; --- -
@@ -73,7 +74,7 @@ const renderedOptions = stroke-linejoin="round"> -
+ - diff --git a/src/frontend/src/components/ScrollToTop.astro b/src/frontend/src/components/ScrollToTop.astro new file mode 100644 index 000000000..d74f1ae0c --- /dev/null +++ b/src/frontend/src/components/ScrollToTop.astro @@ -0,0 +1,184 @@ +--- +const labels: Record = { + da: 'Rul op', + de: 'Nach oben scrollen', + en: 'Scroll to top', + es: 'Ir arriba', + fr: 'Retour en haut', + hi: 'ऊपर स्क्रॉल करें', + id: 'Gulir ke atas', + it: 'Torna su', + ja: 'トップへ戻る', + ko: '맨 위로', + 'pt-br': 'Voltar ao topo', + ru: 'Наверх', + tr: 'Başa dön', + uk: 'Прокрутити вгору', + 'zh-cn': '回到顶部', +}; +const language = (Astro.locals.starlightRoute?.lang ?? 'en').toLowerCase(); +const label = labels[language] ?? labels[language.split('-')[0]] ?? labels.en; +--- + + + + + + + + diff --git a/src/frontend/src/components/pivot-selector.ts b/src/frontend/src/components/pivot-selector.ts new file mode 100644 index 000000000..4899d3b4e --- /dev/null +++ b/src/frontend/src/components/pivot-selector.ts @@ -0,0 +1,294 @@ +import { getStoredPreference, setStoredPreference } from '@utils/browser-storage'; + +const headingSelector = 'main h1, main h2, main h3, main h4, main h5, main h6'; + +function findScrollAnchor() { + let nearest: HTMLElement | undefined; + let minDistance = Infinity; + for (const heading of document.querySelectorAll(headingSelector)) { + if (heading.offsetParent === null) continue; + const distance = -heading.getBoundingClientRect().top; + if (distance >= -100 && distance < minDistance) { + minDistance = distance; + nearest = heading; + } + } + return nearest + ? { id: nearest.id, text: nearest.textContent, offset: nearest.getBoundingClientRect().top } + : undefined; +} + +function restoreScrollAnchor(anchor: ReturnType) { + if (!anchor) return; + let heading = document.getElementById(anchor.id); + if (!heading || heading.offsetParent === null) { + heading = + [...document.querySelectorAll(headingSelector)].find( + (candidate) => candidate.offsetParent !== null && candidate.textContent === anchor.text + ) ?? null; + } + if (heading) { + window.scrollTo({ + top: window.scrollY + heading.getBoundingClientRect().top - anchor.offset, + behavior: 'instant', + }); + } +} + +export class PivotSelector extends HTMLElement { + private controller?: AbortController; + + connectedCallback() { + if (this.controller) return; + this.controller = new AbortController(); + const { signal } = this.controller; + let initialized = false; + const initialize = () => { + if (initialized) return; + initialized = true; + this.initialize(signal); + }; + // A swapped element connects before Astro updates the URL and restores scroll. + document.addEventListener('astro:page-load', initialize, { + once: true, + signal, + }); + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initialize, { + once: true, + signal, + }); + } + document.addEventListener('astro:before-swap', () => this.dispose(), { + once: true, + signal, + }); + } + + disconnectedCallback() { + this.dispose(); + } + + private dispose() { + this.controller?.abort(); + this.controller = undefined; + } + + private initialize(signal: AbortSignal) { + const key = this.dataset.pivotKey; + const selector = this.querySelector('.pivot-selector'); + if (!key || !selector) throw new Error('Pivot selector requires a key and option container.'); + const buttons = [...selector.querySelectorAll('button[data-pivot-option]')]; + const validOptions = buttons + .filter((button) => !button.disabled) + .map((button) => button.dataset.pivotOption); + const allOptions = buttons.map((button) => button.dataset.pivotOption); + const collapseButton = this.querySelector('.pivot-collapse-btn'); + const expandButton = this.querySelector('.pivot-expand-btn'); + let placeholder: HTMLDivElement | undefined; + let floatingFrame: number | undefined; + let restoreFrame: number | undefined; + let resizeTimer: number | undefined; + let autoCollapseTimer: number | undefined; + let scrollTimer: number | undefined; + let originalTop = 0; + let isFloating = false; + let isCollapsed = false; + let userExpanded = false; + let enableScrollCollapse = false; + let lastScrollTop = window.scrollY; + + signal.addEventListener( + 'abort', + () => { + window.clearTimeout(resizeTimer); + window.clearTimeout(autoCollapseTimer); + window.clearTimeout(scrollTimer); + if (floatingFrame !== undefined) window.cancelAnimationFrame(floatingFrame); + if (restoreFrame !== undefined) window.cancelAnimationFrame(restoreFrame); + placeholder?.remove(); + this.classList.remove('floating', 'collapsed'); + delete selector.dataset.pivotInitialized; + }, + { once: true } + ); + + const apply = (id: string, preserveScroll: boolean) => { + const anchor = preserveScroll ? findScrollAnchor() : undefined; + setStoredPreference(key, id); + // Keep destination Starlight tabs on the same AppHost language. + if (key === 'aspire-lang' && (id === 'csharp' || id === 'typescript')) { + setStoredPreference( + 'starlight-synced-tabs__aspire-lang', + id === 'csharp' ? 'C#' : 'TypeScript' + ); + document.documentElement.dataset.apphostLang = id; + } + const url = new URL(window.location.href); + url.searchParams.set(key, id); + window.history.replaceState(window.history.state, '', url); + + for (const root of document.querySelectorAll('aspire-pivot-selector')) { + if (root.dataset.pivotKey !== key) continue; + for (const button of root.querySelectorAll( + 'button[data-pivot-option]' + )) { + button.classList.toggle('active', button.dataset.pivotOption === id); + } + } + for (const block of document.querySelectorAll('[data-pivot-block]')) { + const ids = (block.dataset.pivotBlock ?? '').split(/[,;]/).map((value) => value.trim()); + // Selectors with different option sets must not hide each other's content. + if (ids.some((value) => allOptions.includes(value))) { + block.style.display = ids.includes(id) ? '' : 'none'; + } + } + if (restoreFrame !== undefined) window.cancelAnimationFrame(restoreFrame); + if (anchor) { + restoreFrame = window.requestAnimationFrame(() => { + restoreFrame = undefined; + restoreScrollAnchor(anchor); + }); + } + }; + + for (const button of buttons) { + button.addEventListener( + 'click', + () => { + const id = button.dataset.pivotOption; + if (!button.disabled && id && validOptions.includes(id)) apply(id, true); + }, + { signal } + ); + } + const current = [ + new URLSearchParams(window.location.search).get(key), + getStoredPreference(key), + validOptions[0], + ].find((id) => id && validOptions.includes(id)); + if (current) apply(current, false); + + const clearCollapseTimers = () => { + window.clearTimeout(autoCollapseTimer); + window.clearTimeout(scrollTimer); + autoCollapseTimer = undefined; + scrollTimer = undefined; + enableScrollCollapse = false; + }; + + const setCollapsed = (collapsed: boolean, expandedByUser = false) => { + clearCollapseTimers(); + isCollapsed = collapsed; + userExpanded = expandedByUser; + this.classList.toggle('collapsed', collapsed); + if (expandedByUser) { + // Expansion remains open for five seconds, then the next scroll can collapse it. + autoCollapseTimer = window.setTimeout(() => { + autoCollapseTimer = undefined; + enableScrollCollapse = isFloating && !isCollapsed; + }, 5000); + } + }; + + const updateFloatingState = () => { + floatingFrame = undefined; + const rect = this.getBoundingClientRect(); + const scrollTop = window.scrollY; + if (!isFloating) originalTop = scrollTop + rect.top; + const shouldFloat = scrollTop > originalTop; + if (shouldFloat === isFloating) return; + isFloating = shouldFloat; + if (isFloating) { + if (!placeholder) { + placeholder = document.createElement('div'); + placeholder.className = 'pivot-placeholder'; + placeholder.setAttribute('aria-hidden', 'true'); + this.after(placeholder); + } + placeholder.style.height = `${rect.height}px`; + placeholder.style.marginBottom = getComputedStyle(this).marginBottom; + placeholder.style.display = 'block'; + } else if (placeholder) { + placeholder.style.display = 'none'; + } + this.classList.toggle('floating', isFloating); + setCollapsed(isFloating); + }; + + const scheduleFloatingUpdate = () => { + if (floatingFrame === undefined) { + floatingFrame = window.requestAnimationFrame(updateFloatingState); + } + }; + + const updateTitles = () => { + const isDesktop = window.matchMedia('(min-width: 72rem)').matches; + for (const [button, title] of [ + [collapseButton, 'Collapse selector'], + [expandButton, 'Show selector'], + ] as const) { + if (isDesktop) button?.setAttribute('title', title); + else button?.removeAttribute('title'); + } + }; + + collapseButton?.addEventListener( + 'click', + (event) => { + event.stopPropagation(); + setCollapsed(true); + }, + { signal } + ); + expandButton?.addEventListener( + 'click', + (event) => { + event.stopPropagation(); + setCollapsed(false, true); + }, + { signal } + ); + + window.addEventListener( + 'scroll', + () => { + scheduleFloatingUpdate(); + const scrollTop = window.scrollY; + if ( + isFloating && + !isCollapsed && + userExpanded && + enableScrollCollapse && + Math.abs(scrollTop - lastScrollTop) > 10 + ) { + window.clearTimeout(scrollTimer); + scrollTimer = window.setTimeout(() => { + scrollTimer = undefined; + setCollapsed(true); + }, 150); + } + lastScrollTop = scrollTop; + }, + { passive: true, signal } + ); + window.addEventListener( + 'resize', + () => { + updateTitles(); + window.clearTimeout(resizeTimer); + resizeTimer = window.setTimeout(() => { + resizeTimer = undefined; + scheduleFloatingUpdate(); + }, 150); + }, + { signal } + ); + + updateTitles(); + scheduleFloatingUpdate(); + selector.dataset.pivotInitialized = 'true'; + } +} + +customElements.define('aspire-pivot-selector', PivotSelector); diff --git a/src/frontend/src/components/starlight/Footer.astro b/src/frontend/src/components/starlight/Footer.astro index eee9bdf7a..2cb26730b 100644 --- a/src/frontend/src/components/starlight/Footer.astro +++ b/src/frontend/src/components/starlight/Footer.astro @@ -7,6 +7,7 @@ import FooterLegal from '@components/FooterLegal.astro'; import FooterPreferences from '@components/FooterPreferences.astro'; import FooterResources from '@components/FooterResources.astro'; import FooterSocials from '@components/FooterSocials.astro'; +import ScrollToTop from '@components/ScrollToTop.astro'; import aspireLogo from '@assets/aspire-logo-256.svg'; import { isHomepage } from '@utils/helpers'; @@ -89,6 +90,8 @@ const commit = (import.meta.env.PUBLIC_GIT_COMMIT_ID || import.meta.env.GIT_COMM + + -'); + await route.fulfill({ response, body }); + }); + await page.locator('header a[href="/docs/"]:visible').evaluate((element) => { + element.setAttribute('href', '/docs/?deployment-test=1#_top'); + }); + await page.locator('header a[href^="/docs/?deployment-test=1"]:visible').click(); + await expect(page).toHaveURL(/\/docs\/\?deployment-test=1#_top$/); + await expect + .poll(() => page.evaluate(() => Reflect.get(window, '__deploymentSession'))) + .toBeUndefined(); + expect(nativeDocuments).toBe(1); + expect(executions).toEqual([]); + await expect(page.locator('site-search button[data-open-modal]')).toBeEnabled(); + await page.keyboard.press('Control+k'); + await expect(page.locator('site-search dialog[open]')).toBeVisible(); + await expect(page.locator('site-search input.pagefind-ui__search-input')).toBeVisible(); + expect(errors).toEqual([]); + }); +} + +test('native Document PiP survives same-build navigation and closes on a deployment reload', async ({ + page, + isMobile, +}) => { + test.skip(isMobile, 'Native Document Picture-in-Picture requires a desktop browser.'); + await page.goto('/'); + await dismissCookieConsentIfVisible(page); + const supported = await page.evaluate(() => 'documentPictureInPicture' in window); + test.skip(!supported, 'This browser does not support native Document Picture-in-Picture.'); + + await page.evaluate(() => { + const button = document.createElement('button'); + button.id = 'native-pip-test'; + button.textContent = 'Open native PiP'; + button.style.cssText = 'position:fixed;top:80px;left:0;z-index:2147483647'; + button.addEventListener('click', () => { + const api = ( + window as Window & { + documentPictureInPicture?: { requestWindow: () => Promise }; + } + ).documentPictureInPicture; + if (!api) throw new Error('Native Document Picture-in-Picture is unavailable.'); + void api.requestWindow().then((pip) => { + pip.document.body.textContent = 'Native PiP navigation test'; + Reflect.set(window, '__nativePipTest', pip); + }); + }); + document.body.append(button); + }); + const opened = page.context().waitForEvent('page'); + await page.locator('#native-pip-test').click(); + const pipPage = await opened; + await expect + .poll(() => page.evaluate(() => Boolean(Reflect.get(window, '__nativePipTest')))) + .toBe(true); + await navigateClient(page, () => page.locator('header a[href="/docs/"]:visible').click()); + await expect(page).toHaveURL(/\/docs\/$/); + expect(pipPage.isClosed()).toBe(false); + await expect(pipPage.locator('body')).toHaveText('Native PiP navigation test'); + + await page.route(new URL('/', page.url()).href, async (route) => { + if (route.request().isNavigationRequest()) { + await route.continue(); + return; + } + const response = await route.fetch(); + const body = (await response.text()).replace( + /]*>/, + '' + ); + await route.fulfill({ response, body }); + }); + const closed = pipPage.waitForEvent('close'); + await page.locator('header a[href="/"]:visible').click(); + await expect(page).toHaveURL((url) => url.pathname === '/'); + await closed; + await expect + .poll(() => page.evaluate(() => Boolean(Reflect.get(window, '__nativePipTest')))) + .toBe(false); + expect(pipPage.isClosed()).toBe(true); +}); diff --git a/src/frontend/tests/e2e/homepage.spec.ts b/src/frontend/tests/e2e/homepage.spec.ts index a8f606964..54c7193a9 100644 --- a/src/frontend/tests/e2e/homepage.spec.ts +++ b/src/frontend/tests/e2e/homepage.spec.ts @@ -546,6 +546,9 @@ test('presents the application model as a live polyglot topology', async ({ page await terminalWindow.scrollIntoViewIfNeeded(); await expect(story).toHaveAttribute('data-story-playing', 'true', { timeout: 10_000 }); await expect(story).toHaveAttribute('data-story-focus', 'stage', { timeout: 10_000 }); + // Center the whole stage so clicking its tab does not scroll the terminal out of view. + await story.locator('[data-model-story-surface]').scrollIntoViewIfNeeded(); + await expect(story).toHaveAttribute('data-story-viewport-active', ''); await topologyStage.click(); await expect(story).toHaveAttribute('data-story-playing', 'false'); await expect(story).toHaveAttribute('data-story-stage', 'topology'); @@ -1588,12 +1591,14 @@ test('keeps the environment frame stable while each topology changes', async ({ ) .toBeLessThan(0.1); await expect.poll(async () => Math.max(...(await nodeOffsets()))).toBeLessThan(1); - const centeredTransforms = await productionPanel - .locator('.topology-node') - .evaluateAll((nodes) => nodes.map((node) => getComputedStyle(node).transform)); - expect( - enteringTransforms.some((transform, index) => transform !== centeredTransforms[index]) - ).toBe(true); + await expect + .poll(async () => { + const centeredTransforms = await productionPanel + .locator('.topology-node') + .evaluateAll((nodes) => nodes.map((node) => getComputedStyle(node).transform)); + return enteringTransforms.some((transform, index) => transform !== centeredTransforms[index]); + }) + .toBe(true); expect(await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth)).toBe( false diff --git a/src/frontend/tests/e2e/install-modal-navigation.spec.ts b/src/frontend/tests/e2e/install-modal-navigation.spec.ts new file mode 100644 index 000000000..d8a92587d --- /dev/null +++ b/src/frontend/tests/e2e/install-modal-navigation.spec.ts @@ -0,0 +1,51 @@ +import { expect, test } from '@playwright/test'; +import { dismissCookieConsentIfVisible } from '@tests/e2e/helpers'; + +test('install modal owns one set of listeners and disposes outgoing controls', async ({ + page, isMobile, +}) => { + test.skip(isMobile, 'Touch layouts use the full installation page.'); + const errors: string[] = []; + page.on('pageerror', error => errors.push(error.message)); + await page.goto('/'); + await dismissCookieConsentIfVisible(page); + await page.evaluate(() => { + const close = HTMLDialogElement.prototype.close; + Reflect.set(window, '__installCloseCalls', 0); + Reflect.set(window, '__installSession', true); + HTMLDialogElement.prototype.close = function(value) { + if (this.id === 'install-cli-modal') { + Reflect.set(window, '__installCloseCalls', Number(Reflect.get(window, '__installCloseCalls')) + 1); + } + close.call(this, value); + }; + }); + + for (let visit = 0; visit < 3; visit++) { + await page.evaluate(() => { + document.dispatchEvent(new Event('astro:page-load')); + document.dispatchEvent(new Event('astro:page-load')); + }); + await page.locator('header [data-open-install-modal]:visible').click(); + const modal = page.locator('#install-cli-modal'); + await expect(modal).toBeVisible(); + const before = await page.evaluate(() => Reflect.get(window, '__installCloseCalls')); + await modal.locator('[data-close-modal]').click(); + await expect(modal).toBeHidden(); + expect(await page.evaluate(() => Reflect.get(window, '__installCloseCalls'))).toBe(before + 1); + await modal.evaluate(element => Reflect.set(window, '__outgoingInstallModal', element)); + + const destination = visit % 2 === 0 ? '/docs/' : '/'; + await page.locator(`header a[href="${destination}"]:visible`).click(); + await expect(page).toHaveURL(url => url.pathname === destination); + expect(await page.evaluate(() => Reflect.get(window, '__installSession'))).toBe(true); + const calls = await page.evaluate(() => Reflect.get(window, '__installCloseCalls')); + await page.evaluate(() => { + const outgoing = Reflect.get(window, '__outgoingInstallModal') as HTMLDialogElement; + outgoing.dispatchEvent(new Event('click')); + outgoing.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + }); + expect(await page.evaluate(() => Reflect.get(window, '__installCloseCalls'))).toBe(calls); + } + expect(errors).toEqual([]); +}); diff --git a/src/frontend/tests/e2e/pivot-selector.spec.ts b/src/frontend/tests/e2e/pivot-selector.spec.ts index 2d83f9cc4..9d4d9ee19 100644 --- a/src/frontend/tests/e2e/pivot-selector.spec.ts +++ b/src/frontend/tests/e2e/pivot-selector.spec.ts @@ -139,3 +139,185 @@ test('app host page restores pivot state from the lang query string', async ({ p await expect(javaContent).toBeVisible(); await expect(nodeJsContent).toBeHidden(); }); + +test('first-app pivots default to TypeScript and preserve history and shared preferences', async ({ + page, +}) => { + await page.goto('/get-started/first-app/?keep=1'); + await dismissCookieConsentIfVisible(page); + const selector = page.locator('#pivot-selector-aspire-lang'); + await expect(selector).toHaveAttribute('data-pivot-initialized', 'true'); + await expect(selector.getByRole('button').first()).toHaveText('TypeScript'); + await expect(selector.getByRole('button', { name: 'TypeScript' })).toHaveClass(/active/); + await expect(page.locator('[data-pivot-block="typescript"]').first()).toBeVisible(); + await expect(page.locator('[data-pivot-block="csharp"]').first()).toBeHidden(); + + const index = await page.evaluate(() => { + const state = history.state as { index: number }; + history.replaceState({ ...state, pivotTest: 'preserved' }, ''); + return state.index; + }); + expect(index).toEqual(expect.any(Number)); + await selector.getByRole('button', { name: 'C#', exact: true }).focus(); + await page.keyboard.press('Enter'); + await expect(page).toHaveURL(/\?keep=1&aspire-lang=csharp$/); + await expect(page.locator('[data-pivot-block="csharp"]').first()).toBeVisible(); + await expect(page.locator('[data-pivot-block="typescript"]').first()).toBeHidden(); + await expect + .poll(() => + page.evaluate(() => ({ + state: history.state, + preference: localStorage.getItem('aspire-lang'), + tabs: localStorage.getItem('starlight-synced-tabs__aspire-lang'), + language: document.documentElement.dataset.apphostLang, + })) + ) + .toMatchObject({ + state: { index, pivotTest: 'preserved' }, + preference: 'csharp', + tabs: 'C#', + language: 'csharp', + }); + + await page.goto('/get-started/first-app/?aspire-lang=typescript&keep=2'); + await expect(selector.getByRole('button', { name: 'TypeScript' })).toHaveClass(/active/); + await expect + .poll(() => page.evaluate(() => localStorage.getItem('aspire-lang'))) + .toBe('typescript'); +}); + +test('floating pivot controls clear the TOC across its responsive breakpoint', async ({ + page, +}, testInfo) => { + test.skip(testInfo.project.name !== 'desktop-chromium'); + await page.goto('/get-started/first-app/'); + await dismissCookieConsentIfVisible(page); + const root = page.locator('aspire-pivot-selector[data-pivot-key="aspire-lang"]'); + await expect(root.locator('.pivot-selector')).toHaveAttribute('data-pivot-initialized', 'true'); + await root.getByRole('button', { name: 'C#', exact: true }).click(); + const expand = root.getByRole('button', { name: 'Expand selector', exact: true }); + const collapse = root.getByRole('button', { name: 'Collapse selector', exact: true }); + const toc = page.locator('#starlight__on-this-page--mobile'); + + for (const width of [390, 834, 1152, 1440, 1599, 1600]) { + await page.setViewportSize({ width, height: 900 }); + await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' })); + await expect(root).not.toHaveClass(/floating/); + await page.evaluate(() => window.scrollTo({ top: 1200, behavior: 'instant' })); + await expect(root).toHaveClass(/floating/); + await expect(root).toHaveClass(/collapsed/); + + if (width < 1600) { + await expect(toc).toBeVisible(); + await expect + .poll(async () => { + const buttonBox = await expand.boundingBox(); + const tocBox = await toc.boundingBox(); + // The shared border can overlap by one pixel, but the control must clear the TOC. + return buttonBox && tocBox ? buttonBox.y + 1 >= tocBox.y + tocBox.height : false; + }) + .toBe(true); + } else { + await expect(toc).toBeHidden(); + } + await expand.click(); + await expect(root).not.toHaveClass(/collapsed/); + await expect(root.getByRole('button', { name: 'C#', exact: true })).toHaveClass(/active/); + await collapse.click(); + await expect(root).toHaveClass(/collapsed/); + + if (width < 1600) { + await toc.click(); + await expect(page.locator('#starlight__mobile-toc')).toHaveJSProperty('open', true); + await toc.press('Escape'); + await expect(page.locator('#starlight__mobile-toc')).toHaveJSProperty('open', false); + } + } +}); + +for (const transition of ['native', 'fallback'] as const) { + test(`pivot lifecycle survives repeated visits and history with ${transition} swaps`, async ({ + page, isMobile, + }) => { + test.skip(isMobile && transition === 'native', + 'Chromium touch emulation aborts native transitions; touch projects cover the swap fallback.'); + test.setTimeout(120_000); + const warnings: string[] = []; + const errors: string[] = []; + page.on('console', (message) => { + if (message.type() === 'warning' && /pivot.*not found/i.test(message.text())) { + warnings.push(message.text()); + } + }); + page.on('pageerror', (error) => errors.push(error.message)); + if (transition === 'fallback') { + await page.addInitScript(() => { + Object.defineProperty(document, 'startViewTransition', { value: undefined }); + }); + } + await page.goto('/docs/'); + await dismissCookieConsentIfVisible(page); + const documentMarker = await page.evaluate(() => { + const marker = crypto.randomUUID(); + Object.defineProperty(window, '__pivotNavigationMarker', { value: marker }); + return marker; + }); + const selector = page.locator('#pivot-selector-aspire-lang'); + const root = page.locator('aspire-pivot-selector[data-pivot-key="aspire-lang"]'); + const navigate = async (action: () => Promise) => { + await page.evaluate(() => { + Reflect.set(window, '__pivotPageLoaded', false); + document.addEventListener('astro:page-load', () => { + Reflect.set(window, '__pivotPageLoaded', true); + }, { once: true }); + }); + await action(); + await expect.poll(() => page.evaluate(() => Reflect.get(window, '__pivotPageLoaded'))).toBe(true); + await expect(page.locator('html[data-astro-transition]')).toHaveCount(0); + }; + + for (let visit = 0; visit < 3; visit++) { + await navigate(() => page.locator('a[href="/get-started/first-app/"]:visible').first().click()); + await expect(selector).toHaveAttribute('data-pivot-initialized', 'true'); + await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' })); + await selector.getByRole('button', { name: 'C#', exact: true }).click(); + await expect(page).toHaveURL(/\/get-started\/first-app\/\?aspire-lang=csharp$/); + await expect + .poll(() => page.evaluate(() => history.state)) + .toMatchObject({ + index: expect.any(Number), + }); + await page.evaluate(() => window.scrollTo({ top: 1200, behavior: 'instant' })); + await expect(root).toHaveClass(/floating/); + await expect(root).toHaveClass(/collapsed/); + await root.getByRole('button', { name: 'Expand selector', exact: true }).click(); + await expect(root).not.toHaveClass(/collapsed/); + + // Leave while the old selector still owns its five-second expansion timer. + await navigate(() => page.locator('header a[href="/"]:visible').click()); + await expect(page).toHaveURL(url => url.pathname === '/'); + await expect(root).toHaveCount(0); + await expect(page.locator('.pivot-placeholder')).toHaveCount(0); + await navigate(() => page.goBack()); + await expect(selector).toHaveAttribute('data-pivot-initialized', 'true'); + await expect(selector.locator('[data-pivot-option="csharp"]')).toHaveClass(/active/); + await expect + .poll(() => page.evaluate(() => history.state)) + .toMatchObject({ + index: expect.any(Number), + }); + await navigate(() => page.goForward()); + await expect(page).toHaveURL(url => url.pathname === '/'); + await expect(root).toHaveCount(0); + await page.evaluate(() => { + window.dispatchEvent(new Event('scroll')); + window.dispatchEvent(new Event('resize')); + }); + await expect + .poll(() => page.evaluate(() => Reflect.get(window, '__pivotNavigationMarker'))) + .toBe(documentMarker); + } + expect(warnings).toEqual([]); + expect(errors).toEqual([]); + }); +} diff --git a/src/frontend/tests/e2e/route-style-navigation.spec.ts b/src/frontend/tests/e2e/route-style-navigation.spec.ts new file mode 100644 index 000000000..fafbfcd46 --- /dev/null +++ b/src/frontend/tests/e2e/route-style-navigation.spec.ts @@ -0,0 +1,313 @@ +import { expect, test, type Page } from '@playwright/test'; + +const home = '/'; +const docs = '/docs/'; +const api = (language: string) => `/reference/api/${language}/`; +const redis = (language: string) => `${api(language)}aspire.hosting.redis/redisresource/`; +const routes = [home, docs, api('csharp'), api('typescript'), redis('csharp'), redis('typescript')]; + +async function markNavigation(page: Page) { + await page.evaluate(() => { + Reflect.set(window, '__routeStyleLoaded', false); + document.addEventListener( + 'astro:page-load', + () => { + Reflect.set(window, '__routeStyleLoaded', true); + }, + { once: true } + ); + }); +} + +async function settled(page: Page) { + await expect + .poll(() => page.evaluate(() => Reflect.get(window, '__routeStyleLoaded')), { + timeout: 30_000, + }) + .toBe(true); + await expect(page.locator('html[data-astro-transition]')).toHaveCount(0); + expect(await page.evaluate(() => Reflect.get(window, '__routeStyleSession'))).toBe(true); +} + +async function navigate(page: Page, href: string) { + await markNavigation(page); + await page.evaluate((destination) => { + const anchor = document.createElement('a'); + anchor.id = 'route-style-link'; + anchor.href = destination; + anchor.textContent = 'Navigate'; + anchor.style.cssText = 'position:fixed;top:80px;left:0;z-index:2147483647'; + document.body.append(anchor); + }, href); + await page.locator('#route-style-link').click({ force: true }); + const destination = new URL(href, page.url()); + await expect(page).toHaveURL( + (url) => + url.pathname === destination.pathname && + [...destination.searchParams].every(([key, value]) => url.searchParams.get(key) === value), + { timeout: 30_000 } + ); + await settled(page); +} + +async function appearance(page: Page) { + await expect(page.locator('main h1')).toHaveCount(1); + await page.evaluate(async () => { + await document.fonts.ready; + window.scrollTo({ top: 0, behavior: 'instant' }); + }); + return page.evaluate(async () => { + const selectors = [ + 'html', + 'body', + 'header.header', + 'header .header', + 'header .title-wrapper', + 'header .right-group', + 'header .right-group-mobile', + 'main h1', + 'main > .content-panel', + '.sl-markdown-content', + '.home-hero-story', + '.home-hero-sticky', + '.home-hero-copy', + '.home-hero-summary', + '.home-hero-actions', + '.home-hero-product', + '.api-hero', + '.api-hero-summary', + '.api-search-input', + '.api-kind-badge', + '.topics-sidebar', + ]; + const properties = [ + 'font-family', + 'font-size', + 'font-weight', + 'line-height', + 'letter-spacing', + 'color', + 'background-color', + 'display', + 'position', + 'padding', + 'margin-top', + 'margin-bottom', + 'width', + 'max-width', + 'grid-template-columns', + 'gap', + 'overflow-x', + 'word-break', + 'overflow-wrap', + ]; + const styles = Object.fromEntries( + selectors.map((selector) => { + const element = document.querySelector(selector); + if (!element) return [selector, null]; + element.getBoundingClientRect(); + const computed = getComputedStyle(element); + return [ + selector, + Object.fromEntries( + properties.map((property) => [property, computed.getPropertyValue(property)]) + ), + ]; + }) + ); + const root = document.documentElement; + const rootAttributes = Object.fromEntries( + [ + 'lang', + 'dir', + 'data-theme', + 'data-has-sidebar', + 'data-has-hero', + 'data-apphost-lang', + 'data-home-js', + 'data-aspire-environment-state', + 'data-sidebar-collapsed', + 'data-topic-sidebar-collapsed', + 'data-api-sidebar-ready', + 'data-topic-sidebar-ready', + ].map((name) => [name, root.getAttribute(name)]) + ); + return { + styles, + rootAttributes, + rootClass: root.className, + stylesheets: Array.from(document.head.querySelectorAll('link[rel="stylesheet"]'), (link) => + link.getAttribute('href') + ).sort(), + bodyClass: document.body.className, + bodyStyle: document.body.getAttribute('style'), + apiTitle: document.querySelector('main h1')?.classList.contains('api-page-title'), + searchCount: document.querySelectorAll('site-search').length, + actionsCount: document.querySelectorAll('.actions-container').length, + canonical: document.querySelector('link[rel="canonical"]')?.getAttribute('href'), + canonicalCount: document.querySelectorAll('link[rel="canonical"]').length, + titleCount: document.head.querySelectorAll('title').length, + }; + }); +} + +for (const theme of ['light', 'dark']) { + test.describe(`${theme} route styles`, () => { + test.beforeEach(async ({ page, isMobile }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.route('**/scripts/analytics/*.js', (route) => + route.fulfill({ contentType: 'application/javascript', body: '' }) + ); + await page.route('**/wcp-consent.js', (route) => + route.fulfill({ contentType: 'application/javascript', body: '' }) + ); + await page.addInitScript( + ({ theme, fallback }) => { + localStorage.setItem('starlight-theme', theme); + localStorage.setItem('aspireConsentRequired', 'false'); + if (fallback) + Object.defineProperty(document, 'startViewTransition', { + configurable: true, + value: undefined, + }); + }, + { theme, fallback: isMobile } + ); + }); + + test('fresh rendering matches API/search/Home, docs/Home, cross-language and history swaps', async ({ + page, + }) => { + test.setTimeout(180000); + const baseline = new Map>>(); + for (const route of routes) { + await page.goto(route); + await expect(page.locator('html')).toHaveAttribute('data-theme', theme); + baseline.set(route, await appearance(page)); + } + const matchesFresh = async (route: string) => { + await expect + .poll(() => appearance(page), { + message: `${route} must match its fresh ${theme} rendering after a client swap`, + }) + .toEqual(baseline.get(route)); + }; + await page.goto(api('csharp')); + await page.evaluate(() => Reflect.set(window, '__routeStyleSession', true)); + await page.locator('#api-search-input').fill('RedisResource'); + const result = page.locator(`#api-search-results a[href="${redis('csharp')}"]`).first(); + await expect(result).toBeVisible(); + await markNavigation(page); + await result.click(); + await settled(page); + await matchesFresh(redis('csharp')); + await navigate(page, home); + await matchesFresh(home); + for (const route of [ + docs, + home, + api('csharp'), + api('typescript'), + redis('typescript'), + redis('csharp'), + home, + ]) { + await navigate(page, route); + await matchesFresh(route); + } + await markNavigation(page); + await page.goBack(); + await settled(page); + await matchesFresh(redis('csharp')); + await markNavigation(page); + await page.goForward(); + await settled(page); + await matchesFresh(home); + }); + + test('restores saved language and route-specific collapsed sidebars, with URL language taking precedence', async ({ + page, + }) => { + test.setTimeout(120000); + await page.addInitScript(() => { + localStorage.setItem('aspire-lang', 'csharp'); + localStorage.setItem('api-sidebar-collapsed', '1'); + localStorage.setItem('topic-sidebar-collapsed', '1'); + }); + await page.goto(api('csharp')); + await expect(page.locator('html')).toHaveAttribute('data-sidebar-collapsed', ''); + await page.evaluate(() => Reflect.set(window, '__routeStyleSession', true)); + for (const route of [home, '/get-started/app-host/', api('typescript'), api('csharp')]) { + await navigate(page, route); + const isApi = route.startsWith('/reference/api/'); + const isDoc = route === '/get-started/app-host/'; + await expect(page.locator('html')).toHaveAttribute('data-apphost-lang', 'csharp'); + await expect(page.locator('html[data-sidebar-collapsed]')).toHaveCount(isApi ? 1 : 0); + await expect(page.locator('html[data-topic-sidebar-collapsed]')).toHaveCount(isDoc ? 1 : 0); + if (isApi || isDoc) { + const prefix = isApi ? 'sidebar' : 'topic-sidebar'; + await expect(page.locator(`#${prefix}-expand-btn`)).toHaveAttribute( + 'aria-expanded', + 'false' + ); + await expect(page.locator(`#${prefix}-collapse-btn`)).toHaveAttribute( + 'aria-hidden', + 'true' + ); + } + } + await navigate(page, '/get-started/app-host/?aspire-lang=typescript'); + await expect(page.locator('html')).toHaveAttribute('data-apphost-lang', 'typescript'); + await navigate(page, home); + await expect(page.locator('html')).toHaveAttribute('data-apphost-lang', 'typescript'); + }); + + test('header install affordance retains responsive visibility and dialog layout after swaps', async ({ + page, + isMobile, + }) => { + test.setTimeout(120000); + await page.goto(home); + await page.evaluate(() => Reflect.set(window, '__routeStyleSession', true)); + const dialog = page.locator('#install-cli-modal'); + const trigger = page.locator('header [data-open-install-modal]:visible'); + if (page.viewportSize()!.width < 800) { + // The compact header intentionally omits the install action. + for (const route of [api('csharp'), docs, home]) { + await expect(trigger).toHaveCount(0); + await expect(dialog).not.toBeVisible(); + await navigate(page, route); + } + await expect(trigger).toHaveCount(0); + return; + } + const openAndMeasure = async () => { + if (isMobile) await trigger.tap(); + else await trigger.click(); + await expect(page.locator('dialog[open]')).toHaveCount(1); + await expect(dialog).toBeVisible(); + return dialog.evaluate((element) => { + const style = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return { + color: style.color, + background: style.backgroundColor, + font: style.font, + width: rect.width, + fits: rect.left >= 0 && rect.right <= window.innerWidth, + }; + }); + }; + const baseline = await openAndMeasure(); + expect(baseline.fits).toBe(true); + for (const route of [api('csharp'), docs, home]) { + const close = dialog.getByRole('button', { name: 'Close modal', exact: true }); + if (isMobile) await close.tap(); + else await close.click(); + await expect(dialog).not.toBeVisible(); + await navigate(page, route); + expect(await openAndMeasure()).toEqual(baseline); + } + }); + }); +} diff --git a/src/frontend/tests/e2e/site-search.spec.ts b/src/frontend/tests/e2e/site-search.spec.ts index 46c1a7281..d7c4c9c17 100644 --- a/src/frontend/tests/e2e/site-search.spec.ts +++ b/src/frontend/tests/e2e/site-search.spec.ts @@ -146,6 +146,38 @@ test.describe('site search dialog', () => { } }); + test('API search result navigation restores homepage hero typography', async ({ page }) => { + await page.setViewportSize({ width: 2000, height: 1001 }); + await page.goto('/'); + await dismissCookieConsentIfVisible(page); + + const hero = page.getByRole('heading', { + level: 1, + name: 'Compose distributed apps in code.', + }); + const expectedFontSize = await hero.evaluate((element) => getComputedStyle(element).fontSize); + + await page.goto('/reference/api/csharp/'); + await page.evaluate(() => { + const result = document.createElement('a'); + result.className = 'pagefind-ui__result-link'; + result.href = '/'; + result.textContent = 'Home'; + result.style.cssText = + 'position:fixed;inset-block-start:0;inset-inline-start:0;z-index:2147483647'; + document.body.append(result); + }); + + await navigateClient(page, () => + page.locator('.pagefind-ui__result-link', { hasText: 'Home' }).click() + ); + await expect(page).toHaveURL((url) => url.pathname === '/'); + await expect(hero).not.toHaveClass(/api-page-title/); + await expect.poll(() => hero.evaluate((element) => getComputedStyle(element).fontSize)).toBe( + expectedFontSize + ); + }); + test('navigation during the lazy UI import mounts only the current search instance', async ({ page, }) => { diff --git a/src/frontend/tests/e2e/site-ui-navigation.spec.ts b/src/frontend/tests/e2e/site-ui-navigation.spec.ts new file mode 100644 index 000000000..e4ec9bd73 --- /dev/null +++ b/src/frontend/tests/e2e/site-ui-navigation.spec.ts @@ -0,0 +1,173 @@ +import { expect, test, type Page } from '@playwright/test'; +import { dismissCookieConsentIfVisible } from '@tests/e2e/helpers'; + +async function navigateClient(page: Page, href: string) { + await page.evaluate((destination) => { + Reflect.set(window, '__dependencyPageLoaded', false); + document.addEventListener( + 'astro:page-load', + () => { + Reflect.set(window, '__dependencyPageLoaded', true); + }, + { once: true } + ); + const link = document.createElement('a'); + link.id = 'dependency-navigation-link'; + link.href = destination; + link.textContent = 'Navigate to dependency test page'; + link.style.cssText = 'position:fixed;top:80px;left:0;z-index:2147483647'; + document.body.append(link); + }, href); + await page.locator('#dependency-navigation-link').click(); + await expect + .poll(() => page.evaluate(() => Reflect.get(window, '__dependencyPageLoaded')), { + timeout: 30000, + }) + .toBe(true); + await expect(page.locator('html[data-astro-transition]')).toHaveCount(0); + expect(await page.evaluate(() => Reflect.get(window, '__dependencySession'))).toBe(true); +} + +for (const fallback of [false, true]) { + test.describe(fallback ? 'site UI swap fallback' : 'site UI native transitions', () => { + test.beforeEach(async ({ page, isMobile }) => { + test.skip( + isMobile && !fallback, + 'Chromium touch emulation aborts native transitions; touch projects cover the swap fallback.' + ); + // These runtime tests must never submit analytics events. + await page.route('**/scripts/analytics/*.js', (route) => + route.fulfill({ + contentType: 'application/javascript', + body: '', + }) + ); + await page.addInitScript((swap) => { + localStorage.setItem('starlight-theme', 'light'); + if (swap) { + Object.defineProperty(document, 'startViewTransition', { + configurable: true, + value: undefined, + }); + } + const renders = new WeakMap(); + new MutationObserver((mutations) => { + for (const { target } of mutations) { + if (!(target instanceof Element) || !target.matches('pre.mermaid[data-processed]')) + continue; + const count = (renders.get(target) ?? 0) + 1; + renders.set(target, count); + target.setAttribute('data-test-render-count', String(count)); + } + }).observe(document, { + subtree: true, + attributes: true, + attributeFilter: ['data-processed'], + }); + }, fallback); + }); + + test('renders two diagrams once per page/theme across repeat visits and history', async ({ + page, + }) => { + test.setTimeout(120000); + const errors: string[] = []; + page.on('pageerror', (error) => errors.push(error.message)); + page.on('console', (message) => { + if (/\[(?:astro-)?mermaid\]/.test(message.text())) errors.push(message.text()); + }); + await page.goto('/docs/'); + await dismissCookieConsentIfVisible(page); + await page.evaluate(() => Reflect.set(window, '__dependencySession', true)); + const diagrams = page.locator('pre.mermaid'); + const expectRenders = async (count: number) => { + await expect(diagrams).toHaveCount(2); + for (const diagram of await diagrams.all()) { + await expect(diagram.locator(':scope > svg')).toHaveCount(1, { timeout: 30000 }); + await expect(diagram).toHaveAttribute('data-test-render-count', String(count)); + } + }; + for (let visit = 0; visit < 2; visit++) { + await navigateClient(page, '/get-started/first-app/'); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'light'); + await expectRenders(1); + await page.evaluate(() => { + const html = document.documentElement; + const theme = html.getAttribute('data-theme')!; + html.removeAttribute('data-theme'); + html.setAttribute('data-theme', theme); + }); + await expectRenders(1); + for (const [theme, count] of [ + ['dark', 2], + ['light', 3], + ] as const) { + await page.locator(`#footer-theme-toggle [data-theme-option="${theme}"]`).click(); + await expect(page.locator('html')).toHaveAttribute('data-theme', theme); + await expectRenders(count); + } + await navigateClient(page, '/docs/'); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'light'); + } + await page.goBack(); + await expectRenders(1); + expect(await page.evaluate(() => Reflect.get(window, '__dependencySession'))).toBe(true); + expect(errors).toEqual([]); + }); + + test('activates scroll once per mouse, touch, Enter and Space after repeated swaps', async ({ + page, + isMobile, + }) => { + test.setTimeout(120000); + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.goto('/docs/'); + await dismissCookieConsentIfVisible(page); + await page.evaluate(() => { + Reflect.set(window, '__dependencySession', true); + const scrolls: ScrollToOptions[] = []; + Reflect.set(window, '__dependencyScrolls', scrolls); + const scrollTo = window.scrollTo.bind(window); + window.scrollTo = (options?: ScrollToOptions | number, y?: number) => { + if (typeof options === 'number') scrollTo(options, y ?? 0); + else { + if (options?.top === 0) scrolls.push(options); + scrollTo(options); + } + }; + }); + for (let visit = 0; visit < 2; visit++) { + await navigateClient(page, '/get-started/app-host/'); + const button = page.locator('#scroll-to-top-button'); + await expect(button).toHaveCount(1); + for (const activation of ['pointer', 'Enter', 'Space']) { + await page.evaluate(() => window.scrollTo({ top: 800, behavior: 'instant' })); + await expect(button).toBeVisible(); + await page.evaluate(() => + Reflect.set( + window, + '__dependencyScrollCountBefore', + Reflect.get(window, '__dependencyScrolls').length + ) + ); + if (activation === 'pointer') { + if (isMobile) await button.tap(); + else await button.click(); + } else { + await button.focus(); + await page.keyboard.press(activation); + } + await expect.poll(() => page.evaluate(() => window.scrollY)).toBe(0); + expect( + await page.evaluate(() => { + const scrolls: ScrollToOptions[] = Reflect.get(window, '__dependencyScrolls'); + const before: number = Reflect.get(window, '__dependencyScrollCountBefore'); + return scrolls.slice(before); + }) + ).toEqual([{ top: 0, behavior: 'auto' }]); + } + await navigateClient(page, '/docs/'); + } + }); + }); +} diff --git a/src/frontend/tests/unit/analytics-script-contracts.vitest.test.ts b/src/frontend/tests/unit/analytics-script-contracts.vitest.test.ts index b28b01225..ef3c1804b 100644 --- a/src/frontend/tests/unit/analytics-script-contracts.vitest.test.ts +++ b/src/frontend/tests/unit/analytics-script-contracts.vitest.test.ts @@ -1,7 +1,8 @@ import { existsSync, readFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { expect, test } from 'vitest'; +import { runInNewContext } from 'node:vm'; +import { expect, test, vi } from 'vitest'; const testsDir = path.dirname(fileURLToPath(import.meta.url)); const frontendRoot = path.resolve(testsDir, '..', '..'); @@ -41,3 +42,104 @@ test('analytics asset files contain javascript bootstrap code', () => { expect(oneDsScript.trimStart().startsWith('<')).toBe(false); expect(trackScript.trimStart().startsWith('<')).toBe(false); }); + +function analyticsFixture(origin = 'https://aspire.dev') { + type TrackedElement = { + tagName: string; + href?: string; + textContent: string; + attributes: { name: string; value: string }[]; + getAttribute: (name: string) => string | null; + }; + type TrackEvent = { target: { closest: (selector: string) => TrackedElement | null } }; + const initialize = vi.fn<(config: unknown, extensions: unknown[]) => void>(); + const capturePageAction = vi.fn<(target: TrackedElement, overrides: Record) => void>(); + const addEventListener = vi.fn<(type: string, listener: (event: TrackEvent) => void) => void>(); + const console = { debug: vi.fn(), warn: vi.fn() }; + const context = { + location: { origin }, + window: {}, + document: { addEventListener }, + console, + oneDS: { + ApplicationInsights: class { + initialize = initialize; + capturePageAction = capturePageAction; + }, + }, + }; + const bootstrap = () => { runInNewContext(read('public/scripts/analytics/1ds.js'), context); }; + const tracking = () => { runInNewContext(read('public/scripts/analytics/track.js'), context); }; + return { initialize, capturePageAction, addEventListener, console, bootstrap, tracking }; +} + +test('analytics excludes unload without disabling other lifecycle or click capture', () => { + const fixture = analyticsFixture(); + fixture.bootstrap(); + fixture.bootstrap(); + expect(fixture.initialize).toHaveBeenCalledOnce(); + expect(fixture.initialize.mock.calls[0][0]).toMatchObject({ + disablePageUnloadEvents: ['unload'], + webAnalyticsConfiguration: { + autoCapture: { onUnload: true, click: true, pageView: true }, + }, + }); + expect(fixture.console.debug).not.toHaveBeenCalled(); + expect(fixture.console.warn).not.toHaveBeenCalled(); +}); + +test('analytics and tracking remain inactive on other origins', () => { + const fixture = analyticsFixture('http://localhost:4321'); + fixture.bootstrap(); + fixture.tracking(); + expect(fixture.initialize).not.toHaveBeenCalled(); + expect(fixture.addEventListener).not.toHaveBeenCalled(); + expect(fixture.console.debug).not.toHaveBeenCalled(); +}); + +test('tracking binds once, forwards nested clicks and stays quiet on success', () => { + const fixture = analyticsFixture(); + fixture.tracking(); + expect(fixture.addEventListener).not.toHaveBeenCalled(); + fixture.bootstrap(); + fixture.tracking(); + fixture.tracking(); + expect(fixture.addEventListener).toHaveBeenCalledOnce(); + const [event, listener] = fixture.addEventListener.mock.calls[0]; + expect(event).toBe('click'); + const link = { + tagName: 'A', + href: 'https://aspire.dev/docs/', + textContent: ' Docs ', + attributes: [{ name: 'data-track-source-name', value: 'header' }], + getAttribute: () => 'docs-link', + }; + listener({ target: { closest: () => link } }); + expect(fixture.capturePageAction).toHaveBeenCalledExactlyOnceWith(link, { + name: 'docs-link', sourceName: 'header', href: link.href, text: 'Docs', + }); + expect(fixture.console.debug).not.toHaveBeenCalled(); + expect(fixture.console.warn).not.toHaveBeenCalled(); +}); + +test('initialization and capture failures remain visible', () => { + const initialization = analyticsFixture(); + initialization.initialize.mockImplementation(() => { throw new Error('initialization failed'); }); + initialization.bootstrap(); + expect(initialization.console.warn).toHaveBeenCalledWith( + '[1ds] Failed to initialize Application Insights:', expect.any(Error), + ); + + const tracking = analyticsFixture(); + tracking.bootstrap(); + tracking.tracking(); + tracking.capturePageAction.mockImplementation(() => { throw new Error('capture failed'); }); + tracking.addEventListener.mock.calls[0][1]({ + target: { closest: () => ({ + tagName: 'BUTTON', textContent: '', attributes: [], getAttribute: () => 'button', + }) }, + }); + expect(tracking.console.warn).toHaveBeenCalledWith( + '[track] Failed to track event:', expect.any(Error), + ); +}); diff --git a/src/frontend/tests/unit/api-markdown.vitest.test.ts b/src/frontend/tests/unit/api-markdown.vitest.test.ts index 159015258..5b32213c8 100644 --- a/src/frontend/tests/unit/api-markdown.vitest.test.ts +++ b/src/frontend/tests/unit/api-markdown.vitest.test.ts @@ -6,7 +6,13 @@ -- route module imports and test props are intentionally dynamic in this harness */ import { describe, expect, it, vi } from 'vitest'; +import { createMarkdownProcessor } from '@astrojs/markdown-remark'; +import { selectAll } from 'hast-util-select'; +import { toHtml } from 'hast-util-to-html'; +import rehypeParse from 'rehype-parse'; +import { unified } from 'unified'; +import { escapeTableCell } from '@utils/api-markdown-shared'; import { renderCSharpDocMarkdown, renderCSharpMemberKindMarkdown, @@ -236,6 +242,92 @@ describe('API markdown routes', () => { }); describe('API markdown helpers', () => { + const tableProcessor = createMarkdownProcessor({ smartypants: false, syntaxHighlight: false }); + + async function renderCell(value: string) { + const processor = await tableProcessor; + const result = await processor.render( + `| Value | Sentinel |\n| --- | --- |\n| ${escapeTableCell(value)} | intact |` + ); + const tree = unified().use(rehypeParse, { fragment: true }).parse(result.code); + expect(selectAll('tbody tr', tree)).toHaveLength(1); + const cells = selectAll('td', tree); + expect(cells).toHaveLength(2); + expect(toHtml(cells[1])).toBe('intact'); + return toHtml(cells[0]); + } + + it.each([0, 1, 2, 3, 4])('preserves %i literal backslashes before a pipe in a GFM cell', async (count) => { + const value = `left${'\\'.repeat(count)}|right`; + expect(await renderCell(value)).toBe(`${value}`); + }); + + it('preserves ordinary inline code containing pipes', async () => { + expect(await renderCell('`left|middle|right`')).toBe('left|middle|right'); + }); + + it.each(['\r\n', '\r', '\n'])('keeps %j line endings within a single table row', async (newline) => { + expect(await renderCell(`first${newline}second`)).toBe('first
second'); + }); + + it('preserves links, emphasis, code spans and literal paths in rendered table cells', async () => { + expect(await renderCell( + '[API | docs](/reference/api/) **important** `C:\\src\\app` and C:\\src\\app\\' + )).toBe( + 'API | docs important ' + + 'C:\\src\\app and C:\\src\\app\\' + ); + }); + + it('encodes repeated prose backslashes and pipes once, without changing plain code spans', async () => { + const value = 'a\\|b\\\\|c | `C:\\src\\app` | `left|right`'; + expect(escapeTableCell(value)).toBe( + 'a\\\\\\|b\\\\\\\\\\|c \\| `C:\\src\\app` \\| `left\\|right`' + ); + expect(await renderCell(value)).toBe( + 'a\\|b\\\\|c | C:\\src\\app | left|right' + ); + }); + + it('preserves code backslashes when a pipe occurs elsewhere in the same span', async () => { + expect(await renderCell('before\r\n`C:\\src | D:\\data` after\\|end')).toBe( + 'before
C:\\src | D:\\data after\\|end' + ); + }); + + it('preserves existing Markdown escapes instead of treating the cell as raw text', async () => { + expect(await renderCell('\\*literal\\* and \\[label\\] | `C:\\src\\app`')).toBe( + '*literal* and [label] | C:\\src\\app' + ); + }); + + it('preserves inline-code link labels and single-line code from API renderers', async () => { + expect(await renderCell('[`left|right`](/reference/api/) and `first second`')).toBe( + 'left|right and first second' + ); + }); + + it('preserves rich Markdown from the C# documentation table renderer', async () => { + const markdown = renderCSharpDocMarkdown([{ + kind: 'list', + style: 'table', + items: [{ + term: [{ kind: 'text', text: 'path\\|name' }], + description: [ + { kind: 'href', text: 'API | docs', value: '/reference/api/' }, + { kind: 'code', text: '%LocalAppData%\\Aspire\\BrowserData' }, + ], + }], + }], { allTypes: [], base: '', packageName: 'Test.Package' }); + const result = await (await tableProcessor).render(markdown); + const tree = unified().use(rehypeParse, { fragment: true }).parse(result.code); + expect(selectAll('tbody tr', tree)).toHaveLength(1); + expect(selectAll('td', tree).map((node) => toHtml(node))).toEqual([ + 'path\\|name', + 'API | docs %LocalAppData%\\Aspire\\BrowserData', + ]); + }); + it('normalizes note blockquotes to a single level', () => { const markdown = renderCSharpDocMarkdown( [ diff --git a/src/frontend/tests/unit/api-search-lifecycle.vitest.test.ts b/src/frontend/tests/unit/api-search-lifecycle.vitest.test.ts index 5fd4016bb..f02cf811e 100644 --- a/src/frontend/tests/unit/api-search-lifecycle.vitest.test.ts +++ b/src/frontend/tests/unit/api-search-lifecycle.vitest.test.ts @@ -4,6 +4,9 @@ import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { runInNewContext } from 'node:vm'; import { ModuleKind, ScriptTarget, transpileModule } from 'typescript'; +import { selectAll } from 'hast-util-select'; +import rehypeParse from 'rehype-parse'; +import { unified } from 'unified'; import { readApiSearchIndex, registerApiSearch } from '@components/api-reference/search-lifecycle'; import * as searchStats from '@utils/ts-api-search-stats'; @@ -18,6 +21,32 @@ const surfaces = [ type MountSearch = (root: HTMLElement, signal: AbortSignal) => void; +function readControllerScript(source: string): string { + const tree = unified().use(rehypeParse, { fragment: true }).parse(source); + const scripts = selectAll('script', tree).filter((node) => + !('src' in node.properties) && !('is:inline' in node.properties) + && (!node.properties.type || node.properties.type === 'module')); + expect(scripts).toHaveLength(1); + return scripts[0].children.map((node) => node.type === 'text' ? node.value : '').join(''); +} + +describe('Astro controller script extraction', () => { + it.each(['script', 'ScRiPt'])('parses %s boundaries and quoted attributes, not script-like tags', (tag) => { + expect(readControllerScript(` + not a controller + + + + <${tag} data-label="a > b">const value = "&"; + `)).toBe('const value = "&";'); + }); + + it('rejects missing or ambiguous controllers', () => { + expect(() => readControllerScript('no')).toThrow(); + expect(() => readControllerScript('')).toThrow(); + }); +}); + describe('API search navigation lifecycle', () => { let events: EventTarget; let roots: Map; @@ -162,9 +191,14 @@ describe('API search navigation lifecycle', () => { if (kind === 'type' || kind === 'item') segments.push(language === 'csharp' ? '[type]' : '[item]'); const filename = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'src', 'pages', 'reference', 'api', ...segments, 'index.astro'); - const script = readFileSync(filename, 'utf8').match(/>', 'scriptnamescript'], + ['', 'section'], + ['<> & !!!', 'section'], + ])('allows only slug characters in plain text %j', (text, expected) => { + expect(slugifyHeading(text)).toBe(expected); + expect(slugifyHeading(text)).toMatch(/^[\p{Letter}\p{Number}-]+$/u); + }); + + it('preserves heading IDs when the caller extracts MDAST text before slugging', () => { + const text = headingPlainText([ + { type: 'html', value: '' }, + { type: 'text', value: 'Running ' }, + { type: 'strong', children: [{ type: 'text', value: 'the ' }] }, + { type: 'link', url: '/app/', children: [{ type: 'inlineCode', value: 'AppHost' }] }, + { type: 'html', value: '' }, + ]); + expect(text).toBe('Running the AppHost'); + expect(slugifyHeading(text)).toBe('running-the-apphost'); + }); }); describe('toSentenceCase', () => { diff --git a/src/frontend/tests/unit/search.vitest.test.ts b/src/frontend/tests/unit/search.vitest.test.ts new file mode 100644 index 000000000..b6af7b639 --- /dev/null +++ b/src/frontend/tests/unit/search.vitest.test.ts @@ -0,0 +1,108 @@ +import type { RootContent } from 'hast'; +import rehypeParse from 'rehype-parse'; +import { unified } from 'unified'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { SearchResponse } from '@scripts/search'; + +const pagefind = vi.hoisted(() => ({ search: vi.fn() })); +vi.mock('https://aspire.dev/pagefind/pagefind.js', () => pagefind); + +function textContent(node: RootContent): string { + if (node.type === 'text') return node.value; + return 'children' in node ? node.children.map(textContent).join('') : ''; +} + +describe('WebMCP search excerpts', () => { + const parse = unified().use(rehypeParse, { fragment: true }); + const createElement = vi.fn((tag: string) => { + expect(tag).toBe('template'); + // Model detached template content with a real HTML parser, not a tag regex. + const content = { textContent: '' }; + return { + content, + set innerHTML(html: string) { + content.textContent = parse.parse(html).children.map(textContent).join(''); + }, + }; + }); + + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + vi.stubGlobal('window', { location: new URL('https://aspire.dev/') }); + vi.stubGlobal('document', { createElement }); + pagefind.search.mockResolvedValue({ results: [] }); + }); + + afterEach(() => vi.unstubAllGlobals()); + + function hit(excerpt?: string, title?: string) { + return { + data: vi.fn().mockResolvedValue({ url: '/get-started/', meta: { title }, excerpt }), + }; + } + + it.each([ + [' Aspire Redis & cache ', 'Aspire Redis & cache'], + ['match <T> | \', 'match | \\'], + ['beforenested match after', 'beforenested match after'], + ['x < y and unfinished', 'x < y and unfinished'], + ['text', + 'globalThis.injected = truetext'], + ['<img src=x onerror="fail()">', ''], + [undefined, ''], + ])('extracts text from the inert HTML excerpt %j', async (excerpt, expected) => { + pagefind.search.mockResolvedValue({ results: [hit(excerpt, 'Getting started')] }); + const { searchAspireDocs } = await import('@scripts/search'); + expect(await searchAspireDocs(' redis ', 10)).toEqual({ + results: [{ title: 'Getting started', url: '/get-started/', excerpt: expected }], + }); + expect(pagefind.search).toHaveBeenCalledWith('redis'); + expect(createElement).toHaveBeenCalledExactlyOnceWith('template'); + }); + + it('preserves result limits, title fallback, and empty queries/results', async () => { + const hits = [hit('one'), hit('two'), hit('three')]; + pagefind.search.mockResolvedValue({ results: hits }); + const { searchAspireDocs } = await import('@scripts/search'); + const response = await searchAspireDocs('redis', 2); + expect(response.results).toHaveLength(2); + expect(response.results[0].title).toBe('/get-started/'); + expect(hits[2].data).not.toHaveBeenCalled(); + expect((await searchAspireDocs('redis', 0)).results).toHaveLength(1); + pagefind.search.mockClear(); + expect(await searchAspireDocs(' \n ', 10)).toEqual({ results: [] }); + expect(pagefind.search).not.toHaveBeenCalled(); + pagefind.search.mockResolvedValue({ results: [] }); + expect(await searchAspireDocs('no matches', 10)).toEqual({ results: [] }); + }); + + it('preserves the unavailable response without a browser environment', async () => { + vi.stubGlobal('window', undefined); + const { searchAspireDocs } = await import('@scripts/search'); + expect(await searchAspireDocs('redis', 10)).toEqual({ + results: [], + unavailable: true, + reason: 'Pagefind is not available in this environment.', + }); + }); + + it('keeps decoded excerpts inside the unchanged WebMCP JSON-text envelope', async () => { + const registerTool = vi.fn<(tool: { + execute: (input: unknown) => Promise; + }) => void>(); + vi.stubGlobal('navigator', { modelContext: { registerTool } }); + const excerpt = ' & "quoted"'; + pagefind.search.mockResolvedValue({ + results: [hit('<img src=x onerror="fail()"> & "quoted"')], + }); + await import('@scripts/webmcp'); + const response: SearchResponse = { + results: [{ title: '/get-started/', url: '/get-started/', excerpt }], + }; + expect(await registerTool.mock.calls[0][0].execute({ query: 'redis' })).toEqual({ + content: [{ type: 'text', text: JSON.stringify(response) }], + isError: false, + }); + }); +}); diff --git a/src/frontend/tests/unit/site-ui-runtime.vitest.test.ts b/src/frontend/tests/unit/site-ui-runtime.vitest.test.ts new file mode 100644 index 000000000..34fd4b06a --- /dev/null +++ b/src/frontend/tests/unit/site-ui-runtime.vitest.test.ts @@ -0,0 +1,564 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { getEventListeners } from 'node:events'; +import { readFileSync } from 'node:fs'; +import { runInNewContext } from 'node:vm'; +import { ModuleKind, ScriptTarget, transpileModule } from 'typescript'; +import type { AsyncIconLoader } from 'mermaid'; + +const mermaidSource = readFileSync( + new URL('../../src/scripts/mermaid.ts', import.meta.url), + 'utf8' +); +const scrollComponent = readFileSync( + new URL('../../src/components/ScrollToTop.astro', import.meta.url), + 'utf8' +); +const extractScript = (component: string) => + component.match(/')).toBe('const ready = true;'); +}); + +class Element extends EventTarget { + id = ''; + lang = 'en'; + type = ''; + ariaLabel = ''; + textContent = ''; + innerHTML = ''; + scrollHeight = 2000; + style: Record = {}; + attributes = new Map(); + dataset: Record = {}; + classes = new Set(); + classList = { + add: (...names: string[]) => names.forEach((name) => this.classes.add(name)), + remove: (...names: string[]) => names.forEach((name) => this.classes.delete(name)), + contains: (name: string) => this.classes.has(name), + toggle: (name: string, force = !this.classes.has(name)) => { + if (force) this.classes.add(name); + else this.classes.delete(name); + return force; + }, + }; + parentNode: Element | null = null; + children: Element[] = []; + root = false; + constructor(readonly tagName = 'div') { + super(); + } + get parentElement() { + return this.parentNode; + } + get isConnected(): boolean { + return this.root || Boolean(this.parentNode?.isConnected); + } + setAttribute(name: string, value: string) { + this.attributes.set(name, value); + if (name.startsWith('data-')) this.dataset[name.slice(5)] = value; + } + getAttribute(name: string) { + return name.startsWith('data-') + ? (this.dataset[name.slice(5)] ?? null) + : (this.attributes.get(name) ?? null); + } + hasAttribute(name: string) { + return this.getAttribute(name) !== null; + } + removeAttribute(name: string) { + this.attributes.delete(name); + if (name.startsWith('data-')) delete this.dataset[name.slice(5)]; + } + append(child: Element) { + child.remove(); + this.appendChild(child); + } + appendChild(child: T) { + child.parentNode = this; + this.children.push(child); + return child; + } + removeChild(child: Element) { + this.children = this.children.filter((element) => element !== child); + child.parentNode = null; + } + remove() { + this.parentNode?.removeChild(this); + } + contains(element: Element): boolean { + return this === element || this.children.some((child) => child.contains(element)); + } + all(): Element[] { + return this.children.flatMap((child) => [child, ...child.all()]); + } + querySelectorAll(selector: string) { + return this.all().filter((element) => { + if (selector.startsWith('#')) return element.id === selector.slice(1); + if (selector === 'pre.mermaid') { + return element.tagName === 'pre' && element.classList.contains('mermaid'); + } + if (selector.startsWith('.')) return element.classList.contains(selector.slice(1)); + return element.tagName === selector; + }); + } + querySelector(selector: string): Element | null { + return this.querySelectorAll(selector)[0] ?? null; + } +} + +class Document extends EventTarget { + documentElement = new Element('html'); + head = this.documentElement.appendChild(new Element('head')); + body = this.documentElement.appendChild(new Element('body')); + readyState = 'complete'; + constructor() { + super(); + this.documentElement.root = true; + this.documentElement.setAttribute('data-theme', 'light'); + } + createElement(tag: string) { + return new Element(tag); + } + querySelectorAll(selector: string) { + return this.documentElement.querySelectorAll(selector); + } + querySelector(selector: string) { + return this.documentElement.querySelector(selector); + } + getElementById(id: string) { + return this.querySelector(`#${id}`); + } + emit(name: string) { + this.dispatchEvent(new Event(name)); + } + replaceBody(sources: string[] = []) { + this.body.remove(); + this.body = this.documentElement.appendChild(new Element('body')); + return sources.map((source) => { + const diagram = this.body.appendChild(new Element('pre')); + diagram.classList.add('mermaid'); + diagram.textContent = source; + return diagram; + }); + } +} + +function observers() { + const instances: Observer[] = []; + class Observer { + targets = new Set(); + constructor(readonly callback: () => void) { + instances.push(this); + } + observe(target: Element) { + this.targets.add(target); + } + disconnect() { + this.targets.clear(); + } + } + return { + Observer, + instances, + notify: (target: Element) => { + instances + .filter((observer) => observer.targets.has(target)) + .forEach((observer) => observer.callback()); + }, + }; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} + +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +async function mermaidRuntime({ + sources = ['graph TD; A-->B', 'graph TD; C-->D'], + loading = false, +} = {}) { + const { iconPacks } = await import('../../config/icon-packs.mjs'); + const icons = { prefix: 'test', icons: { app: { body: '' } } }; + const fetch = vi + .fn() + .mockImplementation(() => Promise.resolve(new Response(JSON.stringify(icons)))); + const document = new Document(); + const diagrams = document.replaceBody(sources); + if (loading) document.readyState = 'loading'; + const mutation = observers(); + const logger = { log: vi.fn(), error: vi.fn() }; + const mermaid = { + initialize: vi.fn<(config: { theme: string }) => void>(), + registerIconPacks: vi.fn<(packs: AsyncIconLoader[]) => void>(), + render: vi + .fn<(id: string, source: string) => Promise<{ svg: string }>>() + .mockImplementation((_id, source) => Promise.resolve({ svg: `${source}` })), + }; + const imported = deferred<{ default: typeof mermaid }>(); + const importMermaid = vi.fn(() => imported.promise); + // Replace only the module boundary; execute the actual site runtime. + runInNewContext(compile(mermaidSource).replace("import('mermaid')", 'importMermaid()'), { + document, + MutationObserver: mutation.Observer, + console: logger, + queueMicrotask, + importMermaid, + iconPacks, + fetch, + Error, + }); + if (!loading) document.emit('astro:page-load'); + return { + document, + diagrams, + mutation, + mermaid, + imported, + importMermaid, + logger, + icons, + iconPacks, + fetch, + theme(value: string, target = document.documentElement) { + target.setAttribute('data-theme', value); + mutation.notify(target); + }, + swap(sources: string[]) { + document.emit('astro:before-swap'); + const diagrams = document.replaceBody(sources); + document.emit('astro:after-swap'); + document.emit('astro:page-load'); + return diagrams; + }, + }; +} + +describe('site-owned Mermaid runtime', () => { + it('coalesces readiness, page-load and same-theme notifications before and during rendering', async () => { + const fixture = await mermaidRuntime({ loading: true }); + const { document, mermaid, imported, diagrams, importMermaid, logger } = fixture; + expect(importMermaid).not.toHaveBeenCalled(); + document.emit('DOMContentLoaded'); + document.emit('astro:page-load'); + fixture.theme('light'); + await flush(); + expect(importMermaid).toHaveBeenCalledOnce(); + const render = deferred<{ svg: string }>(); + mermaid.render.mockReturnValueOnce(render.promise); + imported.resolve({ default: mermaid }); + await flush(); + document.emit('astro:after-swap'); + document.emit('astro:page-load'); + fixture.theme('light'); + await flush(); + expect(mermaid.render).toHaveBeenCalledOnce(); + render.resolve({ svg: 'first' }); + await flush(); + expect(mermaid.render).toHaveBeenCalledTimes(2); + expect(mermaid.initialize).toHaveBeenCalledTimes(1); + expect(diagrams.map((diagram) => diagram.getAttribute('data-processed'))).toEqual([ + 'true', + 'true', + ]); + expect(mermaid.registerIconPacks).toHaveBeenCalledOnce(); + expect(mermaid.registerIconPacks.mock.calls[0][0].map((pack) => pack.name)).toEqual( + fixture.iconPacks.map((pack) => pack.name) + ); + expect(logger.log).not.toHaveBeenCalled(); + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('keeps imports lazy on empty pages and renders two diagrams once on each return visit', async () => { + const fixture = await mermaidRuntime({ sources: [] }); + await flush(); + expect(fixture.importMermaid).not.toHaveBeenCalled(); + fixture.imported.resolve({ default: fixture.mermaid }); + for (let visit = 1; visit <= 3; visit++) { + fixture.swap(['graph TD; A-->B', 'graph TD; C-->D']); + await flush(); + expect(fixture.mermaid.render).toHaveBeenCalledTimes(visit * 2); + fixture.swap([]); + await flush(); + } + expect(fixture.importMermaid).toHaveBeenCalledOnce(); + }); + + it('ignores equivalent attribute churn and rerenders only for effective theme changes', async () => { + const fixture = await mermaidRuntime(); + fixture.imported.resolve({ default: fixture.mermaid }); + await flush(); + fixture.document.documentElement.removeAttribute('data-theme'); + fixture.theme('light'); + fixture.theme('dark', fixture.document.body); + await flush(); + expect(fixture.mermaid.render).toHaveBeenCalledTimes(2); + fixture.theme('dark'); + await flush(); + expect(fixture.mermaid.render).toHaveBeenCalledTimes(4); + expect(fixture.mermaid.initialize.mock.calls.map(([config]) => config.theme)).toEqual([ + 'default', + 'dark', + ]); + expect(fixture.mermaid.render.mock.calls.map(([, source]) => source)).toEqual([ + 'graph TD; A-->B', + 'graph TD; C-->D', + 'graph TD; A-->B', + 'graph TD; C-->D', + ]); + }); + + it('serializes global theme configuration and discards superseded in-flight SVGs', async () => { + const fixture = await mermaidRuntime({ sources: ['graph TD; A-->B'] }); + const render = deferred<{ svg: string }>(); + fixture.mermaid.render.mockReturnValueOnce(render.promise); + fixture.imported.resolve({ default: fixture.mermaid }); + await flush(); + fixture.theme('dark'); + fixture.theme('light'); + fixture.theme('dark'); + await flush(); + expect(fixture.mermaid.initialize).toHaveBeenCalledOnce(); + expect(fixture.mermaid.render).toHaveBeenCalledOnce(); + const latest = deferred<{ svg: string }>(); + fixture.mermaid.render.mockReturnValueOnce(latest.promise); + render.resolve({ svg: 'obsolete light' }); + await flush(); + expect(fixture.diagrams[0].innerHTML).toBe(''); + expect(fixture.mermaid.initialize.mock.calls.map(([config]) => config.theme)).toEqual([ + 'default', + 'dark', + ]); + latest.resolve({ svg: 'latest dark' }); + await flush(); + expect(fixture.diagrams[0].innerHTML).toBe('latest dark'); + }); + + it('abandons imports owned by a replaced page and observes the new body, not the old body', async () => { + const fixture = await mermaidRuntime(); + await flush(); + const oldBody = fixture.document.body; + const next = fixture.swap(['graph TD; New-->Page']); + fixture.imported.resolve({ default: fixture.mermaid }); + await flush(); + expect(fixture.mermaid.render).toHaveBeenCalledExactlyOnceWith( + expect.any(String), + 'graph TD; New-->Page' + ); + expect(fixture.diagrams.every((diagram) => diagram.innerHTML === '')).toBe(true); + expect(fixture.mutation.instances[0].targets.has(oldBody)).toBe(false); + fixture.theme('dark'); + await flush(); + expect(fixture.mermaid.render).toHaveBeenCalledTimes(2); + expect(next[0].getAttribute('data-processed')).toBe('true'); + }); + + it.each(['resolve', 'reject'] as const)( + 'does not write stale %s completions after a swap', + async (outcome) => { + const fixture = await mermaidRuntime(); + const render = deferred<{ svg: string }>(); + fixture.mermaid.render.mockReturnValueOnce(render.promise); + fixture.imported.resolve({ default: fixture.mermaid }); + await flush(); + const next = fixture.swap(['graph TD; New-->Page']); + await flush(); + expect(fixture.mermaid.initialize).toHaveBeenCalledOnce(); + if (outcome === 'resolve') render.resolve({ svg: 'stale' }); + else render.reject(new Error('obsolete render failed')); + await flush(); + expect(fixture.diagrams[0].innerHTML).toBe(''); + expect(fixture.diagrams[0].children).toHaveLength(0); + expect(fixture.diagrams[0].hasAttribute('data-processed')).toBe(false); + expect(next[0].innerHTML).toContain('New-->Page'); + expect(fixture.mermaid.render).toHaveBeenCalledTimes(2); + expect(fixture.logger.error).toHaveBeenCalledTimes(outcome === 'reject' ? 1 : 0); + } + ); + + it('keeps current work alive when navigation preparation is canceled', async () => { + const fixture = await mermaidRuntime(); + fixture.document.emit('astro:before-preparation'); + fixture.imported.resolve({ default: fixture.mermaid }); + await flush(); + expect(fixture.mermaid.render).toHaveBeenCalledTimes(2); + fixture.theme('dark'); + await flush(); + expect(fixture.mermaid.render).toHaveBeenCalledTimes(4); + }); + + it('preserves visible escaped errors and source without retrying an unchanged failed diagram', async () => { + const fixture = await mermaidRuntime({ sources: ['invalid '] }); + fixture.mermaid.render.mockRejectedValueOnce(new Error('')); + fixture.imported.resolve({ default: fixture.mermaid }); + await flush(); + const [diagram] = fixture.diagrams; + expect(fixture.logger.error).toHaveBeenCalledOnce(); + expect(diagram.getAttribute('data-diagram')).toBe('invalid '); + expect(diagram.textContent).toBe('Unable to render diagram: '); + expect(diagram.classList.contains('mermaid-error')).toBe(true); + expect(diagram.innerHTML).toBe(''); + fixture.document.emit('astro:page-load'); + await flush(); + expect(fixture.mermaid.render).toHaveBeenCalledOnce(); + fixture.theme('dark'); + await flush(); + expect(fixture.mermaid.render.mock.calls[1][1]).toBe('invalid '); + }); + + it('logs import failures, clears the failed import promise and retries on the next page load', async () => { + const fixture = await mermaidRuntime(); + fixture.imported.reject(new Error('import failed')); + await flush(); + expect(fixture.logger.error).toHaveBeenCalled(); + fixture.importMermaid.mockResolvedValueOnce({ default: fixture.mermaid }); + fixture.document.emit('astro:page-load'); + await flush(); + expect(fixture.importMermaid).toHaveBeenCalledTimes(2); + expect(fixture.mermaid.render).toHaveBeenCalledTimes(2); + }); + + it('loads configured icon packs lazily and reports failed responses', async () => { + const fixture = await mermaidRuntime(); + fixture.imported.resolve({ default: fixture.mermaid }); + await flush(); + expect(fixture.fetch).not.toHaveBeenCalled(); + const pack = fixture.mermaid.registerIconPacks.mock.calls[0][0][0]; + await expect(pack.loader()).resolves.toEqual(fixture.icons); + expect(fixture.fetch).toHaveBeenCalledWith(fixture.iconPacks[0].url); + fixture.fetch.mockResolvedValueOnce(new Response('', { status: 503 })); + await expect(pack.loader()).rejects.toThrow('HTTP 503'); + }); +}); + +function scrollRuntime({ reducedMotion = false } = {}) { + vi.useFakeTimers(); + const document = new Document(); + const window = Object.assign(new EventTarget(), { + scrollY: 500, + innerHeight: 900, + innerWidth: 1440, + outerWidth: 1440, + scrollTo: vi.fn(), + matchMedia: () => ({ matches: reducedMotion }), + requestAnimationFrame: (callback: () => void) => setTimeout(callback, 16), + cancelAnimationFrame: clearTimeout, + }); + type ScrollElement = Element & { connectedCallback(): void; disconnectedCallback(): void }; + let Constructor: (new () => ScrollElement) | undefined; + runInNewContext(compile(scrollSource!), { + document, + window, + HTMLElement: Element, + AbortController, + customElements: { + define: (_name: string, value: new () => ScrollElement) => { + Constructor = value; + }, + }, + }); + if (!Constructor) throw new Error('Scroll-to-top custom element was not registered.'); + const root = document.body.appendChild(new Constructor()); + const button = root.appendChild(new Element('button')); + button.id = 'scroll-to-top-button'; + button.type = 'button'; + root.connectedCallback(); + return { document, window, root, button: () => button }; +} + +describe('site-owned scroll-to-top control', () => { + afterEach(() => vi.useRealTimers()); + + it('mounts once per connection without document readiness or keyboard handlers', () => { + const fixture = scrollRuntime(); + fixture.root.connectedCallback(); + fixture.document.emit('DOMContentLoaded'); + fixture.document.emit('astro:page-load'); + expect(vi.getTimerCount()).toBe(1); + vi.runAllTimers(); + const button = fixture.button(); + fixture.document.emit('astro:page-load'); + vi.runAllTimers(); + expect(fixture.button()).toBe(button); + expect(fixture.document.querySelectorAll('#scroll-to-top-button')).toHaveLength(1); + expect(getEventListeners(fixture.document, 'keydown')).toHaveLength(0); + expect(getEventListeners(fixture.document, 'DOMContentLoaded')).toHaveLength(0); + }); + + it.each([false, true])( + 'uses only native click activation (reduced motion: %s)', + (reducedMotion) => { + const fixture = scrollRuntime({ reducedMotion }); + vi.runAllTimers(); + const button = fixture.button(); + expect(button.type).toBe('button'); + for (const name of ['touchstart', 'touchend', 'keydown']) { + expect(getEventListeners(button, name)).toHaveLength(0); + } + const click = new Event('click', { cancelable: true }); + button.dispatchEvent(click); + expect(click.defaultPrevented).toBe(false); + expect(fixture.window.scrollTo).toHaveBeenCalledExactlyOnceWith({ + top: 0, + behavior: reducedMotion ? 'auto' : 'smooth', + }); + expect(scrollComponent).toContain('button:focus-visible'); + } + ); + + it('disposes element/window handlers and pending frames when disconnected', () => { + const fixture = scrollRuntime(); + fixture.root.disconnectedCallback(); + expect(vi.getTimerCount()).toBe(0); + vi.runAllTimers(); + for (let visit = 0; visit < 3; visit++) { + fixture.root.connectedCallback(); + vi.runAllTimers(); + expect(getEventListeners(fixture.document, 'keydown')).toHaveLength(0); + expect(getEventListeners(fixture.window, 'scroll')).toHaveLength(1); + fixture.window.dispatchEvent(new Event('scroll')); + expect(vi.getTimerCount()).toBe(1); + fixture.root.disconnectedCallback(); + expect(vi.getTimerCount()).toBe(0); + expect(getEventListeners(fixture.document, 'keydown')).toHaveLength(0); + expect(getEventListeners(fixture.window, 'scroll')).toHaveLength(0); + expect(getEventListeners(fixture.window, 'resize')).toHaveLength(0); + expect(getEventListeners(fixture.button(), 'click')).toHaveLength(0); + } + }); + + it('survives canceled preparation and updates scroll/zoom visibility in one frame', () => { + const fixture = scrollRuntime(); + vi.runAllTimers(); + const button = fixture.button(); + fixture.document.emit('astro:before-preparation'); + expect(fixture.button()).toBe(button); + expect(getEventListeners(fixture.document, 'keydown')).toHaveLength(0); + fixture.window.outerWidth = 6000; + fixture.window.dispatchEvent(new Event('resize')); + vi.runAllTimers(); + expect(button.classList.contains('visible')).toBe(false); + fixture.window.outerWidth = 1440; + fixture.window.dispatchEvent(new Event('resize')); + vi.runAllTimers(); + expect(button.classList.contains('visible')).toBe(true); + fixture.window.dispatchEvent(new Event('scroll')); + fixture.window.scrollY = 0; + fixture.window.dispatchEvent(new Event('scroll')); + vi.runAllTimers(); + expect(button.classList.contains('visible')).toBe(false); + }); +}); diff --git a/src/frontend/tests/unit/update-integrations.vitest.test.ts b/src/frontend/tests/unit/update-integrations.vitest.test.ts index e53478e95..6ef2b88a0 100644 --- a/src/frontend/tests/unit/update-integrations.vitest.test.ts +++ b/src/frontend/tests/unit/update-integrations.vitest.test.ts @@ -33,6 +33,15 @@ function docsPageExists( ); } +describe('integration catalog integrity', () => { + test('contains each NuGet package ID only once, ignoring case', () => { + const packageIds = aspireIntegrations.map(({ title }) => title.toLowerCase()); + const duplicateIds = packageIds.filter((id, index) => packageIds.indexOf(id) !== index); + + expect(duplicateIds).toEqual([]); + }); +}); + describe('update-integrations icon handling', () => { test('uses the package version for official Aspire packages from nuget.org', () => { expect( diff --git a/src/statichost/StaticHost/Live/LiveEndpoints.cs b/src/statichost/StaticHost/Live/LiveEndpoints.cs index 96d88de12..98a0ac86c 100644 --- a/src/statichost/StaticHost/Live/LiveEndpoints.cs +++ b/src/statichost/StaticHost/Live/LiveEndpoints.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Security.Cryptography; using System.Text; using System.Text.Json.Serialization; @@ -203,7 +204,8 @@ private static async Task TwitchWebhook( // replayed indefinitely. if (!TwitchWebhookHandler.IsFresh(timestamp, time.GetUtcNow(), TimeSpan.FromMinutes(10))) { - logger.LogWarning("Twitch webhook timestamp {Timestamp} is stale or unparseable; rejecting.", timestamp); + logger.LogWarning("Twitch webhook timestamp {Timestamp} (sanitized) is stale or unparseable; rejecting.", + TwitchWebhookHandler.SanitizeDiagnosticValue(timestamp)); return Results.Unauthorized(); } @@ -226,15 +228,16 @@ private static async Task TwitchWebhook( if (acquisition.Status == TwitchMessageAcquisitionStatus.Completed) { - logger.LogDebug("Twitch webhook replay ignored for {MessageId}.", messageId); + logger.LogDebug("Twitch webhook replay ignored for message {MessageId}.", + TwitchWebhookHandler.SanitizeDiagnosticValue(messageId)); return Results.Ok(); } if (acquisition.Status == TwitchMessageAcquisitionStatus.Processing) { logger.LogDebug( - "Twitch webhook {MessageId} is already being processed; asking Twitch to retry.", - messageId); + "Twitch webhook message {MessageId} is already being processed; asking Twitch to retry.", + TwitchWebhookHandler.SanitizeDiagnosticValue(messageId)); return Results.StatusCode(StatusCodes.Status503ServiceUnavailable); } @@ -276,6 +279,7 @@ private static async Task TwitchWebhook( private static async Task YouTubeVerify( HttpContext context, IYouTubeWebSubSubscriptionState subscriptions, + IOptions options, TimeProvider time, ILoggerFactory loggerFactory) { @@ -291,6 +295,19 @@ private static async Task YouTubeVerify( var verifyToken = query["hub.verify_token"].ToString(); var logger = loggerFactory.CreateLogger("StaticHost.Live.YouTube.WebSub"); + if (string.Equals(mode, "denied", StringComparison.Ordinal)) + { + var channelId = options.Value.YouTube.ChannelId; + bool? matchesConfiguredTopic = string.IsNullOrEmpty(channelId) + ? null + : string.Equals(topic, YouTubeWebSubSubscriptionTransitions.TopicFor(channelId), StringComparison.Ordinal); + YouTubeDiagnostics.LogUntrustedDenial( + logger, query["hub.reason"].ToString(), !string.IsNullOrEmpty(topic), matchesConfiguredTopic); + return string.IsNullOrEmpty(topic) + ? Results.BadRequest("Invalid WebSub denial report.") + : Results.Ok(); + } + if (!int.TryParse(query["hub.lease_seconds"], out var leaseSeconds) || string.IsNullOrEmpty(challenge)) { @@ -304,7 +321,8 @@ private static async Task YouTubeVerify( verifyToken, leaseSeconds)) { - logger.LogWarning("Rejected malformed YouTube WebSub {Mode} verification for {Topic}.", mode, topic); + LogRejectedVerification( + logger, mode, topic, options.Value.YouTube.ChannelId, malformed: true); return Results.NotFound(); } @@ -330,15 +348,36 @@ private static async Task YouTubeVerify( if (!confirmed) { - logger.LogWarning("Rejected unexpected YouTube WebSub {Mode} verification for {Topic}.", mode, topic); + LogRejectedVerification( + logger, mode, topic, options.Value.YouTube.ChannelId, malformed: false); return Results.NotFound(); } - logger.LogInformation("YouTube WebSub subscription verified for {Topic}; granted lease {LeaseSeconds}s.", - topic, leaseSeconds); + logger.LogInformation( + "YouTube {Operation} acknowledged; callback lease {LeaseSeconds}s. A matching retry does not extend the lease or reset subscription backoff.", + "WebSubVerification", leaseSeconds); return Results.Text(challenge, "text/plain"); } + private static void LogRejectedVerification( + ILogger logger, string mode, string topic, string channelId, bool malformed) + { + var modeClassification = mode switch + { + "subscribe" => "Subscribe", + "unsubscribe" => "Unsubscribe", + _ => "Unknown", + }; + bool? matchesConfiguredTopic = string.IsNullOrEmpty(channelId) + ? null + : string.Equals(topic, YouTubeWebSubSubscriptionTransitions.TopicFor(channelId), StringComparison.Ordinal); + logger.LogWarning( + "YouTube {Operation} rejected: {RejectionReason}; mode {ModeClassification}, " + + "topic present {TopicPresent}, matches configured topic {MatchesConfiguredTopic}.", + "WebSubVerification", malformed ? "Malformed" : "Unexpected", modeClassification, + !string.IsNullOrEmpty(topic), matchesConfiguredTopic); + } + private static async Task YouTubeWebhook( HttpContext context, IOptions options, @@ -365,8 +404,8 @@ private static async Task YouTubeWebhook( var signature = context.Request.Headers["X-Hub-Signature"].ToString(); if (!YouTubeWebhookHandler.IsValidSignature(youtube.WebhookSecret, bodyBytes, signature)) { - logger.LogWarning("YouTube WebSub signature mismatch."); - return Results.Unauthorized(); + logger.LogWarning("YouTube WebSub signature missing or invalid; acknowledging and discarding the notification without processing."); + return Results.Ok(); } if (env.IsDevelopment() && options.Value.EnableDevEndpoint && !youtube.IsConfigured) @@ -408,16 +447,21 @@ internal static async Task ConfirmYouTubeLiveStatusAsync( var channelId = youtube.ChannelId; if (string.IsNullOrWhiteSpace(channelId)) { - channelId = await ytClient.ResolveChannelIdAsync(youtube.ChannelHandle, cancellationToken).ConfigureAwait(false); + channelId = await ConfirmOperationAsync("NotificationChannelResolution", YouTubeDiagnostics.ChannelsEndpoint, + () => ytClient.ResolveChannelIdAsync(youtube.ChannelHandle, cancellationToken)).ConfigureAwait(false); } if (string.IsNullOrWhiteSpace(channelId)) { - logger.LogWarning("Could not resolve YouTube channel id for webhook confirmation."); + logger.LogWarning("YouTube {Operation} returned no channel; notification confirmation is unavailable, not confirmed offline.", + "NotificationChannelResolution"); return; } - var live = await ytClient.GetCurrentLiveAsync(channelId, cancellationToken).ConfigureAwait(false); + var live = await ConfirmOperationAsync("NotificationConfirmation", YouTubeDiagnostics.SearchEndpoint, + () => ytClient.GetCurrentLiveAsync(channelId, cancellationToken)).ConfigureAwait(false); + logger.LogInformation("YouTube {Operation} succeeded at {CheckedAt}; observed live {ObservedLive}.", + "NotificationConfirmation", DateTimeOffset.UtcNow, live.Live); await broadcaster.UpdateAsync( new LiveStatusUpdate { @@ -428,6 +472,22 @@ await broadcaster.UpdateAsync( youtube.OfflineConfirmationCount), }, cancellationToken).ConfigureAwait(false); + + async Task ConfirmOperationAsync(string operation, string endpoint, Func> action) + { + var started = Stopwatch.GetTimestamp(); + try + { + return await action().ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } + catch (Exception exception) + { + YouTubeDiagnostics.LogFailure(logger, exception, operation, endpoint, + elapsedMs: Stopwatch.GetElapsedTime(started).TotalMilliseconds); + throw; + } + } } // --- Dev-only ----------------------------------------------------------- diff --git a/src/statichost/StaticHost/Live/LiveStatusOptions.cs b/src/statichost/StaticHost/Live/LiveStatusOptions.cs index 381ffe530..adb9868f1 100644 --- a/src/statichost/StaticHost/Live/LiveStatusOptions.cs +++ b/src/statichost/StaticHost/Live/LiveStatusOptions.cs @@ -109,9 +109,9 @@ public sealed class YouTubeOptions public int PollingIntervalSeconds { get; set; } = 120; /// - /// How often to run the quota-expensive search.list discovery request - /// while offline. Defaults to 30 minutes, which stays below the standard - /// YouTube Data API daily quota. + /// How often to run search.list discovery while offline. + /// Defaults to 30 minutes; this limits scheduled searches, but does not + /// enforce the project's daily Search Queries quota across all callers. /// [Range(30 * 60, 24 * 60 * 60)] public int DiscoveryPollingIntervalSeconds { get; set; } = 30 * 60; diff --git a/src/statichost/StaticHost/Live/LiveStatusServiceCollectionExtensions.cs b/src/statichost/StaticHost/Live/LiveStatusServiceCollectionExtensions.cs index cbfb9fc95..751f5345c 100644 --- a/src/statichost/StaticHost/Live/LiveStatusServiceCollectionExtensions.cs +++ b/src/statichost/StaticHost/Live/LiveStatusServiceCollectionExtensions.cs @@ -47,11 +47,16 @@ public static TBuilder AddLiveStatus(this TBuilder builder) .AddStandardResilienceHandler(); builder.Services.AddHttpClient(TwitchAppTokenProvider.HttpClientName) .AddStandardResilienceHandler(); - builder.Services.AddHttpClient(YouTubeClient.HttpClientName) + builder.Services.AddHttpClient(YouTubeClient.HttpClientName, + client => client.MaxResponseContentBufferSize = YouTubeClient.ResponseBufferLimit) .AddStandardResilienceHandler(); // Subscription POST retries are scheduled in Redis, not inside the HTTP request. builder.Services.AddHttpClient(YouTubeClient.PubSubHttpClientName, - client => client.Timeout = TimeSpan.FromSeconds(30)); + client => + { + client.Timeout = TimeSpan.FromSeconds(30); + client.MaxResponseContentBufferSize = YouTubeClient.ResponseBufferLimit; + }); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/statichost/StaticHost/Live/README.md b/src/statichost/StaticHost/Live/README.md index d4ed37d3f..345c60124 100644 --- a/src/statichost/StaticHost/Live/README.md +++ b/src/statichost/StaticHost/Live/README.md @@ -51,18 +51,160 @@ restart the retry sequence. Successful verification resets the backoff; acceptance of the subscription POST alone does not. A channel change starts a fresh sequence. -Each failed attempt logs one concise warning for expected HTTP errors or -timeouts, including the failure count and next retry deadline. Exception -details and hub error response bodies are available at Debug; unexpected -exceptions remain Error-level with their stack traces. Successful verification -is logged at Information. Shutdown or leadership cancellation does not count -as an upstream failure. +Each failed attempt logs one warning for expected HTTP errors or timeouts, +including the failure count and next retry deadline. Unexpected failures are +Error-level, with a bounded `SafeStackTrace` rendered from stack frames alone, +without exception messages, argument values, or source file paths. Expected +HTTP and timeout warnings omit stack traces. Diagnostics intentionally omit +raw exception messages, URLs with query strings, and response bodies, which +can contain credentials. HTTP acceptance is logged separately from successful callback +verification: only verification establishes or renews the lease and resets +backoff. Shutdown or leadership cancellation does not count as an upstream +failure. The live and idle polling intervals remain unchanged during subscription backoff. Google documents feed notifications for uploads and video title/description updates, not guaranteed broadcast start/stop events, so fallback polling is still necessary. +### YouTube operational diagnostics + +In the Aspire dashboard, open **Structured logs**, select the `aspiredev` +resource, and filter for `YouTube` (the `StaticHost.Live.YouTube` categories). +Information, Warning, and Error events are sufficient; enabling Debug or +logging HTTP request bodies is not required. + +| `Operation` | Meaning | +| --- | --- | +| `ChannelResolution` | Resolve the configured handle through `channels.list`. A missing channel means detection is unavailable, not offline. | +| `OfflineDiscovery` | Interval-limited `search.list` check for a broadcast while no live video is known; not a daily quota guard. | +| `KnownVideoStatus` | Low-cost `videos.list` check of the currently known live video. | +| `WebSubSubscribe` | Subscription POST accepted or failed; acceptance does **not** prove callback verification. | +| `WebSubVerification` | A matching callback was acknowledged, with its `LeaseSeconds`; matching retries do not extend the original lease or reset backoff. | +| `WebSubDenialReport` | An untrusted, unauthenticated `hub.mode=denied` report; not proof that Google denied a subscription. No state or polling changes. | +| `NotificationChannelResolution` / `NotificationConfirmation` | Resolve the channel or run the confirming search after a signed notification. | +| `BackgroundTick` | A failure outside the provider calls, such as state coordination. | + +Failed provider operations include the query-free outbound `Endpoint`, +`ElapsedMs` measured with a monotonic clock, numeric `StatusCode`, +standard `StatusReason` (not an untrusted reason phrase), `FailureType`, +`IsTimeout`, `HttpRequestError`, `SocketError`, and `InnerFailureType` where +available. Google JSON errors add bounded `ProviderReason` and `ProviderDomain` +codes, for example `quotaExceeded` / `youtube.quota` or `accessNotConfigured` / +`usageLimits`. These distinguish quota exhaustion from API configuration, +network, and hub failures. + +The duration covers the client operation, including any existing Data API +resilience retries and response parsing, but not the later Redis backoff write. +For example, `WebSubSubscribe` with endpoint +`pubsubhubbub.appspot.com/subscribe` and status 503 identifies an outbound hub +failure, not an inbound website 503. Duration and status alone cannot establish +the underlying provider cause. + +HTTP acceptance is logged before recording the sent request in Redis, so a +subsequent persistence failure does not hide the provider's successful response. + +`ProviderDetail` contains only a fixed classification, such as temporary +unavailability, transient error, invalid topic, or callback verification failure. Unknown or +malformed bodies are omitted; `BodyTruncated` indicates the diagnostic read +exceeded 4,096 characters. No raw HTML, provider message, API key, webhook +secret, verification token, authorization header, or subscription form is +logged by these diagnostics. Subscription failures also expose `FailureCount` +and `RetryAt`. A valid provider `Retry-After` header is recorded as typed +`RetryAfterSeconds` or `RetryAfterDate`, never as raw header text. These fields +are diagnostic only and do not change the persisted retry schedule. For +example, a 503 with `Transient error; please try again later` and +`Retry-After: 120` reports a transient error and 120 seconds; it does not stop +polling or prove why a broadcast was missed. + +Both named YouTube HTTP clients cap response buffering at 1 MiB, including +responses without a Content-Length header. The existing request timeouts still +cover buffering. Responses exceeding that limit fail before diagnostic body +classification; they are logged as request failures, not offline observations. +The 4,096-character classification limit applies within that response-size cap. + +When `WebhookSecret` is configured, the callback acknowledges notifications with missing, invalid, or mismatched +signatures with HTTP 200, as required by PubSubHubbub authenticated content +distribution, but discards them before parsing, state updates, coordination, +or confirmation queuing. A warning explicitly records the discard. HTTP 200 +does not mean that a notification was authenticated or processed. Development +overrides still require both a valid signature and the dev command secret. +If `WebhookSecret` is empty, the callback instead returns HTTP 503 before +signature validation and logs the missing configuration. + +A denial GET requires `hub.topic`, but not a challenge, lease, or verification +token. The static callback cannot authenticate these reports, so it logs only +topic presence, a nullable comparison with the configured channel's topic, +reason presence/length, and a bounded fixed classification. If only a channel +handle is configured, topic correlation is unavailable. Even a matching topic +does not authenticate the sender. Denials never confirm, revoke, clear, or +otherwise change subscription state, live status, backoff, or polling. +Subscribe verification still requires the matching `hub.verify_token`, +challenge, and valid lease; repeated matching verifications retain the original +renewal deadline. + +Rejected verification callbacks log a fixed `RejectionReason` (`Malformed` or +`Unexpected`), a `ModeClassification` (`Subscribe`, `Unsubscribe`, or `Unknown`), +`TopicPresent`, and nullable `MatchesConfiguredTopic`. Neither submitted modes +nor topics enter the formatted message or structured fields. Topic correlation +is diagnostic only, not callback authorization. + +Twitch callback diagnostics retain rejected timestamps, unknown message types, +and replay/in-progress message IDs as sanitized fields: values are bounded to +128 characters (including an ellipsis when truncated), and characters outside +ASCII letters, digits, `_`, `.`, `:`, +`+`, and `-` are replaced with `_`. This preserves ordinary RFC3339 timestamps +and message-type names without letting line separators or control characters +forge log entries. Revocations retain the subscription ID, type, and status using +the same sanitizer, but omit the full body, transport URLs, and any other payload +fields. Invalid revocation JSON still returns the original acknowledgment and +logs that details were unavailable. Signature validation, freshness checks, +replay coordination, challenge responses, and HTTP statuses are unchanged. + +Successful discovery logs `LastSuccessfulDiscoveryAt`, `LastDiscoveryLive`, +and `NextDiscoveryAt`. The worker includes its last successful discovery time +and result in subsequent failure logs, so operators can distinguish a +successful offline observation from unavailable detection. These are +process-local diagnostic values, not new Redis or public snapshot fields; +they start empty after a restart, channel change, or before the first successful +discovery. +The reset happens before resolving changed channel settings, so a failed +resolution cannot report a previous channel's successful discovery. +A later successful check advances the timestamp, indicating polling recovery. +Known-video and notification checks log `CheckedAt` and `ObservedLive`. +These observations precede the guarded state update: an offline observation +does not bypass the configured two-check confirmation rule. + +Without a useful notification, a new broadcast may take up to the configured +30-minute discovery interval (plus request/tick delays) to be detected. +Failures can extend that delay. Google's current +[quota guide](https://developers.google.com/youtube/v3/determine_quota_cost) and +[`search.list` reference](https://developers.google.com/youtube/v3/docs/search/list) +describe a separate Search Queries bucket: one unit per search call, with a +default limit of 100 calls per day. Other endpoints used here (`channels.list` +and `videos.list`) each cost one unit in the general bucket, whose default daily +allocation is 10,000 units. Actual project limits and usage must be checked in +Google Cloud; daily quotas reset at midnight Pacific Time. + +The default idle schedule permits approximately 48 scheduled searches per +24 hours for a continuously running leader before retries. Known-video checks +can make 720 calls per 24 hours while continuously live. A Pacific calendar day +can be 23 or 25 hours at daylight-saving transitions. + +These intervals are **not project-wide daily quota enforcement**. Notification +confirmation searches use a separate Redis gate that admits a confirmation +every 30 seconds, not the discovery interval. Data API resilience retries, +restarts/leader changes (the next discovery timestamp is process-local), and +other clients sharing the Google Cloud project can add usage. A budget must +account for each outbound attempt across these paths and all replicas, not just +successful worker ticks. The current implementation does not maintain such a +shared daily budget. + +The WebSub subscription POST does not use a Data API key. Its Retry-After and +subscription backoff are separate from Data API quota accounting; the observed +hub 503 does not establish quota exhaustion. These diagnostic changes do not +alter polling intervals, retry policies, or leader coordination. + ## Configuration Bind from the `Live` section of configuration. In unconfigured local runs, @@ -311,6 +453,13 @@ dotnet test .\tests\Aspire.Dev.AppHost.Tests\Aspire.Dev.AppHost.Tests.csproj --c dotnet test .\tests\StaticHost.Tests\StaticHost.Tests.csproj --configuration Release --filter "Category!=RedisIntegration" ``` +For the focused YouTube diagnostics, callback authorization, and polling regressions, explicitly keep +frontend build scripts and package installation disabled: + +```powershell +dotnet test .\tests\StaticHost.Tests\StaticHost.Tests.csproj --configuration Release --filter "(FullyQualifiedName~YouTube|FullyQualifiedName~LiveEndpointsTests)&Category!=RedisIntegration" -p:ShouldRunBuildScript=false -p:ShouldRunNpmInstall=false +``` + The real-Redis group has the xUnit trait `Category=RedisIntegration` and requires a running container runtime, such as Docker. Following the [Aspire testing pattern](https://aspire.dev/testing/overview/), its shared fixture diff --git a/src/statichost/StaticHost/Live/Twitch/TwitchWebhookHandler.cs b/src/statichost/StaticHost/Live/Twitch/TwitchWebhookHandler.cs index 68dd2424b..8b6b604d0 100644 --- a/src/statichost/StaticHost/Live/Twitch/TwitchWebhookHandler.cs +++ b/src/statichost/StaticHost/Live/Twitch/TwitchWebhookHandler.cs @@ -2,6 +2,7 @@ using System.Security.Cryptography; using System.Text; using System.Text.Json; +using System.Text.RegularExpressions; namespace StaticHost.Live.Twitch; @@ -56,6 +57,42 @@ public static bool IsFresh(string timestamp, DateTimeOffset now, TimeSpan maxAge return age <= maxAge && age >= TimeSpan.FromMinutes(-1); } + internal static string SanitizeDiagnosticValue(string value) + { + const int maxLength = 128; + var bounded = value.Length > maxLength ? value[..(maxLength - 3)] + "..." : value; + return Regex.Replace(bounded, "[^a-zA-Z0-9_.:+-]", "_"); + } + + private static void LogRevocation(string bodyJson, ILogger logger) + { + try + { + using var document = JsonDocument.Parse(bodyJson); + if (document.RootElement.ValueKind != JsonValueKind.Object || + !document.RootElement.TryGetProperty("subscription", out var subscription) || + subscription.ValueKind != JsonValueKind.Object) + { + logger.LogWarning("Twitch EventSub subscription revoked; subscription details unavailable."); + return; + } + + // Retain the subscription state, not transport URLs or other payload data. + logger.LogWarning( + "Twitch EventSub subscription revoked (Id: {SubscriptionId}, Type: {SubscriptionType}, Status: {SubscriptionStatus}).", + LogField("id"), LogField("type"), LogField("status")); + + string LogField(string name) => + subscription.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String + ? SanitizeDiagnosticValue(value.GetString()!) + : ""; + } + catch (JsonException) + { + logger.LogWarning("Twitch EventSub subscription revoked; payload is not valid JSON."); + } + } + /// /// Branches on Twitch-Eventsub-Message-Type: /// @@ -121,10 +158,11 @@ await broadcaster.UpdateAsync( return Results.Ok(); } case "revocation": - logger.LogWarning("Twitch EventSub subscription revoked: {Body}", bodyJson); + LogRevocation(bodyJson, logger); return Results.NoContent(); default: - logger.LogDebug("Twitch webhook of unknown message-type {Type}", messageType); + logger.LogDebug("Twitch webhook of unknown message type {MessageType} (sanitized).", + SanitizeDiagnosticValue(messageType)); return Results.Ok(); } } diff --git a/src/statichost/StaticHost/Live/YouTube/YouTubeClient.cs b/src/statichost/StaticHost/Live/YouTube/YouTubeClient.cs index 106715fea..153ee6036 100644 --- a/src/statichost/StaticHost/Live/YouTube/YouTubeClient.cs +++ b/src/statichost/StaticHost/Live/YouTube/YouTubeClient.cs @@ -11,6 +11,8 @@ public sealed class YouTubeClient( IOptionsMonitor options, ILogger logger) : IYouTubeClient { + internal const int ResponseBufferLimit = 1024 * 1024; + /// Name of the registered for the Data API. public const string HttpClientName = "youtube"; @@ -34,7 +36,8 @@ private HttpClient ApiClient() var url = $"channels?part=id&forHandle={Uri.EscapeDataString(clean)}&key={Uri.EscapeDataString(apiKey)}"; using var response = await ApiClient().GetAsync(url, cancellationToken).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); + await YouTubeDiagnostics.EnsureSuccessAsync(response, cancellationToken, + apiKey, options.CurrentValue.YouTube.WebhookSecret).ConfigureAwait(false); using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false); @@ -56,7 +59,8 @@ public async Task GetCurrentLiveAsync(string channelId, Cance var url = $"search?part=id&channelId={Uri.EscapeDataString(channelId)}&eventType=live&type=video&key={Uri.EscapeDataString(apiKey)}"; using var response = await ApiClient().GetAsync(url, cancellationToken).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); + await YouTubeDiagnostics.EnsureSuccessAsync(response, cancellationToken, + apiKey, options.CurrentValue.YouTube.WebhookSecret).ConfigureAwait(false); using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false); @@ -80,7 +84,8 @@ public async Task GetVideoLiveStatusAsync(string videoId, Can var url = $"videos?part=liveStreamingDetails&id={Uri.EscapeDataString(videoId)}&key={Uri.EscapeDataString(apiKey)}"; using var response = await ApiClient().GetAsync(url, cancellationToken).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); + await YouTubeDiagnostics.EnsureSuccessAsync(response, cancellationToken, + apiKey, options.CurrentValue.YouTube.WebhookSecret).ConfigureAwait(false); using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false); @@ -129,9 +134,9 @@ public async Task SubscribeAsync( using var response = await c.PostAsync("subscribe", form, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { - var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); - logger.LogDebug("YouTube WebSub subscribe failed: {Status} {Body}", response.StatusCode, body); - response.EnsureSuccessStatusCode(); + logger.LogDebug("YouTube WebSub hub returned HTTP {StatusCode}.", (int)response.StatusCode); + await YouTubeDiagnostics.EnsureSuccessAsync(response, cancellationToken, + options.CurrentValue.YouTube.ApiKey, secret, verifyToken).ConfigureAwait(false); } } } diff --git a/src/statichost/StaticHost/Live/YouTube/YouTubeDiagnostics.cs b/src/statichost/StaticHost/Live/YouTube/YouTubeDiagnostics.cs new file mode 100644 index 000000000..36073c282 --- /dev/null +++ b/src/statichost/StaticHost/Live/YouTube/YouTubeDiagnostics.cs @@ -0,0 +1,187 @@ +using System.Diagnostics; +using System.Net.Sockets; +using System.Text.Json; +using Microsoft.AspNetCore.WebUtilities; +using Polly.Timeout; + +namespace StaticHost.Live.YouTube; + +internal static class YouTubeDiagnostics +{ + internal const string ChannelsEndpoint = "www.googleapis.com/youtube/v3/channels"; + internal const string SearchEndpoint = "www.googleapis.com/youtube/v3/search"; + internal const string VideosEndpoint = "www.googleapis.com/youtube/v3/videos"; + internal const string SubscribeEndpoint = "pubsubhubbub.appspot.com/subscribe"; + private const string DetailsKey = "YouTube.ResponseDiagnostics"; + private const string LoggedKey = "YouTube.FailureLogged"; + private const int BodyLimit = 4096; + + private sealed record ResponseDetails(string? Reason, string? Domain, string Detail, bool Truncated) + { + public double? RetryAfterSeconds { get; init; } + public DateTimeOffset? RetryAfterDate { get; init; } + } + + internal static async Task EnsureSuccessAsync( + HttpResponseMessage response, CancellationToken cancellationToken, params string[] secrets) + { + if (response.IsSuccessStatusCode) return; + + ResponseDetails details; + try + { + using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + using var reader = new StreamReader(stream); + var buffer = new char[BodyLimit + 1]; + var count = await reader.ReadBlockAsync(buffer.AsMemory(), cancellationToken).ConfigureAwait(false); + details = DescribeBody(new string(buffer, 0, Math.Min(count, BodyLimit)), count > BodyLimit, secrets); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } + catch (Exception) + { + // Diagnostic decoding must never replace the original HTTP failure. + details = new(null, null, "Response body could not be read; body omitted", false); + } + + var retryAfter = response.Headers.RetryAfter; + details = details with + { + RetryAfterSeconds = retryAfter?.Delta?.TotalSeconds, + RetryAfterDate = retryAfter?.Date, + }; + + try + { + response.EnsureSuccessStatusCode(); + } + catch (HttpRequestException exception) + { + exception.Data[DetailsKey] = details; + throw; + } + } + + private static ResponseDetails DescribeBody(string body, bool truncated, string[] secrets) + { + try + { + using var doc = JsonDocument.Parse(body); + if (doc.RootElement.ValueKind == JsonValueKind.Object && + doc.RootElement.TryGetProperty("error", out var error) && + error.ValueKind == JsonValueKind.Object && + error.TryGetProperty("errors", out var errors) && + errors.ValueKind == JsonValueKind.Array && errors.GetArrayLength() > 0 && + errors[0].ValueKind == JsonValueKind.Object) + { + return new( + ReadCode(errors[0], "reason", secrets), + ReadCode(errors[0], "domain", secrets), + "Google structured error; message omitted", truncated); + } + } + catch (JsonException) { } + + return new(null, null, ClassifyProviderText(body), truncated); + } + + internal static void LogUntrustedDenial( + ILogger logger, string reason, bool topicPresent, bool? matchesConfiguredTopic) + { + var truncated = reason.Length > BodyLimit; + logger.LogWarning( + "YouTube {Operation}: untrusted, unauthenticated denial report; not proof of a hub decision. " + + "Topic present {TopicPresent}, matches configured topic {MatchesConfiguredTopic}; " + + "reason present {ReasonPresent}, length {ReasonLength}, detail {ProviderDetail}, truncated {BodyTruncated}. " + + "Subscription state and live-status polling are unchanged.", + "WebSubDenialReport", topicPresent, matchesConfiguredTopic, reason.Length > 0, reason.Length, + ClassifyProviderText(truncated ? reason[..BodyLimit] : reason), truncated); + } + + private static string ClassifyProviderText(string text) + { + // Only fixed classifications leave this boundary, never echoed HTML, URLs, + // callback values or arbitrary provider messages. + return text.Contains("temporarily unavailable", StringComparison.OrdinalIgnoreCase) + ? "Provider reports temporary unavailability" + : text.Contains("transient error", StringComparison.OrdinalIgnoreCase) + ? "Provider reports a transient error" + : text.Contains("invalid topic", StringComparison.OrdinalIgnoreCase) + ? "Provider reports an invalid topic" + : text.Contains("verification failed", StringComparison.OrdinalIgnoreCase) + ? "Provider reports callback verification failure" + : "Unrecognized provider response; body omitted"; + } + + private static string? ReadCode(JsonElement error, string name, string[] secrets) + { + if (!error.TryGetProperty(name, out var property) || property.ValueKind != JsonValueKind.String) + return null; + var value = property.GetString(); + if (string.IsNullOrEmpty(value) || value.Length > 64 || + value.Any(c => !char.IsAsciiLetterOrDigit(c) && c is not '.' and not '_' and not '-') || + secrets.Any(secret => !string.IsNullOrEmpty(secret) && value.Contains(secret, StringComparison.Ordinal))) + return null; + return value; + } + + internal static void LogFailure( + ILogger logger, Exception exception, string operation, string endpoint, + YouTubeWebSubRetryState? retry = null, + DateTimeOffset? lastSuccessfulDiscoveryAt = null, bool? lastDiscoveryLive = null, + bool skipIfLogged = false, double? elapsedMs = null) + { + if (skipIfLogged && exception.Data[LoggedKey] is true) + { + exception.Data.Remove(LoggedKey); + return; + } + exception.Data[LoggedKey] = true; + var details = exception.Data[DetailsKey] as ResponseDetails; + var http = exception as HttpRequestException; + var status = http?.StatusCode is { } code ? (int?)code : null; + var timeout = exception is OperationCanceledException or TimeoutException or TimeoutRejectedException; + SocketException? socket = null; + var inner = exception.InnerException; + for (var depth = 0; inner is not null && depth < 4; depth++, inner = inner.InnerException) + { + socket ??= inner as SocketException; + timeout |= inner is TimeoutException or TimeoutRejectedException; + } + var expected = http is not null || timeout || exception is JsonException; + var message = + "YouTube {Operation} failed at {Endpoint} after {ElapsedMs} ms: {FailureType}, HTTP {StatusCode} {StatusReason}; " + + "provider reason {ProviderReason}, domain {ProviderDomain}, detail {ProviderDetail}, truncated {BodyTruncated}; " + + "provider Retry-After seconds {RetryAfterSeconds}, date {RetryAfterDate}; " + + "timeout {IsTimeout}, network {HttpRequestError}, socket {SocketError}, inner failure {InnerFailureType}. " + + "Last successful discovery {LastSuccessfulDiscoveryAt}, live {LastDiscoveryLive}. Failure is not an offline observation."; + List fields = + [ + operation, endpoint, elapsedMs, exception.GetType().Name, status, + status is { } number ? ReasonPhrases.GetReasonPhrase(number) : null, + details?.Reason, details?.Domain, details?.Detail, details?.Truncated, + details?.RetryAfterSeconds, details?.RetryAfterDate, + timeout, http?.HttpRequestError, socket?.SocketErrorCode, exception.InnerException?.GetType().Name, + lastSuccessfulDiscoveryAt, lastDiscoveryLive, + ]; + if (operation == "WebSubSubscribe") + { + message += " Attempt {FailureCount}. Next subscription attempt no earlier than {RetryAt}. " + + "Live-status polling remains independently scheduled; this does not indicate a successful poll."; + fields.Add(retry?.FailureCount); + fields.Add(retry?.RetryAt); + } + else if (operation == "BackgroundTick") + { + message += " The worker will retry on its normal tick schedule."; + } + if (!expected) + { + // Render frames independently: exception messages, ToString overrides, + // source paths and argument values must not enter the log. + var stack = new StackTrace(exception, fNeedFileInfo: false).ToString(); + message += " Safe stack trace: {SafeStackTrace}"; + fields.Add(stack.Length > 8192 ? stack[..8192] + " [truncated]" : stack); + } + logger.Log(expected ? LogLevel.Warning : LogLevel.Error, message, fields.ToArray()); + } +} diff --git a/src/statichost/StaticHost/Live/YouTube/YouTubeLiveConfirmationQueue.cs b/src/statichost/StaticHost/Live/YouTube/YouTubeLiveConfirmationQueue.cs index e2eff8064..1d5caf43f 100644 --- a/src/statichost/StaticHost/Live/YouTube/YouTubeLiveConfirmationQueue.cs +++ b/src/statichost/StaticHost/Live/YouTube/YouTubeLiveConfirmationQueue.cs @@ -61,7 +61,8 @@ await LiveStatusEndpointRouteBuilderExtensions.ConfirmYouTubeLiveStatusAsync( } catch (Exception ex) { - logger.LogWarning(ex, "Confirming poll after YouTube webhook failed."); + YouTubeDiagnostics.LogFailure(logger, ex, "NotificationConfirmation", "local coordination/state", + skipIfLogged: true); } } } diff --git a/src/statichost/StaticHost/Live/YouTube/YouTubeWebSubService.cs b/src/statichost/StaticHost/Live/YouTube/YouTubeWebSubService.cs index f60955e26..cdab0af43 100644 --- a/src/statichost/StaticHost/Live/YouTube/YouTubeWebSubService.cs +++ b/src/statichost/StaticHost/Live/YouTube/YouTubeWebSubService.cs @@ -1,5 +1,5 @@ +using System.Diagnostics; using Microsoft.Extensions.Options; -using Polly.Timeout; namespace StaticHost.Live.YouTube; @@ -30,6 +30,10 @@ public sealed class YouTubeWebSubService( private DateTimeOffset _nextDiscoveryPollAt = DateTimeOffset.MinValue; private string? _resolvedChannelHandle; private string? _resolvedChannelId; + private DateTimeOffset? _lastSuccessfulDiscoveryAt; + private bool? _lastDiscoveryLive; + private string? _diagnosticConfiguredChannelId; + private string? _diagnosticChannelHandle; /// protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -63,7 +67,9 @@ private async Task RunLeaderAsync(CancellationToken stoppingToken) catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { return; } catch (Exception ex) { - logger.LogError(ex, "YouTube WebSub tick failed; will retry."); + YouTubeDiagnostics.LogFailure(logger, ex, "BackgroundTick", "local coordination/state", + lastSuccessfulDiscoveryAt: _lastSuccessfulDiscoveryAt, lastDiscoveryLive: _lastDiscoveryLive, + skipIfLogged: true); } try @@ -80,6 +86,15 @@ internal async Task TickAsync(CancellationToken cancellationToken) var opts = options.CurrentValue; var youtube = opts.YouTube; + if (!string.Equals(_diagnosticConfiguredChannelId, youtube.ChannelId, StringComparison.Ordinal) || + !string.Equals(_diagnosticChannelHandle, youtube.ChannelHandle, StringComparison.OrdinalIgnoreCase)) + { + _diagnosticConfiguredChannelId = youtube.ChannelId; + _diagnosticChannelHandle = youtube.ChannelHandle; + _lastSuccessfulDiscoveryAt = null; + _lastDiscoveryLive = null; + } + var channelId = youtube.ChannelId; if (string.IsNullOrEmpty(channelId)) { @@ -92,12 +107,19 @@ internal async Task TickAsync(CancellationToken cancellationToken) channelId = _resolvedChannelId ?? ""; if (string.IsNullOrEmpty(channelId)) { - channelId = await client.ResolveChannelIdAsync(youtube.ChannelHandle, cancellationToken).ConfigureAwait(false) ?? ""; + channelId = await RunOperationAsync("ChannelResolution", YouTubeDiagnostics.ChannelsEndpoint, + () => client.ResolveChannelIdAsync(youtube.ChannelHandle, cancellationToken), + cancellationToken).ConfigureAwait(false) ?? ""; + if (!string.IsNullOrEmpty(channelId)) + { + logger.LogInformation("YouTube {Operation} succeeded at {CheckedAt}.", "ChannelResolution", _time.GetUtcNow()); + } } if (string.IsNullOrEmpty(channelId)) { - logger.LogWarning("Could not resolve YouTube channel id for {Handle}.", youtube.ChannelHandle); + logger.LogWarning("YouTube {Operation} returned no channel; live detection is unavailable, not confirmed offline.", + "ChannelResolution"); return; } @@ -115,6 +137,7 @@ internal async Task TickAsync(CancellationToken cancellationToken) if (request is not null) { var requestSent = false; + var started = Stopwatch.GetTimestamp(); try { var callback = $"{opts.PublicBaseUrl.TrimEnd('/')}/api/live/youtube/webhook"; @@ -134,31 +157,21 @@ await client.SubscribeAsync( } catch (Exception ex) { + var elapsedMs = Stopwatch.GetElapsedTime(started).TotalMilliseconds; var retry = await _subscriptions.MarkRequestFailedAsync( request, CancellationToken.None).ConfigureAwait(false); - if (ex is HttpRequestException or OperationCanceledException or TimeoutRejectedException) - { - if (retry is not null) - { - logger.LogWarning( - "YouTube WebSub subscribe failed ({FailureType}, HTTP {StatusCode}); attempt {FailureCount}. " + - "Next subscription attempt no earlier than {RetryAt}. Live-status polling continues.", - ex.GetType().Name, - (ex as HttpRequestException)?.StatusCode, - retry.FailureCount, - retry.RetryAt); - } - logger.LogDebug(ex, "YouTube WebSub subscription request failure details."); - } - else - { - logger.LogError(ex, "Unexpected YouTube WebSub subscription failure."); - } + YouTubeDiagnostics.LogFailure(logger, ex, "WebSubSubscribe", YouTubeDiagnostics.SubscribeEndpoint, + retry, _lastSuccessfulDiscoveryAt, _lastDiscoveryLive, elapsedMs: elapsedMs); } if (requestSent) { + var elapsedMs = Stopwatch.GetElapsedTime(started).TotalMilliseconds; + logger.LogInformation( + "YouTube {Operation} accepted at {AcceptedAt} after {ElapsedMs} ms; HTTP acceptance does not establish a verified lease. " + + "Only a matching verification callback establishes or renews the subscription.", + "WebSubSubscribe", _time.GetUtcNow(), elapsedMs); await _subscriptions.MarkRequestSentAsync( request, _time.GetUtcNow(), @@ -171,12 +184,24 @@ await _subscriptions.MarkRequestSentAsync( var current = observed.Snapshot.YouTube; if (current.Live && !string.IsNullOrEmpty(current.VideoId)) { - live = await client.GetVideoLiveStatusAsync(current.VideoId, cancellationToken).ConfigureAwait(false); + live = await RunOperationAsync("KnownVideoStatus", YouTubeDiagnostics.VideosEndpoint, + () => client.GetVideoLiveStatusAsync(current.VideoId, cancellationToken), + cancellationToken).ConfigureAwait(false); + logger.LogInformation("YouTube {Operation} succeeded at {CheckedAt}; observed live {ObservedLive}.", + "KnownVideoStatus", _time.GetUtcNow(), live.Live); } else if (now >= _nextDiscoveryPollAt) { _nextDiscoveryPollAt = now.AddSeconds(youtube.DiscoveryPollingIntervalSeconds); - live = await client.GetCurrentLiveAsync(channelId, cancellationToken).ConfigureAwait(false); + live = await RunOperationAsync("OfflineDiscovery", YouTubeDiagnostics.SearchEndpoint, + () => client.GetCurrentLiveAsync(channelId, cancellationToken), + cancellationToken).ConfigureAwait(false); + _lastSuccessfulDiscoveryAt = _time.GetUtcNow(); + _lastDiscoveryLive = live.Live; + logger.LogInformation( + "YouTube {Operation} succeeded; last successful discovery {LastSuccessfulDiscoveryAt}, live {LastDiscoveryLive}. " + + "Next discovery no earlier than {NextDiscoveryAt}.", + "OfflineDiscovery", _lastSuccessfulDiscoveryAt, _lastDiscoveryLive, _nextDiscoveryPollAt); } if (live is null) @@ -195,4 +220,22 @@ await broadcaster.UpdateAsync( }, cancellationToken).ConfigureAwait(false); } + + private async Task RunOperationAsync( + string operation, string endpoint, Func> action, CancellationToken cancellationToken) + { + var started = Stopwatch.GetTimestamp(); + try + { + return await action().ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } + catch (Exception exception) + { + YouTubeDiagnostics.LogFailure(logger, exception, operation, endpoint, + lastSuccessfulDiscoveryAt: _lastSuccessfulDiscoveryAt, lastDiscoveryLive: _lastDiscoveryLive, + elapsedMs: Stopwatch.GetElapsedTime(started).TotalMilliseconds); + throw; + } + } } diff --git a/tests/StaticHost.Tests/Live/InMemoryLiveStatusInfrastructure.cs b/tests/StaticHost.Tests/Live/InMemoryLiveStatusInfrastructure.cs index 6a4959fc6..02176ba5b 100644 --- a/tests/StaticHost.Tests/Live/InMemoryLiveStatusInfrastructure.cs +++ b/tests/StaticHost.Tests/Live/InMemoryLiveStatusInfrastructure.cs @@ -37,6 +37,8 @@ public ValueTask UpdateAsync( internal sealed class SingleInstanceLiveStatusCoordination : ILiveStatusCoordination { + public int YouTubeConfirmationRequests { get; private set; } + private const string CompletedMessage = "completed"; private readonly ConcurrentDictionary _twitchMessageIds = @@ -70,6 +72,7 @@ public ValueTask TryQueueYouTubeConfirmationAsync( CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); + YouTubeConfirmationRequests++; return ValueTask.FromResult(true); } @@ -114,9 +117,21 @@ public ValueTask DisposeAsync() internal sealed class YouTubeWebSubSubscriptionState(TimeProvider? timeProvider = null) : IYouTubeWebSubSubscriptionState { + public Exception? MarkRequestSentException { get; set; } private readonly Lock _gate = new(); private YouTubeWebSubSubscriptionData _state = YouTubeWebSubSubscriptionData.Empty; + public YouTubeWebSubSubscriptionData Current + { + get + { + lock (_gate) + { + return _state; + } + } + } + public ValueTask TryBeginSubscriptionAsync( string channelId, DateTimeOffset now, @@ -156,6 +171,10 @@ public ValueTask MarkRequestSentAsync( CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); + if (MarkRequestSentException is { } exception) + { + return ValueTask.FromException(exception); + } lock (_gate) { diff --git a/tests/StaticHost.Tests/Live/LiveEndpointsTests.cs b/tests/StaticHost.Tests/Live/LiveEndpointsTests.cs index aecc63861..15006b069 100644 --- a/tests/StaticHost.Tests/Live/LiveEndpointsTests.cs +++ b/tests/StaticHost.Tests/Live/LiveEndpointsTests.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; namespace StaticHost.Tests.Live; @@ -155,6 +156,129 @@ await server.Broadcaster.UpdateAsync(new LiveStatusUpdate Assert.Equal(HttpStatusCode.OK, replayResponse.StatusCode); AssertNoStore(replayResponse); Assert.False((await server.Broadcaster.GetCurrentAsync()).Twitch.Live); + Assert.Contains(server.Logs.Entries, entry => + entry.Fields.TryGetValue("MessageId", out var id) && Equals(id, "test-message-1")); + } + + [Fact] + public async Task TwitchWebhook_SignedUnparseableTimestamp_IsRejectedWithSanitizedHeader() + { + await using var server = await LiveHttpServer.StartAsync(); + using var request = TwitchRequest(server, "notification", "{}", + timestamp: LiveTestHelpers.LogInjectionPayload); + using var response = await server.Client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + AssertNoStore(response); + Assert.False((await server.Broadcaster.GetCurrentAsync()).IsLive); + var entry = Assert.Single(server.Logs.Entries); + Assert.Equal(LogLevel.Warning, entry.Level); + Assert.Equal("payload-sentinel__forged-line___", entry.Fields["Timestamp"]); + Assert.Equal("Twitch webhook timestamp payload-sentinel__forged-line___ (sanitized) is stale or unparseable; rejecting.", + entry.Message); + Assert.Null(entry.Exception); + Assert.Equal(2, entry.Fields.Count); + } + + [Theory] + [InlineData(-11)] + [InlineData(5)] + public async Task TwitchWebhook_RejectedTimestamp_RetainsDiagnosticValue(int minutesFromNow) + { + await using var server = await LiveHttpServer.StartAsync(); + var timestamp = server.Time.GetUtcNow().AddMinutes(minutesFromNow).ToString("O"); + using var request = TwitchRequest(server, "notification", "{}", timestamp: timestamp); + using var response = await server.Client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + AssertNoStore(response); + var entry = Assert.Single(server.Logs.Entries); + Assert.Equal(timestamp, entry.Fields["Timestamp"]); + Assert.Contains(timestamp, entry.Message); + LiveTestHelpers.AssertSafeLogs(server.Logs); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task TwitchWebhook_SignedDuplicateId_IsLoggedSafely(bool completed) + { + await using var server = await LiveHttpServer.StartAsync(); + const string messageId = LiveTestHelpers.LogInjectionPayload; + var acquisition = await server.Coordination.AcquireTwitchMessageAsync(messageId); + Assert.Equal(TwitchMessageAcquisitionStatus.Acquired, acquisition.Status); + await using (var lease = Assert.IsAssignableFrom(acquisition.Lease)) + { + if (completed) await lease.CompleteAsync(); + using var request = TwitchRequest(server, "notification", "{}", messageId: messageId); + using var response = await server.Client.SendAsync(request); + + Assert.Equal(completed ? HttpStatusCode.OK : HttpStatusCode.ServiceUnavailable, response.StatusCode); + AssertNoStore(response); + Assert.False((await server.Broadcaster.GetCurrentAsync()).IsLive); + var entry = Assert.Single(server.Logs.Entries); + Assert.Equal(LogLevel.Debug, entry.Level); + Assert.Contains(completed ? "replay ignored" : "already being processed", entry.Message); + Assert.Equal("payload-sentinel__forged-line___", entry.Fields["MessageId"]); + Assert.Equal(completed + ? "Twitch webhook replay ignored for message payload-sentinel__forged-line___." + : "Twitch webhook message payload-sentinel__forged-line___ is already being processed; asking Twitch to retry.", + entry.Message); + Assert.Null(entry.Exception); + Assert.Equal(2, entry.Fields.Count); + } + + server.Logs.Entries.Clear(); + using var retry = TwitchRequest(server, "notification", + """{"subscription":{"type":"stream.online"},"event":{"broadcaster_user_login":"aspiredotdev"}}""", + messageId: messageId); + using var retryResponse = await server.Client.SendAsync(retry); + Assert.Equal(HttpStatusCode.OK, retryResponse.StatusCode); + Assert.Equal(!completed, (await server.Broadcaster.GetCurrentAsync()).Twitch.Live); + if (completed) + { + var entry = Assert.Single(server.Logs.Entries); + Assert.Equal("payload-sentinel__forged-line___", entry.Fields["MessageId"]); + Assert.Equal("Twitch webhook replay ignored for message payload-sentinel__forged-line___.", entry.Message); + Assert.Null(entry.Exception); + Assert.Equal(2, entry.Fields.Count); + } + else + { + LiveTestHelpers.AssertSafeLogs(server.Logs); + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task TwitchWebhook_SignedRevocationAndUnknownType_LogSafely(bool revocation) + { + await using var server = await LiveHttpServer.StartAsync(); + var before = await server.Broadcaster.GetStateAsync(); + using var request = TwitchRequest( + server, revocation ? "revocation" : LiveTestHelpers.LogInjectionPayload, + LiveTestHelpers.LogInjectionPayload); + using var response = await server.Client.SendAsync(request); + + Assert.Equal(revocation ? HttpStatusCode.NoContent : HttpStatusCode.OK, response.StatusCode); + AssertNoStore(response); + Assert.Equal(before, await server.Broadcaster.GetStateAsync()); + var entry = Assert.Single(server.Logs.Entries); + Assert.Equal(revocation ? LogLevel.Warning : LogLevel.Debug, entry.Level); + Assert.Contains(revocation ? "subscription revoked" : "unknown message type", entry.Message); + if (revocation) + { + LiveTestHelpers.AssertSafeLogs(server.Logs); + } + else + { + Assert.Equal("payload-sentinel__forged-line___", entry.Fields["MessageType"]); + Assert.Equal("Twitch webhook of unknown message type payload-sentinel__forged-line___ (sanitized).", + entry.Message); + Assert.Null(entry.Exception); + Assert.Equal(2, entry.Fields.Count); + } } [Fact] @@ -175,6 +299,7 @@ public async Task YouTubeVerification_RetriesEchoNewChallengeWithoutExtendingRen AssertNoStore(retry); Assert.Equal("retry", await retry.Content.ReadAsStringAsync()); Assert.Equal(renewAt, await server.Subscriptions.GetRenewAtAsync()); + LiveTestHelpers.AssertSafeLogs(server.Logs, pending.Topic, pending.VerifyToken); } [Theory] @@ -201,25 +326,208 @@ public async Task YouTubeVerification_RejectsMalformedOrUnsolicitedConfirmation( } [Theory] - [InlineData(true, HttpStatusCode.OK)] - [InlineData(false, HttpStatusCode.Unauthorized)] - public async Task YouTubeWebhook_ValidatesSignatureBeforeQueuing(bool valid, HttpStatusCode expected) + [InlineData("mode", true)] + [InlineData("unsubscribe", true)] + [InlineData("missing-topic", true)] + [InlineData("topic", true)] + [InlineData("topic", false)] + [InlineData("token", true)] + public async Task YouTubeVerification_RejectionLogsOnlyClassificationsAndSafeFields( + string invalid, bool configured) { - await using var server = await LiveHttpServer.StartAsync(); - const string body = "video-123"; - using var request = new HttpRequestMessage(HttpMethod.Post, "/api/live/youtube/webhook") + const string invalidVerifyToken = "verification-secret-sentinel\r\nforged-line\u0085\u2028\u2029"; + await using var server = await LiveHttpServer.StartAsync(youtubeConfigured: configured); + var pending = Assert.IsType( + await server.Subscriptions.TryBeginSubscriptionAsync("channel-123", server.Time.GetUtcNow())); + var mode = invalid switch { - Content = new StringContent(body, Encoding.UTF8, "application/atom+xml"), + "mode" => LiveTestHelpers.LogInjectionPayload, + "unsubscribe" => "unsubscribe", + _ => "subscribe", }; - request.Headers.Add("X-Hub-Signature", valid - ? "sha1=" + Convert.ToHexStringLower(HMACSHA1.HashData( - Encoding.UTF8.GetBytes(LiveHttpServer.WebhookSecret), Encoding.UTF8.GetBytes(body))) - : "sha1=invalid"); + var submitted = pending with + { + Topic = invalid switch + { + "missing-topic" => "", + "token" => pending.Topic, + _ => LiveTestHelpers.LogInjectionPayload, + }, + VerifyToken = invalid == "token" ? invalidVerifyToken : pending.VerifyToken, + }; + var before = server.Subscriptions.Current; + using var response = await server.Client.GetAsync( + VerificationUrl(submitted, LiveTestHelpers.LogInjectionPayload, mode: mode)); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + AssertNoStore(response); + Assert.Equal(before, server.Subscriptions.Current); + Assert.False((await server.Broadcaster.GetCurrentAsync()).IsLive); + var entry = Assert.Single(server.Logs.Entries); + Assert.Equal(LogLevel.Warning, entry.Level); + Assert.Equal("WebSubVerification", entry.Fields["Operation"]); + Assert.Equal(invalid is "mode" or "unsubscribe" or "missing-topic" ? "Malformed" : "Unexpected", + entry.Fields["RejectionReason"]); + Assert.Equal(invalid == "mode" ? "Unknown" : invalid == "unsubscribe" ? "Unsubscribe" : "Subscribe", + entry.Fields["ModeClassification"]); + Assert.Equal(invalid != "missing-topic", entry.Fields["TopicPresent"]); + Assert.Equal(configured ? (bool?)(invalid == "token") : null, entry.Fields["MatchesConfiguredTopic"]); + LiveTestHelpers.AssertSafeLogs(server.Logs, pending.Topic, pending.VerifyToken, "verification-secret-sentinel"); + + using var confirmation = await server.Client.GetAsync( + VerificationUrl(pending, LiveTestHelpers.LogInjectionPayload)); + Assert.Equal(HttpStatusCode.OK, confirmation.StatusCode); + Assert.Equal("text/plain", confirmation.Content.Headers.ContentType?.MediaType); + Assert.Equal(LiveTestHelpers.LogInjectionPayload, await confirmation.Content.ReadAsStringAsync()); + Assert.True(await server.Subscriptions.GetRenewAtAsync() > server.Time.GetUtcNow()); + LiveTestHelpers.AssertSafeLogs(server.Logs, pending.Topic, pending.VerifyToken, "verification-secret-sentinel"); + } + + [Theory] + [InlineData("valid")] + [InlineData("invalid")] + [InlineData("missing")] + [InlineData("tampered")] + public async Task YouTubeWebhook_AcknowledgesButOnlyQueuesValidSignatures(string signature) + { + await using var server = await LiveHttpServer.StartAsync(); + await server.Broadcaster.UpdateAsync(new LiveStatusUpdate + { + YouTube = new YouTubeStatus(true, "existing-video"), + }); + var before = await server.Broadcaster.GetStateAsync(); + using var request = YouTubeRequest(signature); + using var response = await server.Client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + AssertNoStore(response); + Assert.Equal(before, await server.Broadcaster.GetStateAsync()); + Assert.Equal(signature == "valid" ? 1 : 0, server.Coordination.YouTubeConfirmationRequests); + if (signature != "valid") + { + var warning = Assert.Single(server.Logs.Entries); + Assert.Equal(LogLevel.Warning, warning.Level); + Assert.Contains("acknowledging and discarding", warning.Message); + } + } + + [Theory] + [InlineData("valid", false, HttpStatusCode.Unauthorized, false)] + [InlineData("valid", true, HttpStatusCode.OK, true)] + [InlineData("missing", true, HttpStatusCode.OK, false)] + [InlineData("tampered", true, HttpStatusCode.OK, false)] + public async Task YouTubeWebhook_DevOverrideStillRequiresSignatureAndCommandSecret( + string signature, bool correctSecret, HttpStatusCode expected, bool live) + { + await using var server = await LiveHttpServer.StartAsync(Environments.Development, youtubeConfigured: false); + using var request = YouTubeRequest(signature); + request.Headers.Add("X-Aspire-Live-Dev-Command-Key", + correctSecret ? "Key: local-test-command" : "Key: wrong-key"); using var response = await server.Client.SendAsync(request); Assert.Equal(expected, response.StatusCode); + Assert.Equal(live, (await server.Broadcaster.GetCurrentAsync()).YouTube.Live); + Assert.Equal(0, server.Coordination.YouTubeConfirmationRequests); + } + + [Theory] + [InlineData("pending")] + [InlineData("confirmed")] + [InlineData("backoff")] + public async Task YouTubeDenial_WithoutChallengeOrLease_DoesNotMutateState(string subscriptionState) + { + await using var server = await LiveHttpServer.StartAsync(); + var pending = Assert.IsType( + await server.Subscriptions.TryBeginSubscriptionAsync("channel-123", server.Time.GetUtcNow())); + if (subscriptionState == "confirmed") + { + using var confirmation = await server.Client.GetAsync(VerificationUrl(pending, "challenge")); + Assert.Equal(HttpStatusCode.OK, confirmation.StatusCode); + } + else if (subscriptionState == "backoff") + { + await server.Subscriptions.MarkRequestFailedAsync(pending); + } + await server.Broadcaster.UpdateAsync(new LiveStatusUpdate { YouTube = new YouTubeStatus(true, "existing-video") }); + var beforeLive = await server.Broadcaster.GetStateAsync(); + var beforeSubscription = server.Subscriptions.Current; + server.Logs.Entries.Clear(); + + using var response = await server.Client.GetAsync( + $"/api/live/youtube/webhook?hub.mode=denied&hub.topic={Uri.EscapeDataString(pending.Topic)}" + + "&hub.reason=Transient%20error%3B%20please%20try%20again%20later"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); AssertNoStore(response); + Assert.Equal(beforeSubscription, server.Subscriptions.Current); + Assert.Equal(beforeLive, await server.Broadcaster.GetStateAsync()); + Assert.Equal(0, server.Coordination.YouTubeConfirmationRequests); + var entry = Assert.Single(server.Logs.Entries); + Assert.Equal(LogLevel.Warning, entry.Level); + Assert.Equal("WebSubDenialReport", entry.Fields["Operation"]); + Assert.Equal(true, entry.Fields["MatchesConfiguredTopic"]); + Assert.Equal("Provider reports a transient error", entry.Fields["ProviderDetail"]); + Assert.Contains("untrusted, unauthenticated", entry.Message); + Assert.DoesNotContain(pending.Topic, entry.Message); + if (subscriptionState == "pending") + { + using var confirmation = await server.Client.GetAsync(VerificationUrl(pending, "still-pending")); + Assert.Equal(HttpStatusCode.OK, confirmation.StatusCode); + } + } + + [Theory] + [InlineData(null, true, false)] + [InlineData("unknown", true, false)] + [InlineData("oversized", true, false)] + [InlineData("unknown", false, false)] + [InlineData("unknown", true, true)] + public async Task YouTubeDenial_UntrustedInputIsBoundedAndNotLogged( + string? reason, bool topicPresent, bool configured) + { + await using var server = await LiveHttpServer.StartAsync(youtubeConfigured: configured); + const string sensitive = "https://attacker.invalid/?secret=test-webhook-secret&token=private-token\r\nforged-log"; + reason = reason is null ? null : reason == "oversized" ? new string('x', 5000) + sensitive : sensitive; + var url = "/api/live/youtube/webhook?hub.mode=denied"; + if (topicPresent) url += "&hub.topic=" + Uri.EscapeDataString(sensitive); + if (reason is not null) url += "&hub.reason=" + Uri.EscapeDataString(reason); + url += "&hub.verify_token=private-token&hub.challenge=private-challenge"; + var before = server.Subscriptions.Current; + + using var response = await server.Client.GetAsync(url); + + Assert.Equal(topicPresent ? HttpStatusCode.OK : HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(before, server.Subscriptions.Current); Assert.False((await server.Broadcaster.GetCurrentAsync()).IsLive); + Assert.Equal(0, server.Coordination.YouTubeConfirmationRequests); + var entry = Assert.Single(server.Logs.Entries); + Assert.Equal(topicPresent, entry.Fields["TopicPresent"]); + Assert.Equal(configured ? (bool?)false : null, entry.Fields["MatchesConfiguredTopic"]); + Assert.Equal(reason is not null, entry.Fields["ReasonPresent"]); + Assert.Equal(reason?.Length ?? 0, entry.Fields["ReasonLength"]); + Assert.Equal(reason?.Length > 4096, entry.Fields["BodyTruncated"]); + Assert.Equal("Unrecognized provider response; body omitted", entry.Fields["ProviderDetail"]); + Assert.DoesNotContain("attacker", entry.Message); + Assert.DoesNotContain("private-", entry.Message); + Assert.DoesNotContain(LiveHttpServer.WebhookSecret, entry.Message); + Assert.DoesNotContain("forged-log", entry.Message); + Assert.True(entry.Message.Length < 700); + } + + private static HttpRequestMessage YouTubeRequest(string signature) + { + const string body = "video-123"; + var request = new HttpRequestMessage(HttpMethod.Post, "/api/live/youtube/webhook") + { + Content = new StringContent(signature == "tampered" ? body + " " : body, Encoding.UTF8, "application/atom+xml"), + }; + if (signature != "missing") + { + request.Headers.Add("X-Hub-Signature", signature == "invalid" ? "sha1=invalid" : + "sha1=" + Convert.ToHexStringLower(HMACSHA1.HashData( + Encoding.UTF8.GetBytes(LiveHttpServer.WebhookSecret), Encoding.UTF8.GetBytes(body)))); + } + return request; } private static void AssertNoStore(HttpResponseMessage response) => @@ -239,17 +547,17 @@ private static HttpRequestMessage DevRequest(bool correctSecret) } private static HttpRequestMessage TwitchRequest( - LiveHttpServer server, string messageType, string body, bool stale = false) + LiveHttpServer server, string messageType, string body, bool stale = false, + string messageId = "test-message-1", string? timestamp = null) { - const string messageId = "test-message-1"; - var timestamp = server.Time.GetUtcNow().AddMinutes(stale ? -11 : 0).ToString("O"); + timestamp ??= server.Time.GetUtcNow().AddMinutes(stale ? -11 : 0).ToString("O"); var request = new HttpRequestMessage(HttpMethod.Post, "/api/live/twitch/webhook") { Content = new StringContent(body, Encoding.UTF8, "application/json"), }; - request.Headers.Add("Twitch-Eventsub-Message-Id", messageId); - request.Headers.Add("Twitch-Eventsub-Message-Timestamp", timestamp); - request.Headers.Add("Twitch-Eventsub-Message-Type", messageType); + request.Headers.TryAddWithoutValidation("Twitch-Eventsub-Message-Id", messageId); + request.Headers.TryAddWithoutValidation("Twitch-Eventsub-Message-Timestamp", timestamp); + request.Headers.TryAddWithoutValidation("Twitch-Eventsub-Message-Type", messageType); request.Headers.Add("Twitch-Eventsub-Message-Signature", "sha256=" + Convert.ToHexStringLower(HMACSHA256.HashData( Encoding.UTF8.GetBytes(LiveHttpServer.WebhookSecret), @@ -260,29 +568,35 @@ private static HttpRequestMessage TwitchRequest( private static string VerificationUrl( YouTubeWebSubSubscriptionRequest pending, string challenge, string lease = "432000", string mode = "subscribe") => - $"/api/live/youtube/webhook?hub.mode={mode}&hub.topic={Uri.EscapeDataString(pending.Topic)}" + - $"&hub.verify_token={pending.VerifyToken}&hub.lease_seconds={lease}&hub.challenge={challenge}"; + $"/api/live/youtube/webhook?hub.mode={Uri.EscapeDataString(mode)}&hub.topic={Uri.EscapeDataString(pending.Topic)}" + + $"&hub.verify_token={Uri.EscapeDataString(pending.VerifyToken)}&hub.lease_seconds={lease}&hub.challenge={Uri.EscapeDataString(challenge)}"; private sealed class LiveHttpServer( WebApplication app, HttpClient client, FakeTimeProvider time, - TaskCompletionSource streamEnded) : IAsyncDisposable + TaskCompletionSource streamEnded, YouTubeRecordingLogger logs) : IAsyncDisposable { public const string WebhookSecret = "test-webhook-secret"; public HttpClient Client { get; } = client; public FakeTimeProvider Time { get; } = time; public TaskCompletionSource StreamEnded { get; } = streamEnded; public LiveStatusBroadcaster Broadcaster => app.Services.GetRequiredService(); - public IYouTubeWebSubSubscriptionState Subscriptions => - app.Services.GetRequiredService(); + public YouTubeRecordingLogger Logs { get; } = logs; + public SingleInstanceLiveStatusCoordination Coordination => + (SingleInstanceLiveStatusCoordination)app.Services.GetRequiredService(); + public YouTubeWebSubSubscriptionState Subscriptions => + (YouTubeWebSubSubscriptionState)app.Services.GetRequiredService(); public static async Task StartAsync( - string environment = "Production", bool enableDev = true) + string environment = "Production", bool enableDev = true, bool youtubeConfigured = true) { var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = environment, }); builder.WebHost.UseTestServer(); + var logs = new YouTubeRecordingLogger(); + builder.Logging.AddFilter(level => level >= LogLevel.Debug); + builder.Logging.AddProvider(new WebhookLoggerProvider(logs)); var time = new FakeTimeProvider(DateTimeOffset.UnixEpoch); var options = new LiveStatusOptions { @@ -290,7 +604,12 @@ public static async Task StartAsync( DevCommandSecret = "local-test-command", CoalesceWindowMs = 0, Twitch = new TwitchOptions { WebhookSecret = WebhookSecret }, - YouTube = new YouTubeOptions { WebhookSecret = WebhookSecret, ApiKey = "unused-test-key" }, + YouTube = new YouTubeOptions + { + WebhookSecret = WebhookSecret, + ApiKey = youtubeConfigured ? "unused-test-key" : "", + ChannelId = youtubeConfigured ? "channel-123" : "", + }, }; builder.Services.AddSingleton(time); builder.Services.AddSingleton>(Options.Create(options)); @@ -319,7 +638,16 @@ public static async Task StartAsync( }); app.MapLiveStatus(); await app.StartAsync(); - return new LiveHttpServer(app, app.GetTestClient(), time, streamEnded); + return new LiveHttpServer(app, app.GetTestClient(), time, streamEnded, logs); + } + + private sealed class WebhookLoggerProvider(ILogger logger) : ILoggerProvider + { + public ILogger CreateLogger(string categoryName) => + categoryName.StartsWith("StaticHost.Live.YouTube.", StringComparison.Ordinal) || + categoryName == "StaticHost.Live.Twitch.Webhook" + ? logger : NullLogger.Instance; + public void Dispose() { } } public async ValueTask DisposeAsync() diff --git a/tests/StaticHost.Tests/Live/LiveTestHelpers.cs b/tests/StaticHost.Tests/Live/LiveTestHelpers.cs index 482057710..8d5a3ad92 100644 --- a/tests/StaticHost.Tests/Live/LiveTestHelpers.cs +++ b/tests/StaticHost.Tests/Live/LiveTestHelpers.cs @@ -2,6 +2,38 @@ namespace StaticHost.Tests.Live; internal static class LiveTestHelpers { + // Non-secret fixture for testing log injection, distinct from verification credentials. + public const string LogInjectionPayload = "payload-sentinel\r\nforged-line\u0085\u2028\u2029"; + + public static void AssertSafeLogs(YouTubeRecordingLogger logger, params string[] omittedValues) + { + Assert.NotEmpty(logger.Entries); + foreach (var entry in logger.Entries) + { + Assert.Null(entry.Exception); + AssertSafeText(entry.Message); + foreach (var field in entry.Fields) + { + AssertSafeText(field.Key); + AssertSafeText(field.Value?.ToString() ?? ""); + } + } + + void AssertSafeText(string text) + { + Assert.DoesNotContain("payload-sentinel", text); + Assert.DoesNotContain("forged-line", text); + foreach (var separator in new[] { '\r', '\n', '\u0085', '\u2028', '\u2029' }) + { + Assert.DoesNotContain(separator, text); + } + foreach (var value in omittedValues) + { + Assert.DoesNotContain(value, text); + } + } + } + public static LiveStatusBroadcaster CreateBroadcaster( int coalesceMs = 0, TimeProvider? timeProvider = null) diff --git a/tests/StaticHost.Tests/Live/TwitchWebhookHandlerTests.cs b/tests/StaticHost.Tests/Live/TwitchWebhookHandlerTests.cs index 42cba2071..cc19d6f61 100644 --- a/tests/StaticHost.Tests/Live/TwitchWebhookHandlerTests.cs +++ b/tests/StaticHost.Tests/Live/TwitchWebhookHandlerTests.cs @@ -1,3 +1,5 @@ +using Microsoft.Extensions.Logging; + namespace StaticHost.Tests.Live; public sealed class TwitchWebhookHandlerTests @@ -154,6 +156,141 @@ public void IsFresh_ReturnsFalseForUnparseableTimestamp(string timestamp) Assert.False(TwitchWebhookHandler.IsFresh(timestamp, now, TimeSpan.FromMinutes(10))); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Handle_UntrustedRevocationAndUnknownType_LogSafely(bool revocation) + { + var broadcaster = LiveTestHelpers.CreateBroadcaster(); + var before = await broadcaster.GetStateAsync(); + var logger = new YouTubeRecordingLogger(); + var result = await TwitchWebhookHandler.HandleAsync( + revocation ? "revocation" : LiveTestHelpers.LogInjectionPayload, + LiveTestHelpers.LogInjectionPayload, + broadcaster, + new TwitchOptions(), + logger); + using var services = new ServiceCollection().AddLogging().BuildServiceProvider(); + var context = new DefaultHttpContext + { + RequestServices = services, + }; + context.Response.Body = new MemoryStream(); + + await result.ExecuteAsync(context); + + Assert.Equal(revocation ? StatusCodes.Status204NoContent : StatusCodes.Status200OK, + context.Response.StatusCode); + Assert.Equal(before, await broadcaster.GetStateAsync()); + var entry = Assert.Single(logger.Entries); + Assert.Equal(revocation ? LogLevel.Warning : LogLevel.Debug, + entry.Level); + Assert.Contains(revocation ? "subscription revoked" : "unknown message type", entry.Message); + if (revocation) + { + LiveTestHelpers.AssertSafeLogs(logger); + } + else + { + Assert.Equal("payload-sentinel__forged-line___", entry.Fields["MessageType"]); + Assert.Equal("Twitch webhook of unknown message type payload-sentinel__forged-line___ (sanitized).", + entry.Message); + Assert.Null(entry.Exception); + Assert.Equal(2, entry.Fields.Count); + } + } + + [Theory] + [InlineData("new_message_type", "new_message_type")] + [InlineData("new.message-type:1", "new.message-type:1")] + [InlineData("type\r\nother\u0085\u2028\u2029\u001b[31m", "type__other_____31m")] + public async Task Handle_UnknownMessageType_RetainsSanitizedDiagnosticValue(string messageType, string expected) + { + var logger = new YouTubeRecordingLogger(); + await TwitchWebhookHandler.HandleAsync(messageType, "{}", + LiveTestHelpers.CreateBroadcaster(), new TwitchOptions(), logger); + + var entry = Assert.Single(logger.Entries); + Assert.Equal(expected, entry.Fields["MessageType"]); + Assert.Equal($"Twitch webhook of unknown message type {expected} (sanitized).", entry.Message); + Assert.Null(entry.Exception); + Assert.Equal(2, entry.Fields.Count); + } + + [Fact] + public async Task Handle_UnknownMessageType_BoundsDiagnosticLength() + { + var logger = new YouTubeRecordingLogger(); + await TwitchWebhookHandler.HandleAsync(new string('a', 200), "{}", + LiveTestHelpers.CreateBroadcaster(), new TwitchOptions(), logger); + + var entry = Assert.Single(logger.Entries); + Assert.Equal(new string('a', 125) + "...", entry.Fields["MessageType"]); + } + + [Theory] + [InlineData("authorization_revoked")] + [InlineData("user_removed")] + [InlineData("version_removed")] + public async Task Handle_Revocation_RetainsUsefulSubscriptionDetailsOnly(string status) + { + var broadcaster = LiveTestHelpers.CreateBroadcaster(); + var before = await broadcaster.GetStateAsync(); + var logger = new YouTubeRecordingLogger(); + var body = $$""" + {"subscription":{ + "id":"subscription-123", + "type":"stream.online", + "status":"{{status}}", + "transport":{"callback":"https://example.invalid/callback?key=must-not-appear"} + },"extra":"body-only-marker"} + """; + + var result = await TwitchWebhookHandler.HandleAsync( + "revocation", body, broadcaster, new TwitchOptions(), logger); + + Assert.Equal(StatusCodes.Status204NoContent, Assert.IsAssignableFrom(result).StatusCode); + Assert.Equal(before, await broadcaster.GetStateAsync()); + var entry = Assert.Single(logger.Entries); + Assert.Equal("subscription-123", entry.Fields["SubscriptionId"]); + Assert.Equal("stream.online", entry.Fields["SubscriptionType"]); + Assert.Equal(status, entry.Fields["SubscriptionStatus"]); + Assert.Equal(4, entry.Fields.Count); + LiveTestHelpers.AssertSafeLogs(logger, "must-not-appear", "example.invalid", "body-only-marker"); + } + + [Fact] + public async Task Handle_Revocation_SanitizesSubscriptionDetails() + { + var logger = new YouTubeRecordingLogger(); + const string body = """{"subscription":{"id":"id\r\nnext","type":"stream.\u001bonline","status":"status\u2028next"}}"""; + + await TwitchWebhookHandler.HandleAsync("revocation", body, + LiveTestHelpers.CreateBroadcaster(), new TwitchOptions(), logger); + + var entry = Assert.Single(logger.Entries); + Assert.Equal("id__next", entry.Fields["SubscriptionId"]); + Assert.Equal("stream._online", entry.Fields["SubscriptionType"]); + Assert.Equal("status_next", entry.Fields["SubscriptionStatus"]); + LiveTestHelpers.AssertSafeLogs(logger); + } + + [Theory] + [InlineData("{}")] + [InlineData("[]")] + [InlineData("{\"subscription\":null}")] + [InlineData("{\"subscription\":{\"id\":42,\"type\":[],\"status\":false}}")] + public async Task Handle_Revocation_MissingDetailsStillAcknowledges(string body) + { + var logger = new YouTubeRecordingLogger(); + var result = await TwitchWebhookHandler.HandleAsync("revocation", body, + LiveTestHelpers.CreateBroadcaster(), new TwitchOptions(), logger); + + Assert.Equal(StatusCodes.Status204NoContent, Assert.IsAssignableFrom(result).StatusCode); + Assert.Single(logger.Entries); + LiveTestHelpers.AssertSafeLogs(logger); + } + private static string ComputeTwitchSignature(string secret, string messageId, string timestamp, byte[] body) { using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); diff --git a/tests/StaticHost.Tests/Live/YouTubeClientTests.cs b/tests/StaticHost.Tests/Live/YouTubeClientTests.cs index 7ecc32120..f574d1a72 100644 --- a/tests/StaticHost.Tests/Live/YouTubeClientTests.cs +++ b/tests/StaticHost.Tests/Live/YouTubeClientTests.cs @@ -142,6 +142,61 @@ public async Task PubSubHttpClient_HasBoundedTimeoutAndDoesNotRetryPosts() Assert.Single(handler.Requests); } + [Theory] + [InlineData(YouTubeClient.HttpClientName, HttpStatusCode.OK, true)] + [InlineData(YouTubeClient.HttpClientName, HttpStatusCode.OK, false)] + [InlineData(YouTubeClient.HttpClientName, HttpStatusCode.BadRequest, true)] + [InlineData(YouTubeClient.HttpClientName, HttpStatusCode.BadRequest, false)] + [InlineData(YouTubeClient.PubSubHttpClientName, HttpStatusCode.OK, true)] + [InlineData(YouTubeClient.PubSubHttpClientName, HttpStatusCode.OK, false)] + [InlineData(YouTubeClient.PubSubHttpClientName, HttpStatusCode.BadRequest, true)] + [InlineData(YouTubeClient.PubSubHttpClientName, HttpStatusCode.BadRequest, false)] + public async Task NamedYouTubeClients_BoundResponseBuffering( + string clientName, HttpStatusCode status, bool hasContentLength) + { + var builder = Host.CreateApplicationBuilder(); + builder.AddLiveStatus(); + using var content = new OversizedContent(hasContentLength); + var handler = new RecordingHttpMessageHandler(_ => new HttpResponseMessage(status) { Content = content }); + builder.Services.AddHttpClient(clientName).ConfigurePrimaryHttpMessageHandler(() => handler); + using var host = builder.Build(); + using var client = host.Services.GetRequiredService().CreateClient(clientName); + using var request = new HttpRequestMessage( + clientName == YouTubeClient.PubSubHttpClientName ? HttpMethod.Post : HttpMethod.Get, + "https://example.com/provider"); + + Assert.Equal(YouTubeClient.ResponseBufferLimit, client.MaxResponseContentBufferSize); + await Assert.ThrowsAsync(() => client.SendAsync(request)); + + Assert.Single(handler.Requests); + Assert.InRange(content.BytesAttempted, 0, YouTubeClient.ResponseBufferLimit + 1024); + if (!hasContentLength) + { + Assert.True(content.BytesAttempted > YouTubeClient.ResponseBufferLimit); + } + } + + private sealed class OversizedContent(bool hasContentLength) : HttpContent + { + public int BytesAttempted { get; private set; } + + protected override bool TryComputeLength(out long length) + { + length = YouTubeClient.ResponseBufferLimit * 2; + return hasContentLength; + } + + protected override async Task SerializeToStreamAsync(Stream stream, System.Net.TransportContext? context) + { + var chunk = new byte[1024]; + for (var written = 0; written < YouTubeClient.ResponseBufferLimit * 2; written += chunk.Length) + { + BytesAttempted += chunk.Length; + await stream.WriteAsync(chunk); + } + } + } + private static YouTubeClient CreateClient( RecordingHttpMessageHandler? apiHandler = null, RecordingHttpMessageHandler? pubSubHandler = null, diff --git a/tests/StaticHost.Tests/Live/YouTubeDiagnosticsTests.cs b/tests/StaticHost.Tests/Live/YouTubeDiagnosticsTests.cs new file mode 100644 index 000000000..9379febd1 --- /dev/null +++ b/tests/StaticHost.Tests/Live/YouTubeDiagnosticsTests.cs @@ -0,0 +1,423 @@ +using System.Net.Sockets; +using System.Runtime.CompilerServices; +using Microsoft.Extensions.Logging; + +namespace StaticHost.Tests.Live; + +public sealed class YouTubeDiagnosticsTests +{ + [Theory] + [InlineData("""{"error":{"errors":[{"reason":"quotaExceeded","domain":"youtube.quota"}],"message":"key=api-key secret verify-token"}}""", "quotaExceeded", "youtube.quota", false)] + [InlineData("""{"error":{"errors":[{"reason":"api-key","domain":"verify-token"}]}}""", null, null, false)] + [InlineData("""{"error":{"errors":[{"reason":"https://example.com/?key=api-key","domain":123}]}}""", null, null, false)] + [InlineData("""{"error":[]}""", null, null, false)] + [InlineData("""{"error":""", null, null, false)] + [InlineData("Temporarily unavailable api-key secret verify-token", null, null, false)] + [InlineData("Transient error; please try again later api-key secret verify-token", null, null, false)] + [InlineData("oversized", null, null, true)] + public async Task HttpFailure_ReportsOnlyBoundedSafeDiagnostics( + string body, string? reason, string? domain, bool truncated) + { + if (truncated) body = new string('x', 5000) + "api-key secret verify-token"; + using var response = new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) + { + Content = new StringContent(body), + ReasonPhrase = "api-key secret verify-token", + }; + var exception = await Assert.ThrowsAsync(() => + YouTubeDiagnostics.EnsureSuccessAsync(response, CancellationToken.None, "api-key", "secret", "verify-token")); + var logger = new YouTubeRecordingLogger(); + + YouTubeDiagnostics.LogFailure(logger, exception, "WebSubSubscribe", YouTubeDiagnostics.SubscribeEndpoint); + + Assert.Equal(HttpStatusCode.ServiceUnavailable, exception.StatusCode); + var entry = Assert.Single(logger.Entries); + Assert.Equal(LogLevel.Warning, entry.Level); + Assert.Null(entry.Exception); + Assert.False(entry.Fields.ContainsKey("SafeStackTrace")); + Assert.Equal(503, entry.Fields["StatusCode"]); + Assert.Equal("Service Unavailable", entry.Fields["StatusReason"]); + Assert.Equal(reason, entry.Fields["ProviderReason"]); + Assert.Equal(domain, entry.Fields["ProviderDomain"]); + Assert.Equal(truncated, entry.Fields["BodyTruncated"]); + Assert.DoesNotContain("api-key", entry.Message); + Assert.DoesNotContain("verify-token", entry.Message); + Assert.DoesNotContain("secret", entry.Message); + Assert.DoesNotContain("", entry.Message); + Assert.True(entry.Message.Length < 1000); + if (body.StartsWith("", StringComparison.Ordinal)) + Assert.Equal("Provider reports temporary unavailability", entry.Fields["ProviderDetail"]); + if (body.StartsWith("Transient error", StringComparison.Ordinal)) + Assert.Equal("Provider reports a transient error", entry.Fields["ProviderDetail"]); + } + + [Theory] + [InlineData(null, null, null)] + [InlineData("120", 120d, null)] + [InlineData("Wed, 16 Sep 2026 21:00:00 GMT", null, "2026-09-16T21:00:00Z")] + [InlineData("api-key secret verify-token", null, null)] + [InlineData("-120", null, null)] + [InlineData("oversized", null, null)] + public async Task HttpFailure_ReportsTypedRetryAfterWithoutRawHeaders( + string? header, double? seconds, string? date) + { + using var response = new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) + { + Content = new StringContent("Transient error; please try again later"), + }; + if (header == "oversized") header = new string('9', 5000) + "api-key secret verify-token"; + if (header is not null) response.Headers.TryAddWithoutValidation("Retry-After", header); + var exception = await Assert.ThrowsAsync(() => + YouTubeDiagnostics.EnsureSuccessAsync(response, CancellationToken.None, "api-key", "secret", "verify-token")); + var logger = new YouTubeRecordingLogger(); + + YouTubeDiagnostics.LogFailure(logger, exception, "WebSubSubscribe", YouTubeDiagnostics.SubscribeEndpoint); + + var entry = Assert.Single(logger.Entries); + Assert.Equal(seconds, entry.Fields["RetryAfterSeconds"]); + Assert.Equal(date is null ? (DateTimeOffset?)null : DateTimeOffset.Parse(date), entry.Fields["RetryAfterDate"]); + Assert.Equal("Provider reports a transient error", entry.Fields["ProviderDetail"]); + Assert.DoesNotContain("api-key", entry.Message); + Assert.DoesNotContain("secret", entry.Message); + Assert.DoesNotContain("verify-token", entry.Message); + Assert.True(entry.Message.Length < 1000); + } + + [Fact] + public void DenialClassification_DoesNotReadBeyondDiagnosticLimit() + { + var logger = new YouTubeRecordingLogger(); + + YouTubeDiagnostics.LogUntrustedDenial( + logger, new string('x', 4096) + "Transient error", topicPresent: true, matchesConfiguredTopic: null); + + var entry = Assert.Single(logger.Entries); + Assert.Equal(true, entry.Fields["BodyTruncated"]); + Assert.Equal("Unrecognized provider response; body omitted", entry.Fields["ProviderDetail"]); + } + + [Fact] + public async Task UnreadableBody_DoesNotMaskHttpFailure() + { + using var response = new HttpResponseMessage(HttpStatusCode.BadGateway) + { + Content = new StreamContent(new UnreadableStream()), + }; + response.Headers.RetryAfter = new System.Net.Http.Headers.RetryConditionHeaderValue(TimeSpan.FromSeconds(120)); + var exception = await Assert.ThrowsAsync(() => + YouTubeDiagnostics.EnsureSuccessAsync(response, CancellationToken.None)); + var logger = new YouTubeRecordingLogger(); + YouTubeDiagnostics.LogFailure(logger, exception, "OfflineDiscovery", YouTubeDiagnostics.SearchEndpoint); + Assert.Equal(HttpStatusCode.BadGateway, exception.StatusCode); + Assert.Equal("Response body could not be read; body omitted", + Assert.Single(logger.Entries).Fields["ProviderDetail"]); + Assert.Equal(120d, Assert.Single(logger.Entries).Fields["RetryAfterSeconds"]); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void TransportFailure_ReportsCodesWithoutExceptionMessages(bool timeout) + { + Exception exception = timeout + ? new TaskCanceledException("https://example.com/?key=secret") + : new HttpRequestException(HttpRequestError.NameResolutionError, "key=secret", + new SocketException((int)SocketError.HostNotFound)); + var logger = new YouTubeRecordingLogger(); + + YouTubeDiagnostics.LogFailure(logger, exception, "ChannelResolution", YouTubeDiagnostics.ChannelsEndpoint); + + var entry = Assert.Single(logger.Entries); + Assert.Equal(timeout, entry.Fields["IsTimeout"]); + Assert.Null(entry.Exception); + Assert.DoesNotContain("secret", entry.Message); + if (!timeout) + { + Assert.Equal(HttpRequestError.NameResolutionError, entry.Fields["HttpRequestError"]); + Assert.Equal(SocketError.HostNotFound, entry.Fields["SocketError"]); + } + } + + [Fact] + public void UnexpectedFailure_ReportsRealStackFramesWithoutSecretMessages() + { + var exception = Assert.Throws(ThrowUnexpectedFailure); + var logger = new YouTubeRecordingLogger(); + + YouTubeDiagnostics.LogFailure(logger, exception, "BackgroundTick", "local coordination/state"); + + var entry = Assert.Single(logger.Entries); + Assert.Equal(LogLevel.Error, entry.Level); + Assert.Null(entry.Exception); + var stack = Assert.IsType(entry.Fields["SafeStackTrace"]); + Assert.Contains(nameof(ThrowUnexpectedFailure), stack); + Assert.DoesNotContain(".cs:", stack); + Assert.DoesNotContain("api-key-secret", entry.Message); + Assert.DoesNotContain("verify-token-secret", entry.Message); + Assert.DoesNotContain("https://", entry.Message); + Assert.DoesNotContain("Next subscription attempt", entry.Message); + Assert.DoesNotContain("polling", entry.Message); + Assert.Contains("worker will retry on its normal tick schedule", entry.Message); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void ThrowUnexpectedFailure() => + throw new InvalidOperationException("https://provider.example/?key=api-key-secret", + new Exception("verify-token-secret")); + + [Fact] + public async Task SubscriptionUnavailable_OfflineThenLive_DiscoveryHonorsQuotaAndReportsRecovery() + { + var time = new FakeTimeProvider(DateTimeOffset.UnixEpoch); + var searches = 0; + var apiHandler = new RecordingHttpMessageHandler(_ => + LiveTestHelpers.JsonResponse(++searches == 1 + ? """{"items":[]}""" + : """{"items":[{"id":{"videoId":"live-video"}}]}""")); + var hubHandler = new RecordingHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) + { + Content = new StringContent("Temporarily unavailable"), + }); + var factory = new TestHttpClientFactory(); + factory.AddClient(YouTubeClient.HttpClientName, apiHandler); + factory.AddClient(YouTubeClient.PubSubHttpClientName, hubHandler); + var options = Options(); + var client = new YouTubeClient(factory, options, NullLogger.Instance); + var logger = new YouTubeRecordingLogger(); + using var broadcaster = LiveTestHelpers.CreateBroadcaster(timeProvider: time); + using var service = new YouTubeWebSubService(client, broadcaster, options, logger, time, + new YouTubeWebSubSubscriptionState(time), new SingleInstanceLiveStatusCoordination()); + + await service.TickAsync(CancellationToken.None); + Assert.False(broadcaster.Current.YouTube.Live); + for (var tick = 1; tick < 15; tick++) + { + time.Advance(TimeSpan.FromMinutes(2)); + await service.TickAsync(CancellationToken.None); + Assert.Equal(1, searches); + } + Assert.Equal(4, hubHandler.Requests.Count); + time.Advance(TimeSpan.FromMinutes(2)); + await service.TickAsync(CancellationToken.None); + + Assert.Equal(2, searches); + Assert.Equal(4, hubHandler.Requests.Count); + Assert.True(broadcaster.Current.YouTube.Live); + Assert.Equal("live-video", broadcaster.Current.YouTube.VideoId); + var warnings = logger.Entries.Where(entry => entry.Level == LogLevel.Warning).ToArray(); + Assert.Equal(4, warnings.Length); + Assert.All(warnings, entry => + { + Assert.Equal("WebSubSubscribe", entry.Fields["Operation"]); + Assert.Equal(503, entry.Fields["StatusCode"]); + Assert.True(Assert.IsType(entry.Fields["ElapsedMs"]) >= 0); + Assert.NotNull(entry.Fields["RetryAt"]); + }); + var checks = logger.Entries.Where(entry => entry.Level == LogLevel.Information && + Equals(entry.Fields["Operation"], "OfflineDiscovery")).ToArray(); + Assert.Equal(2, checks.Length); + Assert.Equal(false, checks[0].Fields["LastDiscoveryLive"]); + Assert.Equal(DateTimeOffset.UnixEpoch, checks[0].Fields["LastSuccessfulDiscoveryAt"]); + Assert.Equal(true, checks[1].Fields["LastDiscoveryLive"]); + Assert.Equal(time.GetUtcNow(), checks[1].Fields["LastSuccessfulDiscoveryAt"]); + } + + [Fact] + public async Task DiscoveryFailure_IsNotOffline_AndRetainsLastSuccessfulCheck() + { + var time = new FakeTimeProvider(DateTimeOffset.UnixEpoch); + var calls = 0; + var handler = new RecordingHttpMessageHandler(_ => ++calls != 2 + ? LiveTestHelpers.JsonResponse("""{"items":[]}""") + : new HttpResponseMessage(HttpStatusCode.Forbidden) + { + Content = new StringContent("""{"error":{"errors":[{"reason":"quotaExceeded","domain":"youtube.quota"}]}}"""), + }); + var factory = new TestHttpClientFactory(); + factory.AddClient(YouTubeClient.HttpClientName, handler); + var options = Options(webhookSecret: ""); + var client = new YouTubeClient(factory, options, NullLogger.Instance); + var logger = new YouTubeRecordingLogger(); + using var broadcaster = LiveTestHelpers.CreateBroadcaster(timeProvider: time); + using var service = new YouTubeWebSubService(client, broadcaster, options, logger, time, + new YouTubeWebSubSubscriptionState(time), new SingleInstanceLiveStatusCoordination()); + await service.TickAsync(CancellationToken.None); + time.Advance(TimeSpan.FromMinutes(30)); + + await Assert.ThrowsAsync(() => service.TickAsync(CancellationToken.None)); + await service.TickAsync(CancellationToken.None); + + Assert.Equal(2, calls); + var failure = Assert.Single(logger.Entries, entry => entry.Level == LogLevel.Warning); + Assert.Equal("OfflineDiscovery", failure.Fields["Operation"]); + Assert.Equal("quotaExceeded", failure.Fields["ProviderReason"]); + Assert.Equal(DateTimeOffset.UnixEpoch, failure.Fields["LastSuccessfulDiscoveryAt"]); + Assert.Equal(false, failure.Fields["LastDiscoveryLive"]); + Assert.Single(logger.Entries, entry => entry.Level == LogLevel.Information); + + time.Advance(TimeSpan.FromMinutes(30)); + await service.TickAsync(CancellationToken.None); + Assert.Equal(3, calls); + var recovered = logger.Entries.Last(); + Assert.Equal(LogLevel.Information, recovered.Level); + Assert.Equal(time.GetUtcNow(), recovered.Fields["LastSuccessfulDiscoveryAt"]); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ChangedChannelSettings_ClearDiscoveryHistoryBeforeFailedResolution(bool changeConfiguredId) + { + var changed = false; + var searches = 0; + var handler = new RecordingHttpMessageHandler(request => + { + if (changed) return new HttpResponseMessage(HttpStatusCode.ServiceUnavailable); + if (request.RequestUri!.AbsolutePath.EndsWith("/channels", StringComparison.Ordinal)) + return LiveTestHelpers.JsonResponse("""{"items":[{"id":"old-channel"}]}"""); + searches++; + return LiveTestHelpers.JsonResponse("""{"items":[]}"""); + }); + var factory = new TestHttpClientFactory(); + factory.AddClient(YouTubeClient.HttpClientName, handler); + var options = Options(webhookSecret: ""); + options.CurrentValue.YouTube.ChannelId = changeConfiguredId ? "old-channel" : ""; + options.CurrentValue.YouTube.ChannelHandle = "@old-handle"; + var logger = new YouTubeRecordingLogger(); + using var broadcaster = LiveTestHelpers.CreateBroadcaster(); + var time = new FakeTimeProvider(DateTimeOffset.UnixEpoch); + using var service = new YouTubeWebSubService( + new YouTubeClient(factory, options, NullLogger.Instance), + broadcaster, options, logger, time, new YouTubeWebSubSubscriptionState(time), + new SingleInstanceLiveStatusCoordination()); + await service.TickAsync(CancellationToken.None); + var discovery = Assert.Single(logger.Entries, entry => Equals(entry.Fields["Operation"], "OfflineDiscovery")); + Assert.Equal(DateTimeOffset.UnixEpoch, discovery.Fields["LastSuccessfulDiscoveryAt"]); + Assert.Equal(false, discovery.Fields["LastDiscoveryLive"]); + + changed = true; + if (changeConfiguredId) + options.CurrentValue.YouTube.ChannelId = ""; + else + options.CurrentValue.YouTube.ChannelHandle = "@new-handle"; + time.Advance(TimeSpan.FromMinutes(2)); + await Assert.ThrowsAsync(() => service.TickAsync(CancellationToken.None)); + + var failure = Assert.Single(logger.Entries, entry => entry.Level == LogLevel.Warning); + Assert.Equal("ChannelResolution", failure.Fields["Operation"]); + Assert.Null(failure.Fields["LastSuccessfulDiscoveryAt"]); + Assert.Null(failure.Fields["LastDiscoveryLive"]); + Assert.Equal(1, searches); + } + + [Theory] + [InlineData("ChannelResolution", YouTubeDiagnostics.ChannelsEndpoint)] + [InlineData("KnownVideoStatus", YouTubeDiagnostics.VideosEndpoint)] + [InlineData("NotificationChannelResolution", YouTubeDiagnostics.ChannelsEndpoint)] + [InlineData("NotificationConfirmation", YouTubeDiagnostics.SearchEndpoint)] + public async Task FailedChecks_IdentifyOperationAndEndpoint_WithoutChangingState(string operation, string endpoint) + { + var handler = new RecordingHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.Forbidden) + { + Content = new StringContent("""{"error":{"errors":[{"reason":"accessNotConfigured","domain":"usageLimits"}]}}"""), + }); + var factory = new TestHttpClientFactory(); + factory.AddClient(YouTubeClient.HttpClientName, handler); + var options = Options(webhookSecret: ""); + if (operation.EndsWith("ChannelResolution", StringComparison.Ordinal)) + options.CurrentValue.YouTube.ChannelId = ""; + var client = new YouTubeClient(factory, options, NullLogger.Instance); + var logger = new YouTubeRecordingLogger(); + using var broadcaster = LiveTestHelpers.CreateBroadcaster(); + if (operation == "KnownVideoStatus") + broadcaster.Update(new LiveStatusUpdate { YouTube = new YouTubeStatus(true, "video") }); + var before = broadcaster.Current; + using var service = new YouTubeWebSubService(client, broadcaster, options, logger, + new FakeTimeProvider(), new YouTubeWebSubSubscriptionState(), new SingleInstanceLiveStatusCoordination()); + + var exception = await Assert.ThrowsAsync(() => + operation.StartsWith("Notification", StringComparison.Ordinal) + ? LiveStatusEndpointRouteBuilderExtensions.ConfirmYouTubeLiveStatusAsync( + options.CurrentValue.YouTube, client, broadcaster, logger, CancellationToken.None) + : service.TickAsync(CancellationToken.None)); + YouTubeDiagnostics.LogFailure(logger, exception, "BackgroundTick", "local coordination/state", skipIfLogged: true); + + Assert.Equal(before, broadcaster.Current); + Assert.Single(handler.Requests); + var failure = Assert.Single(logger.Entries); + Assert.Equal(operation, failure.Fields["Operation"]); + Assert.Equal(endpoint, failure.Fields["Endpoint"]); + Assert.Equal(403, failure.Fields["StatusCode"]); + Assert.True(Assert.IsType(failure.Fields["ElapsedMs"]) >= 0); + Assert.Equal("accessNotConfigured", failure.Fields["ProviderReason"]); + Assert.Null(failure.Exception); + Assert.False(failure.Fields.ContainsKey("SafeStackTrace")); + Assert.DoesNotContain("Next subscription attempt", failure.Message); + Assert.DoesNotContain("polling", failure.Message); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SubscriptionAcceptance_DoesNotReportVerifiedLeaseEvenIfPersistenceFails(bool failPersistence) + { + var factory = new TestHttpClientFactory(); + factory.AddClient(YouTubeClient.HttpClientName, + new RecordingHttpMessageHandler(_ => LiveTestHelpers.JsonResponse("""{"items":[]}"""))); + factory.AddClient(YouTubeClient.PubSubHttpClientName, + new RecordingHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.Accepted))); + var options = Options(); + var logger = new YouTubeRecordingLogger(); + var time = new FakeTimeProvider(DateTimeOffset.UnixEpoch); + var state = new YouTubeWebSubSubscriptionState(time) + { + MarkRequestSentException = failPersistence ? new IOException("State persistence failed") : null, + }; + using var broadcaster = LiveTestHelpers.CreateBroadcaster(); + using var service = new YouTubeWebSubService( + new YouTubeClient(factory, options, NullLogger.Instance), + broadcaster, options, logger, time, state, new SingleInstanceLiveStatusCoordination()); + + if (failPersistence) + { + var failure = await Assert.ThrowsAsync(() => service.TickAsync(CancellationToken.None)); + Assert.Same(state.MarkRequestSentException, failure); + } + else + { + await service.TickAsync(CancellationToken.None); + } + + var accepted = Assert.Single(logger.Entries, entry => Equals(entry.Fields["Operation"], "WebSubSubscribe")); + Assert.Equal(LogLevel.Information, accepted.Level); + Assert.Equal(time.GetUtcNow(), accepted.Fields["AcceptedAt"]); + Assert.True(Assert.IsType(accepted.Fields["ElapsedMs"]) >= 0); + Assert.Contains("HTTP acceptance does not establish a verified lease", accepted.Message); + Assert.Equal(DateTimeOffset.MinValue, await state.GetRenewAtAsync()); + } + + private static TestOptionsMonitor Options(string webhookSecret = "secret") => + new(new LiveStatusOptions + { + PublicBaseUrl = "https://example.com", + YouTube = new YouTubeOptions { ApiKey = "api-key", ChannelId = "channel", WebhookSecret = webhookSecret }, + }); + + private sealed class UnreadableStream : MemoryStream + { + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) => + ValueTask.FromException(new IOException("key=secret")); + } +} + +internal sealed class YouTubeRecordingLogger : ILogger +{ + internal sealed record Entry(LogLevel Level, Exception? Exception, string Message, Dictionary Fields); + public List Entries { get; } = []; + public IDisposable? BeginScope(TState state) where TState : notnull => null; + public bool IsEnabled(LogLevel logLevel) => true; + public void Log(LogLevel logLevel, EventId eventId, TState state, + Exception? exception, Func formatter) => + Entries.Add(new(logLevel, exception, formatter(state, exception), + ((IEnumerable>)state!).ToDictionary(pair => pair.Key, pair => pair.Value))); +} diff --git a/tests/StaticHost.Tests/Live/YouTubeWebSubServiceTests.cs b/tests/StaticHost.Tests/Live/YouTubeWebSubServiceTests.cs index 502740134..393a66ec8 100644 --- a/tests/StaticHost.Tests/Live/YouTubeWebSubServiceTests.cs +++ b/tests/StaticHost.Tests/Live/YouTubeWebSubServiceTests.cs @@ -370,14 +370,14 @@ public async Task TickAsync_BacksOffSubscriptionsWithoutStoppingLivePolling(stri if (failureKind == "unexpected") { Assert.Equal(LogLevel.Error, report.Level); - Assert.Same(failure, report.Exception); + Assert.Null(report.Exception); } else { Assert.Equal(LogLevel.Warning, report.Level); Assert.Null(report.Exception); Assert.Contains("Next subscription attempt", report.Message, StringComparison.Ordinal); - Assert.Contains(logger.Entries, entry => entry.Level == LogLevel.Debug && entry.Exception == failure); + Assert.DoesNotContain(logger.Entries, entry => entry.Exception is not null); } time.Advance(TimeSpan.FromMinutes(2));