From 47909947a23628192a5a6e90bcdefc936028f02b Mon Sep 17 00:00:00 2001 From: Paul Schmiedmayer Date: Mon, 12 Oct 2026 14:27:03 -0700 Subject: [PATCH] Verify Blocked Documentation Links With Public Probes --- .github/actionlint.yaml | 1 + .github/workflows/link-check-regressions.yml | 47 +++++ .github/workflows/markdown-links.yml | 13 +- .github/workflows/repository-standards.yml | 10 +- .github/workflows/self-standards.yml | 3 + .gitignore | 3 + README.md | 37 +++- actions/markdown-links/action.yml | 56 +++++ actions/markdown-links/check_links.py | 206 +++++++++++++++++++ actions/markdown-links/live_check.py | 77 +++++++ actions/markdown-links/test_check_links.py | 185 +++++++++++++++++ 11 files changed, 631 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/link-check-regressions.yml create mode 100644 actions/markdown-links/action.yml create mode 100644 actions/markdown-links/check_links.py create mode 100644 actions/markdown-links/live_check.py create mode 100644 actions/markdown-links/test_check_links.py diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml index 638160a..ce5b137 100644 --- a/.github/actionlint.yaml +++ b/.github/actionlint.yaml @@ -3,3 +3,4 @@ paths: .github/workflows/**: ignore: - 'reusable workflow call "\$/.*" at "uses" is not following the format' + - 'specifying action "\$/actions/markdown-links" in invalid format because ref is missing' diff --git a/.github/workflows/link-check-regressions.yml b/.github/workflows/link-check-regressions.yml new file mode 100644 index 0000000..326ec8b --- /dev/null +++ b/.github/workflows/link-check-regressions.yml @@ -0,0 +1,47 @@ +# This source file is part of the Schmiedmayer Lab open-source organization +# SPDX-FileCopyrightText: 2026 Schmiedmayer Lab and the project authors (see CONTRIBUTORS.md) +# SPDX-License-Identifier: MIT + +name: Link Check Regressions + +on: + pull_request: + push: + branches: [main] + schedule: + - cron: '23 9 * * 1' + workflow_dispatch: + +permissions: + contents: read + +jobs: + live-links: + name: Bluetooth, Zenodo, and Broken-Link Detection + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - name: Prepare isolated link fixtures + run: python3 actions/markdown-links/live_check.py prepare "$RUNNER_TEMP/link-fixture" + - name: Run the shared action, expecting the broken-link failure + id: links + continue-on-error: true + uses: $/actions/markdown-links + with: + working_directory: ${{ runner.temp }}/link-fixture + globalping_hosts: 'www.bluetooth.com,zenodo.org' + - name: Verify live results and the expected failure + if: ${{ !cancelled() }} + env: + LINK_EVIDENCE: ${{ steps.links.outputs.evidence }} + LINK_OUTCOME: ${{ steps.links.outcome }} + run: python3 actions/markdown-links/live_check.py verify "$LINK_EVIDENCE" --outcome "$LINK_OUTCOME" + - name: Retain strict live verification + if: ${{ always() && steps.links.outputs.evidence != '' }} + uses: actions/upload-artifact@v7 + with: + name: live-link-verification + path: ${{ steps.links.outputs.evidence }}/live-verification.json + if-no-files-found: warn + retention-days: 7 diff --git a/.github/workflows/markdown-links.yml b/.github/workflows/markdown-links.yml index 459711e..aa3e0a9 100644 --- a/.github/workflows/markdown-links.yml +++ b/.github/workflows/markdown-links.yml @@ -17,6 +17,13 @@ on: required: false type: string default: '["ubuntu-latest"]' + globalping_hosts: + description: | + Comma-separated public documentation hosts whose HTTP 403 results may be checked by Globalping. + Measurement URLs and results become public. Empty disables the fallback. + required: false + type: string + default: '' permissions: contents: read @@ -79,8 +86,6 @@ jobs: print(f"Private repository; ignoring {pattern}") PY - name: Linkspector - uses: umbrelladocs/action-linkspector@v1 + uses: $/actions/markdown-links with: - filter_mode: nofilter - reporter: github-pr-annotations - fail_level: error + globalping_hosts: ${{ inputs.globalping_hosts }} diff --git a/.github/workflows/repository-standards.yml b/.github/workflows/repository-standards.yml index fbb32e9..b5b30c6 100644 --- a/.github/workflows/repository-standards.yml +++ b/.github/workflows/repository-standards.yml @@ -18,6 +18,13 @@ on: required: false type: string default: '["ubuntu-latest"]' + globalping_hosts: + description: | + Comma-separated public documentation hosts permitted for the optional Globalping HTTP 403 fallback. + Measurement URLs and results become public. Empty disables the fallback. + required: false + type: string + default: '' permissions: contents: read @@ -35,9 +42,10 @@ jobs: links: name: Docs - uses: SchmiedmayerLab/.github/.github/workflows/markdown-links.yml@v0.5 + uses: $/.github/workflows/markdown-links.yml with: runs_on_labels: ${{ inputs.runs_on_labels }} + globalping_hosts: ${{ inputs.globalping_hosts }} actions: name: Actions diff --git a/.github/workflows/self-standards.yml b/.github/workflows/self-standards.yml index c63e769..6e065e0 100644 --- a/.github/workflows/self-standards.yml +++ b/.github/workflows/self-standards.yml @@ -31,6 +31,9 @@ jobs: steps: - uses: actions/checkout@v7 + - name: Link fallback regressions + run: python3 -m unittest discover -s actions/markdown-links -p 'test_*.py' -v + - name: The README matches the reusable workflows run: | python3 -c 'import yaml' 2>/dev/null || python3 -m pip install --quiet pyyaml diff --git a/.gitignore b/.gitignore index 8d59a2e..1a656c3 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ # editors .idea/ + +# Python test and action caches +__pycache__/ diff --git a/README.md b/README.md index d023f28..6f5a2ab 100644 --- a/README.md +++ b/README.md @@ -162,14 +162,34 @@ jobs: ##### Check Markdown Links [`markdown-links.yml`](.github/workflows/markdown-links.yml) checks Markdown links with Linkspector. -Use it for documentation-heavy repositories. The workflow needs read access to pull requests so -Linkspector can map diagnostics to the pull request diff. +Use it for documentation-heavy repositories. Findings appear as workflow annotations, not PR comments. +It uses Linkspector's latest `0.5.x` release and the runner's installed Google Chrome. GitHub-hosted +Ubuntu runners provide Chrome; custom runners must provide it with a working browser sandbox. Linkspector browses anonymously, so in a **private** repository every link back into that repository — badge targets, `blob/main/LICENSE.md` — answers 404 no matter what the file tree contains. The workflow therefore adds an ignore pattern for the repository's own URL when the repository is private, and leaves every other link checked. +For sites that block the initial browser check, `globalping_hosts` enables a second check of +HTTP 403 results from those exact hosts. Linkspector still checks them first; this is not an +ignore list. The fallback uses [Globalping](https://globalping.io/) without an account or API key. +**Measurement URLs and results are public.** Opt in only hosts whose documentation paths may be +disclosed. HTTPS URLs with credentials, query strings, fragments, or nonstandard ports are never +submitted. Other hosts and error codes retain their original failure behavior. + +The fallback asks two non-datacenter probes to GET the exact path. Two successful responses with +verified TLS resolve the finding; two 404/410 responses fail the check. Redirects, mixed responses, +blocked requests, API failures, and exhausted quotas remain **unverified warnings**. They do not +block a merge and must not be interpreted as successful verification. This verifies HTTP +reachability, not page contents or JavaScript-rendered content. The probes are community operated +and may share a network; they do not provide independent security attestations. + +At most ten unique URLs are submitted per run (two probes each); repeated references share one +result. The job summary lists outcomes and measurement links, and a seven-day artifact retains +the original Linkspector report and processed results. Checker failures and unreadable reports +fail the job. Leaving `globalping_hosts` empty preserves strict Linkspector results. + ```yml jobs: markdown-links: @@ -178,8 +198,21 @@ jobs: permissions: contents: read pull-requests: read + with: + globalping_hosts: 'www.bluetooth.com,zenodo.org' ``` +`repository-standards.yml` accepts and forwards the same optional input. Existing callers need +an updated shared-workflow release before enabling it. Self-repository references keep the +workflow and action at the same commit, independently of the caller's checkout. + +`Link Check Regressions` runs on pull requests, pushes to `main`, and weekly. It exercises the +shared action against Bluetooth, Zenodo, a valid local document, and a deliberately missing +document. A separate live fallback check requires both public URLs to return successful +responses with verified TLS; unverified warnings fail this regression. Unit tests cover +blocked probes, outages, malformed reports, and preservation of other link errors without +depending on external services. Reports are retained for seven days; no PR comments are posted. + ##### Run Periphery [`periphery.yml`](.github/workflows/periphery.yml) runs Periphery to detect unused Swift declarations. diff --git a/actions/markdown-links/action.yml b/actions/markdown-links/action.yml new file mode 100644 index 0000000..8cea531 --- /dev/null +++ b/actions/markdown-links/action.yml @@ -0,0 +1,56 @@ +# This source file is part of the Schmiedmayer Lab open-source organization +# SPDX-FileCopyrightText: 2026 Schmiedmayer Lab and the project authors (see CONTRIBUTORS.md) +# SPDX-License-Identifier: MIT + +name: Check Markdown Links +description: Run Linkspector once and optionally verify public HTTP 403 results through Globalping. +inputs: + working_directory: + description: Directory containing the Markdown files and Linkspector configuration. + default: '.' + globalping_hosts: + description: Comma-separated public documentation hosts permitted for public Globalping measurements. + default: '' +outputs: + evidence: + description: Directory containing the original and processed link reports. + value: ${{ steps.check.outputs.evidence }} +runs: + using: composite + steps: + - uses: actions/setup-node@v7 + with: + node-version: 24 + package-manager-cache: false + - name: Check links + id: check + shell: bash + working-directory: ${{ inputs.working_directory }} + env: + CHECK_ACTION_PATH: ${{ github.action_path }} + GLOBALPING_HOSTS: ${{ inputs.globalping_hosts }} + run: | + set -euo pipefail + tools_dir=$(mktemp -d "$RUNNER_TEMP/link-tools.XXXXXX") + evidence_dir=$(mktemp -d "$RUNNER_TEMP/link-evidence.XXXXXX") + echo "evidence=$evidence_dir" >> "$GITHUB_OUTPUT" + npm install --prefix "$tools_dir" --ignore-scripts --no-audit --no-fund @umbrelladocs/linkspector@0.5 + # The system Chrome has Ubuntu's sandbox configuration; downloaded test + # builds can be blocked by AppArmor's user-namespace restrictions. + if [ -z "${PUPPETEER_EXECUTABLE_PATH:-}" ]; then + PUPPETEER_EXECUTABLE_PATH=$(command -v google-chrome) + fi + export PUPPETEER_EXECUTABLE_PATH + "$PUPPETEER_EXECUTABLE_PATH" --version + checker_status=0 + "$tools_dir/node_modules/.bin/linkspector" check --json > "$evidence_dir/linkspector.json" || checker_status=$? + python3 "$CHECK_ACTION_PATH/check_links.py" \ + --report "$evidence_dir/linkspector.json" --checker-exit-code "$checker_status" \ + --hosts "$GLOBALPING_HOSTS" --output "$evidence_dir/results.json" + - name: Retain link results + if: ${{ always() && steps.check.outputs.evidence != '' }} + uses: actions/upload-artifact@v7 + with: + name: markdown-links-${{ github.job }} + path: ${{ steps.check.outputs.evidence }} + retention-days: 7 diff --git a/actions/markdown-links/check_links.py b/actions/markdown-links/check_links.py new file mode 100644 index 0000000..74a9a84 --- /dev/null +++ b/actions/markdown-links/check_links.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +# This source file is part of the Schmiedmayer Lab open-source organization +# SPDX-FileCopyrightText: 2026 Schmiedmayer Lab and the project authors (see CONTRIBUTORS.md) +# SPDX-License-Identifier: MIT + +"""Resolve opted-in public 403 diagnostics without suppressing other link errors. + +Linkspector 0.5 embeds URL/status in RDJSON messages. Unknown messages remain +errors. Globalping measurements are public; never submit credentials or queries. +""" + +from __future__ import annotations + +import argparse +from dataclasses import asdict, dataclass +from enum import Enum +import html +import json +import os +from pathlib import Path +import re +import time +from urllib.error import URLError +from urllib.parse import urlsplit +from urllib.request import Request, build_opener, HTTPRedirectHandler + + +API = "https://api.globalping.io/v1/measurements" +FORBIDDEN = re.compile(r"Cannot reach (https?://\S+) Status: 403(?: .*)?", re.DOTALL) +HOST = re.compile(r"(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}") +MAX_URLS = 10 + + +class Outcome(str, Enum): + VERIFIED = "verified" + BROKEN = "broken" + UNVERIFIED = "unverified" + + +@dataclass(frozen=True) +class Finding: + outcome: Outcome + reason: str + measurement: str = "" + + +class NoRedirects(HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + +def request_json(url: str, payload: dict | None = None) -> dict: + # Only contact the fixed API, never a URL supplied by a report or response. + request = Request(url, data=json.dumps(payload).encode() if payload is not None else None, + headers={"Content-Type": "application/json", "User-Agent": "SchmiedmayerLab-link-check/1.0"}) + with build_opener(NoRedirects()).open(request, timeout=10) as response: + value = json.load(response) + if not isinstance(value, dict): + raise ValueError("Invalid Globalping response") + return value + + +def allowed_hosts(value: str) -> set[str]: + hosts = {host.strip().lower() for host in value.split(",") if host.strip()} + if any(not HOST.fullmatch(host) or host.endswith((".localhost", ".local", ".internal")) for host in hosts): + raise ValueError("Fallback hosts must be exact public DNS names, without wildcards or URLs") + return hosts + + +def measurement_payload(url: str, hosts: set[str]) -> dict | None: + parts = urlsplit(url) + if (parts.scheme != "https" or parts.hostname not in hosts or parts.username is not None + or parts.password is not None or parts.port not in (None, 443) + or parts.query or parts.fragment or any(char.isspace() for char in url)): + return None + return {"type": "http", "target": parts.hostname, "limit": 2, + "locations": [{"magic": "eyeball-network"}], + "measurementOptions": {"protocol": "HTTPS", "request": {"method": "GET", "path": parts.path or "/"}}} + + +def classify_measurement(report: dict) -> Finding: + results = report.get("results", []) + if report.get("status") != "finished" or not isinstance(results, list) or len(results) != 2: + return Finding(Outcome.UNVERIFIED, "Incomplete measurement") + codes = [] + for entry in results: + result = entry.get("result", {}) + if result.get("status") != "finished" or (result.get("tls") or {}).get("authorized") is not True: + return Finding(Outcome.UNVERIFIED, "Probe failed or TLS was not verified") + code = result.get("statusCode") + if type(code) is not int: + return Finding(Outcome.UNVERIFIED, "Missing destination HTTP status") + codes.append(code) + if all(200 <= code < 300 for code in codes): + return Finding(Outcome.VERIFIED, "Both probes reached the URL with verified TLS") + if all(code in (404, 410) for code in codes): + return Finding(Outcome.BROKEN, "Both probes returned 404/410") + return Finding(Outcome.UNVERIFIED, "Probe HTTP statuses: " + ", ".join(map(str, codes))) + + +def verify(url: str, hosts: set[str]) -> Finding: + payload = measurement_payload(url, hosts) + if payload is None: + return Finding(Outcome.UNVERIFIED, "URL not eligible for public measurement") + measurement = "" + try: + created = request_json(API, payload) + identifier = created.get("id") + if not isinstance(identifier, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,100}", identifier): + raise ValueError("Invalid measurement ID") + measurement = f"{API}/{identifier}" + deadline = time.monotonic() + 40 + while time.monotonic() < deadline: + report = request_json(measurement) + if report.get("status") != "in-progress": + finding = classify_measurement(report) + return Finding(finding.outcome, finding.reason, measurement) + time.sleep(1) + return Finding(Outcome.UNVERIFIED, "Measurement timed out", measurement) + except (URLError, TimeoutError, ValueError, TypeError, AttributeError): + return Finding(Outcome.UNVERIFIED, "Globalping unavailable or returned invalid results", measurement) + + +def read_diagnostics(report: dict, exit_code: int) -> list[dict]: + if not isinstance(report, dict) or not isinstance(report.get("diagnostics"), list): + raise ValueError("Linkspector did not produce a complete RDJSON report") + diagnostics = report["diagnostics"] + for item in diagnostics: + if (not isinstance(item, dict) or not isinstance(item.get("message"), str) + or item.get("severity", report.get("severity")) not in ("ERROR", "WARNING", "INFO")): + raise ValueError("Invalid Linkspector diagnostic") + item.setdefault("severity", report.get("severity")) + errors = any(item["severity"] == "ERROR" for item in diagnostics) + if exit_code not in (0, 1) or (exit_code == 1) != errors: + raise ValueError("Linkspector failed unexpectedly or returned inconsistent results") + return diagnostics + + +def process(report: dict, exit_code: int, hosts: set[str]) -> dict: + diagnostics = read_diagnostics(report, exit_code) + findings: dict[str, Finding] = {} + for item in diagnostics: + match = FORBIDDEN.fullmatch(item["message"]) + if item["severity"] != "ERROR" or not match: + continue + url = match[1] + # Opt-in governs disclosure AND downgrading. All other diagnostics stay errors. + if measurement_payload(url, hosts) is None: + continue + if url not in findings: + findings[url] = (verify(url, hosts) if len(findings) < MAX_URLS else + Finding(Outcome.UNVERIFIED, "Fallback limit reached")) + finding = findings[url] + item["severity"] = {Outcome.VERIFIED: "INFO", Outcome.BROKEN: "ERROR", Outcome.UNVERIFIED: "WARNING"}[finding.outcome] + item["message"] = f"{finding.outcome.value}: {url} — {finding.reason}" + return {"diagnostics": diagnostics, "fallbacks": {url: asdict(result) for url, result in findings.items()}} + + +def escape_annotation(value: str, *, property_value: bool = False) -> str: + value = value.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") + return value.replace(",", "%2C").replace(":", "%3A") if property_value else value + + +def emit(result: dict) -> None: + for item in result["diagnostics"]: + level = {"ERROR": "error", "WARNING": "warning", "INFO": "notice"}[item["severity"]] + location = item.get("location", {}) + properties = "" + if isinstance(location.get("path"), str): + properties = " file=" + escape_annotation(location["path"], property_value=True) + line = location.get("range", {}).get("start", {}).get("line") + if type(line) is int and line > 0: + properties += f",line={line}" + print(f"::{level}{properties}::{escape_annotation(item['message'])}") + summary = os.environ.get("GITHUB_STEP_SUMMARY") + if summary: + with open(summary, "a") as file: + file.write("### External link verification\n\n") + for url, item in result["fallbacks"].items(): + file.write(f"- {item['outcome']}: {html.escape(url)} — {html.escape(item['reason'])}\n") + if item["measurement"]: + file.write(f" [Measurement]({item['measurement']})\n") + if not result["fallbacks"]: + file.write("No eligible HTTP 403 results required secondary verification.\n") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--report", type=Path, required=True) + parser.add_argument("--checker-exit-code", type=int, required=True) + parser.add_argument("--hosts", default="") + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + try: + report = json.loads(args.report.read_text()) + result = process(report, args.checker_exit_code, allowed_hosts(args.hosts)) + args.output.write_text(json.dumps(result, indent=2) + "\n") + emit(result) + return int(any(item["severity"] == "ERROR" for item in result["diagnostics"])) + except (OSError, ValueError, TypeError, AttributeError) as error: + print(f"::error::{escape_annotation(str(error))}") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/actions/markdown-links/live_check.py b/actions/markdown-links/live_check.py new file mode 100644 index 0000000..35f50b7 --- /dev/null +++ b/actions/markdown-links/live_check.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +# This source file is part of the Schmiedmayer Lab open-source organization +# SPDX-FileCopyrightText: 2026 Schmiedmayer Lab and the project authors (see CONTRIBUTORS.md) +# SPDX-License-Identifier: MIT + +"""Exercise the real shared action and require successful public-probe verification.""" + +import argparse +import json +from pathlib import Path + +import check_links as links + + +URLS = ( + "https://www.bluetooth.com/blog/a-developers-guide-to-bluetooth/", + "https://zenodo.org", +) +MISSING = "does-not-exist.md" + + +def prepare(directory: Path) -> None: + directory.mkdir() # Never replace an existing fixture or caller's documentation. + directory.joinpath("README.md").write_text( + "# Link regression fixtures\n\n" + + "\n".join(f"[Public documentation]({url})\n" for url in URLS) + + f"\n[Existing local document](present.md)\n\n[Missing local document]({MISSING})\n" + ) + directory.joinpath("present.md").write_text("# Existing document\n") + directory.joinpath(".linkspector.yml").write_text("dirs:\n - .\nuseGitIgnore: false\n") + + +def verify(directory: Path, outcome: str) -> None: + raw = json.loads(directory.joinpath("linkspector.json").read_text()) + processed = json.loads(directory.joinpath("results.json").read_text()) + diagnostics = links.read_diagnostics(raw, 1) + missing = [item for item in diagnostics if item["message"].startswith(f"Cannot reach {MISSING} Status: 404")] + if outcome != "failure" or len(missing) != 1: + raise ValueError("The action must fail on exactly one deliberate missing-file diagnostic") + remaining = [item for item in processed["diagnostics"] if item["severity"] != "INFO"] + if remaining != missing: + raise ValueError("Unexpected failures or unverified links in the shared action's results") + + # Exercise the fallback even if the runner can reach both sites directly. These + # synthetic 403s test the fallback path; they are not observations from Linkspector. + forced = {"diagnostics": [ + {"severity": "ERROR", "message": f"Cannot reach {url} Status: 403"} for url in URLS + ]} + result = links.process(forced, 1, {"www.bluetooth.com", "zenodo.org"}) + directory.joinpath("live-verification.json").write_text(json.dumps(result, indent=2) + "\n") + links.emit(result) + if set(result["fallbacks"]) != set(URLS) or any( + item["outcome"] != links.Outcome.VERIFIED for item in result["fallbacks"].values() + ): + raise ValueError("Both live URLs must be verified; warnings do not pass this regression") + print("Both public URLs verified; valid local links passed and missing local links still failed.") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("mode", choices=("prepare", "verify")) + parser.add_argument("directory", type=Path) + parser.add_argument("--outcome", default="") + args = parser.parse_args() + try: + if args.mode == "prepare": + prepare(args.directory) + else: + verify(args.directory, args.outcome) + return 0 + except (OSError, ValueError, KeyError, TypeError) as error: + print(f"::error::{links.escape_annotation(str(error))}") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/actions/markdown-links/test_check_links.py b/actions/markdown-links/test_check_links.py new file mode 100644 index 0000000..a5b0acc --- /dev/null +++ b/actions/markdown-links/test_check_links.py @@ -0,0 +1,185 @@ +# This source file is part of the Schmiedmayer Lab open-source organization +# SPDX-FileCopyrightText: 2026 Schmiedmayer Lab and the project authors (see CONTRIBUTORS.md) +# SPDX-License-Identifier: MIT + +import copy +import io +import json +from pathlib import Path +import tempfile +import unittest +from contextlib import redirect_stdout +from unittest.mock import patch +from urllib.error import HTTPError + +import check_links as links +import live_check + + +URL = "https://docs.example.org/guide/" +HOSTS = {"docs.example.org"} + + +def diagnostic(url=URL, code=403): + return {"severity": "ERROR", "message": f"Cannot reach {url} Status: {code}", + "location": {"path": "README.md", "range": {"start": {"line": 12}}}} + + +def measurement(*codes, authorized=True): + return {"status": "finished", "results": [ + {"result": {"status": "finished", "statusCode": code, "tls": {"authorized": authorized}}} + for code in codes]} + + +class FallbackTests(unittest.TestCase): + def test_payload_preserves_exact_path(self): + payload = links.measurement_payload(URL, HOSTS) + self.assertEqual(payload["measurementOptions"]["request"], {"method": "GET", "path": "/guide/"}) + self.assertEqual(payload["target"], "docs.example.org") + self.assertEqual(payload["locations"], [{"magic": "eyeball-network"}]) + self.assertEqual(payload["limit"], 2) + + def test_private_or_unsupported_urls_are_not_submitted(self): + urls = ["http://docs.example.org/", URL + "?key=private", URL + "#heading", + "https://user:password@docs.example.org/", "https://docs.example.org:8443/", + "https://docs.example.org.attacker.example/", "https://127.0.0.1/", + "https://169.254.169.254/", "https://other.example.org/"] + for url in urls: + with self.subTest(url=url), patch.object(links, "verify") as verify: + result = links.process({"diagnostics": [diagnostic(url)]}, 1, HOSTS) + verify.assert_not_called() + self.assertEqual(result["diagnostics"][0]["severity"], "ERROR") + + def test_host_configuration(self): + self.assertEqual(links.allowed_hosts("Docs.Example.org, docs.example.org"), HOSTS) + self.assertEqual(links.allowed_hosts(""), set()) + for value in ["*.example.org", "https://example.org", "localhost", "127.0.0.1", "api.local"]: + with self.subTest(value=value), self.assertRaises(ValueError): + links.allowed_hosts(value) + + def test_destination_status_not_api_success_determines_outcome(self): + cases = [((200, 200), links.Outcome.VERIFIED), ((404, 410), links.Outcome.BROKEN), + ((403, 403), links.Outcome.UNVERIFIED), ((200, 404), links.Outcome.UNVERIFIED), + ((301, 301), links.Outcome.UNVERIFIED), ((500, 500), links.Outcome.UNVERIFIED), + ((200,), links.Outcome.UNVERIFIED), ((), links.Outcome.UNVERIFIED)] + for codes, expected in cases: + with self.subTest(codes=codes): + self.assertEqual(links.classify_measurement(measurement(*codes)).outcome, expected) + + def test_incomplete_or_untrusted_tls_cannot_verify(self): + report = measurement(200, 200) + report["status"] = "in-progress" + self.assertEqual(links.classify_measurement(report).outcome, links.Outcome.UNVERIFIED) + self.assertEqual(links.classify_measurement(measurement(200, 200, authorized=False)).outcome, + links.Outcome.UNVERIFIED) + + def test_deduplicates_and_preserves_locations_and_other_errors(self): + second = diagnostic() + second["location"]["path"] = "docs/guide.md" + report = {"diagnostics": [diagnostic(), second, diagnostic(code=404), diagnostic(code=429)]} + original = copy.deepcopy(report) + with patch.object(links, "verify", return_value=links.Finding(links.Outcome.VERIFIED, "OK")) as verify: + result = links.process(report, 1, HOSTS) + verify.assert_called_once_with(URL, HOSTS) + self.assertEqual([item["severity"] for item in result["diagnostics"]], ["INFO", "INFO", "ERROR", "ERROR"]) + self.assertEqual([item["location"] for item in result["diagnostics"]], + [item["location"] for item in original["diagnostics"]]) + self.assertEqual(result["diagnostics"][2:], original["diagnostics"][2:]) + + def test_unverified_warns_but_confirmed_missing_fails(self): + for outcome, severity in [(links.Outcome.UNVERIFIED, "WARNING"), (links.Outcome.BROKEN, "ERROR")]: + with patch.object(links, "verify", return_value=links.Finding(outcome, "Result")): + result = links.process({"diagnostics": [diagnostic()]}, 1, HOSTS) + self.assertEqual(result["diagnostics"][0]["severity"], severity) + + def test_disabled_fallback_and_unknown_messages_stay_errors(self): + for hosts, item in [(set(), diagnostic()), (HOSTS, {"severity": "ERROR", "message": "Browser crashed"})]: + with patch.object(links, "verify") as verify: + result = links.process({"diagnostics": [item]}, 1, hosts) + verify.assert_not_called() + self.assertEqual(result["diagnostics"][0]["severity"], "ERROR") + + def test_crashes_and_invalid_reports_fail_closed(self): + for report, status in [({}, 0), ({"diagnostics": []}, 1), ({"diagnostics": [diagnostic()]}, 0), + ({"diagnostics": []}, 2), ({"diagnostics": ["bad"]}, 1)]: + with self.subTest(report=report, status=status), self.assertRaises(ValueError): + links.process(report, status, HOSTS) + self.assertEqual(links.process({"diagnostics": []}, 0, HOSTS)["diagnostics"], []) + + def test_request_budget(self): + report = {"diagnostics": [diagnostic(URL + str(index)) for index in range(12)]} + with patch.object(links, "verify", return_value=links.Finding(links.Outcome.VERIFIED, "OK")) as verify: + result = links.process(report, 1, HOSTS) + self.assertEqual(verify.call_count, links.MAX_URLS) + self.assertEqual(result["diagnostics"][-1]["severity"], "WARNING") + + def test_polling_uses_fixed_api_and_waits(self): + responses = [{"id": "measurement-1"}, {"status": "in-progress"}, measurement(200, 200)] + with patch.object(links, "request_json", side_effect=responses) as request, patch.object(links.time, "sleep") as sleep: + result = links.verify(URL, HOSTS) + self.assertEqual(result.outcome, links.Outcome.VERIFIED) + self.assertEqual(result.measurement, links.API + "/measurement-1") + self.assertEqual(request.call_count, 3) + sleep.assert_called_once_with(1) + + def test_api_failure_and_invalid_identifier_are_not_passes(self): + with patch.object(links, "request_json", side_effect=HTTPError(links.API, 429, "Quota", {}, None)): + self.assertEqual(links.verify(URL, HOSTS).outcome, links.Outcome.UNVERIFIED) + with patch.object(links, "request_json", return_value={"id": "https://attacker.example/"}) as request: + self.assertEqual(links.verify(URL, HOSTS).outcome, links.Outcome.UNVERIFIED) + self.assertEqual(request.call_count, 1) + + def test_timeout_is_bounded(self): + with patch.object(links, "request_json", return_value={"id": "test"}) as request: + with patch.object(links.time, "monotonic", side_effect=[0, 41]): + self.assertEqual(links.verify(URL, HOSTS).outcome, links.Outcome.UNVERIFIED) + self.assertEqual(request.call_count, 1) + + def test_annotations_escape_untrusted_content(self): + report = {"diagnostics": [{"severity": "ERROR", "message": "message\n::warning::injected", + "location": {"path": "file,unexpected=value"}}]} + output = io.StringIO() + with redirect_stdout(output), patch.dict(links.os.environ, {"GITHUB_STEP_SUMMARY": ""}): + links.emit(report) + self.assertEqual(len(output.getvalue().splitlines()), 1) + self.assertIn("file%2Cunexpected=value", output.getvalue()) + self.assertIn("%0A::warning", output.getvalue()) + + +class LiveRegressionTests(unittest.TestCase): + def test_fixture_contains_both_sites_and_local_controls(self): + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) / "fixture" + live_check.prepare(directory) + for url in live_check.URLS: + self.assertIn(url, directory.joinpath("README.md").read_text()) + self.assertTrue(directory.joinpath("present.md").is_file()) + self.assertFalse(directory.joinpath(live_check.MISSING).exists()) + with self.assertRaises(FileExistsError): + live_check.prepare(directory) + + def test_live_regression_requires_verified_results_and_a_real_failure(self): + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + report = {"diagnostics": [diagnostic(live_check.MISSING, 404)]} + directory.joinpath("linkspector.json").write_text(json.dumps(report)) + directory.joinpath("results.json").write_text(json.dumps(report)) + for outcome in links.Outcome: + with self.subTest(outcome=outcome), patch.object(links, "emit"): + with patch.object(links, "verify", return_value=links.Finding(outcome, "Test")): + if outcome == links.Outcome.VERIFIED: + with redirect_stdout(io.StringIO()): + live_check.verify(directory, "failure") + else: + with self.assertRaises(ValueError): + live_check.verify(directory, "failure") + with self.assertRaises(ValueError): + live_check.verify(directory, "success") + report["diagnostics"].append(diagnostic(code=429)) + directory.joinpath("results.json").write_text(json.dumps(report)) + with self.assertRaises(ValueError): + live_check.verify(directory, "failure") + + +if __name__ == "__main__": + unittest.main()