From 1e3e5f68f9c33aab0680cbf196be63d65f5a71b4 Mon Sep 17 00:00:00 2001 From: Daveamhs Date: Wed, 30 Sep 2026 11:53:51 +0100 Subject: [PATCH 1/5] ci: add production link checker --- .github/workflows/ci.yml | 26 +++ README.md | 41 ++++ package.json | 2 + scripts/check-links.mjs | 94 +++++++++ scripts/link-check-allowlist.json | 1 + scripts/link-checker.mjs | 262 +++++++++++++++++++++++++ tests/link-checker.test.mjs | 307 ++++++++++++++++++++++++++++++ 7 files changed, 733 insertions(+) create mode 100644 scripts/check-links.mjs create mode 100644 scripts/link-check-allowlist.json create mode 100644 scripts/link-checker.mjs create mode 100644 tests/link-checker.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15fd6c9..04bde49 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,32 @@ jobs: - name: Smoke test the serverless routes run: pnpm test:smoke + production-links: + name: Production Links + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm test:links + - run: pnpm build + - run: pnpm exec playwright install --with-deps chromium + - run: pnpm check:links + - name: Upload link check report + if: always() + uses: actions/upload-artifact@v4 + with: + name: production-links + path: playwright-report/link-check.json + if-no-files-found: ignore + retention-days: 14 + playwright-a11y: name: Playwright A11y Gate needs: build diff --git a/README.md b/README.md index dbcac74..57bfcc2 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,47 @@ the OG image endpoint (rendering, query escaping, cache headers and the failure response). Buttondown is stubbed, so no mail is sent and no test touches the network. The suite runs on every pull request in CI. +## Production link checks + +```bash +pnpm build +pnpm exec playwright install chromium +pnpm check:links +pnpm test:links +``` + +The pull request `Production Links` job runs the same checker against `dist/`. +It discovers all built HTML routes and sitemap URLs, then uses Chromium to crawl +rendered links and validate same-page and cross-page anchors (including MDX headings). +Production-origin URLs resolve against the local build. Missing files and rendered +not-found pages fail even when the preview server returns an SPA fallback with HTTP 200. +The checker parses the sitemap, checks built-route coverage, and validates the main +RSS feed, every generated tag feed, and feeds expected by sitemap tag archives. +RSS channel, item, permalink GUID, and self URLs are checked too. + +External HTTP(S) destinations use GET with a ten-second timeout, at most two attempts, +and six concurrent requests. External fragments are not checked. `mailto:` and `tel:` +links are skipped. For an offline internal check, use `pnpm check:links --internal-only`; +CI always runs the full check. Failures include source and destination in the console +and `playwright-report/link-check.json`, uploaded as a CI artifact. + +Intentional external failures must be recorded in `scripts/link-check-allowlist.json` +as exact normalized URLs (no fragment or wildcard), with a reason and the specific +allowed HTTP statuses or `"network"` for a known unavailable host. For example: + +```json +[ + { + "url": "https://example.org/protected", + "reason": "Intentionally requires authentication", + "failures": [401] + } +] +``` + +The allowlist starts empty. It cannot suppress internal links, anchors, or XML failures. +Allowed failures remain visible in the report; unexpected failure statuses still fail CI. + ## SEO & Metadata - **`robots.txt`**: Located in `public/robots.txt` and copied to the build root. It allows search engine crawling while excluding staging, preview, admin, and 404 routes, and points crawlers to the sitemap location. diff --git a/package.json b/package.json index d008b34..0965330 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "lint:i18n": "node scripts/lint-i18n.mjs", "generate:og": "node scripts/generate-og.mjs", "validate:jsonld": "tsx scripts/validate-jsonld.mjs", + "check:links": "node scripts/check-links.mjs", + "test:links": "vitest run tests/link-checker.test.mjs", "prepare": "husky" }, "dependencies": { diff --git a/scripts/check-links.mjs b/scripts/check-links.mjs new file mode 100644 index 0000000..16c896e --- /dev/null +++ b/scripts/check-links.mjs @@ -0,0 +1,94 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { join } from 'node:path'; +import { chromium } from '@playwright/test'; +import { preview } from 'vite'; +import { checkBuild } from './link-checker.mjs'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const internalOnly = process.argv.includes('--internal-only'); +if (process.argv.slice(2).some((arg) => arg !== '--internal-only')) { + throw new Error('Usage: pnpm check:links [--internal-only]'); +} +let server; +let browser; +try { + const allowlist = JSON.parse( + await readFile(join(root, 'scripts/link-check-allowlist.json'), 'utf8'), + ); + server = await preview({ + root, + configFile: false, + preview: { host: '127.0.0.1', port: 0, open: false }, + }); + const origin = `http://127.0.0.1:${server.httpServer.address().port}`; + browser = await chromium.launch(); + const context = await browser.newContext({ locale: 'en-US', serviceWorkers: 'block' }); + // Only page content comes from the local production build. External destinations + // are checked separately, with bounded retries, rather than loading third-party JS. + await context.route('**/*', (route) => + new URL(route.request().url()).origin === origin ? route.continue() : route.abort(), + ); + const page = await context.newPage(); + page.setDefaultTimeout(15_000); + let pageErrors = []; + page.on('pageerror', (error) => pageErrors.push(error.message)); + page.on('requestfailed', (request) => { + if (request.url().startsWith(origin + '/') && request.resourceType() === 'script') { + pageErrors.push(`Script failed: ${request.url()}`); + } + }); + page.on('response', (response) => { + if ( + response.url().startsWith(origin + '/') && + response.status() >= 400 && + response.request().resourceType() === 'script' + ) { + pageErrors.push(`Script HTTP ${response.status()}: ${response.url()}`); + } + }); + const report = await checkBuild({ + distDir: join(root, 'dist'), + allowlist, + internalOnly, + progress: console.log, + renderPage: async (path) => { + pageErrors = []; + const response = await page.goto(origin + path, { waitUntil: 'networkidle' }); + await page.locator('h1').first().waitFor({ state: 'attached' }); + if (pageErrors.length) throw new Error(pageErrors.join('; ')); + const snapshot = await page.evaluate(() => ({ + heading: document.querySelector('h1')?.textContent.trim(), + links: [ + ...document.querySelectorAll('a[href], area[href], link[rel="alternate"][href]'), + ].map((node) => node.getAttribute('href')), + anchors: [...document.querySelectorAll('[id], a[name]')].flatMap((node) => + [node.id, node.getAttribute('name')].filter(Boolean), + ), + })); + return { ...snapshot, status: response.status() }; + }, + }); + await mkdir(join(root, 'playwright-report'), { recursive: true }); + await writeFile( + join(root, 'playwright-report/link-check.json'), + JSON.stringify(report, null, 2) + '\n', + ); + for (const finding of report.errors) + console.error(`${finding.source} -> ${finding.target}: ${finding.message}`); + for (const finding of report.allowed) + console.log(`Allowed: ${finding.target}: ${finding.reason}`); + console.log( + `Checked ${report.pages} pages, ${report.feeds} feeds, ${report.links} links; ${report.errors.length} failures, ${report.allowed.length} allowed external failures.${internalOnly ? ' External checks skipped.' : ''}`, + ); + if (report.errors.length) process.exitCode = 1; +} catch (error) { + console.error(error); + process.exitCode = 1; +} finally { + await browser?.close(); + if (server) + await new Promise((resolve, reject) => + server.httpServer.close((error) => (error ? reject(error) : resolve())), + ); +} diff --git a/scripts/link-check-allowlist.json b/scripts/link-check-allowlist.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/scripts/link-check-allowlist.json @@ -0,0 +1 @@ +[] diff --git a/scripts/link-checker.mjs b/scripts/link-checker.mjs new file mode 100644 index 0000000..ac3c1ca --- /dev/null +++ b/scripts/link-checker.mjs @@ -0,0 +1,262 @@ +import { readdir, readFile } from 'node:fs/promises'; +import { join, posix } from 'node:path'; +import { JSDOM } from 'jsdom'; + +export const SITE_URL = 'https://usewraith.xyz'; + +export async function listFiles(directory, prefix = '') { + const entries = await readdir(directory, { withFileTypes: true }); + const files = await Promise.all( + entries.map((entry) => { + const name = posix.join(prefix, entry.name); + return entry.isDirectory() ? listFiles(join(directory, entry.name), name) : [name]; + }), + ); + return files.flat().sort(); +} + +export function routePath(pathname) { + return pathname.replace(/\/index\.html$/, '/').replace(/\/$/, '') || '/'; +} + +export function validateAllowlist(entries, siteUrl = SITE_URL) { + if (!Array.isArray(entries)) throw new Error('External allowlist must be an array'); + const seen = new Set(); + for (const entry of entries) { + const url = new URL(entry.url); + if ( + !['http:', 'https:'].includes(url.protocol) || + url.origin === new URL(siteUrl).origin || + url.hash || + entry.url.includes('*') || + url.href !== entry.url || + seen.has(entry.url) || + typeof entry.reason !== 'string' || + !entry.reason.trim() || + !Array.isArray(entry.failures) || + !entry.failures.length || + entry.failures.some( + (value) => value !== 'network' && (!Number.isInteger(value) || value < 400 || value > 599), + ) + ) { + throw new Error(`Invalid external allowlist entry: ${entry.url}`); + } + seen.add(entry.url); + } + return entries; +} + +// GET avoids false failures from sites that reject HEAD. Retry transient failures once. +export async function checkExternal(url, fetchUrl = fetch) { + let result; + for (let attempt = 0; attempt < 2; attempt++) { + try { + const response = await fetchUrl(url, { + redirect: 'follow', + signal: AbortSignal.timeout(10_000), + headers: { 'User-Agent': 'Wraith-Link-Checker/1.0' }, + }); + result = { status: response.status, ok: response.ok }; + await response.body?.cancel(); + } catch (error) { + result = { status: 'network', ok: false, detail: error.message }; + } + if (result.ok || (result.status !== 'network' && result.status !== 429 && result.status < 500)) + break; + } + return result; +} + +export async function checkBuild({ + distDir, + renderPage, + externalCheck = checkExternal, + allowlist = [], + internalOnly = false, + siteUrl = SITE_URL, + progress = () => {}, +}) { + validateAllowlist(allowlist, siteUrl); + const files = new Set(await listFiles(distDir)); + if (!files.has('index.html')) throw new Error('dist/index.html missing; run pnpm build first'); + const errors = []; + const allowed = []; + const links = new Map(); + const pages = new Map(); + const routes = new Set(); + const feeds = new Set(['feed.xml']); + const fail = (source, target, message) => errors.push({ source, target, message }); + const addLink = (raw, source, absolute = false) => { + try { + const url = absolute ? new URL(raw) : new URL(raw, new URL(source, siteUrl)); + if (!absolute && ['mailto:', 'tel:'].includes(url.protocol)) return; + if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Unsupported link protocol'); + if (!links.has(url.href)) links.set(url.href, new Set()); + links.get(url.href).add(source); + return url; + } catch (error) { + fail(source, raw, `Invalid URL: ${error.message}`); + } + }; + const readXml = async (file, root) => { + try { + const dom = new JSDOM(await readFile(join(distDir, file), 'utf8'), { + contentType: 'text/xml', + }); + if (dom.window.document.documentElement.localName !== root) { + dom.window.close(); + throw new Error(`Expected <${root}>`); + } + return dom; + } catch (error) { + fail(file, file, `Invalid or missing XML: ${error.message}`); + } + }; + + for (const file of files) { + if (file.endsWith('.html') && file !== '404.html' && !file.startsWith('404/')) { + routes.add(routePath(`/${file}`)); + } + if (file === 'feed.xml' || (file.startsWith('feed/') && file.endsWith('.xml'))) feeds.add(file); + } + const sitemap = await readXml('sitemap.xml', 'urlset'); + const sitemapRoutes = new Set(); + if (sitemap) { + const entries = [...sitemap.window.document.querySelectorAll('urlset > url')]; + if (!entries.length) fail('sitemap.xml', '', 'Sitemap has no URLs'); + for (const entry of entries) { + const raw = entry.querySelector('loc')?.textContent.trim() || ''; + const url = addLink(raw, 'sitemap.xml', true); + if (!url) continue; + if (url.origin !== new URL(siteUrl).origin || url.search || url.hash) { + fail('sitemap.xml', raw, 'Sitemap URL must be a canonical production URL'); + continue; + } + const route = routePath(url.pathname); + if (sitemapRoutes.has(route)) fail('sitemap.xml', raw, 'Duplicate sitemap route'); + sitemapRoutes.add(route); + routes.add(route); + if (route.startsWith('/blog/tag/')) + feeds.add(`feed/tag/${route.slice('/blog/tag/'.length)}.xml`); + } + sitemap.window.close(); + for (const route of routes) { + if (!sitemapRoutes.has(route)) fail('sitemap.xml', route, 'Built route missing from sitemap'); + } + } + + for (const file of feeds) { + const dom = await readXml(file, 'rss'); + if (!dom) continue; + const document = dom.window.document; + const channel = document.querySelector('rss > channel'); + if (!channel) fail(file, file, 'RSS channel missing'); + const self = [...document.getElementsByTagNameNS('http://www.w3.org/2005/Atom', 'link')] + .find((node) => node.getAttribute('rel') === 'self') + ?.getAttribute('href'); + if (self !== new URL(`/${file}`, siteUrl).href) + fail(file, self, 'RSS self URL does not match feed file'); + if (self) addLink(self, file, true); + for (const entry of document.querySelectorAll('channel, item')) { + const link = entry.querySelector(':scope > link')?.textContent.trim() || ''; + addLink(link, file, true); + if (!entry.querySelector(':scope > title')?.textContent.trim()) + fail(file, link, 'RSS title missing'); + } + for (const guid of document.querySelectorAll('guid')) { + if (guid.getAttribute('isPermaLink') !== 'false') + addLink(guid.textContent.trim(), file, true); + } + dom.window.close(); + } + + // Seed every built/sitemap route, including routes with no incoming links. + for (const route of routes) addLink(route, 'route matrix'); + const external = new Map(); + for (const [href, sources] of links) { + const url = new URL(href); + const source = [...sources].join(', '); + if (url.origin !== new URL(siteUrl).origin) { + url.hash = ''; + if (!external.has(url.href)) external.set(url.href, new Set()); + for (const item of sources) external.get(url.href).add(item); + continue; + } + let pathname; + try { + pathname = decodeURIComponent(url.pathname); + } catch { + fail(source, href, 'Malformed URL encoding'); + continue; + } + const file = pathname.slice(1); + const isHtml = pathname.endsWith('.html'); + if (files.has(file) && !isHtml) continue; + if (posix.extname(pathname) && !isHtml) { + fail(source, href, 'Missing built file (SPA fallback is not a file)'); + continue; + } + // Preserve the requested path: /blog/index.html can render differently from + // /blog in a client router, and trailing slashes affect relative links. + const key = url.pathname + url.search; + if (!pages.has(key)) { + progress(`Crawling ${key}`); + try { + const page = await renderPage(key); + if (page.status >= 400) throw new Error(`HTTP ${page.status}`); + if (!page.heading || /not found|too stealth for us/i.test(page.heading)) + throw new Error('Rendered page is missing or not found'); + pages.set(key, page); + for (const link of page.links) addLink(link, key); + } catch (error) { + pages.set(key, null); + fail(source, href, `Page failed: ${error.message}`); + } + } + const page = pages.get(key); + if (page && url.hash) { + try { + const anchor = decodeURIComponent(url.hash.slice(1)).split(':~:text=')[0]; + if (anchor && !page.anchors.includes(anchor) && anchor.toLowerCase() !== 'top') { + fail(source, href, `Missing anchor #${anchor}`); + } + } catch { + fail(source, href, 'Malformed anchor encoding'); + } + } + } + + if (!internalOnly) { + const queue = [...external]; + await Promise.all( + Array.from({ length: Math.min(6, queue.length) }, async () => { + while (queue.length) { + const [url, sources] = queue.shift(); + progress(`Checking ${url}`); + const result = await externalCheck(url); + if (result.ok) continue; + const exception = allowlist.find( + (entry) => entry.url === url && entry.failures.includes(result.status), + ); + const finding = { + source: [...sources].join(', '), + target: url, + message: `External ${result.status}${result.detail ? `: ${result.detail}` : ''}`, + }; + if (exception) allowed.push({ ...finding, reason: exception.reason }); + else errors.push(finding); + } + }), + ); + } + return { + routes: routes.size, + pages: pages.size, + feeds: feeds.size, + links: links.size, + external: external.size, + internalOnly, + allowed, + errors, + }; +} diff --git a/tests/link-checker.test.mjs b/tests/link-checker.test.mjs new file mode 100644 index 0000000..d1c1200 --- /dev/null +++ b/tests/link-checker.test.mjs @@ -0,0 +1,307 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { + checkBuild, + checkExternal, + SITE_URL, + validateAllowlist, +} from '../scripts/link-checker.mjs'; + +const directories = []; +afterEach(async () => { + await Promise.all( + directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +function sitemap(routes = ['/', '/blog', '/blog/post', '/blog/tag/privacy']) { + return `${routes.map((route) => `${SITE_URL}${route}`).join('')}`; +} + +function feed(path = '/feed.xml') { + return ` + Blog${SITE_URL}/blog + + Post${SITE_URL}/blog/post${SITE_URL}/blog/post + `; +} + +async function fixture(overrides = {}, snapshots = {}) { + const distDir = await mkdtemp(join(tmpdir(), 'wraith-link-check-')); + directories.push(distDir); + const files = { + 'index.html': '
', + 'blog/index.html': '
', + 'blog/post/index.html': '
', + 'sitemap.xml': sitemap(), + 'feed.xml': feed(), + 'feed/tag/privacy.xml': feed('/feed/tag/privacy.xml'), + 'press-kit/brand.zip': 'download', + ...overrides, + }; + for (const [file, content] of Object.entries(files)) { + if (content === null) continue; + await mkdir(dirname(join(distDir, file)), { recursive: true }); + await writeFile(join(distDir, file), content); + } + const pages = { + '/': { + links: [ + '/blog/post#hello%20world', + '/press-kit/brand.zip?download=1', + 'mailto:team@example.org', + 'tel:+1234', + ], + }, + '/blog': { links: ['/feed.xml', '/blog/tag/privacy'] }, + '/blog/post': { links: ['#hello%20world', './post?mode=print#hello%20world'] }, + '/blog/post?mode=print': { links: [] }, + '/blog/tag/privacy': { links: ['/feed/tag/privacy.xml'] }, + ...snapshots, + }; + const renderPage = vi.fn(async (route) => { + const page = pages[route]; + return { + status: 200, + heading: page ? 'Page' : 'This address is too stealth for us', + anchors: ['hello world'], + links: [], + ...page, + }; + }); + const externalCheck = vi.fn(async () => ({ ok: true, status: 200 })); + return { distDir, renderPage, externalCheck }; +} + +describe('production link checker', () => { + it('crawls built routes, archives, RSS and query links with decoded cross-page and same-page anchors', async () => { + const options = await fixture(); + const result = await checkBuild(options); + expect(result.errors).toEqual([]); + expect(result.feeds).toBe(2); + expect(options.renderPage).toHaveBeenCalledWith('/blog/tag/privacy'); + expect(options.renderPage).toHaveBeenCalledWith('/blog/post?mode=print'); + expect(options.externalCheck).not.toHaveBeenCalled(); + }); + + it('finds unlinked built routes and missing sitemap coverage', async () => { + const result = await checkBuild( + await fixture({ 'orphan/index.html': '' }, { '/orphan': {} }), + ); + expect(result.errors).toContainEqual( + expect.objectContaining({ target: '/orphan', message: 'Built route missing from sitemap' }), + ); + expect(result.pages).toBe(6); + }); + + it('preserves trailing-slash and index.html destinations when rendering', async () => { + const options = await fixture( + {}, + { + '/': { links: ['/blog/', '/blog/index.html'] }, + '/blog/': { links: ['post#hello%20world'] }, + }, + ); + const result = await checkBuild(options); + expect(options.renderPage).toHaveBeenCalledWith('/blog/'); + expect(options.renderPage).toHaveBeenCalledWith('/blog/index.html'); + expect(result.errors).toEqual([ + expect.objectContaining({ target: `${SITE_URL}/blog/index.html` }), + ]); + }); + + it('rejects soft 404 pages, missing downloads, broken and malformed anchors', async () => { + const result = await checkBuild( + await fixture( + {}, + { + '/': { + links: [ + '/missing', + '/missing.pdf', + '/blog/post#absent', + '/blog/post#%ZZ', + '/blog/post#top', + ], + }, + }, + ), + ); + expect(result.errors.map((error) => error.message)).toEqual( + expect.arrayContaining([ + expect.stringContaining('Rendered page is missing or not found'), + 'Missing built file (SPA fallback is not a file)', + 'Missing anchor #absent', + 'Malformed anchor encoding', + ]), + ); + expect(result.errors).toHaveLength(4); + }); + + it.each(['Post Not Found', 'Tag Not Found', 'Author Not Found', 'Case Study Not Found'])( + 'rejects dynamic soft 404: %s', + async (heading) => { + const result = await checkBuild(await fixture({}, { '/blog/post': { heading, links: [] } })); + expect(result.errors).toContainEqual( + expect.objectContaining({ message: expect.stringContaining('not found') }), + ); + }, + ); + + it('reports malformed XML and missing expected tag feeds', async () => { + const result = await checkBuild( + await fixture({ 'feed.xml': '', 'feed/tag/privacy.xml': null }), + ); + expect(result.errors).toContainEqual( + expect.objectContaining({ + source: 'feed.xml', + message: expect.stringContaining('Invalid or missing XML'), + }), + ); + expect(result.errors).toContainEqual( + expect.objectContaining({ + source: 'feed/tag/privacy.xml', + message: expect.stringContaining('Invalid or missing XML'), + }), + ); + }); + + it('rejects absent sitemap, empty sitemap, invalid and foreign sitemap URLs', async () => { + for (const xml of [ + null, + '', + sitemap(['relative']), + sitemap().replace(SITE_URL, 'https://other.example'), + ]) { + const result = await checkBuild(await fixture({ 'sitemap.xml': xml })); + expect(result.errors.some((error) => error.source === 'sitemap.xml')).toBe(true); + } + }); + + it('validates feed structure, self URL and item destinations', async () => { + const result = await checkBuild( + await fixture({ 'feed.xml': feed('/wrong.xml').replaceAll('/blog/post', '/bad-post') }), + ); + expect(result.errors).toContainEqual( + expect.objectContaining({ message: 'RSS self URL does not match feed file' }), + ); + expect(result.errors).toContainEqual( + expect.objectContaining({ + target: `${SITE_URL}/bad-post`, + message: expect.stringContaining('not found'), + }), + ); + const malformed = await checkBuild(await fixture({ 'feed.xml': '' })); + expect(malformed.errors).toContainEqual( + expect.objectContaining({ message: 'RSS channel missing' }), + ); + }); + + it('rejects non-HTTP URLs in XML and unsafe protocols in page links', async () => { + const result = await checkBuild( + await fixture( + { + 'sitemap.xml': sitemap().replace(`${SITE_URL}/blog/post`, 'mailto:someone@example.org'), + 'feed.xml': feed().replaceAll(`${SITE_URL}/blog/post`, 'tel:+1234'), + }, + { '/': { links: ['javascript:void(0)'] } }, + ), + ); + for (const source of ['sitemap.xml', 'feed.xml', '/']) { + expect(result.errors).toContainEqual( + expect.objectContaining({ + source, + message: expect.stringContaining('Unsupported link protocol'), + }), + ); + } + }); + + it('deduplicates external URLs and only allows explicitly listed failure statuses', async () => { + const options = await fixture( + {}, + { + '/': { + links: [ + 'https://example.org/private#one', + 'https://example.org/private#two', + 'https://example.org/missing', + ], + }, + }, + ); + options.externalCheck.mockResolvedValue({ ok: false, status: 403 }); + const allowlist = [ + { url: 'https://example.org/private', reason: 'Requires login', failures: [403] }, + ]; + const result = await checkBuild({ ...options, allowlist }); + expect(options.externalCheck).toHaveBeenCalledTimes(2); + expect(result.allowed).toHaveLength(1); + expect(result.errors).toEqual([ + expect.objectContaining({ target: 'https://example.org/missing' }), + ]); + options.externalCheck.mockResolvedValue({ ok: false, status: 404 }); + expect((await checkBuild({ ...options, allowlist })).errors).toHaveLength(2); + }); + + it('allows explicit network exceptions and supports an offline internal-only run', async () => { + const options = await fixture({}, { '/': { links: ['https://example.org/'] } }); + options.externalCheck.mockResolvedValue({ ok: false, status: 'network' }); + const result = await checkBuild({ + ...options, + allowlist: [ + { url: 'https://example.org/', reason: 'Intentionally offline', failures: ['network'] }, + ], + }); + expect(result.allowed).toHaveLength(1); + options.externalCheck.mockClear(); + const offline = await checkBuild({ ...options, internalOnly: true }); + expect(offline.internalOnly).toBe(true); + expect(options.externalCheck).not.toHaveBeenCalled(); + }); + + it('rejects internal, wildcard-like and undocumented allowlist entries', () => { + for (const entry of [ + { url: `${SITE_URL}/missing`, reason: 'No', failures: [404] }, + { url: 'https://example.org/', reason: '', failures: [403] }, + { url: 'https://example.org/#anchor', reason: 'No', failures: [403] }, + { url: 'https://example.org/', reason: 'No', failures: ['*'] }, + ]) + expect(() => validateAllowlist([entry])).toThrow(); + }); +}); + +describe('external requests', () => { + it('uses GET, follows redirects, and retries transient server failures', async () => { + const fetchUrl = vi + .fn() + .mockResolvedValueOnce(new Response('', { status: 503 })) + .mockResolvedValueOnce(new Response('', { status: 200 })); + expect(await checkExternal('https://example.org/', fetchUrl)).toEqual({ + ok: true, + status: 200, + }); + expect(fetchUrl).toHaveBeenCalledTimes(2); + expect(fetchUrl.mock.calls[0][1]).toMatchObject({ + redirect: 'follow', + signal: expect.any(AbortSignal), + }); + }); + + it('does not retry 404s and reports exhausted network failures', async () => { + const fetchUrl = vi.fn().mockResolvedValue(new Response('', { status: 404 })); + expect(await checkExternal('https://example.org/', fetchUrl)).toEqual({ + ok: false, + status: 404, + }); + expect(fetchUrl).toHaveBeenCalledTimes(1); + fetchUrl.mockReset().mockRejectedValue(new Error('offline')); + expect(await checkExternal('https://example.org/', fetchUrl)).toMatchObject({ + ok: false, + status: 'network', + }); + expect(fetchUrl).toHaveBeenCalledTimes(2); + }); +}); From e23a887b35040b2d94775d043a3a3791ce1bffd4 Mon Sep 17 00:00:00 2001 From: Daveamhs Date: Sun, 4 Oct 2026 12:31:52 +0100 Subject: [PATCH 2/5] fix: resolve production link checker findings --- public/.well-known/security.txt | 9 --------- src/components/Footer.tsx | 10 ---------- src/components/TrustStrip.tsx | 11 +++-------- src/data/authors.json | 3 +-- src/data/security.json | 9 +++------ src/data/team.json | 15 +++++++-------- src/data/trust.json | 9 +++------ src/pages/Security.tsx | 33 --------------------------------- src/pages/Stellar.tsx | 18 ------------------ 9 files changed, 17 insertions(+), 100 deletions(-) diff --git a/public/.well-known/security.txt b/public/.well-known/security.txt index a4fa2dd..2780fec 100644 --- a/public/.well-known/security.txt +++ b/public/.well-known/security.txt @@ -5,18 +5,9 @@ Canonical: https://usewraith.xyz/.well-known/security.txt Contact: mailto:security@usewraith.xyz Contact: https://usewraith.xyz/security -# PGP key for encrypted communications -Encryption: https://keys.openpgp.org/vks/v1/by-fingerprint/ABCD123456789... - # Preferred languages Preferred-Languages: en -# Disclosure policy -Policy: https://docs.usewraith.xyz/security/disclosure-policy - -# Acknowledgments page -Acknowledgments: https://docs.usewraith.xyz/security/disclosures - # Hiring security researchers Hiring: https://usewraith.xyz/careers diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index 25662a0..d7ffcef 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -216,14 +216,12 @@ export default function Footer() { { label: t('footer.product.console'), href: 'https://console.usewraith.xyz' }, { label: t('footer.product.compare'), href: '/#compare' }, { label: t('footer.product.faq'), href: '/faq' }, - { label: t('footer.product.changelog'), href: 'https://docs.usewraith.xyz/changelog' }, ], }, { title: t('footer.columns.developers'), links: [ { label: t('footer.developers.sdk'), href: 'https://docs.usewraith.xyz/sdk/overview' }, - { label: t('footer.developers.apiReference'), href: 'https://docs.usewraith.xyz/api' }, { label: t('footer.developers.github'), href: 'https://github.com/wraith-protocol' }, { label: t('footer.developers.npm'), @@ -236,7 +234,6 @@ export default function Footer() { links: [ { label: t('footer.resources.erc5564'), href: 'https://eips.ethereum.org/EIPS/eip-5564' }, { label: t('footer.resources.erc6538'), href: 'https://eips.ethereum.org/EIPS/eip-6538' }, - { label: t('footer.resources.security'), href: 'https://docs.usewraith.xyz/security' }, { label: t('footer.resources.press'), href: '/press' }, { label: 'Blog', href: '/blog' }, { label: 'Stellar Integration', href: '/stellar' }, @@ -373,13 +370,6 @@ export default function Footer() { > {t('footer.legal.privacy')} - - {t('footer.legal.terms')} - ))} - @@ -127,8 +123,7 @@ export default function TrustStrip() { {integrations.label} - - + {audits.nextScheduled} - - View Audit Reports ↗ - @@ -329,15 +321,6 @@ export default function Security() {

- - - View Bounty Program ↗ - @@ -358,14 +341,6 @@ export default function Security() { {disclosures.count}

- - View Disclosure Log ↗ - @@ -515,14 +490,6 @@ export default function Security() { > Privacy Policy → - - Full Security Docs ↗ - diff --git a/src/pages/Stellar.tsx b/src/pages/Stellar.tsx index f02a5b7..0930c36 100644 --- a/src/pages/Stellar.tsx +++ b/src/pages/Stellar.tsx @@ -284,15 +284,6 @@ export default function Stellar() { > Read Stellar docs - track('cta_click', { source: 'stellar-spectre' })} - className="flex h-12 items-center justify-center border border-outline-variant px-7 font-heading text-[13px] font-semibold uppercase tracking-[1.5px] text-primary transition-colors duration-150 hover:bg-surface-bright" - > - Spectre on Stellar - {/* Value prop stats — matches Hero.tsx stat block */} @@ -612,15 +603,6 @@ export default function Stellar() { > Read Stellar docs - track('cta_click', { source: 'stellar-spectre' })} - className="flex h-12 items-center justify-center border border-outline-variant px-7 font-heading text-[13px] font-semibold uppercase tracking-[1.5px] text-primary transition-colors duration-150 hover:bg-surface-bright" - > - Spectre on Stellar - Date: Wed, 7 Oct 2026 12:44:46 +0100 Subject: [PATCH 3/5] fix: address production link review feedback --- scripts/link-checker.mjs | 134 ++++++++++++++++++++++++++++++---- scripts/validate-content.ts | 13 +++- scripts/validate-jsonld.mjs | 5 -- src/__tests__/Footer.test.tsx | 2 +- src/__tests__/grants.test.tsx | 11 +-- src/components/Footer.tsx | 2 +- src/data/chains.json | 14 ++-- src/data/ecosystem.json | 2 +- src/data/trust.json | 2 +- src/pages/Grants.tsx | 14 ---- src/pages/Stellar.tsx | 4 +- tests/link-checker.test.mjs | 64 +++++++++++++++- 12 files changed, 212 insertions(+), 55 deletions(-) diff --git a/scripts/link-checker.mjs b/scripts/link-checker.mjs index ac3c1ca..a9ccf9c 100644 --- a/scripts/link-checker.mjs +++ b/scripts/link-checker.mjs @@ -1,9 +1,68 @@ +import { lookup } from 'node:dns/promises'; +import { isIP } from 'node:net'; import { readdir, readFile } from 'node:fs/promises'; import { join, posix } from 'node:path'; import { JSDOM } from 'jsdom'; export const SITE_URL = 'https://usewraith.xyz'; +function isPrivateOrLocalIp(address) { + const version = isIP(address); + if (version === 4) { + const parts = address.split('.').map(Number); + const [a, b] = parts; + + return ( + a === 0 || + a === 10 || + a === 127 || + (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) + ); + } + + if (version === 6) { + const normalized = address.toLowerCase(); + + return ( + normalized === '::' || + normalized === '::1' || + normalized.startsWith('fc') || + normalized.startsWith('fd') || + /^fe[89ab]/.test(normalized) + ); + } + + return false; +} + +async function assertSafeExternalDestination(url, resolveHost = lookup) { + const parsed = new URL(url); + + if (!['http:', 'https:'].includes(parsed.protocol)) { + throw new Error(`Unsafe external protocol: ${parsed.protocol}`); + } + + const hostname = parsed.hostname.replace(/^\[|\]$/g, ''); + + if (hostname === 'localhost' || hostname.endsWith('.localhost')) { + throw new Error(`Blocked private or link-local destination: ${hostname}`); + } + + if (isIP(hostname)) { + if (isPrivateOrLocalIp(hostname)) { + throw new Error(`Blocked private or link-local destination: ${hostname}`); + } + return; + } + + const addresses = await resolveHost(hostname, { all: true, verbatim: true }); + + if (!addresses.length || addresses.some(({ address }) => isPrivateOrLocalIp(address))) { + throw new Error(`Blocked private or link-local destination: ${hostname}`); + } +} export async function listFiles(directory, prefix = '') { const entries = await readdir(directory, { withFileTypes: true }); const files = await Promise.all( @@ -47,24 +106,73 @@ export function validateAllowlist(entries, siteUrl = SITE_URL) { } // GET avoids false failures from sites that reject HEAD. Retry transient failures once. -export async function checkExternal(url, fetchUrl = fetch) { - let result; +export async function checkExternal(url, fetchUrl = fetch, resolveHost = lookup) { + const maxRedirects = 5; + for (let attempt = 0; attempt < 2; attempt++) { + let currentUrl = url; + try { - const response = await fetchUrl(url, { - redirect: 'follow', - signal: AbortSignal.timeout(10_000), - headers: { 'User-Agent': 'Wraith-Link-Checker/1.0' }, - }); - result = { status: response.status, ok: response.ok }; - await response.body?.cancel(); + for (let redirects = 0; redirects <= maxRedirects; redirects++) { + await assertSafeExternalDestination(currentUrl, resolveHost); + + const response = await fetchUrl(currentUrl, { + redirect: 'manual', + signal: AbortSignal.timeout(10_000), + headers: { 'User-Agent': 'Wraith-Link-Checker/1.0' }, + }); + + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get('location'); + await response.body?.cancel(); + + if (!location) { + return { status: response.status, ok: false }; + } + + if (redirects === maxRedirects) { + throw new Error('Too many redirects'); + } + + currentUrl = new URL(location, currentUrl).href; + + // Validate the redirect destination before the next request. + await assertSafeExternalDestination(currentUrl, resolveHost); + continue; + } + + const result = { status: response.status, ok: response.ok }; + await response.body?.cancel(); + + if (result.ok || (result.status !== 429 && result.status < 500)) { + return result; + } + + break; + } } catch (error) { - result = { status: 'network', ok: false, detail: error.message }; + if ( + error.message.startsWith('Blocked private or link-local destination:') || + error.message.startsWith('Unsafe external protocol:') + ) { + return { + status: 'network', + ok: false, + detail: error.message, + }; + } + + if (attempt === 1) { + return { + status: 'network', + ok: false, + detail: error.message, + }; + } } - if (result.ok || (result.status !== 'network' && result.status !== 429 && result.status < 500)) - break; } - return result; + + return { status: 'network', ok: false }; } export async function checkBuild({ diff --git a/scripts/validate-content.ts b/scripts/validate-content.ts index e68ecd1..8ee4caa 100644 --- a/scripts/validate-content.ts +++ b/scripts/validate-content.ts @@ -114,6 +114,17 @@ function requireUrl(ctx: Ctx, obj: Record, field: string): void } } +function requireUrlOrInternalPath(ctx: Ctx, obj: Record, field: string): void { + const value = obj[field]; + + if (!isString(value) || (!isUrl(value) && !/^\/(?!\/)/.test(value))) { + fail( + ctx, + `field "${field}" must be a valid absolute URL or root-relative path (got ${JSON.stringify(value)})`, + ); + } +} + function optionalUrl(ctx: Ctx, obj: Record, field: string): void { if (field in obj && obj[field] !== '' && obj[field] != null && !isUrl(obj[field])) { fail( @@ -690,7 +701,7 @@ function validateTrust(file: string): void { requireNonEmpty(ctx, uptime, 'eyebrow'); requireNonEmpty(ctx, uptime, 'fallbackPercent'); requireNonEmpty(ctx, uptime, 'windowLabel'); - requireUrl(ctx, uptime, 'statusPageUrl'); + requireUrlOrInternalPath(ctx, uptime, 'statusPageUrl'); } } } diff --git a/scripts/validate-jsonld.mjs b/scripts/validate-jsonld.mjs index 2c4262b..923d252 100644 --- a/scripts/validate-jsonld.mjs +++ b/scripts/validate-jsonld.mjs @@ -247,11 +247,6 @@ if (wave) { name: 'Prepare your proposal', text: wave.howToApply ?? 'Include a clear scope, timeline, and budget breakdown.', }, - { - name: 'Submit on Drips', - text: `Open the Drips grant page and submit before the wave closes: ${wave.applyUrl}`, - url: wave.applyUrl, - }, { name: 'Await review', text: `Proposals are reviewed against: ${(wave.reviewCriteria ?? []).join(' ')}`, diff --git a/src/__tests__/Footer.test.tsx b/src/__tests__/Footer.test.tsx index 2caa44f..2d09109 100644 --- a/src/__tests__/Footer.test.tsx +++ b/src/__tests__/Footer.test.tsx @@ -26,7 +26,7 @@ describe('Footer status badge', () => { expect(await screen.findByText(/all systems normal/i)).toBeInTheDocument(); expect(screen.getByRole('link', { name: /open wraith protocol status page/i })).toHaveAttribute( 'href', - 'https://status.usewraith.xyz', + '/status', ); }); }); diff --git a/src/__tests__/grants.test.tsx b/src/__tests__/grants.test.tsx index 3869fc6..a6d24cb 100644 --- a/src/__tests__/grants.test.tsx +++ b/src/__tests__/grants.test.tsx @@ -30,17 +30,14 @@ describe('Grants page', () => { } }); - it('renders the apply button with correct URL when wave is open', async () => { + it('does not render a stale grant application CTA', async () => { window.history.replaceState({}, '', '/grants'); render(); - const currentWave = waveData.currentWave; - if (currentWave?.status === 'open') { - const applyLink = await screen.findByRole('link', { name: /apply on drips/i }); - expect(applyLink).toHaveAttribute('href', currentWave.applyUrl); - expect(applyLink).toHaveAttribute('target', '_blank'); - } + await screen.findByRole('heading', { level: 1, name: /build private payments/i }); + + expect(screen.queryByRole('link', { name: /apply on drips/i })).not.toBeInTheDocument(); }); it('renders eligibility and review criteria lists', async () => { diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index d7ffcef..5fbb35c 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -19,7 +19,7 @@ const acknowledgments = [ { label: 'Drips', src: '/logos/drips-mark.svg' }, ]; -const statusPageUrl = import.meta.env.VITE_STATUS_PAGE_URL || 'https://status.usewraith.xyz'; +const statusPageUrl = import.meta.env.VITE_STATUS_PAGE_URL || '/status'; const statusApiUrl = import.meta.env.VITE_STATUS_API_URL || ''; function normalizeStatus(payload: unknown): StatusState { diff --git a/src/data/chains.json b/src/data/chains.json index 98a0760..87b3290 100644 --- a/src/data/chains.json +++ b/src/data/chains.json @@ -21,7 +21,7 @@ "wallets": "Horizen Wallet, MetaMask (EON)", "status": "live", "audit": "In-house + third-party", - "docs": "https://docs.usewraith.xyz/chains/horizen", + "docs": "https://docs.usewraith.xyz/sdk/chains/evm", "description": "Horizen EON is an EVM-compatible sidechain with low fees and fast block times. Wraith stealth addresses are fully operational on EON mainnet with SDK support for send and scan flows." }, { @@ -33,7 +33,7 @@ "wallets": "Freighter,LOBSTR,Rabet", "status": "live", "audit": "Stellar Development Foundation audits", - "docs": "https://docs.usewraith.xyz/chains/stellar", + "docs": "https://docs.usewraith.xyz/sdk/chains/stellar", "description": "Stellar offers sub-cent transaction fees and deterministic finality. Wraith stealth addresses on Stellar use the Soroban smart contract layer for on-chain scanning and withdrawal." }, { @@ -45,7 +45,7 @@ "wallets": "Phantom, Solflare, Backpack", "status": "devnet", "audit": "In progress", - "docs": "https://docs.usewraith.xyz/chains/solana", + "docs": "https://docs.usewraith.xyz/sdk/chains/solana", "description": "Solana provides high throughput with sub-second block times. Wraith stealth addresses on Solana are in active development on devnet, targeting low-latency privacy transfers." }, { @@ -57,7 +57,7 @@ "wallets": "Neuron, JoyID", "status": "testnet", "audit": "Nervos Foundation audits", - "docs": "https://docs.usewraith.xyz/chains/nervos", + "docs": "https://docs.usewraith.xyz/sdk/chains/ckb", "description": "Nervos CKB uses a UTXO model with RISC-V smart contracts. Wraith stealth addresses leverage CKB's cell model for on-chain lock ownership verification." }, { @@ -69,7 +69,7 @@ "wallets": "MetaMask, Ledger, Rainbow, Coinbase Wallet", "status": "planned", "audit": "Pending", - "docs": "https://docs.usewraith.xyz/chains/ethereum", + "docs": "https://docs.usewraith.xyz/sdk/chains/evm", "description": "Ethereum mainnet support is planned for a future release. Higher fees are offset by the deepest wallet ecosystem and largest DeFi surface." }, { @@ -81,7 +81,7 @@ "wallets": "MetaMask, Ledger, Rainbow", "status": "planned", "audit": "Pending", - "docs": "https://docs.usewraith.xyz/chains/polygon", + "docs": "https://docs.usewraith.xyz/sdk/chains/evm", "description": "Polygon PoS offers EVM compatibility with low fees. Wraith support is planned to extend stealth payments to Polygon's growing DeFi ecosystem." }, { @@ -93,7 +93,7 @@ "wallets": "MetaMask, Coinbase Wallet, Rainbow", "status": "planned", "audit": "Pending", - "docs": "https://docs.usewraith.xyz/chains/base", + "docs": "https://docs.usewraith.xyz/sdk/chains/evm", "description": "Base is an Optimism-powered L2 with Coinbase distribution. Planned Wraith integration targets the L2's growing developer and consumer app ecosystem." } ] diff --git a/src/data/ecosystem.json b/src/data/ecosystem.json index 42307a4..5b59d80 100644 --- a/src/data/ecosystem.json +++ b/src/data/ecosystem.json @@ -86,7 +86,7 @@ "shortName": "Aquarius", "description": "Liquidity aggregator and DEX on Stellar offering best-price routing for private stealth swaps via Wraith.", "category": "dexes", - "link": "https://aquarius.fi", + "link": "https://aqua.network", "logo": "/logos/ecosystem/aquarius.svg", "width": 28 }, diff --git a/src/data/trust.json b/src/data/trust.json index 86cdb26..4338ee6 100644 --- a/src/data/trust.json +++ b/src/data/trust.json @@ -23,6 +23,6 @@ "eyebrow": "Reliability", "fallbackPercent": "99.95", "windowLabel": "Last 90 days", - "statusPageUrl": "https://status.usewraith.xyz" + "statusPageUrl": "/status" } } diff --git a/src/pages/Grants.tsx b/src/pages/Grants.tsx index 3c6b497..7c21bd1 100644 --- a/src/pages/Grants.tsx +++ b/src/pages/Grants.tsx @@ -29,11 +29,6 @@ const grantsHowTo = howTo({ currentWave?.howToApply ?? 'Include a clear scope, timeline, and budget breakdown with defined milestones.', }, - { - name: 'Submit on Drips', - text: `Open the Drips grant page and submit your proposal before the wave closes. Apply here: ${currentWave?.applyUrl}`, - url: currentWave?.applyUrl, - }, { name: 'Await review', text: `The Wraith team reviews proposals against the published review criteria: ${( @@ -118,15 +113,6 @@ export default function Grants() {

{currentWave.howToApply}

- - Apply on Drips - diff --git a/src/pages/Stellar.tsx b/src/pages/Stellar.tsx index 0930c36..847884a 100644 --- a/src/pages/Stellar.tsx +++ b/src/pages/Stellar.tsx @@ -276,7 +276,7 @@ export default function Stellar() { Try Stellar Demo track('cta_click', { source: 'stellar-docs' })} @@ -595,7 +595,7 @@ export default function Stellar() { Try Stellar Demo track('cta_click', { source: 'stellar-docs' })} diff --git a/tests/link-checker.test.mjs b/tests/link-checker.test.mjs index d1c1200..8e0e3d8 100644 --- a/tests/link-checker.test.mjs +++ b/tests/link-checker.test.mjs @@ -274,34 +274,94 @@ describe('production link checker', () => { }); describe('external requests', () => { - it('uses GET, follows redirects, and retries transient server failures', async () => { + it('uses GET with manual redirects and retries transient server failures', async () => { const fetchUrl = vi .fn() .mockResolvedValueOnce(new Response('', { status: 503 })) .mockResolvedValueOnce(new Response('', { status: 200 })); + expect(await checkExternal('https://example.org/', fetchUrl)).toEqual({ ok: true, status: 200, }); + expect(fetchUrl).toHaveBeenCalledTimes(2); expect(fetchUrl.mock.calls[0][1]).toMatchObject({ - redirect: 'follow', + redirect: 'manual', signal: expect.any(AbortSignal), }); }); it('does not retry 404s and reports exhausted network failures', async () => { const fetchUrl = vi.fn().mockResolvedValue(new Response('', { status: 404 })); + expect(await checkExternal('https://example.org/', fetchUrl)).toEqual({ ok: false, status: 404, }); + expect(fetchUrl).toHaveBeenCalledTimes(1); + fetchUrl.mockReset().mockRejectedValue(new Error('offline')); + expect(await checkExternal('https://example.org/', fetchUrl)).toMatchObject({ ok: false, status: 'network', }); + expect(fetchUrl).toHaveBeenCalledTimes(2); }); + + it('blocks private and link-local destinations before making a request', async () => { + const fetchUrl = vi.fn(); + + for (const url of [ + 'http://127.0.0.1/', + 'http://10.0.0.1/', + 'http://192.168.1.1/', + 'http://169.254.169.254/', + 'http://[::1]/', + 'http://[fe80::1]/', + ]) { + const result = await checkExternal(url, fetchUrl); + + expect(result).toMatchObject({ + ok: false, + status: 'network', + }); + + expect(result.detail).toMatch(/Blocked private or link-local destination/); + } + + expect(fetchUrl).not.toHaveBeenCalled(); + }); + + it('blocks redirects to private and link-local destinations', async () => { + const fetchUrl = vi.fn().mockResolvedValueOnce( + new Response('', { + status: 302, + headers: { + location: 'http://169.254.169.254/latest/meta-data/', + }, + }), + ); + + const resolveHost = vi.fn().mockResolvedValue([ + { + address: '93.184.216.34', + family: 4, + }, + ]); + + const result = await checkExternal('https://example.org/', fetchUrl, resolveHost); + + expect(result).toMatchObject({ + ok: false, + status: 'network', + }); + + expect(result.detail).toMatch(/Blocked private or link-local destination/); + + expect(fetchUrl).toHaveBeenCalledTimes(1); + }); }); From a91e6b46d3f8a8b7b7ce9ded5bc1d371306f0ac6 Mon Sep 17 00:00:00 2001 From: Daveamhs Date: Wed, 7 Oct 2026 13:11:37 +0100 Subject: [PATCH 4/5] test: mock DNS resolution in link checker tests --- tests/link-checker.test.mjs | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/link-checker.test.mjs b/tests/link-checker.test.mjs index 8e0e3d8..09731b0 100644 --- a/tests/link-checker.test.mjs +++ b/tests/link-checker.test.mjs @@ -274,13 +274,20 @@ describe('production link checker', () => { }); describe('external requests', () => { + const resolvePublicHost = vi.fn().mockResolvedValue([ + { + address: '93.184.216.34', + family: 4, + }, + ]); + it('uses GET with manual redirects and retries transient server failures', async () => { const fetchUrl = vi .fn() .mockResolvedValueOnce(new Response('', { status: 503 })) .mockResolvedValueOnce(new Response('', { status: 200 })); - expect(await checkExternal('https://example.org/', fetchUrl)).toEqual({ + expect(await checkExternal('https://example.org/', fetchUrl, resolvePublicHost)).toEqual({ ok: true, status: 200, }); @@ -295,7 +302,7 @@ describe('external requests', () => { it('does not retry 404s and reports exhausted network failures', async () => { const fetchUrl = vi.fn().mockResolvedValue(new Response('', { status: 404 })); - expect(await checkExternal('https://example.org/', fetchUrl)).toEqual({ + expect(await checkExternal('https://example.org/', fetchUrl, resolvePublicHost)).toEqual({ ok: false, status: 404, }); @@ -304,7 +311,7 @@ describe('external requests', () => { fetchUrl.mockReset().mockRejectedValue(new Error('offline')); - expect(await checkExternal('https://example.org/', fetchUrl)).toMatchObject({ + expect(await checkExternal('https://example.org/', fetchUrl, resolvePublicHost)).toMatchObject({ ok: false, status: 'network', }); @@ -346,14 +353,7 @@ describe('external requests', () => { }), ); - const resolveHost = vi.fn().mockResolvedValue([ - { - address: '93.184.216.34', - family: 4, - }, - ]); - - const result = await checkExternal('https://example.org/', fetchUrl, resolveHost); + const result = await checkExternal('https://example.org/', fetchUrl, resolvePublicHost); expect(result).toMatchObject({ ok: false, From 6d1464cfac873a9da2c08caba0f772e09393c460 Mon Sep 17 00:00:00 2001 From: Daveamhs Date: Thu, 8 Oct 2026 11:32:25 +0100 Subject: [PATCH 5/5] fix: correct localized navigation and link checker tests --- src/components/Header.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 2fe5bcd..1b590be 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { useTheme } from '../context/ThemeContext'; import { changeLocale, Locale } from '../i18n'; -import { useLocalePath } from '../hooks/useLocalePath'; +import { useLocalizedPath } from '../hooks/useLocalePath'; import { trackOutbound } from '../utils/track'; export default function Header() { @@ -12,7 +12,7 @@ export default function Header() { const { theme, toggleTheme } = useTheme(); const menuButtonRef = useRef(null); const menuRef = useRef(null); - const lp = useLocalePath(); + const lp = useLocalizedPath(); const currentLocale = (i18n.language.split('-')[0] ?? 'en') as Locale; const nextLocale: Locale = currentLocale === 'en' ? 'es' : 'en';