diff --git a/.editorconfig b/.editorconfig index 8c52ff9..7e851f1 100644 --- a/.editorconfig +++ b/.editorconfig @@ -8,5 +8,9 @@ indent_size = 2 insert_final_newline = true trim_trailing_whitespace = true +[*.cs] +indent_size = 4 +tab_width = 4 + [*.md] trim_trailing_whitespace = false diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 01cb5fd..c905e19 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,39 +8,25 @@ updates: open-pull-requests-limit: 5 versioning-strategy: increase groups: - react-and-router: - patterns: - [ - "react", - "react-dom", - "react-router", - "@react-router/*", - "@types/react", - "@types/react-dom", - ] contracts: - patterns: ["@arcforges/*", "@bufbuild/*", "@connectrpc/*"] + patterns: ["@arcforges/*"] cloudflare: patterns: ["wrangler", "@cloudflare/*"] tooling: patterns: ["*"] - exclude-patterns: - [ - "react", - "react-dom", - "react-router", - "@react-router/*", - "@types/react", - "@types/react-dom", - "@arcforges/*", - "@bufbuild/*", - "@connectrpc/*", - "wrangler", - "@cloudflare/*", - ] + exclude-patterns: ["@arcforges/*", "wrangler", "@cloudflare/*"] ignore: - dependency-name: "@types/node" versions: [">=25"] + - package-ecosystem: nuget + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + groups: + nuget: + patterns: ["*"] - package-ecosystem: github-actions directory: / schedule: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5011a5..8061f29 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,30 @@ concurrency: env: CI: "true" WRANGLER_SEND_METRICS: "false" + DOTNET_CLI_TELEMETRY_OPTOUT: "1" + DOTNET_NOLOGO: "1" + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: "1" + # The C# product projects. The browser test project (tests/browser) is local opt-in tooling and is never restored + # or built in CI (validation policy). win.slnx is not used here: the explicit project list is the CI authority. + CSHARP_PROJECTS: >- + src/ArcForges.Web.Ui/ArcForges.Web.Ui.csproj + src/ArcForges.Web.Site/ArcForges.Web.Site.csproj + src/ArcForges.Web.App/ArcForges.Web.App.csproj + src/ArcForges.Web.Operations/ArcForges.Web.Operations.csproj + tools/ArcForges.Web.Tooling/ArcForges.Web.Tooling.csproj + tests/ArcForges.Web.Tooling.Tests/ArcForges.Web.Tooling.Tests.csproj + tests/ArcForges.Web.Site.Tests/ArcForges.Web.Site.Tests.csproj + tests/ArcForges.Web.Ui.Tests/ArcForges.Web.Ui.Tests.csproj + tests/ArcForges.Web.App.Tests/ArcForges.Web.App.Tests.csproj + tests/ArcForges.Web.Operations.Tests/ArcForges.Web.Operations.Tests.csproj + tests/ArcForges.Web.Policy.Tests/ArcForges.Web.Policy.Tests.csproj + CSHARP_TEST_PROJECTS: >- + tests/ArcForges.Web.Tooling.Tests/ArcForges.Web.Tooling.Tests.csproj + tests/ArcForges.Web.Site.Tests/ArcForges.Web.Site.Tests.csproj + tests/ArcForges.Web.Ui.Tests/ArcForges.Web.Ui.Tests.csproj + tests/ArcForges.Web.App.Tests/ArcForges.Web.App.Tests.csproj + tests/ArcForges.Web.Operations.Tests/ArcForges.Web.Operations.Tests.csproj + tests/ArcForges.Web.Policy.Tests/ArcForges.Web.Policy.Tests.csproj jobs: source: @@ -39,6 +63,19 @@ jobs: cache: ${{ matrix.os == 'ubuntu-latest' && 'npm' || '' }} package-manager-cache: false - run: npm ci --ignore-scripts + # The candidate tests (tests/provenance/csharp-candidate.test.ts) run the built C# tool. Its Release build is produced + # here from the locked restore, so this job never depends on stale bin/ output from an earlier local run. + - name: Set up the pinned .NET SDK for the candidate tests + if: runner.os == 'Linux' + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 + with: + dotnet-version: "10.0.401" + - name: Build the C# tool for the candidate tests + if: runner.os == 'Linux' + run: | + set -euo pipefail + dotnet restore tools/ArcForges.Web.Tooling/ArcForges.Web.Tooling.csproj --locked-mode + dotnet build tools/ArcForges.Web.Tooling/ArcForges.Web.Tooling.csproj --no-restore -c Release - run: npm run check if: runner.os == 'Linux' - name: Set up the IDE project evaluation toolchain @@ -57,7 +94,80 @@ jobs: path: | artifacts/evidence/licence-*.json artifacts/evidence/source-provenance.json - artifacts/evidence/browser-graph.json + if-no-files-found: error + retention-days: 30 + + csharp: + name: C# restore, build, test and publish (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + persist-credentials: false + - name: Set up the pinned .NET SDK + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 + with: + dotnet-version: "10.0.401" + - name: Restore with the locked NuGet closure + run: | + set -euo pipefail + for project in $CSHARP_PROJECTS; do + dotnet restore "$project" --locked-mode + done + # The formatting gate of the C# sources (WEB.40 U5 replaces the removed Prettier and Biome gates). The text rules + # (final newline, no trailing whitespace) of the audited C#, MSBuild, npm and Worker inputs are enforced by the + # policy suite in the Test step, and the Worker type check runs in the source job (npm run typecheck). + - name: Check the C# formatting gate + run: | + set -euo pipefail + for project in $CSHARP_PROJECTS; do + dotnet format "$project" --verify-no-changes --no-restore + done + - name: Build + run: | + set -euo pipefail + for project in $CSHARP_PROJECTS; do + dotnet build "$project" --no-restore -c Release + done + - name: Test (no browser E2E; the browser project is not built here) + run: | + set -euo pipefail + for project in $CSHARP_TEST_PROJECTS; do + dotnet test "$project" --no-build -c Release + done + - name: Publish the Account and Chat profiles + run: | + set -euo pipefail + dotnet publish src/ArcForges.Web.App/ArcForges.Web.App.csproj --no-restore -c Release -o artifacts/profiles/app + - name: Publish the Operations profile skeleton + run: | + set -euo pipefail + dotnet publish src/ArcForges.Web.Operations/ArcForges.Web.Operations.csproj --no-restore -c Release -o artifacts/profiles/operations + - name: Build the static Site twice and compare the bytes + run: | + set -euo pipefail + tool=tools/ArcForges.Web.Tooling/bin/Release/net10.0/ArcForges.Web.Tooling.dll + dotnet "$tool" site build --out artifacts/site --source-ref "$GITHUB_SHA" + dotnet "$tool" site build --out artifacts/site-repeat --source-ref "$GITHUB_SHA" + diff -r artifacts/site artifacts/site-repeat + rm -rf artifacts/site-repeat + - name: Retain the C# candidate outputs + if: runner.os == 'Linux' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: web-csharp-${{ github.run_id }}-${{ github.run_attempt }} + path: | + artifacts/site + artifacts/profiles if-no-files-found: error retention-days: 30 @@ -109,22 +219,38 @@ jobs: strategy: fail-fast: false matrix: - language: [javascript-typescript, actions] + language: [javascript-typescript, actions, csharp] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 persist-credentials: false + - name: Set up the pinned .NET SDK for CodeQL + if: matrix.language == 'csharp' + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 + with: + dotnet-version: "10.0.401" - uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4 with: languages: ${{ matrix.language }} - build-mode: none + # C# is built explicitly with the locked restore below, so CodeQL observes the real compilation. + build-mode: ${{ matrix.language == 'csharp' && 'manual' || 'none' }} + - name: Restore and build the C# projects for CodeQL + if: matrix.language == 'csharp' + run: | + set -euo pipefail + for project in $CSHARP_PROJECTS; do + dotnet restore "$project" --locked-mode + done + for project in $CSHARP_PROJECTS; do + dotnet build "$project" --no-restore -c Release + done - uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4 with: category: /language:${{ matrix.language }} candidate: - name: Build static candidate + name: Build and verify the C# static candidate and profile bundle needs: [source, quality] runs-on: ubuntu-latest timeout-minutes: 20 @@ -141,9 +267,44 @@ jobs: node-version-file: .node-version cache: npm - run: npm ci --ignore-scripts - - run: npm run build - - name: Build, measure and gate the Account and Chat production profiles - run: npm run build:profiles + - name: Set up the pinned .NET SDK + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 + with: + dotnet-version: "10.0.401" + # WEB.40 deploy switch: the candidate is the C# static Site (tools/ArcForges.Web.Tooling, candidate build), sealed and + # verified before upload. The Worker JavaScript and the build identity are emitted by Node from the reviewed sources, + # because only Node can type-strip worker/index.ts and derive the identity from git. + - name: Restore the locked tooling and the Blazor profile application + run: | + set -euo pipefail + dotnet restore tools/ArcForges.Web.Tooling/ArcForges.Web.Tooling.csproj --locked-mode + dotnet build tools/ArcForges.Web.Tooling/ArcForges.Web.Tooling.csproj --no-restore -c Release + dotnet restore src/ArcForges.Web.App/ArcForges.Web.App.csproj --locked-mode + dotnet publish src/ArcForges.Web.App/ArcForges.Web.App.csproj --no-restore -c Release -o artifacts/publish/app + # The Blazor profile budgets (eng/policy/profile-budgets.json): every initial resource of the published application + # stays within its reviewed baseline plus the allowed regression. A breach fails the candidate before any bundle. + - name: Enforce the Blazor profile budgets + run: | + set -euo pipefail + dotnet tools/ArcForges.Web.Tooling/bin/Release/net10.0/ArcForges.Web.Tooling.dll profiles budget --publish artifacts/publish/app --budgets eng/policy/profile-budgets.json + - name: Emit the Worker and the build identity from the reviewed sources + run: | + set -euo pipefail + mkdir -p artifacts/inputs + node tooling/candidate.ts worker artifacts/inputs/index.js + node tooling/candidate.ts identity "0.1.0-ci.${GITHUB_RUN_NUMBER}.${GITHUB_RUN_ATTEMPT}" artifacts/inputs/build-info.json + - name: Build the sealed candidate twice and compare the bytes + run: | + set -euo pipefail + tool=tools/ArcForges.Web.Tooling/bin/Release/net10.0/ArcForges.Web.Tooling.dll + version="0.1.0-ci.${GITHUB_RUN_NUMBER}.${GITHUB_RUN_ATTEMPT}" + for out in artifacts/candidate artifacts/candidate-repeat; do + dotnet "$tool" candidate build --out "$out" --repo "$GITHUB_WORKSPACE" --source-ref "$GITHUB_SHA" --version "$version" --identity artifacts/inputs/build-info.json --worker artifacts/inputs/index.js --dirty false + done + diff -r artifacts/candidate artifacts/candidate-repeat + rm -rf artifacts/candidate-repeat + - name: Verify the sealed candidate before upload + run: node tooling/candidate.ts verify artifacts/candidate - id: upload uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: @@ -151,8 +312,13 @@ jobs: path: artifacts/candidate if-no-files-found: error retention-days: 30 - # CLOUD.71: the profile bundle written by build:profiles above (one deterministic, digest-named archive of the - # two profile builds), carried to the deployment job unchanged. It stays outside the sealed candidate directory. + # CLOUD.71: the profile bundle (one deterministic, digest-named archive of the Blazor application's publish), carried + # to the deployment job unchanged. It stays outside the sealed candidate directory. + - name: Build the Account and Chat profile bundle + run: | + set -euo pipefail + dotnet tools/ArcForges.Web.Tooling/bin/Release/net10.0/ArcForges.Web.Tooling.dll profiles bundle --publish artifacts/publish/app --out artifacts/profiles + dotnet tools/ArcForges.Web.Tooling/bin/Release/net10.0/ArcForges.Web.Tooling.dll profiles verify --bundle "$(ls artifacts/profiles/web-profiles-*.tar)" - id: upload-profiles if: github.event_name == 'push' && github.ref == 'refs/heads/main' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 @@ -165,13 +331,14 @@ jobs: verify: name: Verify if: always() - needs: [source, quality, dependency-review, codeql, candidate] + needs: [source, csharp, quality, dependency-review, codeql, candidate] runs-on: ubuntu-latest timeout-minutes: 2 steps: - name: Require every applicable check env: SOURCE: ${{ needs.source.result }} + CSHARP: ${{ needs.csharp.result }} QUALITY: ${{ needs.quality.result }} REVIEW: ${{ needs.dependency-review.result }} CODEQL: ${{ needs.codeql.result }} @@ -179,11 +346,14 @@ jobs: EVENT: ${{ github.event_name }} run: | test "$SOURCE" = success + test "$CSHARP" = success test "$QUALITY" = success test "$CODEQL" = success test "$CANDIDATE" = success if [ "$EVENT" = pull_request ]; then test "$REVIEW" = success; else test "$REVIEW" = skipped; fi + # Deployment step, outside validation (brief section 10). It ships the same sealed C# candidate that the candidate job + # built and verified, after verifying it again here; the Cloudflare upload guards are unchanged (main push only). deploy: name: Deploy Cloudflare if: >- @@ -208,6 +378,15 @@ jobs: node-version-file: .node-version cache: npm - run: npm ci --ignore-scripts + - name: Set up the pinned .NET SDK + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 + with: + dotnet-version: "10.0.401" + - name: Build the candidate verifier + run: | + set -euo pipefail + dotnet restore tools/ArcForges.Web.Tooling/ArcForges.Web.Tooling.csproj --locked-mode + dotnet build tools/ArcForges.Web.Tooling/ArcForges.Web.Tooling.csproj --no-restore -c Release - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: artifact-ids: ${{ needs.candidate.outputs.artifact-id }} @@ -227,9 +406,19 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | + set -euo pipefail VERSION=$(node --input-type=module -e 'import fs from "node:fs"; console.log(JSON.parse(fs.readFileSync("artifacts/candidate/manifest.json")).version)') + # The Site archive (web-site-.tar) is the candidate's one sealed root member, verified by npm run deploy above. + # It is published here beside web-profiles, and only by this main-push release. It is not a deployed asset. + shopt -s nullglob + site_archives=(artifacts/candidate/web-site-*.tar) + shopt -u nullglob + test "${#site_archives[@]}" = 1 + site_archive="${site_archives[0]}" + site_digest=$(sha256sum "$site_archive" | cut -d ' ' -f 1) + test "$(basename "$site_archive")" = "web-site-${site_digest}.tar" tar -czf "artifacts/web-${VERSION}.tar.gz" -C artifacts/candidate . - gh release create "web-${VERSION}" "artifacts/web-${VERSION}.tar.gz" artifacts/deployment.json artifacts/profiles/web-profiles-*.tar --target "$GITHUB_SHA" --title "Web ${VERSION}" --prerelease --generate-notes + gh release create "web-${VERSION}" "artifacts/web-${VERSION}.tar.gz" artifacts/deployment.json artifacts/profiles/web-profiles-*.tar "$site_archive" --target "$GITHUB_SHA" --title "Web ${VERSION}" --prerelease --generate-notes - name: Preserve deployment evidence if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 diff --git a/.gitleaks.toml b/.gitleaks.toml index f2f376f..e70ac2e 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -7,7 +7,7 @@ description = "Reviewed public source hashes in the immutable browser resource p condition = "AND" targetRules = ["generic-api-key"] regexTarget = "line" -paths = ['''^eng/provenance/profiles/browser-resources-r(?:[123456789]|10)\.json$'''] +paths = ['''^eng/provenance/profiles/browser-resources-r(?:[123456789]|1[01])\.json$'''] regexes = [ '''^\s*"(?:apps/site/)?node_modules/@bufbuild/protobuf/dist/esm/wkt/gen/google/protobuf/api_pb\.js": "73e489001027c703bc0224ae73f78ecefe028e88284bd06952e8603c3d81472c",?$''', '''^\s*"node_modules/@connectrpc/connect-web/dist/esm/assert-fetch-api\.js": "bd56033776818aaa82959c12561dd084d3a730180e6e8f1e681f5d3d439b6474",?$''', @@ -21,3 +21,13 @@ regexes = [ '''^\s*"node_modules/@arcforges/api-client/dist/shapes/gen/NativeTokenRequest\.js": "79d75ee79a5b6d52433e6425de6eefdaa1e829fd58ee2dcda204cf391e3517df",?$''', '''^\s*"node_modules/@arcforges/api-client/dist/shapes/gen/NativeTokenResponse\.js": "725cac5a5ceeba78f775804a26b8b5de99a7eeaf250d37335fc59bdd5ec58ce1",?$''', ] + +[[allowlists]] +description = "Reviewed public schema digest in the CON.07 Contracts publication record: the key name contains auth, and the value is the SHA-256 of the packaged public schema (verified against the NuGet package bytes)" +condition = "AND" +targetRules = ["generic-api-key"] +regexTarget = "line" +paths = ['''^eng/contracts/ArcForges\.Contracts\.PublicApi/1\.0\.0-ci\.287\.1/source\.json$'''] +regexes = [ + '''^\s*"public/http/v1/native-auth\.schema\.json": "e7807ae0ab6b4530d817941a922d7df90e4feb525c68d81a245a4c089ad3d296",?$''', +] diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index f70bfa9..0000000 --- a/.prettierignore +++ /dev/null @@ -1,11 +0,0 @@ -node_modules -.worktree -.wrangler -**/.react-router -**/build -artifacts -playwright-report -playwright-live-report -test-results -package-lock.json -LICENSE diff --git a/.prettierrc.json b/.prettierrc.json deleted file mode 100644 index 6b79255..0000000 --- a/.prettierrc.json +++ /dev/null @@ -1 +0,0 @@ -{ "printWidth": 100, "trailingComma": "all" } diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index 13a9d8e..0000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "version": "0.2.0", - "configurations": [ - { - "name": "ArcForges Web (Chrome)", - "type": "chrome", - "request": "launch", - "url": "http://127.0.0.1:5173", - "webRoot": "${workspaceFolder}/apps/site" - } - ] -} diff --git a/AGENTS.md b/AGENTS.md index 7ae2728..4fe0f26 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,7 @@ - Read README and the relevant development/deployment guide before changes. Use English for source and repository documentation. - Collect the relevant issues, decide a bounded plan, implement and verify. Use a separate worktree; leave adjacent repositories and primary checkouts unchanged. - Preserve AGPL-3.0-only and third-party notices. Consume exact published Contracts packages; no sibling source imports or submodules. -- Use the pinned Node/npm toolchain, strict TypeScript, one root workspace lockfile and the documented commands. The server connection page only prepares the published Hello packaging example; business APIs, sessions, billing and AI execution remain outside this Web bootstrap. +- Use the pinned .NET 10 SDK (10.0.401, `global.json`) with central NuGet management and locked restores, and the documented commands. C# is the implementation language of every product surface (P2-021): the static Site, the Blazor WebAssembly profiles and the Razor class library. TypeScript is only the thin Cloudflare Worker adapter (`worker/index.ts`); Node runs wrangler and the reviewed policy checks. The server connection page only prepares the published Hello packaging example; business APIs, sessions, billing and AI execution remain outside this Web bootstrap. - Keep PR checks credential-free. Never print or commit tokens or put private values in browser bundles. A Cloudflare API token belongs in the GitHub environment secret, not a client variable. - Build and verify once, then deploy those same bytes. Distinguish mocked contract tests, local production-browser tests, real Cloudflare delivery and future C# product integration. - Follow the user's authorization for remote settings/deployment. Do not merge a PR without authorization. @@ -21,6 +21,6 @@ Work is scheduled as delivery tasks in the [delivery graph](https://github.com/A ## Required validation limits -Follow [validation policy](docs/validation-policy.md), which supersedes older runtime and release-test requirements. Never add or execute macOS CI, device/emulator/GUI/browser E2E CI, live service or inference CI, installed-consumer CI or public-download verification. Keep runtime checks explicit local opt-in. Do not repeat public archive/hash checks, passing tests or post-merge runtime cycles. Preserve lock/signature/licence/provenance checks at actual trust handoffs. Do not invoke wsl.exe, configure proxy 7890 or install toolchains solely for testing. Stop and report the exact failed network operation. Hooks do not rebuild/test on commit or push. +Follow [validation policy](docs/validation-policy.md), which supersedes older runtime and release-test requirements. Never add or execute macOS CI, device/emulator/GUI/browser E2E CI, live service or inference CI, installed-consumer CI or public-download verification. Keep runtime checks explicit local opt-in. Do not repeat public archive/hash checks, passing tests or post-merge runtime cycles. Preserve lock/signature/licence/provenance checks at actual trust handoffs. Linux-affected checks may run once, scope-limited, in the local WSL2 Debian distribution through PowerShell on a Linux-native filesystem (P2-024 supersedes the earlier wsl.exe prohibition). Do not configure proxy 7890 or install toolchains solely for testing. Stop and report the exact failed network operation. Hooks do not rebuild/test on commit or push. Dependency additions and upgrades follow [the enforced admission policy](docs/dependency-policy.md); update its input-bound review and retain the existing class and provenance gates. diff --git a/ArcForges.Web.esproj b/ArcForges.Web.esproj deleted file mode 100644 index 746ac66..0000000 --- a/ArcForges.Web.esproj +++ /dev/null @@ -1,18 +0,0 @@ - - - npm run dev - npm run build && npm run build:profiles - false - true - $(MSBuildProjectDirectory)\artifacts\candidate - - - AGPL - AGPL-3.0-only - - - - - - - diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3d6f6f8..7194124 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ Work on a branch in a separate worktree. Read [development](docs/development.md) and the relevant product design before implementation. Keep changes bounded and describe observable behavior and acceptance criteria in the PR. -Use English in source and documentation, exact dependency versions, the committed lockfile, and the pinned toolchain. Install the worktree-local hooks with `npm run hooks`. Run relevant offline checks once. Hosted CI builds the candidate; browser/runtime checks are explicit local opt-in under [validation policy](docs/validation-policy.md). Include any unverified external configuration in the PR description. +Use English in source and documentation, exact dependency versions, the committed lockfile, and the pinned toolchain. C# is the language of the product: change C# projects through the explicit project list in the CI workflow (`dotnet restore --locked-mode`, build and test); the Node tooling covers the Worker adapter and the reviewed policy checks. Install the worktree-local hooks with `npm run hooks`. Run relevant offline checks once. Hosted CI builds the candidate; browser/runtime checks are explicit local opt-in under [validation policy](docs/validation-policy.md). Include any unverified external configuration in the PR description. Do not commit credentials, generated builds or private account data. Security issues use the [private reporting process](SECURITY.md). Dependency source and license notices must be retained; sibling repositories are consumed only through their published packages. diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..cab9efd --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,28 @@ + + + + net10.0 + 14 + enable + enable + true + true + true + true + latest + true + true + true + true + true + all + false + ArcForges + https://github.com/ArcForges/Web + AGPL + AGPL-3.0-only + + + + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..a01c7eb --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,19 @@ + + + + true + true + + + + + + + + + + + + + + diff --git a/NuGet.config b/NuGet.config new file mode 100644 index 0000000..4e800bf --- /dev/null +++ b/NuGet.config @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/README.md b/README.md index b0f6739..8b0cf33 100644 --- a/README.md +++ b/README.md @@ -1,61 +1,35 @@ # ArcForges Web -React and TypeScript Web foundation for the ArcForges family. This first increment contains a public Hello World site, an interactive local greeting, shared UI, published Contracts consumption and an automated Cloudflare Workers Static Assets delivery pipeline. +C#-first Web foundation for the ArcForges family (P2-021). The public Site is a static C# generator and publishes no JavaScript. The Account and Chat profiles are Blazor WebAssembly standalone applications. The Cloudflare Worker that serves the static assets is a thin TypeScript adapter. -It does not implement the planned Account/Chat application, authentication, payments or a C# backend; `apps/app` holds only two minimal production profiles that prove the build and the generated SDK usage ([record and limits](docs/prf-08-profile-proof.md)). The `/hello/` greeting runs locally and sends no name to a server. A separate `/cloud-hello/` page uses the published gRPC-Web client to call the deployed Cloud container after the user clicks **Check connection**. Business authority remains in ArcForges Cloud. +The public Site serves `/`, `/hello/` and `/cloud-hello/`. Its pages, texts, element structure, stylesheet bytes and security headers match the React prerender they replace, as recorded in [the parity record](docs/web-40-site-parity.md). Its interactive greeting and server-connection checks have no static equivalent; their placement is an open decision recorded there. -## Start locally +The Account and Chat profiles are not a product yet. They prove the cookie-session, CSRF, Origin and binary gRPC-Web paths, and are delivered as a digest-named profile bundle ([layout and rules](docs/profile-bundle.md)). The Operations profile is a skeleton on its own origin. -Use **Node 24.21.0 / npm 11.19.0**, matching `.node-version` and `packageManager`. +## Toolchain -```sh -npm ci --ignore-scripts -npm run hooks -npm run dev -``` +- .NET SDK **10.0.401** (`global.json`), with central NuGet package management and locked restores. +- Node.js **24.21.0** and npm **11.19.0** (`.node-version`, `packageManager`), for the Worker adapter, wrangler and the reviewed policy checks. +- Exact versions throughout: Blazor WebAssembly standalone with `RunAOTCompilation=false`, Contracts packages at **1.0.0-ci.287.1**, wrangler **4.143.1**, TypeScript **7.0.2**. -Open the local URL printed by React Router. Public pages are rendered at build time; there is no request-time Node server in the deployed artifact. A small Cloudflare Worker redirects `www.arcforges.com` to the canonical HTTPS apex before serving pages; other requests use the static asset binding. - -```sh -npm run check -npm run build -npm run preview -``` - -The preview serves the actual candidate through local Wrangler at `http://127.0.0.1:4173`. Browser checks are explicit local opt-in only when existing browser binaries support the affected behavior; do not install browsers to expand validation. No Cloudflare login is required for preview. +Start with [development](docs/development.md) for the commands and the solution layout. ## Layout -| Path | Purpose | -| ----------------------------------- | ------------------------------------------------------------------------ | -| `apps/site` | Prerendered home, local greeting and server connection pages | -| `apps/app` | Minimal Account and Chat production profiles (PRF.08 proof, not product) | -| `packages/ui` | Shared components and Tailwind/CSS styles | -| `worker` | Private canonical-host redirect and static asset fallback | -| `tooling` | TypeScript build, provenance, policy and Cloudflare delivery commands | -| `tests` | Unit, published SDK wire fixtures, browser and accessibility tests | -| `artifacts/candidate` | Ignored immutable delivery artifact, manifest and SBOMs | -| `win.slnx` / `ArcForges.Web.esproj` | Optional Visual Studio JavaScript project | - -Baseline: TypeScript **7.0.2**, React **19.3.0**, React Router **8.4.0**, Vite **8.3.0**, Tailwind **4.3.3**, Wrangler **4.143.1**. One committed npm lockfile covers the entire workspace. Contracts packages are pinned to **1.0.0-ci.287.1**; no submodules or adjacent source references are used. +| Path | Role | +| --- | --- | +| `src/ArcForges.Web.Site` | Static public Site generator (first-party Razor `HtmlRenderer`) | +| `src/ArcForges.Web.Ui` | Razor class library: shared components and the profile CSP helper | +| `src/ArcForges.Web.App` | Blazor WebAssembly Account and Chat profiles | +| `src/ArcForges.Web.Operations` | Operations profile skeleton | +| `tools/ArcForges.Web.Tooling` | C# candidate, Site, profile bundle and budget tool | +| `tests/` | xUnit, bUnit and policy suites; local opt-in browser checks | +| `worker/index.ts` | Canonical-host Worker adapter (`www` to apex redirect, otherwise `env.ASSETS`) | +| `tooling/` | Node tooling: Worker emission, identity, the Cloudflare deployment wrapper and the policy checks | +| `eng/` | Reviewed policy, dependency receipts and provenance records | ## Delivery -PRs run source/static/offline checks, Windows IDE declaration evaluation, security scans and one Linux candidate build containing the prerendered assets and redirect Worker. Main deploys those same bytes and records provider completion in a GitHub prerelease. CI does not install browsers, run E2E, fetch public assets or call Cloud. See [validation policy](docs/validation-policy.md). Private workspaces are not published to npm; Workers subdomains and preview URLs remain disabled. - -The main-only GitHub `cloudflare` environment contains the account variable and deployment secret. Domain bindings are managed in Cloudflare; CI verifies that the apex custom domain belongs to this Worker before deploying. Preserve the existing `www.arcforges.com/*` Web route and its proxied DNS record. The redirect retains the path and query, so a new visit to the www connection page reaches the apex before its same-origin Cloud call. PR checks remain credential-free. See [deployment setup and recovery](docs/deploying.md) and [evidence](docs/validation.md). - -Workers Static Assets serves the static React build behind the canonical-host redirect. Frameworks that need request-time rendering require a Workers-compatible adapter/runtime. This setup does not host C# or provide an API proxy. The Cloud Worker owns `arcforges.com/api/*` and forwards to its Native AOT container; see the [Hello integration boundary and Cloud ownership](docs/cloud-hello.md). See also the [official React guide](https://developers.cloudflare.com/workers/framework-guides/web-apps/react/) and [static assets guide](https://developers.cloudflare.com/workers/static-assets/get-started/). - -## Contribute - -The build and CI enforce the [project licence declarations](docs/licence-boundary.md) -across every npm workspace and the JavaScript IDE adapter. -They also enforce [source and actual browser artifact provenance](docs/provenance.md), -including complete legal notices, immutable reuse records and the emitted browser SBOM. - -Read [development](docs/development.md), [contributing](CONTRIBUTING.md), [security](SECURITY.md), [validation](docs/validation.md), and the [bootstrap plan](docs/bootstrap-plan.md). - -The existing repository license is **AGPL-3.0-only**; see [LICENSE](LICENSE). Upstream Contracts and other dependencies retain their own licenses. The built site exposes the license, source link and generated third-party notices. See [third-party notices](THIRD_PARTY_NOTICES.md). +CI builds the C# static Site and the profiles once, seals the Site as a candidate and verifies it before upload. Main pushes deploy those same bytes to Cloudflare Workers Static Assets through the same guarded deployment step. Previews and `workers.dev` are disabled. Local runs of the candidate use `npm run preview` with the local Wrangler. -The sealed [build identity](docs/build-identity.md) is available at `/__build-info.json` for explicit support diagnostics; CI seals it from independent inputs without a browser runtime. +Browser checks, live-service checks, device and macOS checks are local opt-in only. Hosted CI runs no browser, device, live-service or inference job. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 535ead9..aae683f 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -2,13 +2,13 @@ First-party Web code retains this repository's **AGPL-3.0-only** license. Dependencies keep their own terms; this document does not relicense them. -- `@arcforges/proto` and `@arcforges/api-client` 1.0.0-ci.25.1 are published Apache-2.0 packages from [ArcForges Contracts](https://github.com/ArcForges/Contracts). Their original LICENSE, NOTICE, source manifest and SBOM are copied into the candidate. -- The protobuf runtime is Apache-2.0 AND BSD-3-Clause. Connect runtimes are Apache-2.0. The build preserves the Buf/Connect copyright headers, Apache license and complete Google varint BSD notice; see [supplementary upstream licenses](third-party/README.md). -- React, React DOM, Scheduler, React Router and related runtime dependencies carry their upstream license texts. Those texts and the other production dependency notices are collected from the installed lockfile closure into public `/third-party-notices.txt`. +- The naming authority (`eng/naming`) and the CON.07 identity record (`eng/contracts`) are byte copies of the Apache-2.0 publication files of [ArcForges Contracts](https://github.com/ArcForges/Contracts) 1.0.0-ci.287.1: the `tools/naming` assets of the NuGet package `ArcForges.Contracts.Validation` and the build identity of `ArcForges.Contracts.PublicApi`. Neither is shipped in the public candidate. The C# Contracts packages are admitted by NuGet receipts. The former npm packages `@arcforges/proto` and `@arcforges/api-client` are retired (WEB.40 unit U5). +- The retained upstream notices for the React-era protobuf, Connect, Vite, Rolldown, turbo-stream and punycode material are kept in [supplementary upstream licenses](third-party/README.md) as immutable history. The current public candidate ships none of them. +- The public candidate ships the Tailwind CSS v4.3.3 preflight (MIT) in its stylesheet. Its notice, and the full AGPL text, are published as `/third-party-notices.txt` and `/license.txt`. - Development/build dependencies and platform alternatives are recorded in the full lockfile CycloneDX SBOM. The separate browser SBOM describes actual emitted modules, stylesheet/helper origins and their relationships; parsed-only and prerender-only implementations are excluded from that browser closure. No external fonts, photographs, GPL media library source or reference-product assets are copied into this Hello site. The simple mark and visual elements are first-party SVG/CSS. ## Browser packaging provenance -The generated public notices preserve full production dependency terms and the additional Vite, Rolldown (including its referenced MIT companions), Tailwind, turbo-stream/devalue and original Google protobuf terms. Immutable source/artifact records and `eng/provenance/NOTICE.txt` document these obligations. The source summary does not replace full legal text. See [the provenance process](docs/provenance.md) and [retained documents](third-party/README.md). +The public notices carry the Tailwind v4.3.3 notice and the full AGPL text. The React-era browser graph (Vite, Rolldown, React Router, protobuf-es and Connect-ES) was retired with the React build; its immutable records and `eng/provenance/NOTICE.txt` document the history. The source summary does not replace full legal text. See [the provenance process](docs/provenance.md) and [retained documents](third-party/README.md). diff --git a/apps/app/README.md b/apps/app/README.md deleted file mode 100644 index ebc45b4..0000000 --- a/apps/app/README.md +++ /dev/null @@ -1,27 +0,0 @@ -# Account and Chat production profiles (PRF.08 proof) - -This workspace holds the two minimal production React profiles of the future Account/Chat application. They exist to prove, offline and once locally, that a production build from the Web root lock can use the exact published generated SDK. They are not product surfaces and are not linked from the public site. See [the proof record](../../docs/prf-08-profile-proof.md) for what was and was not observed. - -| Profile | Page | Wire | -| --------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | -| `account` | Reads the session once on open, shows it, signs out with the bootstrap CSRF token | Same-origin cookie `GET /session/v1/bootstrap` and `POST /session/v1/logout` (HTTP exception) | -| `chat` | One anonymous greeting per click, cancellable, typed refusals | Binary gRPC-Web `HelloService/SayHello` to same-origin `/api` | - -A build serves one profile: `ARCFORGES_PROFILE` selects the route module before bundling, so one profile's wire code never reaches the other's assets. - -```sh -npm run build:profiles # build, gate and measure both profiles -npm run build --workspace @arcforges/web-app -- --twice # also require byte-identical rebuilds -npm run build --workspace @arcforges/web-app -- --write-baseline # reviewed change only -AOT_HOST_EXE= npm run probe:local --workspace @arcforges/web-app # local opt-in, never CI -``` - -Rules this code keeps: - -- Wire shapes, route paths and bodies come from the published SDK: the generated `browserSessionRoutes` catalogue and strict JSON codecs for the session records, the generated Hello client for gRPC-Web. No handwritten DTO, no `JSON.parse` or `JSON.stringify`, and the calls go through an injected fetcher, not a global `fetch` call. Error bodies of the session routes are not modelled by the contract, so they are never parsed: the HTTP status alone decides the failure kind. -- 64-bit values (`recoveryGeneration`) stay canonical decimal text, validated by the generated `parseUInt64`, and are displayed as the same text. -- The CSRF token and everything else about the session live only in memory. Nothing is written to storage, and the page never sees the cookie. -- Server text is never echoed into a failure message; each failure kind has fixed text. -- The profile builds are measured and gated against `budgets.json`, but are not sealed or deployed. They carry no browser-graph observer or provenance profile; the deployable candidate remains `apps/site`. - -Browser sessions, business APIs and authentication stay in the C# Cloud host. Do not introduce a Node business server, share parent-domain cookies, or use route placement as an authorization boundary. diff --git a/apps/app/app/app.css b/apps/app/app/app.css deleted file mode 100644 index a05319c..0000000 --- a/apps/app/app/app.css +++ /dev/null @@ -1,60 +0,0 @@ -/* SPDX-License-Identifier: AGPL-3.0-only */ -.profile-note { - color: #4c5d53; - font-size: 14px; - font-weight: 550; -} -.panel { - margin-top: 32px; - max-width: 640px; -} -.facts { - display: grid; - grid-template-columns: max-content 1fr; - gap: 8px 24px; - margin: 0; -} -.facts dt { - color: #4c5d53; - font-size: 14px; -} -.facts dd { - margin: 0; - overflow-wrap: anywhere; -} -.exact { - font-family: ui-monospace, monospace; -} -.field { - display: flex; - flex-wrap: wrap; - gap: 12px; - align-items: center; - margin: 24px 0; -} -.field input { - min-width: 240px; - padding: 12px 14px; - border: 1px solid #b9c1b4; - border-radius: 6px; - background: white; -} -.transcript { - list-style: none; - margin: 24px 0 0; - padding: 0; - display: grid; - gap: 12px; -} -.transcript li { - padding: 12px 14px; - border: 1px solid #d5d9cf; - border-radius: 6px; -} -.transcript .kind { - display: block; - color: #4c5d53; - font-size: 12px; - letter-spacing: 1px; - text-transform: uppercase; -} diff --git a/apps/app/app/probe/bounded.ts b/apps/app/app/probe/bounded.ts deleted file mode 100644 index bf1f2ef..0000000 --- a/apps/app/app/probe/bounded.ts +++ /dev/null @@ -1,39 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -import { ProbeFailure } from "./failure.ts"; - -/** Reads a response body without ever holding more than `limit` bytes; a longer body is malformed. */ -export async function readBounded(response: Response, limit: number): Promise { - const declared = response.headers.get("content-length"); - if (declared !== null && (!/^\d+$/u.test(declared) || Number(declared) > limit)) - throw new ProbeFailure("malformed"); - if (!response.body) return new Uint8Array(); - const reader = response.body.getReader(); - const chunks: Uint8Array[] = []; - let length = 0; - try { - for (;;) { - const next = await reader.read(); - if (next.done) break; - length += next.value.byteLength; - if (length > limit) { - await reader.cancel(); - throw new ProbeFailure("malformed"); - } - chunks.push(next.value); - } - } catch (error) { - if (error instanceof ProbeFailure) throw error; - throw new ProbeFailure("unavailable", { cause: error }); - } - const bytes = new Uint8Array(length); - let offset = 0; - for (const chunk of chunks) { - bytes.set(chunk, offset); - offset += chunk.byteLength; - } - return bytes; -} - -export function isJsonContentType(value: string | null): boolean { - return value !== null && /^application\/json\s*(?:;|$)/iu.test(value); -} diff --git a/apps/app/app/probe/exact.ts b/apps/app/app/probe/exact.ts deleted file mode 100644 index ff85d98..0000000 --- a/apps/app/app/probe/exact.ts +++ /dev/null @@ -1,21 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -import { parseUInt64 } from "@arcforges/proto"; - -/** - * A uint64 that crossed the wire as canonical decimal text (JSON exception records carry 64-bit values - * as strings). It is validated by the generated package, held as a bigint and shown as the same - * canonical text: no value passes through a JavaScript number. - */ -export function exactUnsigned(wire: string): { value: bigint; text: string } { - const value = parseUInt64(wire); - return { value, text: value.toString(10) }; -} - -/** Returns the canonical text, or `undefined` when the generated parser refuses the value. */ -export function tryExactUnsigned(wire: string): string | undefined { - try { - return exactUnsigned(wire).text; - } catch { - return undefined; - } -} diff --git a/apps/app/app/probe/failure.ts b/apps/app/app/probe/failure.ts deleted file mode 100644 index 5db397c..0000000 --- a/apps/app/app/probe/failure.ts +++ /dev/null @@ -1,53 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only - -/** The closed set of reasons a probe call ends without a usable answer. */ -export const failureKinds = [ - "cancelled", - "timeout", - "unavailable", - "unauthenticated", - "forbidden", - "rejected", - "limit", - "malformed", - "unexpected", -] as const; -export type FailureKind = (typeof failureKinds)[number]; - -/** A typed failure. The message is fixed per kind: it never echoes server text or request content. */ -export class ProbeFailure extends Error { - readonly kind: FailureKind; - constructor(kind: FailureKind, options?: { cause?: unknown }) { - super(failureText[kind], options); - this.name = "ProbeFailure"; - this.kind = kind; - } -} - -export const failureText: Record = { - cancelled: "The request was cancelled.", - timeout: "The server did not answer in time.", - unavailable: "The server is unavailable. Try again later.", - unauthenticated: "Your session has ended. Reload to check it again.", - forbidden: "The server refused this request.", - rejected: "The server rejected the request.", - limit: "The request exceeded a server limit.", - malformed: "The server answered with something this page cannot read.", - unexpected: "The server failed unexpectedly. Try again later.", -}; - -export function isProbeFailure(value: unknown): value is ProbeFailure { - return value instanceof ProbeFailure; -} - -/** HTTP statuses of the same-origin session exception routes, mapped to a failure kind. */ -export function failureFromStatus(status: number): FailureKind { - if (status === 401) return "unauthenticated"; - if (status === 403) return "forbidden"; - if (status === 408 || status === 504) return "timeout"; - if (status === 413 || status === 429) return "limit"; - if (status === 502 || status === 503) return "unavailable"; - if (status >= 500) return "unexpected"; - if (status >= 400) return "rejected"; - return "malformed"; -} diff --git a/apps/app/app/probe/hello.ts b/apps/app/app/probe/hello.ts deleted file mode 100644 index 17d929a..0000000 --- a/apps/app/app/probe/hello.ts +++ /dev/null @@ -1,102 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -import { createHelloClient } from "@arcforges/api-client"; -import { Code, ConnectError } from "@connectrpc/connect"; -import { type FailureKind, failureFromStatus, ProbeFailure } from "./failure.ts"; - -export const helloApiPath = "/api/arcforges.hello.v1.HelloService/SayHello"; -export const helloTimeoutMs = 10_000; - -/** - * The server's gRPC status, mapped to the page's closed failure kinds. A `Canceled` status the user did not - * ask for is an unexpected server answer; the user's own cancellation is detected from the signal. - */ -export function failureFromCode(code: Code): FailureKind { - switch (code) { - case Code.DeadlineExceeded: - return "timeout"; - case Code.Unavailable: - case Code.Aborted: - return "unavailable"; - case Code.Unauthenticated: - return "unauthenticated"; - case Code.PermissionDenied: - return "forbidden"; - case Code.ResourceExhausted: - return "limit"; - case Code.InvalidArgument: - case Code.NotFound: - case Code.AlreadyExists: - case Code.FailedPrecondition: - case Code.OutOfRange: - case Code.Unimplemented: - return "rejected"; - default: - return "unexpected"; - } -} - -/** The client library's own protocol-error texts; a server status carries the server's text instead. */ -const protocolText = /^(?:protocol error:|missing trailer|missing message|invalid grpc-status)/u; - -/** Sends one anonymous greeting over binary gRPC-Web to same-origin `/api`; the caller may cancel it. */ -export async function sayHello( - origin: string, - name: string, - signal: AbortSignal, - fetcher: typeof fetch, -): Promise { - // What the transport saw before the library mapped it, so a lossy library code never decides the kind. - const seen: { network: boolean; status: number | undefined; foreign: boolean } = { - network: false, - status: undefined, - foreign: false, - }; - const client = createHelloClient({ - baseUrl: new URL("/api", origin).href, - defaultTimeoutMs: helloTimeoutMs, - // The greeting is anonymous: no cookie, no redirect. - fetch: async (input, init) => { - let response: Response; - try { - response = await fetcher(input, { ...init, credentials: "omit", redirect: "error" }); - } catch (error) { - seen.network = true; - throw error; - } - seen.status = response.status; - if ( - response.status === 200 && - !/^application\/grpc-web/iu.test(response.headers.get("content-type") ?? "") - ) { - seen.foreign = true; - await response.body?.cancel().catch(() => undefined); - throw new Error("The answer is not gRPC-Web"); - } - return response; - }, - }); - let message: string; - try { - ({ message } = await client.sayHello({ name }, { signal })); - } catch (error) { - if (signal.aborted) throw new ProbeFailure("cancelled", { cause: error }); - if (seen.foreign) throw new ProbeFailure("malformed", { cause: error }); - if (seen.status !== undefined && seen.status !== 200) - throw new ProbeFailure(failureFromStatus(seen.status), { cause: error }); - if (seen.network) { - // A deadline abort surfaces as DeadlineExceeded; any other transport failure is unavailability. - const code = error instanceof ConnectError ? error.code : undefined; - throw new ProbeFailure(code === Code.DeadlineExceeded ? "timeout" : "unavailable", { - cause: error, - }); - } - if (error instanceof ConnectError) - throw new ProbeFailure( - protocolText.test(error.rawMessage) ? "malformed" : failureFromCode(error.code), - { cause: error }, - ); - throw new ProbeFailure("unexpected", { cause: error }); - } - if (message !== `Hello, ${name}!`) throw new ProbeFailure("malformed"); - return message; -} diff --git a/apps/app/app/probe/session.ts b/apps/app/app/probe/session.ts deleted file mode 100644 index c1861e7..0000000 --- a/apps/app/app/probe/session.ts +++ /dev/null @@ -1,124 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -import { - type BrowserBootstrapResponse, - browserSessionRoutes, - tryParseBrowserBootstrapResponseJson, - tryParseBrowserReceiptJson, -} from "@arcforges/api-client"; -import { readBounded, isJsonContentType } from "./bounded.ts"; -import { tryExactUnsigned } from "./exact.ts"; -import { failureFromStatus, ProbeFailure } from "./failure.ts"; - -/** The CSRF request header of cookie-authenticated unsafe operations (Design AU-08). */ -export const csrfHeader = "X-AF-CSRF"; - -const route = (id: string) => { - const found = browserSessionRoutes.find((candidate) => candidate.id === id); - if (!found) throw new Error(`The published SDK has no browser session route ${id}`); - return found; -}; -const bootstrapRoute = route("browser.bootstrap"); -const logoutRoute = route("browser.logout"); - -type BootstrapSession = NonNullable; -type BootstrapProfile = NonNullable; - -/** What the page may show about the current session; ids and the cookie are never exposed to the UI. */ -export type SessionSnapshot = - | { state: "anonymous"; csrfToken: string } - | { - state: "authenticated"; - csrfToken: string; - displayName: string | undefined; - locale: string | undefined; - timezone: string | undefined; - expiresAt: string; - idleExpiresAt: string | undefined; - workspaces: number; - recoveryGeneration: string; - }; - -interface CallOptions { - origin: string; - signal: AbortSignal; - fetcher: typeof fetch; -} - -async function send(options: CallOptions, path: string, init: RequestInit): Promise { - try { - return await options.fetcher(new URL(path, options.origin), { - ...init, - credentials: "same-origin", - redirect: "error", - cache: "no-store", - signal: options.signal, - }); - } catch (error) { - if (options.signal.aborted) throw new ProbeFailure("cancelled", { cause: error }); - throw new ProbeFailure("unavailable", { cause: error }); - } -} - -async function jsonBody(response: Response, limit: number): Promise { - if (response.status !== 200) { - await response.body?.cancel().catch(() => undefined); - throw new ProbeFailure(failureFromStatus(response.status)); - } - if (!isJsonContentType(response.headers.get("content-type"))) throw new ProbeFailure("malformed"); - return readBounded(response, limit); -} - -function snapshot(body: BrowserBootstrapResponse): SessionSnapshot { - if (!body.authenticated) { - // An anonymous answer that still carries a session or profile is contradictory, not anonymous. - if (body.session !== undefined || body.profile !== undefined) - throw new ProbeFailure("malformed"); - return { state: "anonymous", csrfToken: body.csrfToken }; - } - const session: BootstrapSession | undefined = body.session; - if (session === undefined) throw new ProbeFailure("malformed"); - const generation = tryExactUnsigned(session.recoveryGeneration); - if (generation === undefined) throw new ProbeFailure("malformed"); - const profile: BootstrapProfile | undefined = body.profile; - return { - state: "authenticated", - csrfToken: body.csrfToken, - displayName: profile?.displayName, - locale: profile?.locale, - timezone: profile?.timezone, - expiresAt: session.expiresAt, - idleExpiresAt: session.idleExpiresAt, - workspaces: session.workspaceIds.length, - recoveryGeneration: generation, - }; -} - -/** GET the bootstrap route: the cookie rides along, the answer is parsed only by the generated codec. */ -export async function readSession(options: CallOptions): Promise { - const response = await send(options, bootstrapRoute.path, { - method: bootstrapRoute.method, - headers: { accept: "application/json" }, - }); - const bytes = await jsonBody(response, 16384); - const parsed = tryParseBrowserBootstrapResponseJson(bytes); - if (!parsed.ok) throw new ProbeFailure("malformed"); - return snapshot(parsed.value); -} - -/** - * POST the logout route with the CSRF token. Only the receipt effect `happened` ends the session on the - * page; `didNotHappen` and `unknown` are failures, because the cookie may still be valid. - */ -export async function endSession( - options: CallOptions & { csrfToken: string }, -): Promise<"happened"> { - const response = await send(options, logoutRoute.path, { - method: logoutRoute.method, - headers: { accept: "application/json", [csrfHeader]: options.csrfToken }, - }); - const bytes = await jsonBody(response, 4096); - const parsed = tryParseBrowserReceiptJson(bytes); - if (!parsed.ok) throw new ProbeFailure("malformed"); - if (parsed.value.effect === "happened") return "happened"; - throw new ProbeFailure(parsed.value.effect === "didNotHappen" ? "rejected" : "unexpected"); -} diff --git a/apps/app/app/root.tsx b/apps/app/app/root.tsx deleted file mode 100644 index b94880e..0000000 --- a/apps/app/app/root.tsx +++ /dev/null @@ -1,65 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -import "@arcforges/web-ui/styles.css"; -import "./app.css"; -import type { ReactNode } from "react"; -import { - isRouteErrorResponse, - Links, - Meta, - Outlet, - Scripts, - ScrollRestoration, - useRouteError, -} from "react-router"; - -export function Layout({ children }: { children: ReactNode }) { - return ( - - - - - - - - - - - -
- - Skip to content - -
- ArcForges - Production profile proof -
-
- {children} -
-
- Minimal proof profile. Not a product surface. -
-
- - - - - ); -} - -export default function App() { - return ; -} - -export function ErrorBoundary() { - const error = useRouteError(); - const missing = isRouteErrorResponse(error) && error.status === 404; - return ( -
-

{missing ? "Page not found." : "Something went wrong."}

-

- {missing ? "This profile has a single page." : "Reload the page to try again."} -

-
- ); -} diff --git a/apps/app/app/routes.ts b/apps/app/app/routes.ts deleted file mode 100644 index 7ddd2a4..0000000 --- a/apps/app/app/routes.ts +++ /dev/null @@ -1,6 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -import { index, type RouteConfig } from "@react-router/dev/routes"; -import { activeProfile } from "../profile.ts"; - -// One profile per build: only that profile's route module enters the bundle graph. -export default [index(`routes/${activeProfile()}.tsx`)] satisfies RouteConfig; diff --git a/apps/app/app/routes/account.tsx b/apps/app/app/routes/account.tsx deleted file mode 100644 index 0df091f..0000000 --- a/apps/app/app/routes/account.tsx +++ /dev/null @@ -1,125 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -import { Button } from "@arcforges/web-ui"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { type FailureKind, failureText, isProbeFailure } from "../probe/failure.ts"; -import { endSession, readSession, type SessionSnapshot } from "../probe/session.ts"; - -export function meta() { - return [{ title: "Account session — ArcForges" }]; -} - -type View = - | { phase: "loading" } - | { phase: "ready"; session: SessionSnapshot } - | { phase: "failed"; kind: FailureKind } - | { phase: "ended" }; - -function kindOf(error: unknown): FailureKind { - return isProbeFailure(error) ? error.kind : "unexpected"; -} - -export default function Account() { - const [view, setView] = useState({ phase: "loading" }); - const [working, setWorking] = useState(false); - const active = useRef(null); - - const run = useCallback(async function run( - task: (signal: AbortSignal) => Promise, - ): Promise { - active.current?.abort(); - const controller = new AbortController(); - active.current = controller; - try { - const result = await task(controller.signal); - return controller.signal.aborted ? undefined : result; - } catch (error) { - if (!controller.signal.aborted) { - const kind = kindOf(error); - // An ended session is reported as such, never as a transient fault. - setView(kind === "unauthenticated" ? { phase: "ended" } : { phase: "failed", kind }); - } - return undefined; - } finally { - if (active.current === controller) active.current = null; - } - }, []); - - const check = useCallback(() => { - setView({ phase: "loading" }); - void run((signal) => - readSession({ origin: window.location.origin, signal, fetcher: window.fetch.bind(window) }), - ).then((session) => { - if (session) setView({ phase: "ready", session }); - }); - }, [run]); - - useEffect(() => { - // The session is read once when the page opens. - check(); - return () => active.current?.abort(); - }, [check]); - - function signOut(csrfToken: string) { - setWorking(true); - void run((signal) => - endSession({ - origin: window.location.origin, - signal, - fetcher: window.fetch.bind(window), - csrfToken, - }), - ).then((effect) => { - setWorking(false); - if (effect !== undefined) setView({ phase: "ended" }); - }); - } - - const session = view.phase === "ready" ? view.session : undefined; - return ( -
-

Account profile

-

- Your session, as the server sees it. -

- -
- {view.phase === "loading" ?

Checking your session…

: null} - {view.phase === "ended" ?

You are signed out. Reload to check again.

: null} - {view.phase === "failed" ?

{failureText[view.kind]}

: null} - {session?.state === "anonymous" ?

You are not signed in.

: null} - {session?.state === "authenticated" ? ( -
-
Signed in as
-
{session.displayName ?? "(no display name)"}
-
Locale
-
{session.locale ?? "—"}
-
Time zone
-
{session.timezone ?? "—"}
-
Workspaces
-
{session.workspaces}
-
Session ends
-
{session.expiresAt}
-
Idle expiry
-
{session.idleExpiresAt ?? "—"}
-
Recovery generation
-
{session.recoveryGeneration}
-
- ) : null} -
-
- {session?.state === "authenticated" ? ( - - ) : null} - {view.phase === "failed" ? ( - - ) : null} -
-
- ); -} diff --git a/apps/app/app/routes/chat.tsx b/apps/app/app/routes/chat.tsx deleted file mode 100644 index 5c7e64b..0000000 --- a/apps/app/app/routes/chat.tsx +++ /dev/null @@ -1,101 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -import { Button } from "@arcforges/web-ui"; -import { useEffect, useRef, useState } from "react"; -import { failureText, isProbeFailure } from "../probe/failure.ts"; -import { sayHello } from "../probe/hello.ts"; - -export function meta() { - return [{ title: "Chat probe — ArcForges" }]; -} - -interface Entry { - id: number; - kind: "you" | "server" | "notice"; - text: string; -} -const maxEntries = 20; - -export default function Chat() { - const [name, setName] = useState("ArcForges"); - const [entries, setEntries] = useState([]); - const [pending, setPending] = useState(false); - const active = useRef(null); - const counter = useRef(0); - - useEffect(() => () => active.current?.abort(), []); - - function add(...next: Omit[]) { - setEntries((current) => - [...current, ...next.map((entry) => ({ ...entry, id: ++counter.current }))].slice( - -maxEntries, - ), - ); - } - - async function send() { - if (active.current) return; - const controller = new AbortController(); - active.current = controller; - setPending(true); - add({ kind: "you", text: name }); - try { - const reply = await sayHello( - window.location.origin, - name, - controller.signal, - window.fetch.bind(window), - ); - add({ kind: "server", text: reply }); - } catch (error) { - add({ - kind: "notice", - text: isProbeFailure(error) ? failureText[error.kind] : failureText.unexpected, - }); - } finally { - if (active.current === controller) active.current = null; - setPending(false); - } - } - - return ( -
-

Chat profile

-

- Say hello, to the server. -

-

- Each message is one anonymous gRPC-Web call. Nothing is sent until you choose to send. -

- -
{ - event.preventDefault(); - void send(); - }} - > - - - {pending ? ( - - ) : null} -
-
    - {entries.map((entry) => ( -
  1. - {entry.kind} - {entry.text} -
  2. - ))} -
-
- ); -} diff --git a/apps/app/budgets.json b/apps/app/budgets.json deleted file mode 100644 index 28adefc..0000000 --- a/apps/app/budgets.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "schema": 1, - "regressionPercent": 10, - "profiles": { - "account": { - "initialRequests": 8, - "initialJsGzip": 116363, - "initialCssGzip": 3050, - "totalGzip": 119413, - "htmlBytes": 2967 - }, - "chat": { - "initialRequests": 8, - "initialJsGzip": 144698, - "initialCssGzip": 3050, - "totalGzip": 147748, - "htmlBytes": 3271 - } - } -} diff --git a/apps/app/interaction-budgets.json b/apps/app/interaction-budgets.json deleted file mode 100644 index 1d7841f..0000000 --- a/apps/app/interaction-budgets.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "schema": 1, - "basis": "Regression guards derived from one observation, not product budgets: Design defines no interaction ceiling and the product budgets belong to the WEB tasks. Each ceiling is the value measured by the CLOUD.71 served-profile live run of 2026-10-06 08:11Z (five samples per interaction after the warm-up; Windows 11 workstation over the public internet to https://proof.arcforges.com; proof Worker version 6951f4d7, Cloud main c59f91c) plus 10 percent, the TH-01 regression line, rounded up to the next millisecond: the median ceiling from the measured median, the maximum ceiling from the measured maximum. Record: docs/prf-08-profile-proof.md.", - "minimumSamples": 5, - "interactions": { - "account.read": { "medianCeilingMs": 1138, "maxCeilingMs": 1148 }, - "account.signout": { "medianCeilingMs": 931, "maxCeilingMs": 943 }, - "chat.greeting": { "medianCeilingMs": 950, "maxCeilingMs": 1504 } - } -} diff --git a/apps/app/package.json b/apps/app/package.json deleted file mode 100644 index ef645cd..0000000 --- a/apps/app/package.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "name": "@arcforges/web-app", - "version": "0.0.0", - "private": true, - "type": "module", - "license": "AGPL-3.0-only", - "scripts": { - "build": "node scripts/profiles.ts build", - "typecheck": "tsc -p tsconfig.json", - "probe:local": "node scripts/local-run.ts" - }, - "dependencies": { - "@arcforges/api-client": "1.0.0-ci.287.1", - "@arcforges/proto": "1.0.0-ci.287.1", - "@arcforges/web-ui": "0.0.0", - "@bufbuild/protobuf": "2.15.0", - "@connectrpc/connect": "2.2.0", - "isbot": "5.2.2", - "react": "19.3.0", - "react-dom": "19.3.0", - "react-router": "8.4.0" - }, - "arcforges": { - "licenceBoundary": "AGPL" - } -} diff --git a/apps/app/profile.ts b/apps/app/profile.ts deleted file mode 100644 index 16ddd35..0000000 --- a/apps/app/profile.ts +++ /dev/null @@ -1,17 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -// The two minimal production profiles of the Account/Chat application. A build serves exactly one -// of them: its routes are chosen before bundling, so one profile's code never reaches the other's assets. -export const profiles = ["account", "chat"] as const; -export type Profile = (typeof profiles)[number]; - -export function isProfile(value: unknown): value is Profile { - return typeof value === "string" && (profiles as readonly string[]).includes(value); -} - -export function activeProfile( - environment: Record = process.env, -): Profile { - const value = environment.ARCFORGES_PROFILE; - if (!isProfile(value)) throw new Error(`Set ARCFORGES_PROFILE to one of: ${profiles.join(", ")}`); - return value; -} diff --git a/apps/app/react-router.config.ts b/apps/app/react-router.config.ts deleted file mode 100644 index ec0ef4e..0000000 --- a/apps/app/react-router.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -import type { Config } from "@react-router/dev/config"; -import { activeProfile } from "./profile.ts"; - -// Each profile runs on its own path of one origin (CLOUD.71 serves both from the proof origin), so its router -// base is that path. Code, chunk names and stylesheet are unchanged by the base (it is not applied to Vite's -// asset base: references stay /assets/...); the prerendered page is written to `/index.html` and carries -// the base. A profile whose base is `/` answers "Page not found" on any other path. -export default { - ssr: false, - prerender: ["/"], - basename: `/${activeProfile()}/`, - buildDirectory: `build/${activeProfile()}`, -} satisfies Config; diff --git a/apps/app/scripts/bundle.ts b/apps/app/scripts/bundle.ts deleted file mode 100644 index 9771e63..0000000 --- a/apps/app/scripts/bundle.ts +++ /dev/null @@ -1,282 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -// The profile bundle: the two built production profiles (Account and Chat) as ONE immutable, deterministic archive, -// named by its own SHA-256, for the Cloud proof deployment job to serve as static assets on the proof origin. -// node scripts/bundle.ts [outputDirectory] (after the profile builds; `npm run build:profiles` runs it) -// -// Nothing is rebuilt and nothing is rewritten here except what the candidate build also does: the served bytes are -// the bytes of the two builds (a Windows build's CRLF page is normalised to the LF bytes a browser executes, so the -// Content-Security-Policy hashes match them). The two builds share the content-hashed `assets/` directory and the -// root files; a path with two different contents refuses the bundle. The archive is plain POSIX ustar with a fixed -// order, zero times and owners and no compression, so equal content is the same bytes and the same name. -// -// Layout: manifest.json (first), `_headers`, the root files, `assets/**`, `account/index.html`, `chat/index.html`. -// `_headers` carries each profile's Content-Security-Policy on its own path (derived from that page's inline scripts -// exactly as the site candidate derives its policy), the security headers, and the immutable cache of `assets/**`. -import assert from "node:assert/strict"; -import { createHash } from "node:crypto"; -import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import { contentSecurityPolicy } from "../../../tooling/project.ts"; -import { type Profile, profiles } from "../profile.ts"; -import { listFiles, measureProfile, notServed, pageFile } from "./measure.ts"; - -const app = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const repository = resolve(app, "../.."); - -export const bundleSchema = 1; -export const manifestName = "manifest.json"; -export const headersName = "_headers"; -/** Release assets are named by the digest of their own bytes. */ -export const bundleName = (digest: string) => `web-profiles-${digest}.tar`; - -const sha256 = (value: Uint8Array) => createHash("sha256").update(value).digest("hex"); - -export interface BundleManifest { - schema: typeof bundleSchema; - /** Per profile: the page, the measured content digest of its own build and the policy applied to its path. */ - profiles: Record; - /** Every other file of the archive with its SHA-256 and size. */ - files: Record; -} - -/** The policy of one profile page: the site candidate's derivation over that page alone. */ -export function profileCsp(page: string): string { - return contentSecurityPolicy([page.replaceAll("\r\n", "\n")]); -} - -/** `_headers` for the merged tree. Each profile's policy is applied to its own path only. */ -export function headersFile(policies: Record): string { - const lines = [ - "/*", - " X-Content-Type-Options: nosniff", - " Referrer-Policy: no-referrer", - " Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()", - " X-Robots-Tag: noindex, nofollow", - ]; - for (const profile of profiles) { - const policy = policies[profile]; - assert(policy, `No policy for ${profile}`); - // A Cloudflare _headers line is at most 2000 characters. - assert(policy.length < 1800, `The ${profile} policy exceeds the header line budget`); - lines.push( - `/${profile}/*`, - ` Content-Security-Policy: ${policy}`, - " X-Frame-Options: DENY", - " Cache-Control: public, no-cache, no-transform", - ); - } - lines.push( - "/assets/*", - " ! Cache-Control", - " Cache-Control: public, max-age=31536000, immutable, no-transform", - ); - return `${lines.join("\n")}\n`; -} - -export interface Entry { - path: string; - bytes: Uint8Array; -} - -/** The merged served tree of the profile builds, refusing any path that two builds fill differently. */ -export function mergeProfiles(clients: Record): Map { - const merged = new Map(); - for (const profile of profiles) { - const client = clients[profile]; - assert(client, `No build directory for ${profile}`); - const served = listFiles(client).filter((file) => !notServed(file)); - assert(served.includes(pageFile(profile)), `${profile} has no ${pageFile(profile)}`); - for (const file of served) { - // Only a profile's own page lives under its path; every other profile's page would be a foreign file. - const owner = profiles.find((name) => file.startsWith(`${name}/`)); - assert( - owner === undefined || (owner === profile && file === pageFile(profile)), - `${profile} build has a foreign path: ${file}`, - ); - let bytes: Uint8Array = readFileSync(join(client, file)); - if (file === pageFile(profile)) - bytes = Buffer.from(Buffer.from(bytes).toString("utf8").replaceAll("\r\n", "\n")); - const existing = merged.get(file); - if (existing !== undefined) - assert( - Buffer.compare(Buffer.from(existing), Buffer.from(bytes)) === 0, - `Two builds fill ${file} with different bytes`, - ); - else merged.set(file, bytes); - } - } - return merged; -} - -const block = 512; - -function octal(value: number, length: number): string { - const text = value.toString(8); - assert(text.length < length, "Value does not fit its tar field"); - return `${text.padStart(length - 1, "0")}\0`; -} - -function header(path: string, size: number): Buffer { - assert(/^[A-Za-z0-9_][A-Za-z0-9._/-]*$/u.test(path) && !path.includes(".."), `Bad path ${path}`); - assert(!path.includes("//") && !path.endsWith("/"), `Bad path ${path}`); - const name = Buffer.from(path, "ascii"); - assert(name.length <= 100, `Path too long for a plain tar header: ${path}`); - const head = Buffer.alloc(block); - name.copy(head, 0); - head.write(octal(0o644, 8), 100, "ascii"); - head.write(octal(0, 8), 108, "ascii"); - head.write(octal(0, 8), 116, "ascii"); - head.write(octal(size, 12), 124, "ascii"); - head.write(octal(0, 12), 136, "ascii"); - head.fill(0x20, 148, 156); - head.write("0", 156, "ascii"); - head.write("ustar\0", 257, "ascii"); - head.write("00", 263, "ascii"); - let sum = 0; - for (const byte of head) sum += byte; - head.write(`${sum.toString(8).padStart(6, "0")}\0 `, 148, "ascii"); - return head; -} - -/** Deterministic archive of the entries in the given order. */ -export function writeTar(entries: Entry[]): Buffer { - const seen = new Set(); - const parts: Buffer[] = []; - for (const entry of entries) { - assert(!seen.has(entry.path), `Duplicate entry ${entry.path}`); - seen.add(entry.path); - parts.push(header(entry.path, entry.bytes.byteLength), Buffer.from(entry.bytes)); - const pad = (block - (entry.bytes.byteLength % block)) % block; - if (pad > 0) parts.push(Buffer.alloc(pad)); - } - parts.push(Buffer.alloc(block * 2)); - return Buffer.concat(parts); -} - -/** Strict reader of exactly what `writeTar` writes; anything else is refused. */ -export function readTar(archive: Uint8Array): Entry[] { - const data = Buffer.from(archive); - assert(data.length % block === 0 && data.length >= block * 2, "Not a bundle archive"); - const entries: Entry[] = []; - let offset = 0; - for (;;) { - assert(offset + block <= data.length, "Archive ends without its terminator"); - const head = data.subarray(offset, offset + block); - if (head.every((byte) => byte === 0)) { - assert(offset + block * 2 === data.length, "Data after the terminator"); - assert(data.subarray(offset + block, offset + block * 2).every((byte) => byte === 0)); - return entries; - } - const end = head.indexOf(0); - const path = head.subarray(0, end < 0 || end > 100 ? 100 : end).toString("ascii"); - const size = Number.parseInt(head.subarray(124, 135).toString("ascii"), 8); - assert(Number.isSafeInteger(size) && size >= 0, "Bad entry size"); - const expected = header(path, size); - assert( - Buffer.compare(head, expected) === 0, - `Entry ${path} is not a plain regular file header`, - ); - const start = offset + block; - assert(start + size <= data.length, `Entry ${path} is truncated`); - entries.push({ path, bytes: data.subarray(start, start + size) }); - offset = start + Math.ceil(size / block) * block; - } -} - -export interface Bundle { - digest: string; - name: string; - archive: Buffer; - manifest: BundleManifest; -} - -/** The bundle of the merged tree and the per-profile build measurements. */ -export function buildBundle( - clients: Record, - measure: (client: string, profile: Profile) => { digest: string } = (client, profile) => - measureProfile(client, profile), -): Bundle { - const merged = mergeProfiles(clients); - const csp: Record = {}; - const summary: BundleManifest["profiles"] = {}; - for (const profile of profiles) { - const page = merged.get(pageFile(profile)); - assert(page); - csp[profile] = profileCsp(Buffer.from(page).toString("utf8")); - summary[profile] = { - page: pageFile(profile), - buildDigest: measure(clients[profile], profile).digest, - csp: csp[profile] as string, - }; - } - const tree = new Map(merged); - assert(!tree.has(headersName) && !tree.has(manifestName), "A build ships a reserved name"); - tree.set(headersName, Buffer.from(headersFile(csp), "utf8")); - const files: BundleManifest["files"] = {}; - const sorted = [...tree.keys()].sort(); - for (const path of sorted) { - const bytes = tree.get(path) as Uint8Array; - files[path] = { sha256: sha256(bytes), bytes: bytes.byteLength }; - } - const manifest: BundleManifest = { schema: bundleSchema, profiles: summary, files }; - const entries: Entry[] = [ - { path: manifestName, bytes: Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, "utf8") }, - ...sorted.map((path) => ({ path, bytes: tree.get(path) as Uint8Array })), - ]; - const archive = writeTar(entries); - const digest = sha256(archive); - return { digest, name: bundleName(digest), archive, manifest }; -} - -/** Reads a bundle back and checks it against its manifest and its name: what the Cloud job also checks. */ -export function verifyBundle(archive: Uint8Array, expectedDigest?: string): BundleManifest { - const digest = sha256(archive); - if (expectedDigest !== undefined) assert.equal(digest, expectedDigest, "Bundle digest mismatch"); - const entries = readTar(archive); - const [first, ...rest] = entries; - assert(first?.path === manifestName, "The manifest must be the first entry"); - const manifest = JSON.parse(Buffer.from(first.bytes).toString("utf8")) as BundleManifest; - assert.equal(manifest.schema, bundleSchema); - assert.deepEqual( - rest.map((entry) => entry.path), - Object.keys(manifest.files).sort(), - "The archive does not hold exactly the manifest's files in order", - ); - for (const entry of rest) { - const row = manifest.files[entry.path]; - assert(row, `Unlisted file ${entry.path}`); - assert.equal(row.bytes, entry.bytes.byteLength, `Size of ${entry.path}`); - assert.equal(row.sha256, sha256(entry.bytes), `Content of ${entry.path}`); - } - return manifest; -} - -export function writeBundle( - clients: Record, - outputDirectory: string, - measure?: (client: string, profile: Profile) => { digest: string }, -): Bundle { - const bundle = buildBundle(clients, measure); - verifyBundle(bundle.archive, bundle.digest); - mkdirSync(outputDirectory, { recursive: true }); - // Only this task's own earlier bundles are removed, so the directory always holds exactly one. - for (const name of readdirSync(outputDirectory)) - if (/^web-profiles-[0-9a-f]{64}\.tar$/u.test(name)) - rmSync(join(outputDirectory, name), { force: true }); - writeFileSync(join(outputDirectory, bundle.name), bundle.archive); - return bundle; -} - -export const defaultClients = (): Record => ({ - account: join(app, "build", "account", "client"), - chat: join(app, "build", "chat", "client"), -}); -export const defaultOutput = () => join(repository, "artifacts", "profiles"); - -if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { - const bundle = writeBundle(defaultClients(), resolve(process.argv[2] ?? defaultOutput())); - console.log( - `Profile bundle ${bundle.name}: ${Object.keys(bundle.manifest.files).length} files, ${bundle.archive.byteLength} bytes`, - ); -} diff --git a/apps/app/scripts/local-run.ts b/apps/app/scripts/local-run.ts deleted file mode 100644 index c7ea00a..0000000 --- a/apps/app/scripts/local-run.ts +++ /dev/null @@ -1,338 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -// Explicit local opt-in browser run of the built Account and Chat profiles. Never CI. -// -// AOT_HOST_EXE= node scripts/local-run.ts -// -// What is real here: the production profile bytes (run `npm run build:profiles` first), the generated SDK -// inside them, a real Chromium, and the real Cloud Native AOT host process answering the Hello gRPC-Web -// calls (including its own typed refusals). What is not: there is no Cloudflare, Worker or Container, only a -// local same-origin server that serves the profile and forwards `/api/*` to the host with the prefix -// removed, as the deployed Worker route is documented to do. The Account profile has no real session -// source here (a session needs the Worker, D1 and an issued session), so its session routes are labelled -// fixtures answered by the browser test harness. Nothing here proves a deployed result. -import assert from "node:assert/strict"; -import { type ChildProcess, spawn } from "node:child_process"; -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { createServer, request as httpRequest, type Server } from "node:http"; -import { dirname, extname, join, resolve, sep } from "node:path"; -import { fileURLToPath } from "node:url"; -import { chromium, type Page } from "@playwright/test"; -import { - serializeBrowserBootstrapResponseJson, - serializeBrowserReceiptJson, -} from "@arcforges/api-client"; -import { profileCsp } from "./bundle.ts"; -import { failureText } from "../app/probe/failure.ts"; -import { pageFile, servedFile } from "./measure.ts"; - -const app = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const hostPort = 8080; -const types: Record = { - ".html": "text/html; charset=utf-8", - ".js": "text/javascript; charset=utf-8", - ".css": "text/css; charset=utf-8", - ".svg": "image/svg+xml", - ".txt": "text/plain; charset=utf-8", -}; - -function serve(profile: string): Promise<{ server: Server; origin: string }> { - const client = join(app, "build", profile, "client"); - assert(existsSync(join(client, pageFile(profile))), `Build the ${profile} profile first`); - const policy = profileCsp(readFileSync(join(client, pageFile(profile)), "utf8")); - const server = createServer((incoming, outgoing) => { - const url = new URL(incoming.url ?? "/", "http://local"); - if (url.pathname.startsWith("/api/")) { - const forwarded = httpRequest( - { - host: "127.0.0.1", - port: hostPort, - method: incoming.method, - path: url.pathname.slice("/api".length) + url.search, - headers: { ...incoming.headers, host: `127.0.0.1:${hostPort}` }, - }, - (reply) => { - outgoing.writeHead(reply.statusCode ?? 502, reply.headers); - reply.pipe(outgoing); - }, - ); - forwarded.on("error", () => { - outgoing.writeHead(503).end(); - }); - incoming.pipe(forwarded); - return; - } - const name = servedFile(url.pathname); - const file = resolve(client, name ?? ""); - if (name === undefined || !file.startsWith(`${client}${sep}`) || !existsSync(file)) { - outgoing.writeHead(404, { "content-type": "text/plain" }).end("Not found"); - return; - } - outgoing.writeHead(200, { - "content-type": types[extname(file)] ?? "application/octet-stream", - "content-security-policy": policy, - "x-content-type-options": "nosniff", - "cache-control": "no-store", - }); - outgoing.end(readFileSync(file)); - }); - return new Promise((done) => - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - assert(address && typeof address === "object"); - // The page URL: each profile runs on its own path of the origin. - done({ server, origin: `http://127.0.0.1:${address.port}/${profile}/` }); - }), - ); -} - -async function waitForHealth(timeoutMs: number) { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - try { - if ((await fetch(`http://127.0.0.1:${hostPort}/healthz`)).ok) return; - } catch { - // still starting - } - await new Promise((resolveWait) => setTimeout(resolveWait, 250)); - } - throw new Error("The host did not become healthy."); -} - -interface Observation { - scenario: string; - real: boolean; - milliseconds: number | undefined; - result: string; -} - -async function timed(task: () => Promise): Promise<[T, number]> { - const start = performance.now(); - const value = await task(); - return [value, Math.round(performance.now() - start)]; -} - -const authenticated = { - csrfToken: "c".repeat(24), - authenticated: true, - session: { - sessionId: "6d1d4c2a-62a0-4b86-9d3f-0c6a3d0b5c11", - expiresAt: "2026-10-03T08:00:00.000000Z", - idleExpiresAt: "2026-10-02T20:30:00.000000Z", - userId: "0f0e6a30-5d1c-4c7e-8b53-5b6b7f9a4a10", - deviceId: "8f2a1d77-1c1b-4b0a-9a55-2f1d0e5c7b21", - workspaceIds: ["3f1d3b1e-2a8c-4c44-a1c8-77a1e8f0b9a1"], - recoveryGeneration: "18446744073709551615", - purpose: "authenticate", - }, - profile: { - displayName: "Ada Lovelace", - locale: "en-GB", - timezone: "Europe/London", - revision: "9007199254740993", - }, -}; - -function watchPage(page: Page) { - const problems: string[] = []; - page.on("console", (message) => { - if (message.type() === "error") problems.push(`console: ${message.text()}`); - }); - page.on("pageerror", (error) => problems.push(`pageerror: ${error.message}`)); - void page.addInitScript(() => { - document.addEventListener("securitypolicyviolation", (event) => - console.error(`CSP violation: ${event.violatedDirective} ${event.blockedURI}`), - ); - }); - return problems; -} - -async function chat(origin: string, observations: Observation[]) { - const browser = await chromium.launch(); - const page = await browser.newPage(); - const problems = watchPage(page); - const apiCalls: string[] = []; - page.on("request", (request) => { - if (new URL(request.url()).pathname.startsWith("/api/")) apiCalls.push(request.url()); - }); - try { - await page.goto(origin); - await page.getByRole("button", { name: "Send" }).waitFor(); - assert.equal(apiCalls.length, 0, "The page must send nothing before the user acts"); - const send = async (name: string, expected: string, scenario: string) => { - await page.getByLabel("Name").fill(name); - const [, ms] = await timed(async () => { - await page.getByRole("button", { name: "Send" }).click(); - await page - .getByRole("listitem") - .filter({ hasText: expected }) - .last() - .waitFor({ timeout: 10_000 }); - }); - observations.push({ scenario, real: true, milliseconds: ms, result: expected }); - }; - await send("ArcForges", "Hello, ArcForges!", "chat: greeting answered by the real AOT host"); - await send("ArcForges 世界", "Hello, ArcForges 世界!", "chat: Unicode name round trip"); - await send("", failureText.rejected, "chat: empty name, real InvalidArgument refusal"); - await send( - "x".repeat(257), - failureText.limit, - "chat: 257 code units, real ResourceExhausted refusal", - ); - assert.equal(apiCalls.length, 4); - for (const url of apiCalls) - assert.equal(new URL(url).pathname, "/api/arcforges.hello.v1.HelloService/SayHello"); - // Cancellation: the request is held back by the harness, then cancelled by the user before the host sees it. - await page.route("**/api/**", async (route) => { - await new Promise((resolveWait) => setTimeout(resolveWait, 1500)); - await route.continue().catch(() => undefined); - }); - await page.getByLabel("Name").fill("slow"); - await page.getByRole("button", { name: "Send" }).click(); - await page.getByRole("button", { name: "Cancel" }).click(); - await page.getByRole("listitem").filter({ hasText: failureText.cancelled }).waitFor(); - await new Promise((resolveWait) => setTimeout(resolveWait, 2000)); - assert.equal(await page.getByRole("listitem").filter({ hasText: "Hello, slow!" }).count(), 0); - observations.push({ - scenario: "chat: user cancellation of a held request (real host never answers the page)", - real: false, - milliseconds: undefined, - result: failureText.cancelled, - }); - assert.deepEqual(problems, [], "Console errors or CSP violations"); - } finally { - await browser.close(); - } -} - -async function account(origin: string, observations: Observation[]) { - const browser = await chromium.launch(); - const page = await browser.newPage(); - const problems = watchPage(page); - const csrfSeen: (string | undefined)[] = []; - let stage: "authenticated" | "expired" | "down" | "malformed" = "authenticated"; - await page.route("**/session/v1/**", async (route) => { - const request = route.request(); - const path = new URL(request.url()).pathname; - if (path === "/session/v1/bootstrap") { - if (stage === "down") return route.fulfill({ status: 503, body: "down" }); - if (stage === "malformed") - return route.fulfill({ - status: 200, - contentType: "application/json", - body: '{"csrfToken":"x"}', - }); - return route.fulfill({ - status: 200, - contentType: "application/json", - body: Buffer.from(serializeBrowserBootstrapResponseJson(authenticated)), - }); - } - assert.equal(path, "/session/v1/logout"); - csrfSeen.push(request.headers()["x-af-csrf"]); - if (stage === "expired") return route.fulfill({ status: 401, body: "{}" }); - return route.fulfill({ - status: 200, - contentType: "application/json", - body: Buffer.from( - serializeBrowserReceiptJson({ - commandId: "7c9e6679-7425-40de-944b-e07fc1f90ae7", - effect: "happened", - }), - ), - }); - }); - try { - const [, readMs] = await timed(async () => { - await page.goto(origin); - await page.getByText("Ada Lovelace").waitFor(); - }); - await page.getByText("18446744073709551615").waitFor(); - observations.push({ - scenario: "account: authenticated presentation with exact uint64 (labelled session fixture)", - real: false, - milliseconds: readMs, - result: "Ada Lovelace; 18446744073709551615", - }); - const [, outMs] = await timed(async () => { - await page.getByRole("button", { name: "Sign out" }).click(); - await page.getByText("You are signed out. Reload to check again.").waitFor(); - }); - assert.deepEqual(csrfSeen, [authenticated.csrfToken]); - observations.push({ - scenario: "account: sign out sends the bootstrap CSRF token (labelled fixture)", - real: false, - milliseconds: outMs, - result: "signed out", - }); - stage = "expired"; - await page.goto(origin); - await page.getByRole("button", { name: "Sign out" }).click(); - await page.getByText("You are signed out. Reload to check again.").waitFor(); - assert.equal(await page.getByRole("alert").count(), 0); - observations.push({ - scenario: "account: session expiry (logout 401) shown as ended (labelled fixture)", - real: false, - milliseconds: undefined, - result: "ended, no alert", - }); - stage = "down"; - await page.goto(origin); - await page.getByRole("alert").filter({ hasText: failureText.unavailable }).waitFor(); - stage = "malformed"; - await page.getByRole("button", { name: "Try again" }).click(); - await page.getByRole("alert").filter({ hasText: failureText.malformed }).waitFor(); - observations.push({ - scenario: "account: unavailable then malformed bootstrap (labelled fixture)", - real: false, - milliseconds: undefined, - result: "typed alerts", - }); - assert.deepEqual( - problems.filter((problem) => !/Failed to load resource/u.test(problem)), - [], - "Console errors or CSP violations", - ); - } finally { - await browser.close(); - } -} - -async function main() { - assert.notEqual(process.env.CI, "true", "The local browser run is opt-in, never CI."); - const hostExe = process.env.AOT_HOST_EXE ?? ""; - assert( - hostExe && existsSync(hostExe), - "Set AOT_HOST_EXE to the Cloud Native AOT host executable.", - ); - let child: ChildProcess | undefined; - const servers: Server[] = []; - const observations: Observation[] = []; - try { - child = spawn(hostExe, [], { stdio: ["ignore", "ignore", "inherit"], windowsHide: true }); - await waitForHealth(60_000); - const identity = await (await fetch(`http://127.0.0.1:${hostPort}/healthz`)).text(); - const chatServer = await serve("chat"); - const accountServer = await serve("account"); - servers.push(chatServer.server, accountServer.server); - await chat(chatServer.origin, observations); - await account(accountServer.origin, observations); - const output = join(app, "build", "local-run.json"); - mkdirSync(dirname(output), { recursive: true }); - writeFileSync( - output, - `${JSON.stringify({ hostHealth: JSON.parse(identity), observations }, null, 2)}\n`, - ); - for (const observation of observations) - console.log( - `${observation.real ? "REAL " : "FIXTURE"} ${observation.scenario}: ${observation.result}${observation.milliseconds === undefined ? "" : ` (${observation.milliseconds} ms)`}`, - ); - } finally { - for (const server of servers) server.close(); - if (child && child.exitCode === null) { - child.kill(); - await new Promise((resolveWait) => child?.once("exit", resolveWait)); - } - } -} - -if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main(); diff --git a/apps/app/scripts/measure.ts b/apps/app/scripts/measure.ts deleted file mode 100644 index a7d0ca7..0000000 --- a/apps/app/scripts/measure.ts +++ /dev/null @@ -1,207 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -// Measurement and gates for the production profile builds. Pure over a built `client` directory, so the -// tests run them on fixtures; nothing here starts a browser or contacts a network. -import assert from "node:assert/strict"; -import { createHash } from "node:crypto"; -import { readdirSync, readFileSync } from "node:fs"; -import { join, posix } from "node:path"; -import { gzipSync } from "node:zlib"; -import { type DefaultTreeAdapterTypes, parse } from "parse5"; - -export interface Weight { - bytes: number; - gzip: number; -} -export interface Measurement { - profile: string; - files: number; - initialRequests: number; - initialJs: Weight; - initialCss: Weight; - totalJs: Weight; - totalCss: Weight; - htmlBytes: number; - /** SHA-256 of every file name and content hash, so two builds compare in one value. */ - digest: string; -} -export interface Baseline { - initialRequests: number; - initialJsGzip: number; - initialCssGzip: number; - totalGzip: number; - htmlBytes: number; -} -export interface Budgets { - schema: 1; - regressionPercent: number; - profiles: Record; -} - -const sha256 = (value: Uint8Array) => createHash("sha256").update(value).digest("hex"); -const weigh = (bytes: Uint8Array): Weight => ({ - bytes: bytes.byteLength, - gzip: gzipSync(bytes, { level: 9 }).byteLength, -}); -const add = (a: Weight, b: Weight): Weight => ({ - bytes: a.bytes + b.bytes, - gzip: a.gzip + b.gzip, -}); -const empty = (): Weight => ({ bytes: 0, gzip: 0 }); - -/** - * Each profile is built with its own router base (`//`, apps/app/react-router.config.ts), so its prerendered - * page is `/index.html`. The root `index.html` the build also writes is the single-page-app fallback shell: - * it is not a page of either profile and is never served. - */ -export const pageFile = (profile: string) => `${profile}/index.html`; - -/** Build-tool files that are not part of what is served. */ -export const notServed = (file: string) => - file.startsWith(".vite/") || file === "__spa-fallback.html" || file === "index.html"; - -/** The file of a request path on a profile build, or undefined for a path that is not served (the root is not a page). */ -export function servedFile(pathname: string): string | undefined { - const name = pathname.endsWith("/") ? `${pathname.slice(1)}index.html` : pathname.slice(1); - return name === "" || notServed(name) ? undefined : name; -} - -export function listFiles(directory: string, prefix = ""): string[] { - const found: string[] = []; - for (const entry of readdirSync(join(directory, prefix), { withFileTypes: true })) { - assert(!entry.isSymbolicLink(), `Symlink in a profile build: ${entry.name}`); - const file = `${prefix}${entry.name}`; - if (entry.isDirectory()) found.push(...listFiles(directory, `${file}/`)); - else found.push(file); - } - return found.sort(); -} - -/** The resources the entry page needs before it is interactive, in document order. */ -export function initialResources(html: string): { - js: string[]; - css: string[]; - references: string[]; -} { - const js: string[] = []; - const css: string[] = []; - const references: string[] = []; - const visit = (node: DefaultTreeAdapterTypes.Node) => { - if ("tagName" in node) { - const attribute = (name: string) => node.attrs.find((item) => item.name === name)?.value; - const href = attribute("href"); - const src = attribute("src"); - for (const reference of [href, src]) - if (reference !== undefined && node.tagName !== "a") references.push(reference); - if (node.tagName === "link" && attribute("rel") === "modulepreload" && href) js.push(href); - if (node.tagName === "link" && attribute("rel") === "stylesheet" && href) css.push(href); - if (node.tagName === "script" && src && !js.includes(src)) js.push(src); - } - if ("childNodes" in node) for (const child of node.childNodes) visit(child); - }; - visit(parse(html)); - return { js, css, references }; -} - -export function measureProfile(client: string, profile: string): Measurement { - const all = listFiles(client).filter((file) => !notServed(file)); - const bytes = new Map(all.map((file) => [file, readFileSync(join(client, file))])); - const index = bytes.get(pageFile(profile)); - assert(index, `Profile ${profile} has no prerendered ${pageFile(profile)}`); - const initial = initialResources(new TextDecoder("utf-8", { fatal: true }).decode(index)); - const resolve = (reference: string) => { - assert( - reference.startsWith("/") && !reference.startsWith("//"), - `Not same-origin: ${reference}`, - ); - const file = posix.normalize(reference.slice(1)); - assert(bytes.has(file), `Initial resource is missing from the build: ${reference}`); - return bytes.get(file) as Uint8Array; - }; - let initialJs = empty(); - let initialCss = empty(); - for (const reference of initial.js) initialJs = add(initialJs, weigh(resolve(reference))); - for (const reference of initial.css) initialCss = add(initialCss, weigh(resolve(reference))); - let totalJs = empty(); - let totalCss = empty(); - for (const [file, content] of bytes) { - if (file.endsWith(".js")) totalJs = add(totalJs, weigh(content)); - if (file.endsWith(".css")) totalCss = add(totalCss, weigh(content)); - } - const digest = sha256( - new TextEncoder().encode( - all.map((file) => `${file}\n${sha256(bytes.get(file) as Uint8Array)}\n`).join(""), - ), - ); - return { - profile, - files: all.length, - initialRequests: new Set([...initial.js, ...initial.css]).size + 1, - initialJs, - initialCss, - totalJs, - totalCss, - htmlBytes: index.byteLength, - digest, - }; -} - -/** Wire markers that must stay inside the profile that owns them. */ -const owned: Record = { - account: { present: "/session/v1/bootstrap", absent: "application/grpc-web" }, - chat: { present: "application/grpc-web", absent: "/session/v1/bootstrap" }, -}; - -/** Structural gates; returns every violation so a failure is complete. */ -export function verifyProfile(client: string, profile: string): string[] { - const problems: string[] = []; - const all = listFiles(client).filter((file) => !notServed(file)); - const marker = owned[profile]; - if (!marker) return [`Unknown profile ${profile}`]; - for (const file of all) { - if (file.endsWith(".map")) problems.push(`Source map shipped: ${file}`); - if (/(?:^|\/)(?:\.env|\.dev\.vars|node_modules)(?:\/|$)/u.test(file)) - problems.push(`Private file shipped: ${file}`); - } - const html = all.includes(pageFile(profile)) - ? readFileSync(join(client, pageFile(profile)), "utf8") - : ""; - if (!html) problems.push(`No prerendered ${pageFile(profile)}`); - else - for (const reference of initialResources(html).references) - if (!reference.startsWith("/") || reference.startsWith("//")) - problems.push(`Reference is not same-origin: ${reference}`); - const code = all - .filter((file) => file.endsWith(".js")) - .map((file) => readFileSync(join(client, file), "utf8")) - .join("\n"); - if (!code.includes(marker.present)) problems.push(`Profile ${profile} lost its own wire marker`); - if (code.includes(marker.absent)) - problems.push(`Profile ${profile} contains the other profile's wire marker`); - return problems; -} - -export function toBaseline(measurement: Measurement): Baseline { - return { - initialRequests: measurement.initialRequests, - initialJsGzip: measurement.initialJs.gzip, - initialCssGzip: measurement.initialCss.gzip, - totalGzip: measurement.totalJs.gzip + measurement.totalCss.gzip, - htmlBytes: measurement.htmlBytes, - }; -} - -/** The regression budget: a metric may grow at most `regressionPercent` over its baseline (Design TH-01). */ -export function checkBudgets(measurement: Measurement, budgets: Budgets): string[] { - const baseline = budgets.profiles[measurement.profile]; - if (!baseline) return [`No baseline for profile ${measurement.profile}`]; - const actual = toBaseline(measurement); - const problems: string[] = []; - for (const key of Object.keys(baseline) as (keyof Baseline)[]) { - const limit = Math.floor((baseline[key] * (100 + budgets.regressionPercent)) / 100); - if (actual[key] > limit) - problems.push( - `${measurement.profile} ${key} ${actual[key]} exceeds ${limit} (baseline ${baseline[key]})`, - ); - } - return problems; -} diff --git a/apps/app/scripts/profiles.ts b/apps/app/scripts/profiles.ts deleted file mode 100644 index a5f67fd..0000000 --- a/apps/app/scripts/profiles.ts +++ /dev/null @@ -1,102 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -// Builds the Account and Chat production profiles with the pinned toolchain, then measures and gates them. -// node scripts/profiles.ts build [--twice] [--write-baseline] [account] [chat] -// --twice rebuilds each profile from scratch and requires identical bytes (determinism proof). -// --write-baseline records the measured values as the new regression baseline (a reviewed change). -// When both profiles are built, the two builds are also written as one deterministic, digest-named bundle -// (artifacts/profiles/web-profiles-.tar, see bundle.ts) that the main-push CI publishes for the Cloud proof -// deployment. Selecting one profile writes no bundle. -import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; -import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import { contentSecurityPolicy } from "../../../tooling/project.ts"; -import { isProfile, type Profile, profiles } from "../profile.ts"; -import { defaultClients, defaultOutput, writeBundle } from "./bundle.ts"; -import { - type Budgets, - checkBudgets, - type Measurement, - measureProfile, - pageFile, - toBaseline, - verifyProfile, -} from "./measure.ts"; - -const app = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const reactRouter = resolve(app, "../../node_modules/@react-router/dev/bin.cjs"); -const budgetsPath = join(app, "budgets.json"); - -function buildOnce(profile: Profile): Measurement { - const directory = join(app, "build", profile); - // Only this task's generated profile directory is reset, after checking its absolute boundary. - assert.equal(resolve(directory), resolve(app, "build", profile)); - rmSync(directory, { recursive: true, force: true }); - const result = spawnSync(process.execPath, [reactRouter, "build"], { - cwd: app, - stdio: "inherit", - windowsHide: true, - env: { ...process.env, ARCFORGES_PROFILE: profile }, - }); - assert.equal(result.status, 0, `react-router build failed for the ${profile} profile`); - const client = join(directory, "client"); - const problems = verifyProfile(client, profile); - assert.deepEqual(problems, [], `Profile ${profile} violates its build gates`); - // The policy of the profile's own page only: the root index.html of the build is an unserved fallback shell. - const html = [readFileSync(join(client, pageFile(profile)), "utf8").replaceAll("\r\n", "\n")]; - const policy = contentSecurityPolicy(html); - assert(policy.includes("connect-src 'self'"), "Profiles may connect only to their own origin"); - assert(!policy.includes("unsafe-inline"), "Profiles allow no unsafe inline script or style"); - return measureProfile(client, profile); -} - -function main(argv: string[]) { - const [command, ...rest] = argv; - assert.equal(command, "build", "Use: build [--twice] [--write-baseline] [profile...]"); - const flags = new Set(rest.filter((item) => item.startsWith("--"))); - for (const flag of flags) - assert(["--twice", "--write-baseline"].includes(flag), `Unknown flag ${flag}`); - const named = rest.filter((item) => !item.startsWith("--")); - for (const name of named) assert(isProfile(name), `Unknown profile ${name}`); - const selected = (named.length > 0 ? named : [...profiles]) as Profile[]; - const measurements: Measurement[] = []; - for (const profile of selected) { - const first = buildOnce(profile); - if (flags.has("--twice")) - assert.equal( - buildOnce(profile).digest, - first.digest, - `Profile ${profile} is not deterministic`, - ); - measurements.push(first); - } - const output = join(app, "build", "measurements.json"); - mkdirSync(dirname(output), { recursive: true }); - writeFileSync(output, `${JSON.stringify(measurements, null, 2)}\n`); - for (const measurement of measurements) - console.log( - `${measurement.profile}: ${measurement.files} files, initial ${measurement.initialRequests} requests, ` + - `js ${measurement.initialJs.gzip} B gzip, css ${measurement.initialCss.gzip} B gzip, digest ${measurement.digest.slice(0, 12)}`, - ); - if (flags.has("--write-baseline")) { - const budgets: Budgets = JSON.parse(readFileSync(budgetsPath, "utf8")); - for (const measurement of measurements) - budgets.profiles[measurement.profile] = toBaseline(measurement); - writeFileSync(budgetsPath, `${JSON.stringify(budgets, null, 2)}\n`); - console.log("Baseline written; commit it only through a reviewed change."); - return; - } - const budgets: Budgets = JSON.parse(readFileSync(budgetsPath, "utf8")); - const problems = measurements.flatMap((measurement) => checkBudgets(measurement, budgets)); - assert.deepEqual(problems, [], "A profile exceeds its regression budget"); - if (selected.length === profiles.length) { - const bundle = writeBundle(defaultClients(), defaultOutput()); - console.log( - `bundle ${bundle.name}: ${Object.keys(bundle.manifest.files).length} files, ${bundle.archive.byteLength} bytes`, - ); - } -} - -if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) - main(process.argv.slice(2)); diff --git a/apps/app/scripts/proof-lib.ts b/apps/app/scripts/proof-lib.ts deleted file mode 100644 index 32afd84..0000000 --- a/apps/app/scripts/proof-lib.ts +++ /dev/null @@ -1,171 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -// Pure helpers of the opt-in live run against the deployed proof environment (`proof-run.ts`). Nothing here -// reads a file, starts a browser or contacts a network, so the unit tests exercise it directly. -import { createHash, randomBytes, sign, type KeyObject } from "node:crypto"; - -/** The exact text the proof Worker verifies (Cloud `worker/foundation/operator-signature.ts`, `AF-OPERATOR-V2`). */ -export function operatorMessage( - method: string, - host: string, - pathname: string, - time: string, - nonce: string, - bodySha256Hex: string, -): string { - return `AF-OPERATOR-V2\n${method}\n${host}\n${pathname}\n${time}\n${nonce}\n${bodySha256Hex}`; -} - -/** The Authorization header value of one operator request, signed with an Ed25519 private key. */ -export function signOperatorRequest( - key: KeyObject, - method: string, - host: string, - pathname: string, - body: Uint8Array, - options: { nowSeconds?: number; nonce?: string } = {}, -): string { - const time = String(options.nowSeconds ?? Math.floor(Date.now() / 1000)); - const nonce = options.nonce ?? randomBytes(16).toString("base64url"); - const digest = createHash("sha256").update(body).digest("hex"); - const message = operatorMessage(method, host, pathname, time, nonce, digest); - const signature = sign(null, Buffer.from(message, "utf8"), key).toString("base64url"); - return `AF-Operator t=${time},n=${nonce},s=${signature}`; -} - -export const sessionCookieName = "__Host-af_session"; - -/** - * Request headers the forwarder passes on to the deployed origin. The page's own `Origin` is replaced by the - * deployed one (the proof Worker checks the exact configured value) and the cookie comes from the harness jar, - * because a page served from a local origin can neither send nor store a `__Host-` cookie of another host. - */ -export function forwardedHeaders( - incoming: Record, - deployedOrigin: string, - cookie: string | undefined, -): Record { - const allowed = [ - "accept", - "content-type", - "x-af-csrf", - "x-grpc-web", - "x-user-agent", - "grpc-timeout", - ]; - const headers: Record = { origin: deployedOrigin }; - for (const name of allowed) { - const value = incoming[name]; - if (typeof value === "string") headers[name] = value; - } - if (cookie !== undefined) headers.cookie = `${sessionCookieName}=${cookie}`; - return headers; -} - -/** What a browser would do with the session cookie of a response: store a value, clear it, or keep what it has. */ -export function nextCookie( - current: string | undefined, - setCookies: readonly string[], -): string | undefined { - let value = current; - for (const header of setCookies) { - const match = /^__Host-af_session=([^;]*)/u.exec(header); - if (!match) continue; - const text = match[1] ?? ""; - // An empty value, or one that is already expired, deletes the cookie. - const expired = - /(?:^|;\s*)max-age=0(?:;|$)/iu.test(header) || /(?:^|;\s*)expires=.*1970/iu.test(header); - value = text === "" || expired ? undefined : text; - } - return value; -} - -export interface Summary { - samples: number; - minMs: number; - medianMs: number; - maxMs: number; -} - -export function summarize(samples: readonly number[]): Summary { - if (samples.length === 0) throw new Error("No samples to summarize"); - const sorted = [...samples].sort((a, b) => a - b); - const middle = Math.floor(sorted.length / 2); - const median = - sorted.length % 2 === 1 - ? (sorted[middle] as number) - : ((sorted[middle - 1] as number) + (sorted[middle] as number)) / 2; - return { - samples: sorted.length, - minMs: sorted[0] as number, - medianMs: Math.round(median), - maxMs: sorted[sorted.length - 1] as number, - }; -} - -export interface InteractionBudgets { - schema: 1; - /** Where the numbers come from; Design defines no absolute interaction ceiling. */ - basis: string; - minimumSamples: number; - interactions: Record; -} - -/** Problems of a live measurement against the recorded ceilings; empty when it is within them. */ -export function checkInteractionBudgets( - measured: Record, - budgets: InteractionBudgets, -): string[] { - const problems: string[] = []; - for (const [name, ceiling] of Object.entries(budgets.interactions)) { - const summary = measured[name]; - if (!summary) { - problems.push(`${name}: not measured`); - continue; - } - if (summary.samples < budgets.minimumSamples) - problems.push(`${name}: ${summary.samples} samples, ${budgets.minimumSamples} required`); - if (summary.medianMs > ceiling.medianCeilingMs) - problems.push(`${name}: median ${summary.medianMs} ms over ${ceiling.medianCeilingMs} ms`); - if (summary.maxMs > ceiling.maxCeilingMs) - problems.push(`${name}: max ${summary.maxMs} ms over ${ceiling.maxCeilingMs} ms`); - } - for (const name of Object.keys(measured)) - if (!(name in budgets.interactions)) problems.push(`${name}: measured but has no budget`); - return problems; -} - -/** The fixed deployed route for one of the three exact page routes; nothing the page sends chooses a URL. */ -export function forwardTarget(pathname: string): string | undefined { - if (pathname === "/session/v1/bootstrap") return "/session/v1/bootstrap"; - if (pathname === "/session/v1/logout") return "/session/v1/logout"; - if (pathname === "/api/arcforges.hello.v1.HelloService/SayHello") - return "/api/arcforges.hello.v1.HelloService/SayHello"; - return undefined; -} - -/** The only origin a live run may target unless the caller opts in explicitly. */ -export const proofOrigin = "https://proof.arcforges.com"; - -/** The origin of a run: the proof origin by default; any other https origin only with an explicit opt-in. */ -export function assertProofOrigin( - requested: string | undefined, - optIn: string | undefined, -): string { - const origin = requested ?? proofOrigin; - if (!/^https:\/\/[a-z0-9.-]+$/u.test(origin)) - throw new Error("PROOF_BASE_URL must be an https origin without a path."); - if (origin !== proofOrigin && optIn !== "1") - throw new Error(`PROOF_BASE_URL must be ${proofOrigin} unless PROOF_ALLOW_OTHER_ORIGIN=1.`); - return origin; -} - -/** True only for a request addressed to the harness's own loopback listener and not initiated by another origin. */ -export function isOwnLoopbackRequest( - headers: Record, - port: number, -): boolean { - const own = `127.0.0.1:${port}`; - if (headers.host !== own) return false; - const origin = headers.origin; - return origin === undefined || origin === `http://${own}`; -} diff --git a/apps/app/scripts/proof-run.ts b/apps/app/scripts/proof-run.ts deleted file mode 100644 index cdef268..0000000 --- a/apps/app/scripts/proof-run.ts +++ /dev/null @@ -1,834 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -// Explicit local opt-in run of the built Account and Chat profiles against the DEPLOYED proof environment -// (Cloud PRF.07, `https://proof.arcforges.com`). Never CI. The holder of this run must hold the shared lease -// RES-cloud-deployment (python tools/delivery.py claim RES-cloud-deployment --worker W --task PRF.08) only for -// the run and release it immediately afterwards. -// -// npm run build:profiles && node scripts/proof-run.ts -// -// What is real: the production profile bytes, the generated SDK inside them, a real Chromium, and the -// deployed Worker, Container, D1 and session code behind `/api` and `/session/v1`. The session comes from the -// proof-only operator operation `session/issue`, signed by the local operator key (read and used only here, never -// printed or written; only the public key is trusted by the Worker). -// -// Where the pages come from (PROOF_PAGES): -// served (default) the browser loads `https://proof.arcforges.com/account/` and `/chat/` from the deployed proof -// Worker itself (CLOUD.71): page, /api and /session/v1 share one origin, cookie and CSRF boundary. -// The harness only issues the session (operator-signed) and puts its cookie in the browser -// context, as a login would; the page's own requests go straight to the deployed origin. -// forwarded the earlier substitute: a local server serves the local builds and forwards three exact routes -// to the deployed origin, with the page's `Origin` replaced by the deployed one and the session -// cookie kept in a harness jar. It is a substitute for same-origin routing, not the deployed -// same-origin ingress, and the evidence says so. -// Nothing secret is recorded in the evidence file. -import assert from "node:assert/strict"; -import { createHash, createPrivateKey, type KeyObject } from "node:crypto"; -import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; -import { createServer, type Server } from "node:http"; -import { homedir } from "node:os"; -import { dirname, extname, join, resolve, sep } from "node:path"; -import { fileURLToPath } from "node:url"; -import { type BrowserContext, chromium, type Page } from "@playwright/test"; -import { failureText, isProbeFailure } from "../app/probe/failure.ts"; -import { sayHello } from "../app/probe/hello.ts"; -import { endSession, readSession } from "../app/probe/session.ts"; -import { profileCsp } from "./bundle.ts"; -import { initialResources, pageFile, servedFile } from "./measure.ts"; -import type { Profile } from "../profile.ts"; -import { - checkInteractionBudgets, - forwardedHeaders, - type InteractionBudgets, - assertProofOrigin, - forwardTarget, - isOwnLoopbackRequest, - nextCookie, - sessionCookieName, - signOperatorRequest, - summarize, - type Summary, -} from "./proof-lib.ts"; - -const isFile = (file: string) => existsSync(file) && statSync(file).isFile(); -const app = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const repetitions = 5; -const types: Record = { - ".html": "text/html; charset=utf-8", - ".js": "text/javascript; charset=utf-8", - ".css": "text/css; charset=utf-8", - ".svg": "image/svg+xml", - ".txt": "text/plain; charset=utf-8", -}; - -interface Observation { - scenario: string; - /** `deployed` hit the deployed Worker; `harness` is decided by the harness only. */ - path: "deployed" | "harness"; - result: string; -} -interface Hop { - method: string; - path: string; - status: number; -} - -function record(samples: Record, name: string, value: number) { - const list = samples[name] ?? []; - list.push(value); - samples[name] = list; -} - -const sleep = (ms: number) => new Promise((resolveWait) => setTimeout(resolveWait, ms)); - -async function timed(task: () => Promise): Promise { - const start = performance.now(); - await task(); - return Math.round(performance.now() - start); -} - -function loadKey(): KeyObject { - const file = - process.env.PROOF_OPERATOR_KEY_FILE ?? - join(homedir(), ".arcforges", "proof-operator", "ed25519-private.pem"); - assert( - existsSync(file), - "No operator key file; create it with the Cloud `operator:proof` script.", - ); - const key = createPrivateKey(readFileSync(file)); - assert.equal(key.asymmetricKeyType, "ed25519", "The operator key must be Ed25519."); - return key; -} - -interface Issued { - handle: string; - csrfToken: string; -} - -class Proof { - readonly origin: string; - readonly host: string; - private readonly key: KeyObject; - constructor(origin: string, key: KeyObject) { - this.origin = origin; - this.key = key; - this.host = new URL(origin).host; - } - - async issue(options: { idleSeconds?: number; absoluteSeconds?: number } = {}): Promise { - const pathname = "/proof/v1/session/issue"; - const body = JSON.stringify({ - userId: crypto.randomUUID(), - deviceId: crypto.randomUUID(), - workspaceIds: [crypto.randomUUID()], - ...options, - }); - const bytes = new TextEncoder().encode(body); - const response = await fetch(new URL(pathname, this.origin), { - method: "POST", - headers: { - authorization: signOperatorRequest(this.key, "POST", this.host, pathname, bytes), - "content-type": "application/json", - }, - body, - redirect: "error", - signal: AbortSignal.timeout(60_000), - }); - assert.equal(response.status, 200, `session/issue answered ${response.status}`); - const json = (await response.json()) as Record; - assert.equal(typeof json.handle, "string"); - assert.equal(typeof json.csrfToken, "string"); - return { handle: json.handle as string, csrfToken: json.csrfToken as string }; - } - - /** A fetcher for the profile's own session client, carrying `Origin` and the cookie a browser would. */ - fetcher(cookie: string | undefined, origin = this.origin): typeof fetch { - return (input, init) => { - const headers = new Headers(init?.headers); - headers.set("origin", origin); - if (cookie !== undefined) headers.set("cookie", `${sessionCookieName}=${cookie}`); - return fetch(input, { ...init, headers }); - }; - } -} - -/** The earlier substitute: serves one local build and forwards its three exact routes to the deployed origin. */ -function forwardedServer(profile: Profile, proof: Proof) { - const client = join(app, "build", profile, "client"); - assert(existsSync(join(client, pageFile(profile))), `Build the ${profile} profile first`); - const policy = profileCsp(readFileSync(join(client, pageFile(profile)), "utf8")); - const state = { - cookie: undefined as string | undefined, - /** Holds the answer of the next forwarded `/api` call, after the deployed host has answered it. */ - delayApiResponseMs: 0, - hops: [] as Hop[], - forwardedApi: 0, - }; - const server = createServer((incoming, outgoing) => { - void (async () => { - const address = server.address(); - const port = address && typeof address === "object" ? address.port : 0; - // A page or a DNS-rebound name must not drive the session jar of a run: loopback Host and own Origin only. - if (!isOwnLoopbackRequest(incoming.headers, port)) { - outgoing.writeHead(403, { "content-type": "text/plain" }).end("Forbidden"); - return; - } - const url = new URL(incoming.url ?? "/", "http://local"); - // Only these exact routes are forwarded, to fixed targets: nothing the page sends chooses a URL. - const target = forwardTarget(url.pathname); - const forwarded = target !== undefined; - if (forwarded) { - const chunks: Buffer[] = []; - for await (const chunk of incoming) chunks.push(chunk as Buffer); - const aborted = new AbortController(); - outgoing.on("close", () => { - if (!outgoing.writableFinished) aborted.abort(); - }); - let reply: Response; - try { - reply = await fetch(new URL(target ?? "/", proof.origin), { - method: incoming.method ?? "GET", - headers: forwardedHeaders(incoming.headers, proof.origin, state.cookie), - ...(chunks.length > 0 ? { body: Buffer.concat(chunks) } : {}), - redirect: "manual", - signal: AbortSignal.any([aborted.signal, AbortSignal.timeout(60_000)]), - }); - } catch { - if (!outgoing.destroyed) outgoing.writeHead(503).end(); - return; - } - state.cookie = nextCookie(state.cookie, reply.headers.getSetCookie()); - const bytes = Buffer.from(await reply.arrayBuffer()); - state.hops.push({ - method: incoming.method ?? "GET", - path: url.pathname.startsWith("/api/") ? "/api//" : url.pathname, - status: reply.status, - }); - if (url.pathname.startsWith("/api/")) { - state.forwardedApi += 1; - if (state.delayApiResponseMs > 0) await sleep(state.delayApiResponseMs); - } - if (outgoing.destroyed) return; - const headers: Record = {}; - for (const name of ["content-type", "cache-control"]) { - const value = reply.headers.get(name); - if (value !== null) headers[name] = value; - } - outgoing.writeHead(reply.status, headers).end(bytes); - return; - } - const name = servedFile(url.pathname); - const file = resolve(client, name ?? ""); - if (name === undefined || !file.startsWith(`${client}${sep}`) || !isFile(file)) { - outgoing.writeHead(404, { "content-type": "text/plain" }).end("Not found"); - return; - } - outgoing.writeHead(200, { - "content-type": types[extname(file)] ?? "application/octet-stream", - "content-security-policy": policy, - "x-content-type-options": "nosniff", - "cache-control": "no-store", - }); - outgoing.end(readFileSync(file)); - })(); - }); - return new Promise<{ server: Server; origin: string; state: typeof state }>((done) => - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - assert(address && typeof address === "object"); - done({ server, origin: `http://127.0.0.1:${address.port}`, state }); - }), - ); -} - -/** What a stage needs of the place a profile page comes from; the stages are the same in both modes. */ -interface Site { - /** The URL of the profile page. */ - url: string; - state: { hops: Hop[]; forwardedApi: number }; - /** Called once with the browser context and page the stage drives. */ - attach(context: BrowserContext, page: Page): void; - /** Gives the browser the session cookie, as a login would, or removes it. */ - setSession(handle: string | undefined): Promise; - hasSession(): Promise; - /** Holds every `/api` answer back from the page for this long, after the deployed host has answered it. */ - delayApi(milliseconds: number): Promise; - close(): void; -} - -async function forwardedSite(profile: Profile, proof: Proof): Promise { - const served = await forwardedServer(profile, proof); - return { - url: `${served.origin}/${profile}/`, - state: served.state, - attach: () => undefined, - setSession: async (handle) => { - served.state.cookie = handle; - }, - hasSession: async () => served.state.cookie !== undefined, - delayApi: async (milliseconds) => { - served.state.delayApiResponseMs = milliseconds; - }, - close: () => { - served.server.close(); - }, - }; -} - -const kindOfPath = (pathname: string): string | undefined => - pathname === "/session/v1/bootstrap" || pathname === "/session/v1/logout" - ? pathname - : pathname.startsWith("/api/") - ? "/api//" - : undefined; - -/** The deployed origin serves the page: the browser's own requests go straight to it, nothing is forwarded. */ -function servedSite(profile: Profile, proof: Proof): Site { - const state = { hops: [] as Hop[], forwardedApi: 0 }; - const held = new WeakSet(); - let context: BrowserContext | undefined; - let page: Page | undefined; - const note = (method: string, pathname: string, status: number) => { - const path = kindOfPath(pathname); - if (path === undefined) return; - state.hops.push({ method, path, status }); - if (path.startsWith("/api/")) state.forwardedApi += 1; - }; - return { - url: `${proof.origin}/${profile}/`, - state, - attach(browserContext, browserPage) { - context = browserContext; - page = browserPage; - browserPage.on("response", (response) => { - const url = new URL(response.url()); - if (url.origin !== proof.origin || held.has(response.request())) return; - note(response.request().method(), url.pathname, response.status()); - }); - }, - async setSession(handle) { - assert(context, "attach first"); - await context.clearCookies({ name: sessionCookieName }); - if (handle !== undefined) - await context.addCookies([ - { - name: sessionCookieName, - value: handle, - url: proof.origin, - httpOnly: true, - secure: true, - }, - ]); - }, - async hasSession() { - assert(context, "attach first"); - return (await context.cookies(proof.origin)).some( - (cookie) => cookie.name === sessionCookieName, - ); - }, - async delayApi(milliseconds) { - assert(page, "attach first"); - await page.unroute("**/api/**"); - if (milliseconds === 0) return; - await page.route("**/api/**", async (route) => { - held.add(route.request()); - // The deployed host answers for real; only the answer's arrival at the page is held back. - const response = await route.fetch(); - note(route.request().method(), new URL(route.request().url()).pathname, response.status()); - await sleep(milliseconds); - await route.fulfill({ response }).catch(() => undefined); - }); - }, - close: () => undefined, - }; -} - -const pageMode = (): "served" | "forwarded" => { - const mode = process.env.PROOF_PAGES ?? "served"; - assert(["served", "forwarded"].includes(mode), "PROOF_PAGES must be served or forwarded."); - return mode as "served" | "forwarded"; -}; - -async function openSite(profile: Profile, proof: Proof): Promise { - return pageMode() === "served" ? servedSite(profile, proof) : forwardedSite(profile, proof); -} - -const sha256 = (value: Uint8Array) => createHash("sha256").update(value).digest("hex"); - -/** - * Opens the profile page. In served mode the page's own response is part of the observation: the profile's - * Content-Security-Policy and security headers must arrive with it from the deployed origin, and, when this - * checkout holds the local build, the served page and the resources it loads first must be that build's bytes. - */ -async function openPage(page: Page, site: Site, profile: Profile, observations: Observation[]) { - const response = await page.goto(site.url); - assert(response, `no navigation response from ${site.url}`); - assert.equal(response.status(), 200, `${site.url} answered ${response.status()}`); - if (pageMode() !== "served") return; - const headers = response.headers(); - const policy = headers["content-security-policy"] ?? ""; - assert(policy.includes("connect-src 'self'"), "the page arrived without its profile policy"); - assert(!policy.includes("unsafe-inline"), "the profile policy allows unsafe inline content"); - assert.equal(headers["x-content-type-options"], "nosniff"); - assert.equal(headers["x-frame-options"], "DENY"); - const client = join(app, "build", profile, "client"); - const local = join(client, pageFile(profile)); - let comparison = "no local build in this checkout, so the bytes were not compared"; - if (isFile(local)) { - const html = readFileSync(local, "utf8").replaceAll("\r\n", "\n"); - assert.equal( - policy, - profileCsp(html), - "the served policy is not the one derived from the build", - ); - assert.equal( - sha256(await response.body()), - sha256(Buffer.from(html)), - "the served page is not the build's page", - ); - const initial = initialResources(html); - const origin = new URL(site.url).origin; - for (const reference of [...initial.js, ...initial.css]) { - const reply = await fetch(new URL(reference, origin), { - signal: AbortSignal.timeout(30_000), - }); - assert.equal(reply.status, 200, `${reference} answered ${reply.status}`); - assert.equal( - sha256(new Uint8Array(await reply.arrayBuffer())), - sha256(readFileSync(join(client, reference.slice(1)))), - `${reference} is not the build's file`, - ); - } - comparison = `the page and its ${initial.js.length + initial.css.length} initial resources equal the local build's bytes`; - } - observations.push({ - scenario: `${profile} (browser): page served by the deployed origin itself with its profile policy`, - path: "deployed", - result: `HTTP 200 on ${new URL(site.url).pathname}; policy with connect-src 'self' and no unsafe-inline, nosniff, frame denied; ${comparison}`, - }); -} - -function watchPage(page: Page) { - const problems: string[] = []; - page.on("console", (message) => { - if (message.type() === "error") problems.push(`console: ${message.text()}`); - }); - page.on("pageerror", (error) => problems.push(`pageerror: ${error.message}`)); - void page.addInitScript(() => { - document.addEventListener("securitypolicyviolation", (event) => - console.error(`CSP violation: ${event.violatedDirective} ${event.blockedURI}`), - ); - }); - return problems; -} - -/** - * The proof Containers sleep when idle and a cold start takes longer than the profile's ten second deadline - * (observed: the first calls answer 503 or 504 for one to two minutes). Warm both routes with the profile's own - * clients, bounded as the Cloud live scenarios are, so the measured interactions are warm ones. - */ -async function warm(proof: Proof, observations: Observation[]) { - const routes: [string, () => Promise][] = [ - [ - "session route (foundation Container)", - () => - readSession({ - origin: proof.origin, - signal: new AbortController().signal, - fetcher: proof.fetcher(undefined), - }), - ], - [ - "chat route (Hello Container)", - () => - sayHello(proof.origin, "warm-up", new AbortController().signal, proof.fetcher(undefined)), - ], - ]; - for (const [name, call] of routes) { - const start = performance.now(); - for (;;) { - try { - await call(); - break; - } catch (error) { - assert(isProbeFailure(error), "an untyped error escaped the profile client"); - assert( - performance.now() - start < 240_000, - `the ${name} did not warm up in 240 s (${error.kind})`, - ); - await sleep(3000); - } - } - observations.push({ - scenario: `warm-up of the ${name}, not budgeted`, - path: "deployed", - result: `first success after ${Math.round(performance.now() - start)} ms`, - }); - } -} - -/** The profile's own clients, from Node, against the deployed origin: status and failure kinds exactly as the page maps them. */ -async function nodeScenarios(proof: Proof, observations: Observation[]) { - const options = (cookie: string | undefined, signal = new AbortController().signal) => ({ - origin: proof.origin, - signal, - fetcher: proof.fetcher(cookie), - }); - const kindOf = async (task: Promise) => { - try { - await task; - return "no failure"; - } catch (error) { - assert(isProbeFailure(error), "an untyped error escaped the profile client"); - return error.kind; - } - }; - await warm(proof, observations); - const anonymous = await readSession(options(undefined)); - assert.equal(anonymous.state, "anonymous"); - observations.push({ - scenario: "session: anonymous bootstrap parsed by the generated codec", - path: "deployed", - result: "anonymous with a CSRF token", - }); - - const issued = await proof.issue(); - const session = await readSession(options(issued.handle)); - assert.equal(session.state, "authenticated"); - // Compared without printing either value: a failed assert.equal would show both tokens. - assert( - session.csrfToken === issued.csrfToken, - "the bootstrap token must equal the issued one (values withheld)", - ); - assert.equal(session.state === "authenticated" && session.workspaces, 1); - assert.equal(session.state === "authenticated" && session.recoveryGeneration, "0"); - observations.push({ - scenario: "session: real C# bootstrap body parsed by the generated TypeScript codec", - path: "deployed", - result: - "authenticated; one workspace; exact uint64 recovery generation 0; CSRF token equals the issued one", - }); - - assert.equal( - await kindOf(endSession({ ...options(issued.handle), csrfToken: "x".repeat(43) })), - "forbidden", - ); - assert.equal( - await kindOf( - endSession({ - origin: proof.origin, - signal: new AbortController().signal, - fetcher: proof.fetcher(issued.handle, "https://evil.example"), - csrfToken: issued.csrfToken, - }), - ), - "forbidden", - ); - assert.equal( - await kindOf(endSession({ ...options(undefined), csrfToken: issued.csrfToken })), - "unauthenticated", - ); - assert.equal((await readSession(options(issued.handle))).state, "authenticated"); - observations.push({ - scenario: - "session: wrong CSRF token, wrong Origin and missing cookie are refused and leave the session alive", - path: "deployed", - result: "forbidden, forbidden, unauthenticated; session still authenticated", - }); - - assert.equal( - await endSession({ ...options(issued.handle), csrfToken: issued.csrfToken }), - "happened", - ); - assert.equal( - await kindOf(endSession({ ...options(issued.handle), csrfToken: issued.csrfToken })), - "unauthenticated", - ); - assert.equal((await readSession(options(issued.handle))).state, "anonymous"); - observations.push({ - scenario: "session: logout with the bootstrap CSRF token, receipt happened, revocation durable", - path: "deployed", - result: "happened; second logout unauthenticated; bootstrap anonymous", - }); - - const controller = new AbortController(); - const pending = kindOf(readSession(options(undefined, controller.signal))); - controller.abort(); - assert.equal(await pending, "cancelled"); - observations.push({ - scenario: "session: user cancellation of an in-flight bootstrap", - path: "deployed", - result: "cancelled", - }); - - const hello = (name: string) => - sayHello(proof.origin, name, new AbortController().signal, proof.fetcher(undefined)); - assert.equal(await hello("ArcForges"), "Hello, ArcForges!"); - assert.equal(await hello("ArcForges 世界"), "Hello, ArcForges 世界!"); - assert.equal(await kindOf(hello("")), "rejected"); - assert.equal(await kindOf(hello("x".repeat(257))), "limit"); - observations.push({ - scenario: "chat route: generated Hello client through the deployed /api route", - path: "deployed", - result: "greeting, Unicode, empty name rejected, 257 code units limit", - }); -} - -async function chat(proof: Proof, samples: Record, observations: Observation[]) { - const served = await openSite("chat", proof); - const browser = await chromium.launch(); - const context = await browser.newContext(); - const page = await context.newPage(); - served.attach(context, page); - const problems = watchPage(page); - try { - await openPage(page, served, "chat", observations); - await page.getByRole("button", { name: "Send" }).waitFor(); - assert.equal(served.state.forwardedApi, 0, "The page must send nothing before the user acts"); - const send = async (name: string, expected: string) => { - await page.getByLabel("Name").fill(name); - return timed(async () => { - await page.getByRole("button", { name: "Send" }).click(); - await page - .getByRole("listitem") - .filter({ hasText: expected }) - .last() - .waitFor({ timeout: 15_000 }); - }); - }; - for (let index = 0; index < repetitions; index++) - record( - samples, - "chat.greeting", - await send(`ArcForges ${index}`, `Hello, ArcForges ${index}!`), - ); - await send("ArcForges 世界", "Hello, ArcForges 世界!"); - await send("", failureText.rejected); - await send("x".repeat(257), failureText.limit); - assert.equal(served.state.forwardedApi, repetitions + 3); - assert(served.state.hops.every((hop) => hop.status === 200)); - observations.push({ - scenario: - "chat (browser): greetings, Unicode, rejected and limit refusals through the deployed route", - path: "deployed", - result: `${repetitions} greetings, Unicode, rejected, limit; every gRPC-Web call answered HTTP 200`, - }); - // Cancellation on the real path: the deployed host answers, the page's user cancels first and shows nothing. - await served.delayApi(2000); - const before = served.state.forwardedApi; - await page.getByLabel("Name").fill("slow"); - await page.getByRole("button", { name: "Send" }).click(); - await page.getByRole("button", { name: "Cancel" }).click(); - await page.getByRole("listitem").filter({ hasText: failureText.cancelled }).waitFor(); - await sleep(3000); - await served.delayApi(0); - assert.equal(await page.getByRole("listitem").filter({ hasText: "Hello, slow!" }).count(), 0); - assert.equal( - served.state.forwardedApi, - before + 1, - "the request must have reached the deployed host", - ); - observations.push({ - scenario: - "chat (browser): user cancellation of a request the deployed host was still answering", - path: "deployed", - result: `${failureText.cancelled} The answer arriving later was not shown.`, - }); - assert.deepEqual(problems, [], "Console errors or CSP violations"); - } finally { - await browser.close(); - served.close(); - } -} - -async function account( - proof: Proof, - samples: Record, - observations: Observation[], -) { - const served = await openSite("account", proof); - const browser = await chromium.launch(); - const context = await browser.newContext(); - const page = await context.newPage(); - served.attach(context, page); - const problems = watchPage(page); - const signedOut = "You are signed out. Reload to check again."; - try { - await served.setSession(undefined); - await openPage(page, served, "account", observations); - try { - await page.getByText("You are not signed in.").waitFor({ timeout: 15_000 }); - } catch (error) { - console.error( - "page text:", - await page.locator("body").innerText(), - served.state.hops, - problems, - ); - throw error; - } - observations.push({ - scenario: "account (browser): anonymous presentation from the deployed bootstrap", - path: "deployed", - result: "You are not signed in.", - }); - - for (let index = 0; index < repetitions; index++) { - const issued = await proof.issue(); - await served.setSession(issued.handle); - record( - samples, - "account.read", - await timed(async () => { - await page.goto(served.url); - await page.getByText("Recovery generation").waitFor({ timeout: 15_000 }); - }), - ); - if (index === 0) { - await page.locator("dd.exact").filter({ hasText: /^0$/u }).waitFor(); - observations.push({ - scenario: "account (browser): authenticated presentation from a real issued session", - path: "deployed", - result: "display name absent, one workspace, exact uint64 recovery generation 0", - }); - } - const hopsBefore = served.state.hops.length; - record( - samples, - "account.signout", - await timed(async () => { - await page.getByRole("button", { name: "Sign out" }).click(); - await page.getByText(signedOut).waitFor({ timeout: 15_000 }); - }), - ); - const logout = served.state.hops - .slice(hopsBefore) - .find((hop) => hop.path === "/session/v1/logout"); - assert.equal(logout?.status, 200); - assert.equal(await served.hasSession(), false, "the server must clear the session cookie"); - assert.equal( - ( - await readSession({ - origin: proof.origin, - signal: new AbortController().signal, - fetcher: proof.fetcher(issued.handle), - }) - ).state, - "anonymous", - "the signed-out session must be revoked on the deployed host", - ); - } - observations.push({ - scenario: - "account (browser): sign out with the bootstrap CSRF token, cookie cleared by the server, session revoked", - path: "deployed", - result: `${repetitions} times: logout 200, Set-Cookie cleared the cookie, bootstrap then anonymous`, - }); - - // Session expiry on the real path: an idle session that is not used ends; its sign-out is a 401. - const idleSeconds = 8; - const short = await proof.issue({ idleSeconds, absoluteSeconds: 120 }); - await served.setSession(short.handle); - await page.goto(served.url); - await page.getByText("Recovery generation").waitFor(); - await sleep((idleSeconds + 3) * 1000); - const hopsBefore = served.state.hops.length; - await page.getByRole("button", { name: "Sign out" }).click(); - await page.getByText(signedOut).waitFor(); - assert.equal(await page.getByRole("alert").count(), 0); - const expired = served.state.hops - .slice(hopsBefore) - .find((hop) => hop.path === "/session/v1/logout"); - assert.equal(expired?.status, 401, "an expired session must be refused with 401"); - observations.push({ - scenario: "account (browser): idle session expiry, then sign out", - path: "deployed", - result: "deployed logout answered 401; shown as ended with no alert", - }); - assert.deepEqual( - problems.filter((problem) => !/Failed to load resource/u.test(problem)), - [], - ); - } finally { - await browser.close(); - served.close(); - } -} - -/** - * Served mode: the Worker still answers its own route families first, and the assets answer only their own - * paths. Status and content type only; nothing here carries a credential. - */ -async function routeChecks(proof: Proof, observations: Observation[]) { - const status = async (pathname: string) => { - const reply = await fetch(new URL(pathname, proof.origin), { - redirect: "manual", - signal: AbortSignal.timeout(30_000), - }); - await reply.arrayBuffer(); - return { status: reply.status, type: reply.headers.get("content-type") ?? "" }; - }; - const root = await status("/"); - assert.notEqual(root.status, 200, "the origin root is not a profile page"); - const unsigned = await status("/proof/v1/readiness"); - assert( - unsigned.status >= 400 && unsigned.status < 500 && !unsigned.type.startsWith("text/html"), - "an unsigned proof request must be refused by the Worker, not answered by an asset", - ); - const missing = await status("/chat/not-a-file.js"); - assert.notEqual(missing.status, 200); - observations.push({ - scenario: "origin routes: the root, an unsigned /proof/v1 request and an unknown profile path", - path: "deployed", - result: `root ${root.status}, unsigned proof request ${unsigned.status} (${unsigned.type || "no type"}), unknown path ${missing.status}`, - }); -} - -async function main() { - assert.notEqual(process.env.CI, "true", "The live run is local opt-in, never CI."); - const origin = assertProofOrigin( - process.env.PROOF_BASE_URL, - process.env.PROOF_ALLOW_OTHER_ORIGIN, - ); - const proof = new Proof(origin, loadKey()); - const observations: Observation[] = []; - const samples: Record = {}; - const startedAt = new Date().toISOString(); - if (pageMode() === "served") await routeChecks(proof, observations); - await nodeScenarios(proof, observations); - await account(proof, samples, observations); - await chat(proof, samples, observations); - const measured: Record = {}; - for (const [name, values] of Object.entries(samples)) measured[name] = summarize(values); - const budgetsFile = join(app, "interaction-budgets.json"); - const budgets = existsSync(budgetsFile) - ? (JSON.parse(readFileSync(budgetsFile, "utf8")) as InteractionBudgets) - : undefined; - const budgetProblems = budgets - ? checkInteractionBudgets(measured, budgets) - : ["no interaction-budgets.json"]; - const output = join(app, "build", "proof-run.json"); - mkdirSync(dirname(output), { recursive: true }); - writeFileSync( - output, - `${JSON.stringify({ startedAt, finishedAt: new Date().toISOString(), host: new URL(origin).host, pages: pageMode(), observations, measured, budgetProblems }, null, 2)}\n`, - ); - for (const observation of observations) - console.log(`${observation.path.toUpperCase()} ${observation.scenario}: ${observation.result}`); - for (const [name, summary] of Object.entries(measured)) - console.log(`${name}: ${JSON.stringify(summary)}`); - if (!budgets) { - console.log( - "PARTIAL: every scenario passed and the measurements are written, but apps/app/interaction-budgets.json does not exist, so no interaction budget was checked. The run is not a pass.", - ); - process.exitCode = 3; - return; - } - if (budgetProblems.length > 0) { - console.log(`FAILED interaction budgets:\n${budgetProblems.join("\n")}`); - process.exitCode = 1; - return; - } - console.log("Live profile run passed."); -} - -if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main(); diff --git a/apps/app/tsconfig.json b/apps/app/tsconfig.json deleted file mode 100644 index 027a63f..0000000 --- a/apps/app/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "include": [ - "app/**/*.ts", - "app/**/*.tsx", - "*.ts", - "scripts/**/*.ts", - "../../packages/ui/src/**/*" - ] -} diff --git a/apps/app/vite.config.ts b/apps/app/vite.config.ts deleted file mode 100644 index 36b24bb..0000000 --- a/apps/app/vite.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -import { reactRouter } from "@react-router/dev/vite"; -import tailwindcss from "@tailwindcss/vite"; -import { defineConfig } from "vite"; - -// Unlike the site candidate, the profile builds are measured, not sealed or deployed, so they do not -// carry the candidate's browser-graph observer (see docs/prf-08-profile-proof.md). -export default defineConfig({ - plugins: [tailwindcss(), reactRouter()], - build: { sourcemap: false }, -}); diff --git a/apps/site/app/cloud-hello.ts b/apps/site/app/cloud-hello.ts deleted file mode 100644 index 209b40b..0000000 --- a/apps/site/app/cloud-hello.ts +++ /dev/null @@ -1,19 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -import { createHelloClient } from "@arcforges/api-client"; - -export const helloApiPath = "/api/arcforges.hello.v1.HelloService/SayHello"; - -export async function checkServerConnection( - origin: string, - signal: AbortSignal, - fetcher: typeof fetch = fetch, -) { - const client = createHelloClient({ - baseUrl: new URL("/api", origin).href, - defaultTimeoutMs: 10000, - fetch: (input, init) => fetcher(input, { ...init, credentials: "omit", redirect: "error" }), - }); - const response = await client.sayHello({ name: "ArcForges" }, { signal }); - if (response.message !== "Hello, ArcForges!") throw new Error("Unexpected Hello response"); - return response.message; -} diff --git a/apps/site/app/hello.ts b/apps/site/app/hello.ts deleted file mode 100644 index 8054921..0000000 --- a/apps/site/app/hello.ts +++ /dev/null @@ -1,17 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -import { SayHelloRequestSchema, SayHelloResponseSchema } from "@arcforges/proto"; -import { create, fromBinary, toBinary } from "@bufbuild/protobuf"; - -// A local example using real published messages, not a substitute for a Cloud RPC. -export function greet(name: string): string { - const containsControl = [...name].some( - (char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127, - ); - if (!name.trim() || [...name].length > 80 || containsControl) { - throw new Error("Enter a name between 1 and 80 characters, without control characters."); - } - const request = create(SayHelloRequestSchema, { name: name.trim() }); - const decoded = fromBinary(SayHelloRequestSchema, toBinary(SayHelloRequestSchema, request)); - const response = create(SayHelloResponseSchema, { message: `Hello, ${decoded.name}!` }); - return fromBinary(SayHelloResponseSchema, toBinary(SayHelloResponseSchema, response)).message; -} diff --git a/apps/site/app/root.tsx b/apps/site/app/root.tsx deleted file mode 100644 index 50e5c80..0000000 --- a/apps/site/app/root.tsx +++ /dev/null @@ -1,54 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -import "@arcforges/web-ui/styles.css"; -import { Shell } from "@arcforges/web-ui"; -import type { ReactNode } from "react"; -import { - isRouteErrorResponse, - Links, - Meta, - Outlet, - Scripts, - ScrollRestoration, - useRouteError, -} from "react-router"; - -export function Layout({ children }: { children: ReactNode }) { - return ( - - - - - - - - - - - - {children} - - - - - ); -} - -export default function App() { - return ; -} - -export function ErrorBoundary() { - const error = useRouteError(); - const missing = isRouteErrorResponse(error) && error.status === 404; - return ( -
- - ← Back home - -

{missing ? "Page not found." : "Something went wrong."}

-

- {missing ? "This page is not part of the preview." : "Reload the page to try again."} -

-
- ); -} diff --git a/apps/site/app/routes.ts b/apps/site/app/routes.ts deleted file mode 100644 index 48c1847..0000000 --- a/apps/site/app/routes.ts +++ /dev/null @@ -1,7 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -import { index, route, type RouteConfig } from "@react-router/dev/routes"; -export default [ - index("routes/home.tsx"), - route("hello", "routes/hello.tsx"), - route("cloud-hello", "routes/cloud-hello.tsx"), -] satisfies RouteConfig; diff --git a/apps/site/app/routes/cloud-hello.tsx b/apps/site/app/routes/cloud-hello.tsx deleted file mode 100644 index d2251fd..0000000 --- a/apps/site/app/routes/cloud-hello.tsx +++ /dev/null @@ -1,75 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -import { Button } from "@arcforges/web-ui"; -import { useEffect, useRef, useState } from "react"; -import { checkServerConnection } from "../cloud-hello"; - -export function meta() { - return [{ title: "Server connection — ArcForges" }]; -} - -export default function CloudHello() { - const [ready, setReady] = useState(false); - const [state, setState] = useState<"idle" | "pending" | "success" | "error">("idle"); - const [reply, setReply] = useState(""); - const active = useRef(null); - useEffect(() => { - setReady(true); - return () => active.current?.abort(); - }, []); - - async function connect() { - if (active.current) return; - const controller = new AbortController(); - active.current = controller; - setState("pending"); - try { - const message = await checkServerConnection(window.location.origin, controller.signal); - if (!controller.signal.aborted) { - setReply(message); - setState("success"); - } - } catch { - if (!controller.signal.aborted) setState("error"); - } finally { - if (active.current === controller) active.current = null; - } - } - - const message = { - idle: "No request sent yet.", - pending: "Contacting the server…", - success: reply, - error: "The server is unavailable or returned an unexpected response. Try again later.", - }[state]; - - return ( -
- - ← Back to your hello - -

Connection example

-

- A hello, from the server. -

-

- Send a fixed “ArcForges” greeting to check the server connection. Your name from the local - example stays in your browser. Nothing is sent until you choose to connect. -

- - -
- Server response -

{message}

-
-
- ); -} diff --git a/apps/site/app/routes/hello.tsx b/apps/site/app/routes/hello.tsx deleted file mode 100644 index 7dcdec1..0000000 --- a/apps/site/app/routes/hello.tsx +++ /dev/null @@ -1,82 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -import { Button } from "@arcforges/web-ui"; -import { type FormEvent, useEffect, useState } from "react"; -import { greet } from "../hello"; - -export function meta() { - return [{ title: "Your hello — ArcForges" }]; -} - -export default function Hello() { - const [name, setName] = useState("World"); - const [message, setMessage] = useState("Hello, World!"); - const [error, setError] = useState(""); - const [ready, setReady] = useState(false); - useEffect(() => { - setReady(true); - }, []); - function submit(event: FormEvent) { - event.preventDefault(); - try { - setMessage(greet(name)); - setError(""); - } catch { - setError("Enter a name between 1 and 80 characters, without control characters."); - } - } - return ( -
- - ← Back home - -

-

-

- A hello, just for you. -

-

- Start with your name. This example runs in your browser, and your name stays on this page. -

-
- -
- setName(event.target.value)} - autoComplete="off" - spellCheck={false} - aria-invalid={Boolean(error)} - aria-describedby={error ? "name-error name-hint" : "name-hint"} - /> - -
-

- Up to 80 characters. No account needed. -

- {error && ( - - )} -
- -
- Your greeting -

{message}

-
-

- Check the server connection → -

-
- ); -} diff --git a/apps/site/app/routes/home.tsx b/apps/site/app/routes/home.tsx deleted file mode 100644 index 65332e4..0000000 --- a/apps/site/app/routes/home.tsx +++ /dev/null @@ -1,48 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -import { Arrow } from "@arcforges/web-ui"; - -export function meta() { - return [ - { title: "Hello, world. — ArcForges" }, - { name: "description", content: "A small, open-source beginning for ArcForges Web." }, - ]; -} - -export default function Home() { - return ( - <> -
-
-

-

-

- Hello, -
- world. -

-

- Every good thing starts somewhere. -
- Welcome to the first page of ArcForges Web. -

- - Make it your hello - - -
- -
-
-

A beginning you can explore.

-

- This small preview has one job: say hello. Try the example, look around the source, and - follow along as ArcForges takes shape. -

-
- - ); -} diff --git a/apps/site/package.json b/apps/site/package.json deleted file mode 100644 index b5dd446..0000000 --- a/apps/site/package.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "name": "@arcforges/web-site", - "version": "0.0.0", - "private": true, - "type": "module", - "license": "AGPL-3.0-only", - "scripts": { - "dev": "react-router dev --host 127.0.0.1 --port 5173 --strictPort", - "build": "react-router build", - "typecheck": "react-router typegen && tsc -p tsconfig.json" - }, - "dependencies": { - "@arcforges/api-client": "1.0.0-ci.287.1", - "@arcforges/proto": "1.0.0-ci.287.1", - "@arcforges/web-ui": "0.0.0", - "@bufbuild/protobuf": "2.15.0", - "isbot": "5.2.2", - "react": "19.3.0", - "react-dom": "19.3.0", - "react-router": "8.4.0" - }, - "arcforges": { - "licenceBoundary": "AGPL" - } -} diff --git a/apps/site/react-router.config.ts b/apps/site/react-router.config.ts deleted file mode 100644 index 30403bd..0000000 --- a/apps/site/react-router.config.ts +++ /dev/null @@ -1,4 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -import type { Config } from "@react-router/dev/config"; - -export default { ssr: false, prerender: ["/", "/hello", "/cloud-hello"] } satisfies Config; diff --git a/apps/site/tsconfig.json b/apps/site/tsconfig.json deleted file mode 100644 index 9ba5543..0000000 --- a/apps/site/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { "rootDirs": [".", "./.react-router/types"] }, - "include": [ - "app/**/*.ts", - "app/**/*.tsx", - ".react-router/types/**/*", - "*.ts", - "../../packages/ui/src/**/*" - ] -} diff --git a/apps/site/vite.config.ts b/apps/site/vite.config.ts deleted file mode 100644 index 7495d26..0000000 --- a/apps/site/vite.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -import { reactRouter } from "@react-router/dev/vite"; -import tailwindcss from "@tailwindcss/vite"; -import { defineConfig } from "vite"; -import { observeBrowser } from "../../tooling/browser-provenance.ts"; - -export default defineConfig({ - plugins: [tailwindcss(), reactRouter(), observeBrowser()], - build: { sourcemap: false }, -}); diff --git a/biome.json b/biome.json deleted file mode 100644 index a62fcb5..0000000 --- a/biome.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "$schema": "https://biomejs.dev/schemas/2.5.13/schema.json", - "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, - "files": { - "includes": ["**/*.ts", "**/*.tsx", "worker/**/*.js", "!**/.react-router", "!**/build"] - }, - "formatter": { "enabled": false }, - "assist": { "enabled": false }, - "linter": { "enabled": true, "rules": { "preset": "recommended" } } -} diff --git a/docs/bootstrap-plan.md b/docs/bootstrap-plan.md index cfa6e9b..add3324 100644 --- a/docs/bootstrap-plan.md +++ b/docs/bootstrap-plan.md @@ -1,5 +1,7 @@ # Web bootstrap plan +Superseded in part on 2026-10-08 by WEB.40 (P2-021): the React and Node/TypeScript stack described here is replaced by the C#-first stack in `docs/development.md`. This plan is history. + Historical implementation plan. Its former CI/runtime acceptance requirements are superseded by [the current validation policy](validation-policy.md). Retained results are historical, not instructions to repeat them. ## Collected baseline diff --git a/docs/cloud-hello.md b/docs/cloud-hello.md index 07f35bf..8d821c1 100644 --- a/docs/cloud-hello.md +++ b/docs/cloud-hello.md @@ -2,15 +2,15 @@ ## Current Web behavior -`/hello/` remains local. `/cloud-hello/` uses the published Contracts client to send one fixed diagnostic greeting after the user clicks **Check connection**. It sends no user-entered name, account cookie, authorization header or Cloudflare management token. Requests have a ten-second deadline, are cancelled on navigation, and are never automatically retried. Only the expected server response is displayed as success. +Since WEB.40 unit U5 (2026-10-09, brief section 10), the public `/hello/` and `/cloud-hello/` pages are static C# pages. They carry no runtime JavaScript, their former controls are disabled and cannot be enabled, and they make no API request. The Blazor WebAssembly Account and Chat profile (`ArcForges.Web.App`) is the only interactive browser application. Its host supplies the connection check to the shared `/cloud-hello` component, and it uses the NuGet Contracts client `ArcForges.Contracts.PublicApi` 1.0.0-ci.287.1 from the central package pins. The check's rules are unchanged: it sends one fixed diagnostic greeting, with no user-entered name, account cookie, authorization header or Cloudflare management token; requests have a ten-second deadline, are cancelled on navigation and are never automatically retried; only the expected server response is displayed as success. -The Cloud repository deploys the API Worker and Native AOT container independently of Web. A public browser check on 2026-09-18 verified that this page sends no API request on load, then sends exactly one POST after the click and displays `Hello, ArcForges!` from the real service. That observation used Web commit `86d6fded796d060eb064125baca7db690bc65419` and Cloud commit `6554400c04817491fe68d5e6319434034c5dc356`; it is a diagnostic integration result, not acceptance of later product APIs. If the Cloud API route is absent, the static Worker rejects the Hello POST with 405 Method Not Allowed; a GET at that missing path returns 404. The page shows an unavailable response. No mock is deployed and no successful response is generated locally. +The Cloud repository deploys the API Worker and Native AOT container independently of Web. A public browser check on 2026-09-18, made before the public control was retired, verified that this page sends no API request on load, then sends exactly one POST after the click and displays `Hello, ArcForges!` from the real service. That observation used Web commit `86d6fded796d060eb064125baca7db690bc65419` and Cloud commit `6554400c04817491fe68d5e6319434034c5dc356`; it is a diagnostic integration result, not acceptance of later product APIs. If the Cloud API route is absent, the static Worker rejects the Hello POST with 405 Method Not Allowed; a GET at that missing path returns 404. The page shows an unavailable response. No mock is deployed and no successful response is generated locally. ## Fixed integration boundary | Item | Value | | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Browser SDK | `@arcforges/api-client` and `@arcforges/proto`, both `1.0.0-ci.287.1` | +| Client package | NuGet `ArcForges.Contracts.PublicApi` `1.0.0-ci.287.1` in the Blazor App (the npm `@arcforges/api-client` and `@arcforges/proto` packages are retired, WEB.40 U5) | | Browser base URL | Same origin, `/api` | | Public method | `POST https://arcforges.com/api/arcforges.hello.v1.HelloService/SayHello` | | Container method after prefix removal | `/arcforges.hello.v1.HelloService/SayHello` | diff --git a/docs/deploying.md b/docs/deploying.md index 1270506..b35fb5f 100644 --- a/docs/deploying.md +++ b/docs/deploying.md @@ -2,7 +2,7 @@ ## Current setup -The public Hello site uses **Workers Static Assets** and a small importless Worker that redirects `www.arcforges.com` to `https://arcforges.com` with HTTP 308 before serving assets. It preserves the path and query. There is no paid AI binding, request-time SSR or C# host in Web. Wrangler deploys `artifacts/candidate/assets` and the private `artifacts/candidate/worker/index.js` module from the same verified candidate, with `no_bundle: true`, an `ASSETS` binding and `run_worker_first: true`. The Worker is `arcforges-web`; `workers_dev` and preview URLs remain explicitly disabled. +The public Hello site uses **Workers Static Assets** and a small importless Worker that redirects `www.arcforges.com` to `https://arcforges.com` with HTTP 308 before serving assets. It preserves the path and query. There is no paid AI binding, request-time SSR or C# host in Web. The public Site is the C# static generator output (`src/ArcForges.Web.Site`, first-party Razor HtmlRenderer, no runtime JavaScript). Wrangler deploys `artifacts/candidate/assets` (the C# Site output with its identity and legal files) and the private `artifacts/candidate/worker/index.js` module (emitted from the reviewed `worker/index.ts` source) from the same sealed candidate, with `no_bundle: true`, an `ASSETS` binding and `run_worker_first: true`. The Worker is `arcforges-web`; `workers_dev` and preview URLs remain explicitly disabled. Cloudflare manages the existing apex Custom Domain, `www.arcforges.com/*` Web route and proxied www DNS record. Keep all three. The configuration intentionally omits `routes`, does not recreate DNS/domain mappings and requires no new Cloudflare permissions for this redirect change. The separate Cloud Worker retains `arcforges.com/api/*`. @@ -23,13 +23,21 @@ The token stays in CI. The browser never receives Cloudflare management credenti ## Automatic sequence 1. Linux and Windows restore the exact lock; Linux validates source and Windows evaluates IDE declarations. Dependency auditing, history secret scanning and CodeQL run; dependency review additionally runs for PRs. -2. Linux builds one candidate containing the prerendered assets and private redirect Worker, generates required CSP/notices/SBOM/provenance metadata and seals the candidate. -3. `Verify` requires applicable checks. Main deployment consumes the workflow candidate by artifact ID; its entry point performs one promotion integrity/identity check without rebuilding. +2. The candidate job publishes the Blazor WebAssembly application, emits the Worker and the build identity from the reviewed sources (`tooling/candidate.ts`), then builds the sealed candidate with the C# tool (`candidate build`) twice, compares the bytes, and verifies it (`candidate verify`). The Account and Chat profile bundle (`profiles bundle`) is built and verified beside it. +3. `Verify` requires applicable checks. Main deployment consumes the workflow candidate by artifact ID. Before any upload, its entry point verifies the seal again (`candidate verify`: the exact member set, the wrangler configuration, the pinned Worker, the regenerated Site compared byte for byte, the static page graph, and the licence, notice, SBOM and policy receipt against the repository), then re-derives the build identity. It does not rebuild. 4. Deployment confirms the current main commit and intended domain mapping, then runs Wrangler. No DNS mapping is recreated. -5. Successful provider completion records `status: deployed` in `artifacts/deployment.json` and creates a prerelease with the original candidate archive and deployment record. It does not claim live HTTP or browser acceptance. +5. Successful provider completion records `status: deployed` in `artifacts/deployment.json` and creates a prerelease with the original candidate archive, the Site archive, the profile bundle and the deployment record. It does not claim live HTTP or browser acceptance. There are no browser installations, local/live E2E, public-file hash downloads, readiness polling or real Cloud calls in CI. The optional local diagnostic commands reject CI execution. See [validation policy](validation-policy.md). +## Local dry run + +`node tooling/cloudflare.ts dry-run` runs the same sealed-candidate verification as the deployment, then `wrangler deploy --dry-run` against the candidate configuration. It needs no credential, performs no upload and changes no remote setting. Build the candidate first with the commands in the candidate job of `.github/workflows/ci.yml`. + +The Blazor profile bundle (`web-profiles-.tar`) keeps its name pattern, its manifest-first layout, its root `_headers` and its `account/` and `chat/` pages. Its files come from the publish of ArcForges.Web.App; its browser-graph check is the publish's static web assets manifest (every file's integrity). The archive is consumed by the Cloud proof deployment and is not deployed by this Web workflow. + +The sealed candidate also holds one deterministic Site archive at its root, `web-site-.tar` (`candidate build`). It is written by the same ustar writer as the profile bundle, holds the C# Site files in ordinal path order with fixed metadata, and is named by the digest of its own bytes. The seal records that digest. `candidate verify` regenerates the Site and refuses any other bytes, a second archive, or a name that is not the digest. Only the main-push prerelease publishes it, beside `web-profiles-.tar`. Wrangler deploys `artifacts/candidate/assets` only, so the Site archive is not a deployed asset and changes nothing that Cloudflare serves. + PR, schedule and manually dispatched workflows validate but do not deploy. Main pushes deploy automatically once the credential is present. No second Cloudflare Git integration is required; enabling one would create an independent deployment path that bypasses this candidate process. ## Serving and security diff --git a/docs/development.md b/docs/development.md index 7ab78ef..d0034a8 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,68 +1,71 @@ # Development -## Toolchain and commands - -Install the exact Node version in `.node-version`, which includes npm 11.19.0. `fnm use` or an equivalent version manager can select it. Run every command from the repository root. `npm ci --ignore-scripts` restores the reviewed lock without dependency lifecycle scripts; explicit build/test commands still run normally. - -| Command | Purpose | -| -------------------------- | -------------------------------------------------------------------------------------- | -| `npm run hooks` | Enable hooks only in the current Git worktree | -| `npm run dev` | React Router development server, loopback only | -| `npm run format` | Format source/docs/workflow files | -| `npm run check` | Toolchain/lock policy, formatting, Biome, strict TypeScript, unit/component/wire tests | -| `npm run build` | Build once, generate notices/SBOMs/CSP/provenance and seal a candidate | -| `npm run build:profiles` | Build, gate and measure the Account and Chat production profiles (not a candidate) | -| `npm run verify:candidate` | Verify source, complete file set, SHA-256 hashes and delivery configuration | -| `npm run preview` | Run the sealed assets and redirect Worker using local Wrangler | -| `npm run test:e2e` | Optional local browser checks using existing installations; never CI | - -Hooks check whitespace only. They never repeat builds/tests at commit or push. Git enables `extensions.worktreeConfig` so the hook path does not alter the main checkout's configuration. The `.githooks` shell files only dispatch the whitespace check. On Windows they run through Git for Windows. - -The protected `main` branch requires both `Verify` and the separate GitHub `CodeQL` scanning result. A successful CodeQL analysis job alone does not imply that it found no blocking security alerts. - -Open `win.slnx` with Visual Studio's JavaScript project support, installed Chrome, and the pinned Node/npm on its PATH. Select `ArcForges.Web` as the startup project and `ArcForges Web (Chrome)` as its launch profile. The solution explicitly enables Build and local IDE Deploy for the esproj; that local Deploy starts the development profile and never publishes to Cloudflare. - -For an explicit locked restore and solution build on Windows: - -```sh -dotnet msbuild win.slnx -t:Restore -dotnet msbuild win.slnx -t:Build -p:Configuration=Release -node tooling/ide.ts +The Web product is C#-first (P2-021): the public Site is a static C# generator, the Account and Chat profiles are standalone Blazor WebAssembly applications, and the shared UI is a Razor class library. TypeScript is only the thin Cloudflare Worker adapter (`worker/index.ts`), and Node runs only wrangler and the reviewed policy checks. + +## Toolchain + +| Tool | Pin | Source | +| --- | --- | --- | +| .NET SDK | 10.0.401 (`global.json`, rollForward disabled) | `dotnet --version` | +| Node.js | 24.21.0 (`.node-version`, `engines`) | `node --version` | +| npm | 11.19.0 (`packageManager`) | `npm --version` | + +Restores are locked: every project has a `packages.lock.json` and CI runs `dotnet restore --locked-mode`. NuGet versions are central in `Directory.Packages.props`. Every package is admitted by a receipt in `eng/policy/dependency-reviews/`. + +## Commands + +Run every command from the repository root. Use `npm ci --ignore-scripts` once after a checkout. + +| Command | Purpose | +| --- | --- | +| `npm run hooks` | Enable the worktree-local hooks (`git diff --check` only) | +| `npm run check` | Dependency admission, reviewed policy, Worker typecheck and the Node test suites | +| `npm run policy` | Naming, licence, provenance, exact pins and lock provenance (requires the pinned Node) | +| `npm run build:worker` | Emit the Worker JavaScript from `worker/index.ts` by type stripping | +| `npm run verify:candidate` | Verify a sealed candidate through the C# verifier and the identity check | +| `npm run preview` | Run the sealed candidate with the local Wrangler dev server | +| `npm run deploy` | Deploy the sealed candidate (CI main-push only; needs the Cloudflare token) | + +The C# product is built with the explicit project list in `.github/workflows/ci.yml` (`CSHARP_PROJECTS`, `CSHARP_TEST_PROJECTS`): + +```text +dotnet restore --locked-mode +dotnet build --no-restore -c Release +dotnet test --no-build -c Release +dotnet publish src/ArcForges.Web.App/ArcForges.Web.App.csproj --no-restore -c Release -o artifacts/publish/app +dotnet tools/ArcForges.Web.Tooling/bin/Release/net10.0/ArcForges.Web.Tooling.dll site build --out artifacts/site --source-ref <40-hex> +dotnet tools/ArcForges.Web.Tooling/bin/Release/net10.0/ArcForges.Web.Tooling.dll profiles budget --publish artifacts/publish/app --budgets eng/policy/profile-budgets.json +dotnet tools/ArcForges.Web.Tooling/bin/Release/net10.0/ArcForges.Web.Tooling.dll candidate build|verify ... +dotnet tools/ArcForges.Web.Tooling/bin/Release/net10.0/ArcForges.Web.Tooling.dll profiles bundle|verify ... ``` -Restore runs root `npm ci --ignore-scripts`. Build delegates to the same root `npm run build` used by portable CI and never installs dependencies; IDE design-time evaluation does not restore. CI evaluates the IDE declarations without another solution restore/build. The explicit local IDE diagnostic remains available when its dispatch behavior changes. +`tools/ArcForges.Web.Tooling` is the only build tool of the Web product. Its commands are listed in `Program.cs`; every other command is refused. -F5 uses `.vscode/launch.json` and the same root `npm run dev` as the CLI, serving `http://127.0.0.1:5173`. The server uses strict port selection: stop an existing development session before starting another; an occupied port fails rather than moving the IDE to an unrelated service. Stopping browser debugging may leave the development server running; stop that session's terminal when finished. No C# host or business proxy is started. Portable Linux/macOS development uses the root npm commands directly, without Visual Studio or MSBuild. The `.esproj` produces no .NET application or server. +## Solution layout -## Application boundaries +| Path | Role | +| --- | --- | +| `src/ArcForges.Web.Site` | Static public Site generator (first-party Razor `HtmlRenderer`), its stylesheet and public files | +| `src/ArcForges.Web.Ui` | Razor class library (`ArcForges.Web.Ui`): shared components, the greeting and the CSP helper | +| `src/ArcForges.Web.App` | Blazor WebAssembly standalone Account and Chat profiles | +| `src/ArcForges.Web.Operations` | Operations profile skeleton on its own origin (no operator features yet) | +| `tools/ArcForges.Web.Tooling` | C# build tool: Site, candidate, profile bundle and budgets | +| `tests/ArcForges.Web.*.Tests` | xUnit and bUnit suites; `ArcForges.Web.Policy.Tests` holds the reviewed architecture, licence, naming and dependency rules | +| `tests/browser/ArcForges.Web.Browser.Tests` | Local opt-in Microsoft.Playwright for .NET checks (never CI) | +| `worker/index.ts` | Canonical-host Cloudflare Worker adapter | +| `eng/policy` | Reviewed policy: dependency receipts, licence inventory, profile budgets | +| `eng/provenance` | Provenance inventory, records and the active browser-resource profile | -- React Router framework mode is configured with `ssr: false` and build-time prerendering of `/`, `/hello` and `/cloud-hello`. A temporary server build is used by the framework during prerendering and excluded from the candidate. Its generated SPA fallback is also excluded. -- The private importless Worker redirects the exact `www.arcforges.com` hostname to the HTTPS apex with HTTP 308, preserving path and query, before static routing. Other requests use the `ASSETS` binding unchanged. `run_worker_first: true` includes navigations, so the page's same-origin API client starts on the canonical host. This is edge request routing, not SSR or an API proxy. -- Initial text and links work without JavaScript. Greeting controls remain disabled until hydration, cannot submit names as native form query parameters and are covered by `form-action 'none'`. Names are trimmed, limited to 80 Unicode code points and reject control characters. React renders the greeting as text. -- The local example serializes real `@arcforges/proto` messages. The separate server connection page uses `@arcforges/api-client` against same-origin `/api`, with the published Hello contract and an explicitly unavailable state when the service cannot be reached. Browser protobuf fixtures are separate from live verification. The client's `redirect: "error"` remains intact; reload a www page opened before the canonical redirect deployment. See [Cloud Hello](cloud-hello.md) for the exact endpoint and evidence boundary. -- No AI, database, analytics, service worker, privileged proxy or user-data storage is included. The production custom domain is `arcforges.com`. React Router's scroll restoration may store scroll positions in session storage. -- `apps/app` holds only the two minimal PRF.08 proof profiles (Account session and Chat greeting), built by `npm run build:profiles` and never deployed; see [the proof record](prf-08-profile-proof.md). Product Account/Chat/operator/status delivery profiles remain separate work. Shared components live in `packages/ui`; profiles must not import business source from adjacent repositories. +## Browser checks -## Dependencies +Browser checks are local opt-in and never run in CI. Set `ARCFORGES_LOCAL_BROWSER=1` and `ARCFORGES_BROWSER_BASE_URL` to a local or deployed origin, then run `dotnet test tests/browser/ArcForges.Web.Browser.Tests/ArcForges.Web.Browser.Tests.csproj -c Release`. Set `ARCFORGES_AXE_SCRIPT` to a local axe-core script to run the accessibility rules; axe-core is injected only into the page under test. The CI accessibility gate is the bUnit and xUnit semantic set. -Use exact direct versions and commit the root `package-lock.json` after intentional dependency changes. Keep React/DOM/types and Router packages aligned. Keep protobuf/Connect versions compatible with the published Contracts packages. Do not replace pinned Contracts versions with `latest`, Git URLs, local paths or floating ranges. Update the pins and lock through a reviewed PR; Dependabot groups related updates and the full pipeline validates them. - -Lock provenance checks require npm registry URLs and SHA-512 integrity for downloaded artifacts. npm's `inBundle` entries are extracted from an enclosing package tarball and may omit their own URL/hash; the check traces them to that verified artifact, including nested bundles. Orphaned entries and workspace links cannot substitute for a registry artifact. Keep this metadata when Dependabot adds it; see the [npm lockfile format](https://docs.npmjs.com/cli/v11/configuring-npm/package-lock-json/#packages). - -Node runtime and `@types/node` stay on major 24. Updating Node also requires `.node-version`, engines and `packageManager` to remain consistent. Use existing compatible Playwright browsers only for an affected local diagnostic; no browser installation is part of CI. - -If a dependency update changes license packaging, review `third-party/README.md` and update the generated-notice handling. Build failures for missing licenses are deliberate rather than silently discarding notices. - -## Evidence - -Browser reports, traces for failures and responsive screenshots are under `playwright-report` and `test-results`. They are local diagnostic outputs and are not uploaded or required by CI. The candidate contains no tests, source maps, SSR runtime, `.env` files or npm dependencies directory. Do not commit any generated artifacts. - -Redirect coverage uses targeted offline tests for the host boundary, HTTPS destination, path/query retention, 308 behavior and untouched asset fallback. Existing local Wrangler may be used once to inspect the affected navigation redirect and apex asset response; this is local edge-runtime evidence, not a real Cloud service test. Do not add browser/live CI or install toolchains for this check. Main deployment success proves provider completion, not a post-merge browser or RPC test. +## Dependencies -Local Wrangler persistence is explicitly placed at the repository's ignored `.wrangler/state`, outside the immutable candidate. Browser tests are explicit local opt-in; no repeated verification follows them automatically. +Use exact direct versions. Change the NuGet closure only through a successor admission receipt (`eng/policy/dependency-reviews/web-40-admission-rN.json`) and `eng/policy/dependency-policy.json`. Change the npm closure (wrangler, TypeScript and `@types/node`) through the same receipt chain. Commit the root `package-lock.json` after intentional changes. The Contracts identity is the NuGet publication record under `eng/contracts` (bound by `eng/version-sources.json` and the central pin in `Directory.Packages.props`), and the naming authority is the byte copy under `eng/naming` (digests in `eng/policy/naming-candidate.json`); both are bound by provenance records. Prettier and Biome are retired. Formatting gates: `dotnet format --verify-no-changes` for each C# project in the CI `csharp` job, the text rules (one final newline, no trailing whitespace) of the audited inputs in the policy suite, and `npm run typecheck` for the thin TypeScript Worker. -`manifest.json` records a full source SHA, version, dirty flag and hash of every other candidate file. Local modified builds are useful for testing but rejected by automatic deployment. `__build.json` exposes only the non-sensitive source/version identity. A successful build is not evidence of a real Cloudflare deployment or working C# business API. +Use the pinned npm for lock regeneration. A lock regenerated on a Node older than the pin needs `--engine-strict=false` for that one command; the repository setting stays `engine-strict=true`. -## Provenance changes +## Local validation limits (P2-017, P2-024) -Follow [the provenance process](provenance.md) before introducing reused or generated material. `npm run check` includes offline source-policy/resolver tests without rebuilding a candidate. `test:artifact` is an explicit local packaging investigation, separate from normal checks. `npm run verify:candidate` validates the independent browser resource profile and complete legal/SBOM closure as well as the outer manifest. +Hosted CI is the authority for the Linux locked restore and for the CI job graph. Linux-affected checks may run once, scope-limited, in the local WSL2 Debian distribution through PowerShell on a Linux-native filesystem (`wsl.exe -d Debian -- ...`, not `/mnt/c`). Record the distribution, kernel, SDK and toolchain. Toolchain installs inside WSL are performed by the user. A local Node older than the pin (24.20 against 24.21) is a recorded local gap: the policy script's Node assertion is not run locally, and hosted CI is authoritative. diff --git a/docs/licence-boundary.md b/docs/licence-boundary.md index da59d65..ea00088 100644 --- a/docs/licence-boundary.md +++ b/docs/licence-boundary.md @@ -20,7 +20,14 @@ targets reject wrong effective values before build/pack. Source and evaluated reports include the exact commit, dirty state, inventory and findings under `artifacts/evidence/licence-*.json`; CI uploads the reports. -The three npm scopes and the JavaScript IDE adapter are covered. Windows CI evaluates the IDE adapter using .NET SDK 10.0.401; normal npm development remains independent of that optional IDE toolchain. The final static candidate retains its licence/provenance checks. Browser and public-download verification are removed from CI under [validation policy](validation-policy.md). +The local opt-in accessibility re-proof (WEB.40 U6, `tests/browser`, P2-021 item 8) admits four test-only NuGet packages through the +dependency policy: Deque.AxeCore.Playwright 4.13.0 (MIT) and its dependencies Newtonsoft.Json 13.0.1 (MIT) and +System.IO.Abstractions 17.0.24 (MIT), and Deque.AxeCore.Commons 4.13.0, which carries the axe-core script under MPL-2.0. The +MPL-2.0 row is admitted only with `testOnly: true`; no product project restores it, it is never shipped or compiled into a product +output, and the policy suite fails if a product project references a testOnly row. The axe-core script is injected only into the +page under test. The AngleSharp 1.7.0 parser (MIT) is a direct reference of the Site parity test, which is also test-only. + +The npm inventory covers the root manifest only (wrangler, TypeScript and `@types/node`). The former first-party Contracts npm packages `@arcforges/proto` and `@arcforges/api-client` are retired (WEB.40 unit U5); the Contracts naming and identity bytes are recorded by `contracts-publication-r1`. The React workspaces and the JavaScript IDE adapter are retired (WEB.40 unit U5). Windows CI evaluates the owned C# projects with .NET SDK 10.0.401. The final static candidate retains its licence/provenance checks. Browser and public-download verification are removed from CI under [validation policy](validation-policy.md). These source/build-policy results do not establish product functionality or close later commercial gates. Local candidate identity and fixture/runtime evidence are diff --git a/docs/prf-08-profile-proof.md b/docs/prf-08-profile-proof.md index c28076e..72b47c9 100644 --- a/docs/prf-08-profile-proof.md +++ b/docs/prf-08-profile-proof.md @@ -1,5 +1,7 @@ # PRF.08 production profile and generated SDK proof +Superseded in part on 2026-10-08 by WEB.40 (P2-021): the React/Vite `apps/app` profile proof is retired and replaced by the Blazor WebAssembly profiles (`docs/profile-bundle.md`). The record below is history. + This is the record of what the PRF.08 implementation builds and what was and was not observed (Design WP-06.05, PG-23 foundation contribution only). It claims no deployed result: no Cloudflare resource, Worker route, Container, D1, session issuer or deployed ingress was used or exists for this task. ## What was built diff --git a/docs/profile-bundle.md b/docs/profile-bundle.md new file mode 100644 index 0000000..282e50a --- /dev/null +++ b/docs/profile-bundle.md @@ -0,0 +1,39 @@ +# Blazor profile bundle + +The Account and Chat profiles are one published application (`src/ArcForges.Web.App`, `RunAOTCompilation=false`) served at two routes. The C# tool packs its publish output into one deterministic, digest-named archive that the Cloud proof deployment consumes (CLOUD.85). This document records the layout and the rules that consumers may rely on. + +## Name and layout + +- File name: `web-profiles-.tar`, where the digest is the SHA-256 of the archive bytes. The pattern and the first entry are the same as the React bundle it replaces, so the name pattern does not change for consumers. +- First entry: `manifest.json` (schema 1). It lists each profile (`page`, `buildDigest`, `csp`) and every other entry with its SHA-256 and byte count. +- Root `_headers`: security headers for `/*`; one block per profile path (`/account/*`, `/chat/*`) with its Content-Security-Policy, `X-Frame-Options: DENY` and `no-cache`; `/assets/*` immutable; `/_framework/*` immutable; `/_framework/blazor.webassembly.js` and `/_framework/dotnet.js` revalidated (`no-cache`). +- Profile pages: `account/index.html` and `chat/index.html`, each the application shell, with the profile's own policy. +- Served tree: the publish `wwwroot` at its own root paths. The shell `index.html` is not served at the root, and neither are its precompressed siblings `index.html.br` and `index.html.gz`, because they only encode the shell. Blazor's base href is `/`, so `_framework/` files are root-relative. Every other file of the publish is kept, including the `.br` and `.gz` siblings of the `_framework/` files that the SDK writes. + +## Rules the tool enforces + +- Every `_framework` file is fingerprinted (ten lower-case characters before `.js`, `.wasm` or `.dat`, including the `.br` and `.gz` siblings) or is one of the two unfingerprinted loaders. Any other framework file fails the build, so the immutable rule covers fingerprinted content only. +- The bundle verifies its own name, its strict tar layout, its manifest and every member's digest, the profile pages, each profile policy against its page, and the exact headers file. +- Each profile policy stays within the Cloudflare header line budget. No `unsafe-eval`, no `unsafe-inline`, and `wasm-unsafe-eval` only where the App policy requires it (P2-021). + +## Size budgets + +`eng/policy/profile-budgets.json` holds the reviewed baseline of the published application, measured by `dotnet ... profiles budget` with SDK 10.0.401: + +| Metric | Baseline | +| --- | --- | +| `initialRequests` | 57 | +| `htmlBytes` | 719 | +| `initialCssGzip` | 2245 | +| `initialJsGzip` | 125944 | +| `initialWasmGzip` | 3227235 | +| `initialDataGzip` | 848189 | +| `initialOtherGzip` | 238 | +| `totalGzip` | 4203851 | + +Each metric may grow by at most `regressionPercent` (10), rounded down. The candidate job enforces the budgets after the App publish. Gzip sizes are computed in-process at the fixed compression level. A baseline changes only by a reviewed change that names the measurement (AL-06). The React interaction ceilings in `apps/app/interaction-budgets.json` are re-baseline-pending under PRF.11, because the Blazor measurements are not comparable with them. + +## CLOUD.71 and CLOUD.85 coordination + +- CLOUD.85 serves the Blazor WebAssembly profiles and the static Site from the proof origin. It consumes the bundle by its name pattern and its root-relative layout above. Any change to the name pattern, the `manifest.json` schema, the profile paths or the framework layout needs a reviewed change in both repositories. +- CLOUD.71 records the bundle naming need. The coordination note with the Cloud owner must be recorded before the Cloud proof deployment uses this bundle. That note is pending; it is not claimed here. diff --git a/docs/provenance.md b/docs/provenance.md index 45497fe..5ba37c1 100644 --- a/docs/provenance.md +++ b/docs/provenance.md @@ -24,7 +24,7 @@ The build SBOM uses the complete lockfile, including platform alternatives, and The candidate carries its active records, reviewed profile, observed graph and source-bound receipt under private `provenance/`. The receipt contains every actual candidate member hash. Full licences, third-party notices and the provenance summary are also public assets. The same gate checks exact Contracts package legal/source/SBOM bytes, both SBOMs, derived CSP/security/cache headers, deployment configuration, complete file membership and source/version identity at candidate construction and the deployment trust handoff. Source maps, SSR implementation, dependencies and private evidence remain outside public assets. -The canonical-host redirect is an authored importless Worker module, copied from `worker/index.js` with LF normalization into the private candidate `worker/index.js` and deployed with `no_bundle: true`. It does not become a browser resource or add third-party runtime code. Its source bytes, candidate membership and Worker-first `ASSETS` configuration are checked by the candidate gate; it receives the same sealed-candidate promotion as the prerendered assets. Public browser graph and dependency provenance remain their existing independent boundary. +The canonical-host redirect is an authored importless Worker module written in TypeScript (`worker/index.ts`). The candidate build emits its JavaScript form with Node type stripping (LF normalized) into the private candidate `worker/index.js`, and it is deployed with `no_bundle: true`. The emitted bytes are recomputed from the reviewed source at verification time. It does not become a browser resource or add third-party runtime code. Its source bytes, candidate membership and Worker-first `ASSETS` configuration are checked by the candidate gate; it receives the same sealed-candidate promotion as the prerendered assets. Public browser graph and dependency provenance remain their existing independent boundary. `npm run check` runs source enforcement and offline units once on Linux; it does not build or rescan a candidate. CI fetches full trusted history: PR base SHA, push `before` SHA, or the current committed baseline for scheduled/manual runs. Missing history fails. Commit/push hooks check whitespace only. CI retains source and actual graph evidence; the deploy job verifies the downloaded candidate without rebuilding it. @@ -38,6 +38,6 @@ WP02.04 appends `browser-resources-r2` for the independently sourced build-info ## Current validation boundary -The active browser profile is `browser-resources-r10` (r7 moved the Contracts pin to 129.1; r8, PRF.08, moves it to `1.0.0-ci.287.1` and admits the Wrangler 4.143.1 lock change and the `apps/app` workspace without changing any emitted browser byte: the parsed module graph grows by the new generated Contracts modules, every normalized template is unchanged; r9 admits only the development-tool lock change of `source-map-js` 1.2.1 to 1.2.2 for GHSA-68fv-2mgg-jv7q, rebinding the lock, the active-profile selector and the lock-derived build SBOM while retaining every other r8 expectation; r10 admits only the development-tool lock change of `sharp` 0.35.4 to 0.35.5 and its `@img` platform packages for GHSA-wq5f-xc86-pv6w, selected by a root `overrides` entry because Miniflare pins sharp exactly, rebinding the lock, the active-profile selector and the lock-derived build SBOM while retaining every other r9 expectation). The earlier text below describes r6 and remains history. Revision r6 binds the changed packaging inputs for the private redirect Worker while retaining the browser graph, normalized templates and SBOM. Revisions r3/r4 admit only reviewed development/deployment-tool lock changes. Revision r5 consumes Contracts `1.0.0-ci.44.1` and one protobuf `2.15.0` installation: the previously admitted implementations are unchanged, while duplicate module paths and emitted placements are removed. Its module/import graph was derived before compilation; one source-mapped compilation accounted for the three changed protobuf consumer/shared chunks, and the remaining fifteen normalized templates stayed unchanged. The same compiled files passed the final exact profile checks. Earlier profiles and their historical evidence remain immutable. +The active browser profile is `browser-resources-r12` (WEB.40 unit U5, superseding `browser-resources-r11`). The React browser graph, its templates and its npm browser packages are retired with `apps/site`, `apps/app` and `packages/ui`. The profile binds the public candidate's only third-party resource, the Tailwind v4.3.3 stylesheet (`src/ArcForges.Web.Site/Assets/site.css`), the canonical-host Worker source (`worker/index.ts`) and its emitter, and the candidate's build SBOM. The C# candidate verifier regenerates the Site, notices and headers from the reviewed inputs. The admission receipt is `web-40-admission-r10`; earlier receipts remain immutable history. `browser-resources-r11` stays as history, which chains from `web-40-admission-r3`. The r10 text below remains history (r7 moved the Contracts pin to 129.1; r8, PRF.08, moves it to `1.0.0-ci.287.1` and admits the Wrangler 4.143.1 lock change and the `apps/app` workspace without changing any emitted browser byte: the parsed module graph grows by the new generated Contracts modules, every normalized template is unchanged; r9 admits only the development-tool lock change of `source-map-js` 1.2.1 to 1.2.2 for GHSA-68fv-2mgg-jv7q, rebinding the lock, the active-profile selector and the lock-derived build SBOM while retaining every other r8 expectation; r10 admits only the development-tool lock change of `sharp` 0.35.4 to 0.35.5 and its `@img` platform packages for GHSA-wq5f-xc86-pv6w, selected by a root `overrides` entry because Miniflare pins sharp exactly, rebinding the lock, the active-profile selector and the lock-derived build SBOM while retaining every other r9 expectation). The earlier text below describes r6 and remains history. Revision r6 binds the changed packaging inputs for the private redirect Worker while retaining the browser graph, normalized templates and SBOM. Revisions r3/r4 admit only reviewed development/deployment-tool lock changes. Revision r5 consumes Contracts `1.0.0-ci.44.1` and one protobuf `2.15.0` installation: the previously admitted implementations are unchanged, while duplicate module paths and emitted placements are removed. Its module/import graph was derived before compilation; one source-mapped compilation accounted for the three changed protobuf consumer/shared chunks, and the remaining fifteen normalized templates stayed unchanged. The same compiled files passed the final exact profile checks. Earlier profiles and their historical evidence remain immutable. [Validation policy](validation-policy.md) supersedes earlier automatic browser/live/public-byte gates. Normal checks run offline source-policy/resolver tests; `test:artifact` is a separate opt-in investigation. Candidate construction retains required provenance, and the deployment entry point performs one trust-handoff check. Neither path launches a browser or downloads public assets to repeat verification. diff --git a/docs/validation.md b/docs/validation.md index 472997e..c84ac98 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -1,6 +1,6 @@ # Bootstrap validation -Historical verification evidence. Current execution follows [validation policy](validation-policy.md); these recorded runs are not repeated CI or post-merge requirements. +Historical verification evidence. Superseded in part on 2026-10-08 by WEB.40 (P2-021): the Biome, Vitest and Playwright gates named below are retired; the C# suites and the local opt-in browser checks replace them. Current execution follows [validation policy](validation-policy.md); these recorded runs are not repeated CI or post-merge requirements. Evidence is recorded separately for source checks, local production assets, hosted CI and real delivery. The bootstrap plan is in `bootstrap-plan.md`. diff --git a/docs/web-40-accessibility.md b/docs/web-40-accessibility.md new file mode 100644 index 0000000..effdbca --- /dev/null +++ b/docs/web-40-accessibility.md @@ -0,0 +1,301 @@ +# WEB.40 accessibility re-proof: axe-core and WCAG 2.2 AA + +Status: recorded at run head 73173c2 (2026-10-09). This is local opt-in evidence (P2-017); no browser runs in CI. The coordinator re-runs the browser suites at the review head. + +Requirement: P2-021 item 8 (brief section 10, adjudication of 2026-10-09). Before the React apps retire, every replacement screen and state needs axe-core and a recorded WCAG 2.2 AA checklist. This record covers Account (five states), Chat (four states), the App error banner (one state) and the four Site pages: 14 screen states in all. Focus placement (AX-02) is recorded separately in [web-40-ax-02-focus.md](web-40-ax-02-focus.md), which links here for the axe and checklist evidence. The retirement table is [web-40-react-retirement.md](web-40-react-retirement.md), and the Site parity record is [web-40-site-parity.md](web-40-site-parity.md). + +## Method + +**Run.** `LocalAccessibilityBrowserTests` in `tests/browser/ArcForges.Web.Browser.Tests` declares 10 tests, and they write the 14 rows below. The run at head 73173c2 recorded 10 passed, 0 failed and 0 skipped in the local run log, which is not committed. The suite runs only when `ARCFORGES_LOCAL_BROWSER=1`, the build directories are named and no CI marker is set (`LocalOptIn.cs:32-39`). It serves `artifacts/publish/app/wwwroot` (the Account and Chat profiles, published from `src/ArcForges.Web.App`) and `artifacts/site` (the C# static Site) through Playwright routes. The Account and Chat service answers are fixed by the test, so no server or live service is used. + +**Tool versions.** + +| Component | Version | Where it is recorded | +|---|---|---| +| axe-core | 4.13.0 | Bundled in `Deque.AxeCore.Commons` 4.13.0; its embedded script reads `axe.version="4.13.0"` | +| Deque.AxeCore.Playwright | 4.13.0 | `Directory.Packages.props:11` | +| Microsoft.Playwright (.NET) | 1.62.0 | `Directory.Packages.props:15`; test project only | +| Chrome | Not recorded for the run. The run log names neither the browser nor its version, and it does not show `ARCFORGES_CHROMIUM_PATH`. The Chrome installed on this machine reports 156.0.8078.12 when read on 2026-10-09, but the record does not show the run used that executable. | `LocalOptIn.cs:19` names the variable; the focus record ([web-40-ax-02-focus.md](web-40-ax-02-focus.md), "Record of the local browser run") says its run on the same machine used the installed Chromium named by that variable | + +Playwright's own Chromium was not used by the focus run, because the Playwright build expects Chromium 1234 and that build is not installed on this machine (web-40-ax-02-focus.md, "Record of the local browser run"). + +**How axe ran.** `AssertAxeAsync` calls `Page.RunAxe()` with no options (`LocalAccessibilityBrowserTests.cs:326`), so the default rule selection applies and the axe-core script is the one bundled in the Deque package. The suite does not read `ARCFORGES_AXE_CORE_PATH`. Each screen asserts zero violations. + +**Rules evaluated.** The bundled axe-core 4.13.0 rule set has 105 rules. The run evaluated 89 of them on every state. The 16 it did not evaluate are seven experimental rules (css-orientation-lock, focus-order-semantics, hidden-content, label-content-name-mismatch, p-as-heading, table-fake-caption, td-has-header) and nine others (aria-roledescription, audio-caption, color-contrast-enhanced, duplicate-id, duplicate-id-active, identical-links-same-purpose, landmark-complementary-is-top-level, meta-refresh-no-exceptions, target-size). Two of the nine map to criteria in scope: target-size (2.5.8, AA) and audio-caption (1.2.1, tagged deprecated). The other seven are deprecated, obsolete or AAA. The report does not record why these rules were not evaluated, so the rows for 2.5.8 do not use axe. + +**Recording.** Each state appends two lines to the file named by `ARCFORGES_A11Y_REPORT`. The first holds the screen, the state, the URL and the counts of violations, passes, incomplete and inapplicable rules. The second names the sorted rule IDs that passed, were incomplete, were inapplicable and were violated. The rule lines let each pass (axe) cell be checked against the rules that actually passed on that state. + +**Reproduce.** Set `ARCFORGES_LOCAL_BROWSER=1`, `ARCFORGES_PROFILE_DIR` (the published Account and Chat `wwwroot`), `ARCFORGES_SITE_DIR` (the Site output), `ARCFORGES_CHROMIUM_PATH` (an installed Chrome or Edge) and `ARCFORGES_A11Y_REPORT`, with CI markers unset. Then run `dotnet test tests/browser/ArcForges.Web.Browser.Tests --no-build -c Release --filter FullyQualifiedName~LocalAccessibilityBrowserTests`. + +**Status values.** Each checklist cell holds exactly one of five values. + +- `pass (axe)`: a rule mapped to the criterion (its WCAG tag in the axe 4.13.0 rule metadata) is in that state's passed list. A rule that was inapplicable on a state cannot support `pass (axe)` there. +- `pass (inspection)`: checked by reading the Razor sources, CSS and Site HTML. The file and line are cited. Nothing was rendered. +- `pass (browser test)`: asserted by a test in `LocalAccessibilityBrowserTests` that drives the page and checks the criterion itself, and that the run recorded as passed. This value is added for this record, because one assertion (keyboard operation of the banner Dismiss) is neither axe nor inspection. It is used only for 2.1.1 on E1. +- `not applicable`: the reason is given in the Basis column. +- `not verified: needs a human assistive-technology session`: used where the criterion needs a screen reader, a judgement or a manual rendered check (reflow, target spacing, a focus overlap). It is also used where inspection found a likely failure, which is named under Open issues. There is no separate fail value, so this is the only value that stands in for a failure. + +**Derivation.** Where a criterion has mapped rules, a cell is `pass (axe)` when a mapped rule passed on that state, and otherwise it keeps the inspection status. Cells that stay `not verified` where a rule does apply are judgements the rule cannot settle: 2.4.4 on S1 to S3 (link-name passes there, but the purpose of Source in context is a judgement). The 1.4.1 cells on C1, C3 and C4 have no passing rule, because the grey Cancel state is a colour change that axe does not test. The checklist was generated from the rule lines, the axe tags and the criterion data, and every `pass (axe)` cell was checked against the passed list of its state. + +**Limits.** axe covers only part of WCAG 2.2. No rule in the run is tagged with 1.3.2, 1.3.3, 1.4.5, 1.4.10, 1.4.11, 1.4.13, 2.1.2, 2.4.5, 2.4.6, 2.4.7, 2.4.11, 2.5.7, 3.2.6, 3.3.3, 3.3.4, 3.3.7, 3.3.8 or 4.1.3, so their rows rest on inspection, judgement, a browser test or not applicable. The three rules tagged 2.1.1 (frame-focusable-content, scrollable-region-focusable and server-side-image-map) are inapplicable on every state, so 2.1.1 rests on inspection and the banner browser test. The experimental rules that map to 1.3.4 (css-orientation-lock), 2.4.3 (focus-order-semantics) and 2.5.3 (label-content-name-mismatch) are not run. The 2.2 criteria 2.4.11, 2.5.7, 3.2.6, 3.3.7 and 3.3.8 have no axe rule. + +**Not done in this record.** No browser was started, no screenshot was taken and nothing was rendered for this record. The results are those of the recorded run at 73173c2. The bUnit suites, `HostPageAccessibilityTests` and `LocalFocusBrowserTests` were not re-run here. The contrast ratios were recomputed from the hex values in `app.css` and the Site stylesheet. + +## Axe results + +Each row is one screen state. Passes, inapplicable, incomplete and violations sum to 89 on every row. The counts are from the local run at head 73173c2, and the coordinator re-confirms them at the review head. + +| Screen | State | URL | Violations | Incomplete | Passes | Inapplicable | Violated rules | +|---|---|---|---|---|---|---|---| +| Account | anonymous first read | /account | 0 | 0 | 33 | 56 | none | +| Account | signed in | /account | 0 | 0 | 38 | 51 | none | +| Account | sign-out in flight | /account | 0 | 0 | 38 | 51 | none | +| Account | session ended by sign-out (after the sign-out) | /account | 0 | 0 | 36 | 53 | none | +| Account | failed read | /account | 0 | 0 | 36 | 53 | none | +| Chat | idle | /chat | 0 | 0 | 37 | 52 | none | +| Chat | pending | /chat | 0 | 0 | 38 | 51 | none | +| Chat | after reply | /chat | 0 | 0 | 38 | 51 | none | +| Chat | failed send (notice shown) | /chat | 0 | 0 | 38 | 51 | none | +| App error banner | unhandled error shown | /chat | 0 | 0 | 41 | 48 | none | +| Site | page / | / | 0 | 0 | 30 | 59 | none | +| Site | page /hello/ | /hello/ | 0 | 0 | 39 | 50 | none | +| Site | page /cloud-hello/ | /cloud-hello/ | 0 | 0 | 35 | 54 | none | +| Site | page /404.html | /404.html | 0 | 0 | 17 | 72 | none | + +Result: 14 of 14 rows report zero violations and zero incomplete items. The 10 tests write the 14 rows: the Account states, the Chat states including the failed send, the App error banner shown through Blazor's critical-error entry point, and the four Site pages. + +### Rule names per state + +The rule lines of the run, one entry per state. Passed and inapplicable rules are listed exactly as the report names them. + +**A1 Account, anonymous first read** (/account): 33 passed, 0 incomplete, 56 inapplicable, 0 violated. + +- Passed: `aria-allowed-attr`, `aria-allowed-role`, `aria-conditional-attr`, `aria-deprecated-role`, `aria-hidden-body`, `aria-prohibited-attr`, `aria-required-attr`, `aria-roles`, `aria-valid-attr`, `aria-valid-attr-value`, `avoid-inline-spacing`, `bypass`, `color-contrast`, `document-title`, `empty-heading`, `heading-order`, `html-has-lang`, `html-lang-valid`, `landmark-banner-is-top-level`, `landmark-contentinfo-is-top-level`, `landmark-main-is-top-level`, `landmark-no-duplicate-banner`, `landmark-no-duplicate-contentinfo`, `landmark-no-duplicate-main`, `landmark-one-main`, `landmark-unique`, `link-name`, `meta-viewport`, `meta-viewport-large`, `page-has-heading-one`, `region`, `skip-link`, `tabindex`. +- Incomplete: none. +- Inapplicable: `accesskeys`, `area-alt`, `aria-braille-equivalent`, `aria-command-name`, `aria-dialog-name`, `aria-hidden-focus`, `aria-input-field-name`, `aria-meter-name`, `aria-progressbar-name`, `aria-required-children`, `aria-required-parent`, `aria-tab-name`, `aria-text`, `aria-toggle-field-name`, `aria-tooltip-name`, `aria-treeitem-name`, `autocomplete-valid`, `blink`, `button-name`, `definition-list`, `dlitem`, `duplicate-id-aria`, `empty-table-header`, `form-field-multiple-labels`, `frame-focusable-content`, `frame-tested`, `frame-title`, `frame-title-unique`, `html-xml-lang-mismatch`, `image-alt`, `image-redundant-alt`, `input-button-name`, `input-image-alt`, `label`, `label-title-only`, `link-in-text-block`, `list`, `listitem`, `marquee`, `meta-refresh`, `nested-interactive`, `no-autoplay-audio`, `object-alt`, `presentation-role-conflict`, `role-img-alt`, `scope-attr-valid`, `scrollable-region-focusable`, `select-name`, `server-side-image-map`, `summary-name`, `svg-img-alt`, `table-duplicate-name`, `td-headers-attr`, `th-has-data-cells`, `valid-lang`, `video-caption`. +- Violated: none. + +**A2 Account, signed in** (/account): 38 passed, 0 incomplete, 51 inapplicable, 0 violated. + +- Passed: `aria-allowed-attr`, `aria-allowed-role`, `aria-conditional-attr`, `aria-deprecated-role`, `aria-hidden-body`, `aria-hidden-focus`, `aria-prohibited-attr`, `aria-required-attr`, `aria-roles`, `aria-valid-attr`, `aria-valid-attr-value`, `avoid-inline-spacing`, `button-name`, `bypass`, `color-contrast`, `definition-list`, `dlitem`, `document-title`, `empty-heading`, `heading-order`, `html-has-lang`, `html-lang-valid`, `landmark-banner-is-top-level`, `landmark-contentinfo-is-top-level`, `landmark-main-is-top-level`, `landmark-no-duplicate-banner`, `landmark-no-duplicate-contentinfo`, `landmark-no-duplicate-main`, `landmark-one-main`, `landmark-unique`, `link-name`, `meta-viewport`, `meta-viewport-large`, `nested-interactive`, `page-has-heading-one`, `region`, `skip-link`, `tabindex`. +- Incomplete: none. +- Inapplicable: `accesskeys`, `area-alt`, `aria-braille-equivalent`, `aria-command-name`, `aria-dialog-name`, `aria-input-field-name`, `aria-meter-name`, `aria-progressbar-name`, `aria-required-children`, `aria-required-parent`, `aria-tab-name`, `aria-text`, `aria-toggle-field-name`, `aria-tooltip-name`, `aria-treeitem-name`, `autocomplete-valid`, `blink`, `duplicate-id-aria`, `empty-table-header`, `form-field-multiple-labels`, `frame-focusable-content`, `frame-tested`, `frame-title`, `frame-title-unique`, `html-xml-lang-mismatch`, `image-alt`, `image-redundant-alt`, `input-button-name`, `input-image-alt`, `label`, `label-title-only`, `link-in-text-block`, `list`, `listitem`, `marquee`, `meta-refresh`, `no-autoplay-audio`, `object-alt`, `presentation-role-conflict`, `role-img-alt`, `scope-attr-valid`, `scrollable-region-focusable`, `select-name`, `server-side-image-map`, `summary-name`, `svg-img-alt`, `table-duplicate-name`, `td-headers-attr`, `th-has-data-cells`, `valid-lang`, `video-caption`. +- Violated: none. + +**A3 Account, sign-out in flight** (/account): 38 passed, 0 incomplete, 51 inapplicable, 0 violated. + +- Passed: `aria-allowed-attr`, `aria-allowed-role`, `aria-conditional-attr`, `aria-deprecated-role`, `aria-hidden-body`, `aria-hidden-focus`, `aria-prohibited-attr`, `aria-required-attr`, `aria-roles`, `aria-valid-attr`, `aria-valid-attr-value`, `avoid-inline-spacing`, `button-name`, `bypass`, `color-contrast`, `definition-list`, `dlitem`, `document-title`, `empty-heading`, `heading-order`, `html-has-lang`, `html-lang-valid`, `landmark-banner-is-top-level`, `landmark-contentinfo-is-top-level`, `landmark-main-is-top-level`, `landmark-no-duplicate-banner`, `landmark-no-duplicate-contentinfo`, `landmark-no-duplicate-main`, `landmark-one-main`, `landmark-unique`, `link-name`, `meta-viewport`, `meta-viewport-large`, `nested-interactive`, `page-has-heading-one`, `region`, `skip-link`, `tabindex`. +- Incomplete: none. +- Inapplicable: `accesskeys`, `area-alt`, `aria-braille-equivalent`, `aria-command-name`, `aria-dialog-name`, `aria-input-field-name`, `aria-meter-name`, `aria-progressbar-name`, `aria-required-children`, `aria-required-parent`, `aria-tab-name`, `aria-text`, `aria-toggle-field-name`, `aria-tooltip-name`, `aria-treeitem-name`, `autocomplete-valid`, `blink`, `duplicate-id-aria`, `empty-table-header`, `form-field-multiple-labels`, `frame-focusable-content`, `frame-tested`, `frame-title`, `frame-title-unique`, `html-xml-lang-mismatch`, `image-alt`, `image-redundant-alt`, `input-button-name`, `input-image-alt`, `label`, `label-title-only`, `link-in-text-block`, `list`, `listitem`, `marquee`, `meta-refresh`, `no-autoplay-audio`, `object-alt`, `presentation-role-conflict`, `role-img-alt`, `scope-attr-valid`, `scrollable-region-focusable`, `select-name`, `server-side-image-map`, `summary-name`, `svg-img-alt`, `table-duplicate-name`, `td-headers-attr`, `th-has-data-cells`, `valid-lang`, `video-caption`. +- Violated: none. + +**A4 Account, session ended by sign-out (after the sign-out)** (/account): 36 passed, 0 incomplete, 53 inapplicable, 0 violated. + +- Passed: `aria-allowed-attr`, `aria-allowed-role`, `aria-conditional-attr`, `aria-deprecated-role`, `aria-hidden-body`, `aria-hidden-focus`, `aria-prohibited-attr`, `aria-required-attr`, `aria-roles`, `aria-valid-attr`, `aria-valid-attr-value`, `avoid-inline-spacing`, `button-name`, `bypass`, `color-contrast`, `document-title`, `empty-heading`, `heading-order`, `html-has-lang`, `html-lang-valid`, `landmark-banner-is-top-level`, `landmark-contentinfo-is-top-level`, `landmark-main-is-top-level`, `landmark-no-duplicate-banner`, `landmark-no-duplicate-contentinfo`, `landmark-no-duplicate-main`, `landmark-one-main`, `landmark-unique`, `link-name`, `meta-viewport`, `meta-viewport-large`, `nested-interactive`, `page-has-heading-one`, `region`, `skip-link`, `tabindex`. +- Incomplete: none. +- Inapplicable: `accesskeys`, `area-alt`, `aria-braille-equivalent`, `aria-command-name`, `aria-dialog-name`, `aria-input-field-name`, `aria-meter-name`, `aria-progressbar-name`, `aria-required-children`, `aria-required-parent`, `aria-tab-name`, `aria-text`, `aria-toggle-field-name`, `aria-tooltip-name`, `aria-treeitem-name`, `autocomplete-valid`, `blink`, `definition-list`, `dlitem`, `duplicate-id-aria`, `empty-table-header`, `form-field-multiple-labels`, `frame-focusable-content`, `frame-tested`, `frame-title`, `frame-title-unique`, `html-xml-lang-mismatch`, `image-alt`, `image-redundant-alt`, `input-button-name`, `input-image-alt`, `label`, `label-title-only`, `link-in-text-block`, `list`, `listitem`, `marquee`, `meta-refresh`, `no-autoplay-audio`, `object-alt`, `presentation-role-conflict`, `role-img-alt`, `scope-attr-valid`, `scrollable-region-focusable`, `select-name`, `server-side-image-map`, `summary-name`, `svg-img-alt`, `table-duplicate-name`, `td-headers-attr`, `th-has-data-cells`, `valid-lang`, `video-caption`. +- Violated: none. + +**A5 Account, failed read** (/account): 36 passed, 0 incomplete, 53 inapplicable, 0 violated. + +- Passed: `aria-allowed-attr`, `aria-allowed-role`, `aria-conditional-attr`, `aria-deprecated-role`, `aria-hidden-body`, `aria-hidden-focus`, `aria-prohibited-attr`, `aria-required-attr`, `aria-roles`, `aria-valid-attr`, `aria-valid-attr-value`, `avoid-inline-spacing`, `button-name`, `bypass`, `color-contrast`, `document-title`, `empty-heading`, `heading-order`, `html-has-lang`, `html-lang-valid`, `landmark-banner-is-top-level`, `landmark-contentinfo-is-top-level`, `landmark-main-is-top-level`, `landmark-no-duplicate-banner`, `landmark-no-duplicate-contentinfo`, `landmark-no-duplicate-main`, `landmark-one-main`, `landmark-unique`, `link-name`, `meta-viewport`, `meta-viewport-large`, `nested-interactive`, `page-has-heading-one`, `region`, `skip-link`, `tabindex`. +- Incomplete: none. +- Inapplicable: `accesskeys`, `area-alt`, `aria-braille-equivalent`, `aria-command-name`, `aria-dialog-name`, `aria-input-field-name`, `aria-meter-name`, `aria-progressbar-name`, `aria-required-children`, `aria-required-parent`, `aria-tab-name`, `aria-text`, `aria-toggle-field-name`, `aria-tooltip-name`, `aria-treeitem-name`, `autocomplete-valid`, `blink`, `definition-list`, `dlitem`, `duplicate-id-aria`, `empty-table-header`, `form-field-multiple-labels`, `frame-focusable-content`, `frame-tested`, `frame-title`, `frame-title-unique`, `html-xml-lang-mismatch`, `image-alt`, `image-redundant-alt`, `input-button-name`, `input-image-alt`, `label`, `label-title-only`, `link-in-text-block`, `list`, `listitem`, `marquee`, `meta-refresh`, `no-autoplay-audio`, `object-alt`, `presentation-role-conflict`, `role-img-alt`, `scope-attr-valid`, `scrollable-region-focusable`, `select-name`, `server-side-image-map`, `summary-name`, `svg-img-alt`, `table-duplicate-name`, `td-headers-attr`, `th-has-data-cells`, `valid-lang`, `video-caption`. +- Violated: none. + +**C1 Chat, idle** (/chat): 37 passed, 0 incomplete, 52 inapplicable, 0 violated. + +- Passed: `aria-allowed-attr`, `aria-allowed-role`, `aria-conditional-attr`, `aria-hidden-body`, `aria-hidden-focus`, `aria-prohibited-attr`, `aria-valid-attr`, `aria-valid-attr-value`, `avoid-inline-spacing`, `button-name`, `bypass`, `color-contrast`, `document-title`, `empty-heading`, `form-field-multiple-labels`, `heading-order`, `html-has-lang`, `html-lang-valid`, `label`, `label-title-only`, `landmark-banner-is-top-level`, `landmark-contentinfo-is-top-level`, `landmark-main-is-top-level`, `landmark-no-duplicate-banner`, `landmark-no-duplicate-contentinfo`, `landmark-no-duplicate-main`, `landmark-one-main`, `landmark-unique`, `link-name`, `list`, `meta-viewport`, `meta-viewport-large`, `nested-interactive`, `page-has-heading-one`, `region`, `skip-link`, `tabindex`. +- Incomplete: none. +- Inapplicable: `accesskeys`, `area-alt`, `aria-braille-equivalent`, `aria-command-name`, `aria-deprecated-role`, `aria-dialog-name`, `aria-input-field-name`, `aria-meter-name`, `aria-progressbar-name`, `aria-required-attr`, `aria-required-children`, `aria-required-parent`, `aria-roles`, `aria-tab-name`, `aria-text`, `aria-toggle-field-name`, `aria-tooltip-name`, `aria-treeitem-name`, `autocomplete-valid`, `blink`, `definition-list`, `dlitem`, `duplicate-id-aria`, `empty-table-header`, `frame-focusable-content`, `frame-tested`, `frame-title`, `frame-title-unique`, `html-xml-lang-mismatch`, `image-alt`, `image-redundant-alt`, `input-button-name`, `input-image-alt`, `link-in-text-block`, `listitem`, `marquee`, `meta-refresh`, `no-autoplay-audio`, `object-alt`, `presentation-role-conflict`, `role-img-alt`, `scope-attr-valid`, `scrollable-region-focusable`, `select-name`, `server-side-image-map`, `summary-name`, `svg-img-alt`, `table-duplicate-name`, `td-headers-attr`, `th-has-data-cells`, `valid-lang`, `video-caption`. +- Violated: none. + +**C2 Chat, pending** (/chat): 38 passed, 0 incomplete, 51 inapplicable, 0 violated. + +- Passed: `aria-allowed-attr`, `aria-allowed-role`, `aria-conditional-attr`, `aria-hidden-body`, `aria-hidden-focus`, `aria-prohibited-attr`, `aria-valid-attr`, `aria-valid-attr-value`, `avoid-inline-spacing`, `button-name`, `bypass`, `color-contrast`, `document-title`, `empty-heading`, `form-field-multiple-labels`, `heading-order`, `html-has-lang`, `html-lang-valid`, `label`, `label-title-only`, `landmark-banner-is-top-level`, `landmark-contentinfo-is-top-level`, `landmark-main-is-top-level`, `landmark-no-duplicate-banner`, `landmark-no-duplicate-contentinfo`, `landmark-no-duplicate-main`, `landmark-one-main`, `landmark-unique`, `link-name`, `list`, `listitem`, `meta-viewport`, `meta-viewport-large`, `nested-interactive`, `page-has-heading-one`, `region`, `skip-link`, `tabindex`. +- Incomplete: none. +- Inapplicable: `accesskeys`, `area-alt`, `aria-braille-equivalent`, `aria-command-name`, `aria-deprecated-role`, `aria-dialog-name`, `aria-input-field-name`, `aria-meter-name`, `aria-progressbar-name`, `aria-required-attr`, `aria-required-children`, `aria-required-parent`, `aria-roles`, `aria-tab-name`, `aria-text`, `aria-toggle-field-name`, `aria-tooltip-name`, `aria-treeitem-name`, `autocomplete-valid`, `blink`, `definition-list`, `dlitem`, `duplicate-id-aria`, `empty-table-header`, `frame-focusable-content`, `frame-tested`, `frame-title`, `frame-title-unique`, `html-xml-lang-mismatch`, `image-alt`, `image-redundant-alt`, `input-button-name`, `input-image-alt`, `link-in-text-block`, `marquee`, `meta-refresh`, `no-autoplay-audio`, `object-alt`, `presentation-role-conflict`, `role-img-alt`, `scope-attr-valid`, `scrollable-region-focusable`, `select-name`, `server-side-image-map`, `summary-name`, `svg-img-alt`, `table-duplicate-name`, `td-headers-attr`, `th-has-data-cells`, `valid-lang`, `video-caption`. +- Violated: none. + +**C3 Chat, after reply** (/chat): 38 passed, 0 incomplete, 51 inapplicable, 0 violated. + +- Passed: `aria-allowed-attr`, `aria-allowed-role`, `aria-conditional-attr`, `aria-hidden-body`, `aria-hidden-focus`, `aria-prohibited-attr`, `aria-valid-attr`, `aria-valid-attr-value`, `avoid-inline-spacing`, `button-name`, `bypass`, `color-contrast`, `document-title`, `empty-heading`, `form-field-multiple-labels`, `heading-order`, `html-has-lang`, `html-lang-valid`, `label`, `label-title-only`, `landmark-banner-is-top-level`, `landmark-contentinfo-is-top-level`, `landmark-main-is-top-level`, `landmark-no-duplicate-banner`, `landmark-no-duplicate-contentinfo`, `landmark-no-duplicate-main`, `landmark-one-main`, `landmark-unique`, `link-name`, `list`, `listitem`, `meta-viewport`, `meta-viewport-large`, `nested-interactive`, `page-has-heading-one`, `region`, `skip-link`, `tabindex`. +- Incomplete: none. +- Inapplicable: `accesskeys`, `area-alt`, `aria-braille-equivalent`, `aria-command-name`, `aria-deprecated-role`, `aria-dialog-name`, `aria-input-field-name`, `aria-meter-name`, `aria-progressbar-name`, `aria-required-attr`, `aria-required-children`, `aria-required-parent`, `aria-roles`, `aria-tab-name`, `aria-text`, `aria-toggle-field-name`, `aria-tooltip-name`, `aria-treeitem-name`, `autocomplete-valid`, `blink`, `definition-list`, `dlitem`, `duplicate-id-aria`, `empty-table-header`, `frame-focusable-content`, `frame-tested`, `frame-title`, `frame-title-unique`, `html-xml-lang-mismatch`, `image-alt`, `image-redundant-alt`, `input-button-name`, `input-image-alt`, `link-in-text-block`, `marquee`, `meta-refresh`, `no-autoplay-audio`, `object-alt`, `presentation-role-conflict`, `role-img-alt`, `scope-attr-valid`, `scrollable-region-focusable`, `select-name`, `server-side-image-map`, `summary-name`, `svg-img-alt`, `table-duplicate-name`, `td-headers-attr`, `th-has-data-cells`, `valid-lang`, `video-caption`. +- Violated: none. + +**C4 Chat, failed send (notice shown)** (/chat): 38 passed, 0 incomplete, 51 inapplicable, 0 violated. + +- Passed: `aria-allowed-attr`, `aria-allowed-role`, `aria-conditional-attr`, `aria-hidden-body`, `aria-hidden-focus`, `aria-prohibited-attr`, `aria-valid-attr`, `aria-valid-attr-value`, `avoid-inline-spacing`, `button-name`, `bypass`, `color-contrast`, `document-title`, `empty-heading`, `form-field-multiple-labels`, `heading-order`, `html-has-lang`, `html-lang-valid`, `label`, `label-title-only`, `landmark-banner-is-top-level`, `landmark-contentinfo-is-top-level`, `landmark-main-is-top-level`, `landmark-no-duplicate-banner`, `landmark-no-duplicate-contentinfo`, `landmark-no-duplicate-main`, `landmark-one-main`, `landmark-unique`, `link-name`, `list`, `listitem`, `meta-viewport`, `meta-viewport-large`, `nested-interactive`, `page-has-heading-one`, `region`, `skip-link`, `tabindex`. +- Incomplete: none. +- Inapplicable: `accesskeys`, `area-alt`, `aria-braille-equivalent`, `aria-command-name`, `aria-deprecated-role`, `aria-dialog-name`, `aria-input-field-name`, `aria-meter-name`, `aria-progressbar-name`, `aria-required-attr`, `aria-required-children`, `aria-required-parent`, `aria-roles`, `aria-tab-name`, `aria-text`, `aria-toggle-field-name`, `aria-tooltip-name`, `aria-treeitem-name`, `autocomplete-valid`, `blink`, `definition-list`, `dlitem`, `duplicate-id-aria`, `empty-table-header`, `frame-focusable-content`, `frame-tested`, `frame-title`, `frame-title-unique`, `html-xml-lang-mismatch`, `image-alt`, `image-redundant-alt`, `input-button-name`, `input-image-alt`, `link-in-text-block`, `marquee`, `meta-refresh`, `no-autoplay-audio`, `object-alt`, `presentation-role-conflict`, `role-img-alt`, `scope-attr-valid`, `scrollable-region-focusable`, `select-name`, `server-side-image-map`, `summary-name`, `svg-img-alt`, `table-duplicate-name`, `td-headers-attr`, `th-has-data-cells`, `valid-lang`, `video-caption`. +- Violated: none. + +**E1 App error banner, unhandled error shown** (/chat): 41 passed, 0 incomplete, 48 inapplicable, 0 violated. + +- Passed: `aria-allowed-attr`, `aria-allowed-role`, `aria-conditional-attr`, `aria-deprecated-role`, `aria-hidden-body`, `aria-hidden-focus`, `aria-prohibited-attr`, `aria-required-attr`, `aria-roles`, `aria-valid-attr`, `aria-valid-attr-value`, `avoid-inline-spacing`, `button-name`, `bypass`, `color-contrast`, `document-title`, `empty-heading`, `form-field-multiple-labels`, `heading-order`, `html-has-lang`, `html-lang-valid`, `label`, `label-title-only`, `landmark-banner-is-top-level`, `landmark-contentinfo-is-top-level`, `landmark-main-is-top-level`, `landmark-no-duplicate-banner`, `landmark-no-duplicate-contentinfo`, `landmark-no-duplicate-main`, `landmark-one-main`, `landmark-unique`, `link-in-text-block`, `link-name`, `list`, `meta-viewport`, `meta-viewport-large`, `nested-interactive`, `page-has-heading-one`, `region`, `skip-link`, `tabindex`. +- Incomplete: none. +- Inapplicable: `accesskeys`, `area-alt`, `aria-braille-equivalent`, `aria-command-name`, `aria-dialog-name`, `aria-input-field-name`, `aria-meter-name`, `aria-progressbar-name`, `aria-required-children`, `aria-required-parent`, `aria-tab-name`, `aria-text`, `aria-toggle-field-name`, `aria-tooltip-name`, `aria-treeitem-name`, `autocomplete-valid`, `blink`, `definition-list`, `dlitem`, `duplicate-id-aria`, `empty-table-header`, `frame-focusable-content`, `frame-tested`, `frame-title`, `frame-title-unique`, `html-xml-lang-mismatch`, `image-alt`, `image-redundant-alt`, `input-button-name`, `input-image-alt`, `listitem`, `marquee`, `meta-refresh`, `no-autoplay-audio`, `object-alt`, `presentation-role-conflict`, `role-img-alt`, `scope-attr-valid`, `scrollable-region-focusable`, `select-name`, `server-side-image-map`, `summary-name`, `svg-img-alt`, `table-duplicate-name`, `td-headers-attr`, `th-has-data-cells`, `valid-lang`, `video-caption`. +- Violated: none. + +**S1 Site, page /** (/): 30 passed, 0 incomplete, 59 inapplicable, 0 violated. + +- Passed: `aria-allowed-attr`, `aria-conditional-attr`, `aria-hidden-body`, `aria-hidden-focus`, `aria-prohibited-attr`, `aria-valid-attr`, `aria-valid-attr-value`, `bypass`, `color-contrast`, `document-title`, `duplicate-id-aria`, `empty-heading`, `heading-order`, `html-has-lang`, `html-lang-valid`, `landmark-banner-is-top-level`, `landmark-contentinfo-is-top-level`, `landmark-main-is-top-level`, `landmark-no-duplicate-banner`, `landmark-no-duplicate-contentinfo`, `landmark-no-duplicate-main`, `landmark-one-main`, `landmark-unique`, `link-name`, `meta-viewport`, `meta-viewport-large`, `page-has-heading-one`, `region`, `skip-link`, `tabindex`. +- Incomplete: none. +- Inapplicable: `accesskeys`, `area-alt`, `aria-allowed-role`, `aria-braille-equivalent`, `aria-command-name`, `aria-deprecated-role`, `aria-dialog-name`, `aria-input-field-name`, `aria-meter-name`, `aria-progressbar-name`, `aria-required-attr`, `aria-required-children`, `aria-required-parent`, `aria-roles`, `aria-tab-name`, `aria-text`, `aria-toggle-field-name`, `aria-tooltip-name`, `aria-treeitem-name`, `autocomplete-valid`, `avoid-inline-spacing`, `blink`, `button-name`, `definition-list`, `dlitem`, `empty-table-header`, `form-field-multiple-labels`, `frame-focusable-content`, `frame-tested`, `frame-title`, `frame-title-unique`, `html-xml-lang-mismatch`, `image-alt`, `image-redundant-alt`, `input-button-name`, `input-image-alt`, `label`, `label-title-only`, `link-in-text-block`, `list`, `listitem`, `marquee`, `meta-refresh`, `nested-interactive`, `no-autoplay-audio`, `object-alt`, `presentation-role-conflict`, `role-img-alt`, `scope-attr-valid`, `scrollable-region-focusable`, `select-name`, `server-side-image-map`, `summary-name`, `svg-img-alt`, `table-duplicate-name`, `td-headers-attr`, `th-has-data-cells`, `valid-lang`, `video-caption`. +- Violated: none. + +**S2 Site, page /hello/** (/hello/): 39 passed, 0 incomplete, 50 inapplicable, 0 violated. + +- Passed: `aria-allowed-attr`, `aria-allowed-role`, `aria-conditional-attr`, `aria-deprecated-role`, `aria-hidden-body`, `aria-hidden-focus`, `aria-prohibited-attr`, `aria-required-attr`, `aria-roles`, `aria-valid-attr`, `aria-valid-attr-value`, `button-name`, `bypass`, `color-contrast`, `document-title`, `duplicate-id-aria`, `empty-heading`, `form-field-multiple-labels`, `heading-order`, `html-has-lang`, `html-lang-valid`, `label`, `label-title-only`, `landmark-banner-is-top-level`, `landmark-contentinfo-is-top-level`, `landmark-main-is-top-level`, `landmark-no-duplicate-banner`, `landmark-no-duplicate-contentinfo`, `landmark-no-duplicate-main`, `landmark-one-main`, `landmark-unique`, `link-name`, `meta-viewport`, `meta-viewport-large`, `nested-interactive`, `page-has-heading-one`, `region`, `skip-link`, `tabindex`. +- Incomplete: none. +- Inapplicable: `accesskeys`, `area-alt`, `aria-braille-equivalent`, `aria-command-name`, `aria-dialog-name`, `aria-input-field-name`, `aria-meter-name`, `aria-progressbar-name`, `aria-required-children`, `aria-required-parent`, `aria-tab-name`, `aria-text`, `aria-toggle-field-name`, `aria-tooltip-name`, `aria-treeitem-name`, `autocomplete-valid`, `avoid-inline-spacing`, `blink`, `definition-list`, `dlitem`, `empty-table-header`, `frame-focusable-content`, `frame-tested`, `frame-title`, `frame-title-unique`, `html-xml-lang-mismatch`, `image-alt`, `image-redundant-alt`, `input-button-name`, `input-image-alt`, `link-in-text-block`, `list`, `listitem`, `marquee`, `meta-refresh`, `no-autoplay-audio`, `object-alt`, `presentation-role-conflict`, `role-img-alt`, `scope-attr-valid`, `scrollable-region-focusable`, `select-name`, `server-side-image-map`, `summary-name`, `svg-img-alt`, `table-duplicate-name`, `td-headers-attr`, `th-has-data-cells`, `valid-lang`, `video-caption`. +- Violated: none. + +**S3 Site, page /cloud-hello/** (/cloud-hello/): 35 passed, 0 incomplete, 54 inapplicable, 0 violated. + +- Passed: `aria-allowed-attr`, `aria-allowed-role`, `aria-conditional-attr`, `aria-deprecated-role`, `aria-hidden-body`, `aria-hidden-focus`, `aria-prohibited-attr`, `aria-required-attr`, `aria-roles`, `aria-valid-attr`, `aria-valid-attr-value`, `button-name`, `bypass`, `color-contrast`, `document-title`, `empty-heading`, `heading-order`, `html-has-lang`, `html-lang-valid`, `landmark-banner-is-top-level`, `landmark-contentinfo-is-top-level`, `landmark-main-is-top-level`, `landmark-no-duplicate-banner`, `landmark-no-duplicate-contentinfo`, `landmark-no-duplicate-main`, `landmark-one-main`, `landmark-unique`, `link-name`, `meta-viewport`, `meta-viewport-large`, `nested-interactive`, `page-has-heading-one`, `region`, `skip-link`, `tabindex`. +- Incomplete: none. +- Inapplicable: `accesskeys`, `area-alt`, `aria-braille-equivalent`, `aria-command-name`, `aria-dialog-name`, `aria-input-field-name`, `aria-meter-name`, `aria-progressbar-name`, `aria-required-children`, `aria-required-parent`, `aria-tab-name`, `aria-text`, `aria-toggle-field-name`, `aria-tooltip-name`, `aria-treeitem-name`, `autocomplete-valid`, `avoid-inline-spacing`, `blink`, `definition-list`, `dlitem`, `duplicate-id-aria`, `empty-table-header`, `form-field-multiple-labels`, `frame-focusable-content`, `frame-tested`, `frame-title`, `frame-title-unique`, `html-xml-lang-mismatch`, `image-alt`, `image-redundant-alt`, `input-button-name`, `input-image-alt`, `label`, `label-title-only`, `link-in-text-block`, `list`, `listitem`, `marquee`, `meta-refresh`, `no-autoplay-audio`, `object-alt`, `presentation-role-conflict`, `role-img-alt`, `scope-attr-valid`, `scrollable-region-focusable`, `select-name`, `server-side-image-map`, `summary-name`, `svg-img-alt`, `table-duplicate-name`, `td-headers-attr`, `th-has-data-cells`, `valid-lang`, `video-caption`. +- Violated: none. + +**S4 Site, page /404.html** (/404.html): 17 passed, 0 incomplete, 72 inapplicable, 0 violated. + +- Passed: `aria-hidden-body`, `bypass`, `color-contrast`, `document-title`, `empty-heading`, `heading-order`, `html-has-lang`, `html-lang-valid`, `landmark-main-is-top-level`, `landmark-no-duplicate-main`, `landmark-one-main`, `landmark-unique`, `link-name`, `meta-viewport`, `meta-viewport-large`, `page-has-heading-one`, `region`. +- Incomplete: none. +- Inapplicable: `accesskeys`, `area-alt`, `aria-allowed-attr`, `aria-allowed-role`, `aria-braille-equivalent`, `aria-command-name`, `aria-conditional-attr`, `aria-deprecated-role`, `aria-dialog-name`, `aria-hidden-focus`, `aria-input-field-name`, `aria-meter-name`, `aria-progressbar-name`, `aria-prohibited-attr`, `aria-required-attr`, `aria-required-children`, `aria-required-parent`, `aria-roles`, `aria-tab-name`, `aria-text`, `aria-toggle-field-name`, `aria-tooltip-name`, `aria-treeitem-name`, `aria-valid-attr`, `aria-valid-attr-value`, `autocomplete-valid`, `avoid-inline-spacing`, `blink`, `button-name`, `definition-list`, `dlitem`, `duplicate-id-aria`, `empty-table-header`, `form-field-multiple-labels`, `frame-focusable-content`, `frame-tested`, `frame-title`, `frame-title-unique`, `html-xml-lang-mismatch`, `image-alt`, `image-redundant-alt`, `input-button-name`, `input-image-alt`, `label`, `label-title-only`, `landmark-banner-is-top-level`, `landmark-contentinfo-is-top-level`, `landmark-no-duplicate-banner`, `landmark-no-duplicate-contentinfo`, `link-in-text-block`, `list`, `listitem`, `marquee`, `meta-refresh`, `nested-interactive`, `no-autoplay-audio`, `object-alt`, `presentation-role-conflict`, `role-img-alt`, `scope-attr-valid`, `scrollable-region-focusable`, `select-name`, `server-side-image-map`, `skip-link`, `summary-name`, `svg-img-alt`, `tabindex`, `table-duplicate-name`, `td-headers-attr`, `th-has-data-cells`, `valid-lang`, `video-caption`. +- Violated: none. + + +## WCAG 2.2 checklist + +Screen state keys: A1 Account, anonymous first read (`/account`); A2 Account, signed in; A3 Account, sign-out in flight; A4 Account, session ended by sign-out; A5 Account, failed read. C1 Chat, idle (`/chat`); C2 Chat, pending; C3 Chat, after reply; C4 Chat, failed send (notice shown). E1 App error banner, unhandled error shown (on `/chat`). S1 `/`; S2 `/hello/`; S3 `/cloud-hello/`; S4 `/404.html`. + +WCAG 2.2 has 31 Level A and 24 Level AA success criteria. 4.1.1 Parsing was removed in 2.2 and is not listed. The six 2.2 additions in scope are 2.4.11, 2.5.7 and 2.5.8 (AA), 3.2.6 and 3.3.7 (A), and 3.3.8 (AA). Every criterion has one row and every state has one status per row: 55 criteria, 14 states, 770 cells. + +### Level A + +| SC | Criterion | A1 Account, anonymous | A2 Account, signed in | A3 Account, sign-out in flight | A4 Account, session ended | A5 Account, failed read | C1 Chat, idle | C2 Chat, pending | C3 Chat, after reply | C4 Chat, failed send | E1 App error banner | S1 Site / | S2 Site /hello/ | S3 Site /cloud-hello/ | S4 Site /404.html | Basis | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| 1.1.1 | Non-text Content | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | not applicable | Inspection: no img, role=img, area or object on any screen. The icons are decorative and aria-hidden (Arrow.razor:1 through Button.razor:1; Shell.razor:1; HomeContent.razor:1, hello-art). The banner's dismiss control is a text glyph. The five mapped axe rules are inapplicable on every state. 404.html has no non-text content (Assets/404.html:12-17). | +| 1.2.1 | Audio-only and Video-only (Prerecorded) | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | No audio or video on any screen. The audio-caption rule carries the 1.2.1 tag, is deprecated, and is not evaluated by the run (Method). | +| 1.2.2 | Captions (Prerecorded) | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | No prerecorded video on any screen. The video-caption rule is inapplicable on every state. | +| 1.2.3 | Audio Description or Media Alternative (Prerecorded) | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | No prerecorded media on any screen. | +| 1.3.1 | Info and Relationships | pass (inspection) | pass (axe) | pass (axe) | pass (inspection) | pass (inspection) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | axe definition-list and dlitem pass on the signed-in Account states where the dl is shown (A2 and A3; Account.razor:32-47). axe list passes on C1 to C4 and E1, and listitem passes on C2 to C4, where the transcript list is shown (Chat.razor:21-25). Where no mapped rule passes, inspection: headings (Account.razor:9, Chat.razor:9, HomeContent.razor:1, HelloExample.razor:1, CloudHelloExample.razor:2, Assets/404.html:14); label association (Chat.razor:15-16, HelloExample.razor:1); section labelling (HomeContent.razor:1). | +| 1.3.2 | Meaningful Sequence | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | Inspection: DOM order equals visual order; app.css and site.css have no order, row-reverse or column-reverse rule. The sequence a screen reader announces is not checked here, and no axe rule covers it. | +| 1.3.3 | Sensory Characteristics | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | Inspection: the instructions are text (Chat.razor:10; Account.razor:20; HelloExample.razor:1). The arrow glyphs are aria-hidden decoration (Arrow.razor:1; Shell.razor:1), and no text depends on them. No axe rule. | +| 1.4.1 | Use of Color | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | not verified: needs a human assistive-technology session | pass (inspection) | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | pass (axe) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | axe link-in-text-block passes on E1, where the Reload link sits in running text; it is inapplicable on the other states. Chat idle (C1), after a reply (C3) and after a failed send (C4) show Cancel as grey with aria-disabled, and the grey is the only visual cue for that state (app.css:161-166; Chat.razor:19), so those cells are not verified (issue 8). On /hello/ and /cloud-hello/ the disabled controls have no visual state at all (site.css has no disabled rule), so no information is carried by colour there. That missing state is issue 2, not a 1.4.1 failure. | +| 1.4.2 | Audio Control | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | No audio plays on any screen. The no-autoplay-audio rule is inapplicable on every state. | +| 2.1.1 | Keyboard | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (browser test) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | Inspection: every control is a native button, input, form or link (Button.razor:1; Account.razor:53-56; Chat.razor:14-19; Shell.razor:1; HelloExample.razor:1). The sources have no key handler or pointer-only control (grep). Error banner (E1): Dismiss is a named native button (index.html:17). The browser test ErrorBannerDismissIsKeyboardOperableAndPassesAxe asserts that Dismiss takes keyboard focus and that Enter hides the banner; the run recorded it as passed at 73173c2, so E1 is pass (browser test). | +| 2.1.2 | No Keyboard Trap | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | Inspection: no modal, focus trap or focus call. The tabindex attributes are ProfileLayout.razor:9 (main), Chat.razor:19 (Cancel while idle) and Shell.razor:1 (main). Tab can leave every control. | +| 2.1.4 | Character Key Shortcuts | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | No accesskey and no single-key handler in the sources (grep). The accesskeys rule is best-practice and inapplicable on every state. | +| 2.2.1 | Timing Adjustable | pass (inspection) | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | Inspection: no timer or time limit in the Razor sources. Account.razor:41-44 shows Session ends and Idle expiry on the signed-in states (A2, A3) with no warning and no way to extend (issue 4). Whether this server session expiry is a time limit under 2.2.1 is a judgement. The meta-refresh rule is inapplicable on every state. | +| 2.2.2 | Pause, Stop, Hide | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | Inspection: nothing moves, blinks or updates by itself. The only transitions are hover colour changes (app.css:151, 153-156), and prefers-reduced-motion is honoured (app.css:373-380; site.css). The blink and marquee rules are inapplicable on every state. | +| 2.3.1 | Three Flashes or Below Threshold | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | Inspection: no flashing content. The only animation is the 140 ms hover background transition (app.css:151). | +| 2.4.1 | Bypass Blocks | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | axe bypass (wcag241) passes on every state. The App has a skip link (ProfileLayout.razor:4) to main (ProfileLayout.razor:9), and the Site pages have one (Shell.razor:1). 404.html has no skip link and one main landmark (404.html:12). | +| 2.4.2 | Page Titled | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | axe document-title (wcag242) passes on every state. Titles: Account.razor:5 (Account session); Chat.razor:5 (Chat probe); SitePages.cs:15-17 (Hello, world; Your hello; Server connection); Assets/404.html:7 (Page not found). | +| 2.4.3 | Focus Order | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | Inspection: focus follows DOM order, which is the visual order, and no positive tabindex is used. Account: skip link then Sign out (Account.razor:53-56). Chat: Name, Send, and Cancel only while pending (Chat.razor:14-19). FocusOrder.cs (tests/ArcForges.Web.App.Tests, line 35) asserts the same in bUnit; it was not re-run for this record. The axe focus-order-semantics rule is experimental and not run. | +| 2.4.4 | Link Purpose (In Context) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | pass (axe) | axe link-name (wcag244) passes on every state that has a link: the skip link on the App states, the Reload link on E1, and the Site links. On the Site, Source (Shell.razor:1; the arrow is aria-hidden) names its target only through its nav context, which is a judgement (issue 9), so S1 to S3 are not verified. The 404 link Back home (404.html:16) is clear. | +| 2.5.1 | Pointer Gestures | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | Inspection: every action is one tap or click (Account.razor:55; Chat.razor:19; links). The sources contain no path-based or multipoint gesture. | +| 2.5.2 | Pointer Cancellation | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | Inspection: actions run on click (@onclick at Account.razor:55 and Chat.razor:19), which fires on release. The sources have no mousedown, pointerdown or touchstart handler (grep). | +| 2.5.3 | Label in Name | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | not verified: needs a human assistive-technology session | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | Inspection: each accessible name contains its visible text. Sign out, Signing out..., Try again (Account.razor:88-90; the arrow is aria-hidden in Button.razor:1); Send, Sending..., Cancel (Chat.razor:18-19); Name (Chat.razor:15-16); ArcForges home contains ArcForges (Shell.razor:1); Source (Shell.razor:1). E1: the visible text of Dismiss is the glyph x and its accessible name is Dismiss, so whether the glyph is a label under 2.5.3 is a judgement (issue 10). The axe label-content-name-mismatch rule is experimental and not run. | +| 2.5.4 | Motion Actuation | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | No function is operated by device motion or orientation (grep of the sources for devicemotion, deviceorientation and shake). | +| 3.1.1 | Language of Page | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | axe html-has-lang and html-lang-valid (wcag311) pass on every state. lang=en at index.html:2, SiteDocument.razor:1 and Assets/404.html:2. | +| 3.2.1 | On Focus | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | Inspection: no focus handler and no code runs on focus. Chat.razor:16 binds on input, not on focus. | +| 3.2.2 | On Input | not applicable | not applicable | not applicable | not applicable | not applicable | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | not applicable | not applicable | pass (inspection) | not applicable | not applicable | Chat: a change in Name only updates the value (Chat.razor:16, 36), and a submit happens on Send or Enter (Chat.razor:14). /hello/: the static input is disabled and runs no change handler (HelloExample.razor:1). The Account page and the error banner have no input that changes context. | +| 3.2.6 | Consistent Help | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | No help mechanism on any screen: no help link or contact control in ProfileLayout.razor, Shell.razor, Account.razor, Chat.razor, the error banner or 404.html. | +| 3.3.1 | Error Identification | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not verified: needs a human assistive-technology session | not applicable | not applicable | not applicable | not applicable | not applicable | No input error is detected on these screens. The Chat failed-send notice (C4) reports a server rejection, not an input error; whether it must identify a field is a judgement (issue 6). The Account failure text (Account.razor:24) is not an input error, because Account has no input. The static Site cannot show an error: HelloExample.razor:1 sets _error only on submit. | +| 3.3.2 | Labels or Instructions | not applicable | not applicable | not applicable | not applicable | not applicable | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | not applicable | not applicable | pass (inspection) | not applicable | not applicable | Chat: the visible label Name wraps the field (Chat.razor:15-16), and instructions are text (Chat.razor:10). /hello/: label for=hello-name reads Your name, with hint #name-hint (HelloExample.razor:1). The axe label rule applies to these fields but is tagged 4.1.2, not 3.3.2. | +| 3.3.7 | Redundant Entry | not applicable | not applicable | not applicable | not applicable | not applicable | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | not applicable | not applicable | not applicable | not applicable | not applicable | Chat keeps the Name value across sends (Chat.razor:36, 54-63), so no entry is asked for again. The Account and Site pages have no process that asks for the same entry twice. | +| 4.1.2 | Name, Role, Value | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | axe link-name passes on every state. button-name passes on 11 states, label on 6, and aria-allowed-attr and aria-valid-attr-value on 13 each (Rule names per state). The banner's Dismiss has aria-label (index.html:17), and the browser test asserts its role and name. | + +### Level AA + +| SC | Criterion | A1 Account, anonymous | A2 Account, signed in | A3 Account, sign-out in flight | A4 Account, session ended | A5 Account, failed read | C1 Chat, idle | C2 Chat, pending | C3 Chat, after reply | C4 Chat, failed send | E1 App error banner | S1 Site / | S2 Site /hello/ | S3 Site /cloud-hello/ | S4 Site /404.html | Basis | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| 2.4.5 | Multiple Ways | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not applicable | Judgement. The App profile has two pages (/account and /chat) with no link between them (ProfileLayout.razor:3-14), so each is reached only by its path (issue 5). The Site pages link to one another (Shell.razor:1 nav; HelloExample.razor:1 link to /cloud-hello/), and the Site output has no sitemap. 404 is not part of the page set. | +| 2.4.6 | Headings and Labels | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | Judgement on whether headings and labels describe their purpose. Headings: Account.razor:9; Chat.razor:9; HomeContent.razor:1; HelloExample.razor:1; CloudHelloExample.razor:2; Assets/404.html:14. Labels: Name (Chat.razor:15-16); Your name (HelloExample.razor:1). Adequacy needs a human reading. | +| 1.2.4 | Captions (Live) | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | No live media on any screen. | +| 1.2.5 | Audio Description (Prerecorded) | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | No prerecorded video on any screen. | +| 1.3.4 | Orientation | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | Inspection: no orientation media query or orientation lock in app.css, site.css or the Razor sources. The axe css-orientation-lock rule is experimental and not run. | +| 1.3.5 | Identify Input Purpose | not applicable | not applicable | not applicable | not applicable | not applicable | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not applicable | not applicable | not verified: needs a human assistive-technology session | not applicable | not applicable | axe autocomplete-valid (wcag135) is inapplicable on every state, including /hello/, where the disabled input carries autocomplete="off" (HelloExample.razor:1). The Chat Name field has no autocomplete token (Chat.razor:16). Whether that field collects the user's own name, which 1.3.5 covers, is a judgement (issue 7). The /hello/ purpose is not identified either. | +| 1.4.3 | Contrast (Minimum) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | axe color-contrast (wcag143) passes on every state, with 0 violations and 0 incomplete. Inspected pairs, recomputed for this record: body ink #1e2824 on #f4f3ed 13.65:1; hero copy #5a675e on #f4f3ed 5.34:1; eyebrow #496052 on #f4f3ed 6.14:1; button #ffffff on #235844 8.23:1 (app.css:137-147); disabled button #ffffff on #6b7a72 4.51:1; banner ink #1e2824 on #fff3cd 13.69:1. | +| 1.4.4 | Resize Text | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | axe meta-viewport (wcag144) passes on every state. The viewport meta does not disable zoom (index.html:5; SiteDocument.razor:1; Assets/404.html:5). The 200 percent text check is part of 1.4.10 and is not run here. | +| 1.4.5 | Images of Text | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | Inspection: all text is HTML and CSS. The large hello. on the home page is a styled span in an aria-hidden block (HomeContent.razor:1), not an image. No axe rule. | +| 1.4.10 | Reflow | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | A manual check at 320 CSS px and 400 percent zoom was not run. Inspection: body min-width 320 px (app.css:22-25); flex-wrap on .field (app.css:408-411); input min-width 240 px (app.css:415-416); fixed header heights (app.css:52-53; site.css .site-header). The error banner is fixed to the bottom edge (app.css:442-451). No axe rule covers reflow. | +| 1.4.11 | Non-text Contrast | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | Inspection. The Chat Name field border is #6b7a72, 4.51:1 on white (app.css, .field input, around line 415). The App input-row border is #6b7a72 (app.css, .input-row input), 4.44:1 on #fffdf7. Both replaced #b9c1b4 (1.85:1) and #a3b1a4 (2.20:1) in commit 49f72bf. The Send fill #235844 is 8.23:1 on white. The Cancel fill #6b7a72 is 4.06:1 on the page. The focus ring #235844 is 7.41:1 on the page. HostPageAccessibilityTests asserts 3:1 on these boundaries. The Site /hello/ input border #a3b1a4 (site.css, .input-row input) is 2.20:1 on #fffdf7, but that input is disabled in the static output, which is inactive and exempt (issue 2). No axe rule covers 1.4.11. | +| 1.4.12 | Text Spacing | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (axe) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | axe avoid-inline-spacing (wcag1412) passes on the states where it is listed as passed (Rule names per state); elsewhere inspection: no style attribute in any Razor source, so no inline style blocks text spacing. Fixed heights apply only to the header (app.css:52-53; site.css .site-header), which holds one line of text. Not rendered. | +| 1.4.13 | Content on Hover or Focus | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | pass (inspection) | The only content shown on focus is the skip link (app.css:81-91; site.css). Focused, it sits at top 12 px and left 24 px (app.css:90). By geometry it overlaps the top of the brand (header 112 px, app.css:53; brand from 64 px left, app.css:60-70; site-shell padding 64 px). The overlap is not rendered here and dismissal without moving focus is not provided, so a human check is needed (issue 1). The 404 page has no skip link and shows no content on focus, so it is pass (inspection). No axe rule. | +| 2.4.7 | Focus Visible | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | Inspection: global :focus-visible outline, 3 px #235844, offset 5 px (app.css:43-46); button offset (app.css:157-159); site.css focus-visible rule; 404.css:26-29. The ring is 7.41:1 against the paper background and 7.43:1 on the banner background (commit 49f72bf). Checked in CSS, not in a rendered focus state. | +| 2.4.11 | Focus Not Obscured (Minimum) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | not verified: needs a human assistive-technology session | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | Inspection: no sticky or fixed UI in the recorded states except #blazor-error-ui (app.css:442-451), which is hidden unless an unhandled error occurs. On E1 the banner is shown fixed to the bottom edge. The focused element in that state is the banner's own Dismiss, which is visible, but the banner can cover other focus targets on the page, which needs a human check (issue 11). No axe rule covers 2.4.11. | +| 2.5.7 | Dragging Movements | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | No drag operation on any screen. The inputs are text fields and buttons only. No axe rule covers 2.5.7. | +| 2.5.8 | Target Size (Minimum) | not applicable | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | not verified: needs a human assistive-technology session | The axe target-size rule (wcag22aa) is not evaluated by the run: it is absent from both rule lists on every state (Method). Inspection: the Account buttons and the Chat Send and Cancel buttons are 52 px tall (app.css:137-142, .button min-height 52px), and so are the Site buttons (site.css, .button min-height 52px), and the Chat Name field is taller than 24 px (app.css:415-416). The anonymous Account state has no visible target: the skip link is off-screen until focused (app.css:81-86). The Site header links and back links are about 21 px tall (site.css, 14 px text at a 1.5 line height), below 24 px, so they need the spacing exception, which is a manual measurement (issue 12). The banner's Dismiss is a glyph with no padding, which is a manual measurement on E1. | +| 3.1.2 | Language of Parts | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | axe valid-lang (wcag312) is inapplicable on every state, because only the html element carries a lang attribute. Inspection: no passage in another language. | +| 3.2.3 | Consistent Navigation | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | not applicable | The same header and footer wrap both App pages (ProfileLayout.razor:3-14) and every Site page (Shell.razor:1). The banner is not navigation. 404.html has no repeated navigation beyond its home link (404.html:16). | +| 3.2.4 | Consistent Identification | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | not applicable | Each function keeps one label: the brand link reads ArcForges home on every Site page (Shell.razor:1). The App uses one Button component (Button.razor:1) for Sign out, Try again and Send, and the same element persists across states. The banner's Dismiss has one name (aria-label, index.html:17). | +| 3.3.3 | Error Suggestion | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not verified: needs a human assistive-technology session | not applicable | not applicable | not applicable | not applicable | not applicable | No input error is detected on these screens. The failed-send notice (C4) gives no correction, and whether it must is a judgement (issue 6). The failure text for a rejected request is in ProbeFailure.cs:40. | +| 3.3.4 | Error Prevention (Legal, Financial, Data) | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | No legal, financial or stored-data commitment on these screens. Chat sends an anonymous greeting and keeps the transcript in memory (Chat.razor:21-25). Account ends the session (Account.razor:140-153). Server-side handling is not inspected. | +| 3.3.8 | Accessible Authentication (Minimum) | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | not applicable | No sign-in step, password, code or object test on these screens. The anonymous Account state says You are not signed in. (Account.razor:28) and has no form. | +| 4.1.3 | Status Messages | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | pass (inspection) | not applicable | pass (inspection) | pass (inspection) | not applicable | Inspection of the programmatic roles: Account panel role=status (Account.razor:13); failure role=alert (Account.razor:24); Chat transcript aria-live=polite (Chat.razor:21); the failed-send notice is a transcript entry in that region (C4); the banner message is role=alert (E1; index.html:16); greeting role=status (HelloExample.razor:1); connection region role changes (CloudHelloExample.razor:2). Whether each announcement is spoken is for the AT session (issue 3). No axe rule (4.1.3 has no axe tag). | + +### Status counts + +Counts across all 55 criteria and 14 states (770 cells). + +| Status | Cells | +|---|---| +| pass (axe) | 113 | +| pass (inspection) | 328 | +| pass (browser test) | 1 | +| not applicable | 238 | +| not verified: needs a human assistive-technology session | 90 | +| total | 770 | + +## Fixed since the earlier draft + +These were resolved before this record, so they are not open issues. Each is backed by a commit and a test. + +- **Chat Name field boundary (1.4.11).** `.field input` moved from `#b9c1b4` (1.85:1 on white) to `#6b7a72` (4.51:1 on white), and the App input-row border moved from `#a3b1a4` to `#6b7a72` (4.44:1 on `#fffdf7`). Commit 49f72bf; the 3:1 checks are in `HostPageAccessibilityTests`. +- **Error banner Dismiss (2.1.1 and 4.1.2).** The unhandled-error Dismiss was an anchor with no `href`, role or tabindex. It is now ` + +@code { + /// The visible label, placed before the arrow. + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// Attributes passed through to the button element (type, disabled, event handlers). + [Parameter(CaptureUnmatchedValues = true)] + public IDictionary? AdditionalAttributes { get; set; } +} diff --git a/src/ArcForges.Web.Ui/CloudHelloExample.razor b/src/ArcForges.Web.Ui/CloudHelloExample.razor new file mode 100644 index 0000000..74d0a45 --- /dev/null +++ b/src/ArcForges.Web.Ui/CloudHelloExample.razor @@ -0,0 +1,83 @@ +@implements IDisposable +
← Back to your hello

Connection example

A hello, from the server.

Send a fixed “ArcForges” greeting to check the server connection. Your name from the local example stays in your browser. Nothing is sent until you choose to connect.

Server response

@Message

+ +@code { + private enum ConnectionState + { + Idle, + Pending, + Success, + Error, + } + + private bool _ready; + private ConnectionState _state = ConnectionState.Idle; + private string _reply = string.Empty; + private CancellationTokenSource? _active; + + /// + /// The check that the host supplies (the App profile calls the server; the static Site supplies none, so the + /// control stays disabled, as the no-script page requires). + /// + [Parameter] + public Func>? CheckConnection { get; set; } + + private string ButtonLabel => _state == ConnectionState.Pending ? "Connecting…" : "Check connection"; + + private string RegionRole => _state == ConnectionState.Error ? "alert" : "status"; + + private string Message => _state switch + { + ConnectionState.Idle => "No request sent yet.", + ConnectionState.Pending => "Contacting the server…", + ConnectionState.Success => _reply, + _ => "The server is unavailable or returned an unexpected response. Try again later.", + }; + + /// Enables the control after the first interactive render, as the React example did after mount. + protected override void OnAfterRender(bool firstRender) + { + if (firstRender && !_ready) + { + _ready = true; + StateHasChanged(); + } + } + + /// Cancels a check that is still running when the page is left, as the React cleanup did. + public void Dispose() => _active?.Cancel(); + + private async Task ConnectAsync() + { + if (CheckConnection is null || _active is not null) + return; + using var controller = new CancellationTokenSource(); + _active = controller; + _state = ConnectionState.Pending; + StateHasChanged(); + try + { + var message = await CheckConnection(controller.Token); + if (!controller.IsCancellationRequested) + { + _reply = message; + _state = ConnectionState.Success; + } + } + catch (OperationCanceledException) when (controller.IsCancellationRequested) + { + // The component was disposed while the check was running; nothing is shown. + } +#pragma warning disable CA1031 // Every failure shows the same visitor text, whatever the transport raised. + catch (Exception) +#pragma warning restore CA1031 + { + _state = ConnectionState.Error; + } + finally + { + if (ReferenceEquals(_active, controller)) + _active = null; + } + } +} diff --git a/src/ArcForges.Web.Ui/GrpcWebFrames.cs b/src/ArcForges.Web.Ui/GrpcWebFrames.cs new file mode 100644 index 0000000..8c04a66 --- /dev/null +++ b/src/ArcForges.Web.Ui/GrpcWebFrames.cs @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: AGPL-3.0-only +namespace ArcForges.Web.Ui; + +/// +/// Length-prefixed gRPC-Web framing for one unary call: a data frame (flag 0) and a trailers frame (flag 0x80), +/// the binary application/grpc-web+proto form the hello example uses. Compressed frames are refused. +/// +public static class GrpcWebFrames +{ + /// The media type of the binary gRPC-Web form. + public const string MediaType = "application/grpc-web+proto"; + + /// The flag of an uncompressed protobuf data frame. + public const byte DataFlag = 0x00; + + /// The flag of the trailers frame. + public const byte TrailersFlag = 0x80; + + /// Encodes one frame: a flag byte, a big-endian 32-bit payload length and the payload. + public static byte[] Encode(ReadOnlySpan payload, byte flag) + { + var frame = new byte[payload.Length + 5]; + frame[0] = flag; + System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(frame.AsSpan(1, 4), checked((uint)payload.Length)); + payload.CopyTo(frame.AsSpan(5)); + return frame; + } + + /// + /// Returns the single protobuf message of a unary reply and checks the trailers. The reply must be exactly one + /// uncompressed data frame followed by a trailers frame whose grpc-status is 0. + /// + /// The body is truncated, compressed, out of shape or not an OK status. + public static byte[] DecodeUnaryReply(ReadOnlySpan body) + { + var offset = 0; + var message = ReadFrame(body, ref offset); + if (message.Flag != DataFlag) + throw new ServerConnectionException("The reply frame is not an uncompressed protobuf message."); + var trailers = ReadFrame(body, ref offset); + if (trailers.Flag != TrailersFlag) + throw new ServerConnectionException("The reply has no trailers frame."); + if (offset != body.Length) + throw new ServerConnectionException("The reply has bytes after the trailers."); + if (GrpcStatus(body.Slice(trailers.Start, trailers.Length)) != "0") + throw new ServerConnectionException("The server reported a non-OK status."); + return body.Slice(message.Start, message.Length).ToArray(); + } + + private readonly record struct Frame(byte Flag, int Start, int Length); + + private static Frame ReadFrame(ReadOnlySpan body, ref int offset) + { + if (body.Length - offset < 5) + throw new ServerConnectionException("The reply frame header is truncated."); + var flag = body[offset]; + var length = System.Buffers.Binary.BinaryPrimitives.ReadUInt32BigEndian(body.Slice(offset + 1, 4)); + offset += 5; + if (length > body.Length - offset) + throw new ServerConnectionException("The reply frame length is beyond the body."); + var frame = new Frame(flag, offset, (int)length); + offset += (int)length; + return frame; + } + + private static string? GrpcStatus(ReadOnlySpan trailers) + { + var text = System.Text.Encoding.UTF8.GetString(trailers); + foreach (var line in text.Split('\n')) + { + var separator = line.IndexOf(':', StringComparison.Ordinal); + if (separator < 0) + continue; + if (string.Equals(line[..separator].Trim(), "grpc-status", StringComparison.OrdinalIgnoreCase)) + return line[(separator + 1)..].Trim(); + } + return null; + } +} diff --git a/src/ArcForges.Web.Ui/HelloExample.razor b/src/ArcForges.Web.Ui/HelloExample.razor new file mode 100644 index 0000000..c05ba7f --- /dev/null +++ b/src/ArcForges.Web.Ui/HelloExample.razor @@ -0,0 +1,35 @@ +
← Back home

Hello example

A hello, just for you.

Start with your name. This example runs in your browser, and your name stays on this page.

Up to 80 characters. No account needed.

@if (_error.Length > 0) { }
Your greeting

@_message

Check the server connection →

+ +@code { + private string _name = HelloGreeting.DefaultName; + private string _message = HelloGreeting.Greet(HelloGreeting.DefaultName); + private string _error = string.Empty; + private bool _ready; + + private string InvalidText => _error.Length > 0 ? "true" : "false"; + + private string DescribedBy => _error.Length > 0 ? "name-error name-hint" : "name-hint"; + + /// Enables the controls after the first interactive render, as the React example did after mount. + protected override void OnAfterRender(bool firstRender) + { + if (firstRender && !_ready) + { + _ready = true; + StateHasChanged(); + } + } + + private void Submit(EventArgs _) + { + try + { + _message = HelloGreeting.Greet(_name); + _error = string.Empty; + } + catch (InvalidNameException) + { + _error = HelloGreeting.InvalidNameMessage; + } + } +} diff --git a/src/ArcForges.Web.Ui/HelloGreeting.cs b/src/ArcForges.Web.Ui/HelloGreeting.cs new file mode 100644 index 0000000..dfa63c6 --- /dev/null +++ b/src/ArcForges.Web.Ui/HelloGreeting.cs @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: AGPL-3.0-only +using System.Text; +using ArcForges.Contracts.Hello.V1; +using Google.Protobuf; + +namespace ArcForges.Web.Ui; + +/// A name that the hello example refuses. The message is the visitor-facing failure text. +public sealed class InvalidNameException : Exception +{ + /// Creates the refusal with the exact visitor-facing text. + public InvalidNameException() + : base(HelloGreeting.InvalidNameMessage) + { + } +} + +/// +/// The hello example greeting (the TypeScript apps/site/app/hello.ts port). The greeting is built from the published +/// SayHello request and response messages, so it is one real protobuf round trip and not a local string format only. +/// +public static class HelloGreeting +{ + /// The visitor-facing text for a refused name. + public const string InvalidNameMessage = "Enter a name between 1 and 80 characters, without control characters."; + + /// The name shown before the visitor enters one. + public const string DefaultName = "World"; + + /// The largest name, counted in Unicode code points before trimming. + public const int MaximumNameLength = 80; + + /// + /// Returns the greeting for . The name is refused when it is blank after trimming, longer + /// than 80 code points or contains a C0 control or DEL character. Surrounding whitespace is removed before the + /// request is encoded; an unpaired surrogate is encoded as U+FFFD, as the browser text encoder does. + /// + /// The name is not accepted. + public static string Greet(string name) + { + ArgumentNullException.ThrowIfNull(name); + if (ContainsControl(name) || CodePointCount(name) > MaximumNameLength || JsTrim(name).Length == 0) + throw new InvalidNameException(); + var trimmed = JsTrim(name); + var wireName = Encoding.UTF8.GetString(Encoding.UTF8.GetBytes(trimmed)); + var request = new SayHelloRequest { Name = wireName }; + var decoded = SayHelloRequest.Parser.ParseFrom(request.ToByteArray()); + var response = new SayHelloResponse { Message = $"Hello, {decoded.Name}!" }; + return SayHelloResponse.Parser.ParseFrom(response.ToByteArray()).Message; + } + + private static bool ContainsControl(string name) + { + foreach (var character in name) + if (character < 32 || character == 127) + return true; + return false; + } + + private static int CodePointCount(string name) + { + var count = 0; + foreach (var _ in name.EnumerateRunes()) + count++; + return count; + } + + /// Trims the characters that JavaScript String.prototype.trim removes. + private static string JsTrim(string value) + { + var start = 0; + var end = value.Length; + while (start < end && IsJsWhitespace(value[start])) + start++; + while (end > start && IsJsWhitespace(value[end - 1])) + end--; + return value[start..end]; + } + + private static bool IsJsWhitespace(char character) + { + var code = (int)character; + return code is 0x09 or 0x0A or 0x0B or 0x0C or 0x0D or 0x20 or 0xA0 or 0x1680 or 0x2028 or 0x2029 + or 0x202F or 0x205F or 0x3000 or 0xFEFF + || (code >= 0x2000 && code <= 0x200A); + } +} diff --git a/src/ArcForges.Web.Ui/HomeContent.razor b/src/ArcForges.Web.Ui/HomeContent.razor new file mode 100644 index 0000000..5ccfda3 --- /dev/null +++ b/src/ArcForges.Web.Ui/HomeContent.razor @@ -0,0 +1 @@ +

ArcForges Web · Early preview

Hello,
world.

Every good thing starts somewhere.
Welcome to the first page of ArcForges Web.

Make it your hello

A beginning you can explore.

This small preview has one job: say hello. Try the example, look around the source, and follow along as ArcForges takes shape.

diff --git a/src/ArcForges.Web.Ui/ProfileIdentity.cs b/src/ArcForges.Web.Ui/ProfileIdentity.cs new file mode 100644 index 0000000..a9037a2 --- /dev/null +++ b/src/ArcForges.Web.Ui/ProfileIdentity.cs @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: AGPL-3.0-only +namespace ArcForges.Web.Ui; + +/// Stable identity of the shared Razor class library (WEB.40 skeleton). +public static class ProfileIdentity +{ + /// The published library name that replaces packages/ui. + public const string Name = "ui"; +} diff --git a/src/ArcForges.Web.Ui/ServerConnection.cs b/src/ArcForges.Web.Ui/ServerConnection.cs new file mode 100644 index 0000000..cea1a46 --- /dev/null +++ b/src/ArcForges.Web.Ui/ServerConnection.cs @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: AGPL-3.0-only +using System.Net.Http.Headers; +using ArcForges.Contracts.Hello.V1; +using Google.Protobuf; + +namespace ArcForges.Web.Ui; + +/// A refused or failed server connection. The message is for logs, never for the visitor. +public sealed class ServerConnectionException : Exception +{ + /// Creates an exception with a diagnostic message. + public ServerConnectionException(string message) + : base(message) + { + } +} + +/// +/// The server connection check of the cloud-hello example (the TypeScript checkServerConnection port): one anonymous +/// binary gRPC-Web SayHello call to the same-origin /api path, with a ten second deadline. It sends no +/// credentials, follows no redirect and accepts only the fixed greeting. +/// +public sealed class ServerConnectionClient +{ + /// The fixed name sent to the server. + public const string RequestName = "ArcForges"; + + /// The only reply accepted for . + public const string ExpectedReply = "Hello, ArcForges!"; + + /// The API path of the SayHello method, relative to the origin. + public const string HelloApiPath = "/api/arcforges.hello.v1.HelloService/SayHello"; + + /// The overall deadline of one check (the TypeScript defaultTimeoutMs of 10000). + public static readonly TimeSpan Deadline = TimeSpan.FromSeconds(10); + + /// The largest reply body read, a cost control for an unexpected response. + public const int MaximumReplyBytes = 64 * 1024; + + private readonly HttpMessageInvoker _invoker; + + /// Creates a client over a message handler. The caller supplies a handler that keeps no cookies. + public ServerConnectionClient(HttpMessageHandler handler) + { + ArgumentNullException.ThrowIfNull(handler); + _invoker = new HttpMessageInvoker(handler, disposeHandler: true); + } + + /// + /// Sends the check to and returns the greeting. Throws + /// for any reply that is not the fixed greeting over the binary gRPC-Web form; the caller's cancellation propagates + /// as . + /// + public async Task CheckAsync(Uri origin, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(origin); + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(Deadline); + var payload = new SayHelloRequest { Name = RequestName }.ToByteArray(); + using var request = new HttpRequestMessage(HttpMethod.Post, new Uri(origin, HelloApiPath)) + { + Content = new ByteArrayContent(GrpcWebFrames.Encode(payload, GrpcWebFrames.DataFlag)), + }; + request.Content.Headers.ContentType = new MediaTypeHeaderValue(GrpcWebFrames.MediaType); + request.Headers.TryAddWithoutValidation("x-grpc-web", "1"); + using var response = await SendAsync(request, deadline.Token).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + throw new ServerConnectionException($"The server answered HTTP {(int)response.StatusCode}."); + if (!string.Equals(response.Content.Headers.ContentType?.MediaType, GrpcWebFrames.MediaType, StringComparison.OrdinalIgnoreCase)) + throw new ServerConnectionException("The reply is not the binary gRPC-Web media type."); + var body = await ReadBoundedAsync(response.Content, deadline.Token).ConfigureAwait(false); + SayHelloResponse message; + try + { + message = SayHelloResponse.Parser.ParseFrom(GrpcWebFrames.DecodeUnaryReply(body)); + } + catch (InvalidProtocolBufferException) + { + throw new ServerConnectionException("The reply is not a valid SayHello message."); + } + if (message.Message != ExpectedReply) + throw new ServerConnectionException("Unexpected Hello response"); + return message.Message; + } + + private async Task SendAsync(HttpRequestMessage request, CancellationToken token) + { + try + { + return await _invoker.SendAsync(request, token).ConfigureAwait(false); + } + catch (HttpRequestException exception) + { + throw new ServerConnectionException("The server is unreachable: " + exception.GetType().Name); + } + catch (OperationCanceledException) when (!token.IsCancellationRequested) + { + throw new ServerConnectionException("The server did not answer before the deadline."); + } + } + + private static async Task ReadBoundedAsync(HttpContent content, CancellationToken token) + { + if (content.Headers.ContentLength is long declared && declared > MaximumReplyBytes) + throw new ServerConnectionException("The reply is larger than the limit."); + await using var stream = await content.ReadAsStreamAsync(token).ConfigureAwait(false); + using var buffer = new MemoryStream(); + var chunk = new byte[4096]; + while (true) + { + var read = await stream.ReadAsync(chunk, token).ConfigureAwait(false); + if (read == 0) + break; + if (buffer.Length + read > MaximumReplyBytes) + throw new ServerConnectionException("The reply is larger than the limit."); + buffer.Write(chunk, 0, read); + } + return buffer.ToArray(); + } +} diff --git a/src/ArcForges.Web.Ui/Shell.razor b/src/ArcForges.Web.Ui/Shell.razor new file mode 100644 index 0000000..8efc521 --- /dev/null +++ b/src/ArcForges.Web.Ui/Shell.razor @@ -0,0 +1,11 @@ + + +@code { + /// The source link. The Site build passes the commit URL, as the React build did from its source ref. + [Parameter] + public string SourceUrl { get; set; } = "https://github.com/ArcForges/Web"; + + /// The page content inside the main landmark. + [Parameter] + public RenderFragment? ChildContent { get; set; } +} diff --git a/src/ArcForges.Web.Ui/WasmContentSecurityPolicy.cs b/src/ArcForges.Web.Ui/WasmContentSecurityPolicy.cs new file mode 100644 index 0000000..590f0c8 --- /dev/null +++ b/src/ArcForges.Web.Ui/WasmContentSecurityPolicy.cs @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: AGPL-3.0-only +using System.Security.Cryptography; +using System.Text; + +namespace ArcForges.Web.Ui; + +/// +/// The Content-Security-Policy of the Blazor WebAssembly standalone profiles (Account, Chat and Operations; P2-021 item 2). +/// The script sources are exactly 'self' and 'wasm-unsafe-eval' plus the SHA-256 hash of every inline script +/// body of the host page. style-src stays 'self', so components that need inline styles are not used. The +/// policy never carries unsafe-inline or unsafe-eval, and an absolute external script is refused. +/// +public static class WasmContentSecurityPolicy +{ + /// The Cloudflare header line budget that the policy must fit within (the React _headers rule). + public const int HeaderLineBudget = 1800; + + /// The script-src token that lets the WebAssembly runtime compile; the only eval-like token allowed. + public const string WasmUnsafeEval = "'wasm-unsafe-eval'"; + + private const string ScriptTag = "Returns the policy for the given host pages. + /// A page has an absolute external script, an unterminated element, or the policy exceeds the line budget. + public static string FromHostPages(IEnumerable pages) + { + ArgumentNullException.ThrowIfNull(pages); + var hashes = new SortedSet(StringComparer.Ordinal); + foreach (var page in pages) + foreach (var body in InlineScriptBodies(page)) + if (body.Length > 0) + hashes.Add("'sha256-" + Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes(body))) + "'"); + var scriptSources = string.Join(' ', new[] { "'self'", WasmUnsafeEval }.Concat(hashes)); + var policy = "default-src 'self'; script-src " + scriptSources + + "; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'"; + if (policy.Length >= HeaderLineBudget) + throw new InvalidOperationException("The Content-Security-Policy exceeds the Workers header line budget."); + return policy; + } + + /// Splits a policy into its directives, each mapped to its ordered source tokens. + public static IReadOnlyDictionary> ParseDirectives(string policy) + { + ArgumentNullException.ThrowIfNull(policy); + var directives = new Dictionary>(StringComparer.Ordinal); + foreach (var part in policy.Split(';', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + { + var tokens = part.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (!directives.TryAdd(tokens[0], tokens[1..])) + throw new InvalidOperationException("Duplicate directive " + tokens[0]); + } + return directives; + } + + private static IEnumerable InlineScriptBodies(string html) + { + var start = 0; + while (true) + { + var open = html.IndexOf(ScriptTag, start, StringComparison.OrdinalIgnoreCase); + if (open < 0) + yield break; + var tagEnd = html.IndexOf('>', open); + if (tagEnd < 0) + throw new InvalidOperationException("A script element is not terminated."); + // Fail closed: a source attribute of any form is an external script the policy does not admit. + var attributes = html[(open + ScriptTag.Length)..tagEnd]; + if (attributes.Contains("src", StringComparison.OrdinalIgnoreCase)) + ValidateSameOriginSource(attributes.ToString()); + var close = html.IndexOf(ScriptClose, tagEnd + 1, StringComparison.OrdinalIgnoreCase); + if (close < 0) + throw new InvalidOperationException("A script element is not closed."); + if (!attributes.Contains("src", StringComparison.OrdinalIgnoreCase)) + yield return html[(tagEnd + 1)..close]; + start = close + ScriptClose.Length; + } + } + + private static void ValidateSameOriginSource(string attributes) + { + var index = attributes.IndexOf("src", StringComparison.OrdinalIgnoreCase); + var equals = index < 0 ? -1 : attributes.IndexOf('=', index); + if (equals < 0) + throw new InvalidOperationException("A script source without a quoted value is refused."); + var rest = attributes[(equals + 1)..].TrimStart(); + if (rest.Length == 0 || (rest[0] != '"' && rest[0] != '\'')) + throw new InvalidOperationException("A script source without a quoted value is refused."); + var closing = rest.IndexOf(rest[0], 1); + if (closing < 0) + throw new InvalidOperationException("An unterminated script source is refused."); + var value = rest[1..closing]; + if (value.StartsWith("//", StringComparison.Ordinal) || value.Contains("://", StringComparison.Ordinal)) + throw new InvalidOperationException("An external script is refused by the WebAssembly profile policy."); + } +} diff --git a/src/ArcForges.Web.Ui/_Imports.razor b/src/ArcForges.Web.Ui/_Imports.razor new file mode 100644 index 0000000..b9332a2 --- /dev/null +++ b/src/ArcForges.Web.Ui/_Imports.razor @@ -0,0 +1,3 @@ +@* SPDX-License-Identifier: AGPL-3.0-only *@ +@using Microsoft.AspNetCore.Components +@using Microsoft.AspNetCore.Components.Web diff --git a/src/ArcForges.Web.Ui/packages.lock.json b/src/ArcForges.Web.Ui/packages.lock.json new file mode 100644 index 0000000..ee4d42b --- /dev/null +++ b/src/ArcForges.Web.Ui/packages.lock.json @@ -0,0 +1,42 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "ArcForges.Build.Policy": { + "type": "Direct", + "requested": "[1.0.0-ci.94.1, )", + "resolved": "1.0.0-ci.94.1", + "contentHash": "PCpeEXikk2m87zhMF6L8tbHe2QrI87mO4Nc5lJBenEDhOePwwV4TkArc6IaM1G11Pm8ojVjurLAIoPW22ozb/A==" + }, + "ArcForges.Contracts.PublicApi": { + "type": "Direct", + "requested": "[1.0.0-ci.287.1, )", + "resolved": "1.0.0-ci.287.1", + "contentHash": "/chAZbT/NTs4yyQBV5dgh37/dWBTKt3PHCikIidI9T90LheydsJoCWxKHGC6Tjzw0rqA62rYxnQfXGzKhTea5Q==", + "dependencies": { + "ArcForges.Contracts.Foundation": "1.0.0-ci.287.1", + "Google.Protobuf": "3.36.1", + "Grpc.Core.Api": "2.84.0" + } + }, + "ArcForges.Contracts.Foundation": { + "type": "Transitive", + "resolved": "1.0.0-ci.287.1", + "contentHash": "Jwe5Z6K57vDsKmcxQFbbwyQZQUQsOkzYH26ogQIRb/biRtaGxLCtOsNg4felCuNTJt9bwRmC6AGx3eJyCoCk/g==", + "dependencies": { + "Google.Protobuf": "3.36.1" + } + }, + "Google.Protobuf": { + "type": "Transitive", + "resolved": "3.36.1", + "contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ==" + }, + "Grpc.Core.Api": { + "type": "Transitive", + "resolved": "2.84.0", + "contentHash": "p2SOMl6q/GZ4/5MLkgboC/z55g4zKUEruRn/g46QgppjQBVnsLZVBU/9VO7n60ll38G3Eo8Zu9b7x+W01GKFVg==" + } + } + } +} diff --git a/tests/ArcForges.Web.App.Tests/AccountPageTests.cs b/tests/ArcForges.Web.App.Tests/AccountPageTests.cs new file mode 100644 index 0000000..270eaa6 --- /dev/null +++ b/tests/ArcForges.Web.App.Tests/AccountPageTests.cs @@ -0,0 +1,366 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Port of the account cases of tests/unit/app-routes.test.tsx: the Account page rendered with bUnit over the same-origin +// probe. The server is a test-only double; the wire documents are fixtures. +using System.Net; +using System.Text; +using AngleSharp.Dom; +using Bunit; +using ArcForges.Web.App.Pages; +using Microsoft.Extensions.DependencyInjection; +using ArcForges.Web.App.Probe; +using ArcForges.Web.App.Tests.Fixtures; +using ArcForges.Web.App.Tests.TestDoubles; +using Xunit; + +namespace ArcForges.Web.App.Tests; + +public sealed class AccountPageTests +{ + internal const string Ended = "You are signed out. Reload to check again."; + + /// Registers the probes over one scripted server. The handler is the only external input. + internal static void RegisterProbes(BunitContext context, HttpMessageHandler server) + { + context.Services.AddSingleton(new ProbeOrigin(ProbeFixtures.Origin)); + context.Services.AddSingleton(server); + context.Services.AddSingleton(); + context.Services.AddSingleton(); + } + + internal static IElement Button(IRenderedComponent cut, string text) => + cut.FindAll("button").Single(button => button.TextContent.Trim() == text); + + internal static bool HasButton(IRenderedComponent cut, string text) => + cut.FindAll("button").Any(button => button.TextContent.Trim() == text); + + [Fact] + public void AccountReadsTheSessionOnceOnOpenAndPresentsItAsTextWithTheExactGeneration() + { + var server = ScriptedServer.Always(() => Responses.Json(Encoding.UTF8.GetBytes(ProbeFixtures.AuthenticatedJson))); + using var context = new BunitContext(); + RegisterProbes(context, server); + + var cut = context.Render(); + cut.WaitForAssertion(() => Assert.Contains("Ada Lovelace", cut.Markup, StringComparison.Ordinal)); + + Assert.Equal(1, server.Count); + Assert.Equal($"{ProbeFixtures.Origin.Scheme}://{ProbeFixtures.Origin.Authority}/session/v1/bootstrap", Assert.Single(server.Requests).Url.ToString()); + Assert.Equal("18446744073709551615", cut.Find("dd.exact").TextContent); + Assert.Contains("en-GB", cut.Markup, StringComparison.Ordinal); + Assert.Contains("Europe/London", cut.Markup, StringComparison.Ordinal); + Assert.Contains("2026-10-03T08:00:00.000000Z", cut.Markup, StringComparison.Ordinal); + var text = cut.Markup; + foreach (var secret in new[] + { + ProbeFixtures.AuthenticatedCsrf, + "6d1d4c2a-62a0-4b86-9d3f-0c6a3d0b5c11", + "0f0e6a30-5d1c-4c7e-8b53-5b6b7f9a4a10", + "8f2a1d77-1c1b-4b0a-9a55-2f1d0e5c7b21", + }) + Assert.DoesNotContain(secret, text, StringComparison.Ordinal); + } + + [Fact] + public async Task WhileTheReadIsInFlightTheCheckingStateIsShownAndThenTheAnswerReplacesIt() + { + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var server = new ScriptedServer((_, _) => gate.Task); + using var context = new BunitContext(); + RegisterProbes(context, server); + + var cut = context.Render(); + await WaitUntilAsync(() => server.Count == 1); + Assert.Contains("Checking your session…", cut.Markup, StringComparison.Ordinal); + + gate.SetResult(Responses.Json(Encoding.UTF8.GetBytes(ProbeFixtures.AnonymousJson))); + cut.WaitForAssertion(() => Assert.Contains("You are not signed in.", cut.Markup, StringComparison.Ordinal)); + Assert.DoesNotContain("Checking your session…", cut.Markup, StringComparison.Ordinal); + } + + [Fact] + public void AccountRendersAnAnonymousVisitorWithNoSignOutControl() + { + var server = ScriptedServer.Always(() => Responses.Json(Encoding.UTF8.GetBytes(ProbeFixtures.AnonymousJson))); + using var context = new BunitContext(); + RegisterProbes(context, server); + + var cut = context.Render(); + cut.WaitForAssertion(() => Assert.Contains("You are not signed in.", cut.Markup, StringComparison.Ordinal)); + Assert.Empty(cut.FindAll("button")); + } + + [Fact] + public void AccountRendersDisplayTextFromTheServerAsTextNeverMarkup() + { + var node = ProbeFixtures.AuthenticatedNode(); + node["profile"]!["displayName"] = ""; + var server = ScriptedServer.Always(() => Responses.Json(ProbeFixtures.Bytes(node))); + using var context = new BunitContext(); + RegisterProbes(context, server); + + var cut = context.Render(); + cut.WaitForAssertion(() => Assert.Contains("<img src=x onerror=alert(1)>", cut.Markup, StringComparison.Ordinal)); + Assert.Empty(cut.FindAll("img")); + } + + [Fact] + public void SignOutPostsTheCsrfTokenFromThisPagesBootstrapAndThenShowsTheEndedState() + { + var answers = new Queue>( + [ + () => Responses.Json(Encoding.UTF8.GetBytes(ProbeFixtures.AuthenticatedJson)), + () => Responses.Json(ProbeFixtures.ReceiptBody("happened")), + ]); + var server = ScriptedServer.Always(() => answers.Dequeue()()); + using var context = new BunitContext(); + RegisterProbes(context, server); + + var cut = context.Render(); + cut.WaitForAssertion(() => Assert.True(HasButton(cut, "Sign out"))); + Button(cut, "Sign out").Click(); + cut.WaitForAssertion(() => Assert.Contains(Ended, cut.Markup, StringComparison.Ordinal)); + + Assert.Equal(2, server.Count); + var logout = server.Requests[1]; + Assert.Equal(HttpMethod.Post, logout.Method); + Assert.Equal($"{ProbeFixtures.Origin.Scheme}://{ProbeFixtures.Origin.Authority}/session/v1/logout", logout.Url.ToString()); + Assert.Equal(ProbeFixtures.AuthenticatedCsrf, logout.CsrfHeader); + Assert.DoesNotContain("Ada Lovelace", cut.Markup, StringComparison.Ordinal); + } + + [Fact] + public void ASessionThatEndedOnTheServerIsShownAsEndedNotAsATransientFault() + { + var answers = new Queue>( + [ + () => Responses.Json(Encoding.UTF8.GetBytes(ProbeFixtures.AuthenticatedJson)), + () => Responses.Text("{}", HttpStatusCode.Unauthorized), + ]); + using var context = new BunitContext(); + RegisterProbes(context, ScriptedServer.Always(() => answers.Dequeue()())); + + var cut = context.Render(); + cut.WaitForAssertion(() => Assert.True(HasButton(cut, "Sign out"))); + Button(cut, "Sign out").Click(); + cut.WaitForAssertion(() => Assert.Contains(Ended, cut.Markup, StringComparison.Ordinal)); + Assert.Empty(cut.FindAll("[role=alert]")); + } + + [Fact] + public void ARefusedOrUnconfirmedSignOutIsReportedAsAFailureAndOffersAFreshRead() + { + var cases = new (Func Answer, string Text)[] + { + (() => Responses.Text("{}", HttpStatusCode.Forbidden), "The server refused this request."), + (() => Responses.Json(ProbeFixtures.ReceiptBody("didNotHappen")), "The server rejected the request."), + (() => Responses.Json(ProbeFixtures.ReceiptBody("unknown")), "The server failed unexpectedly. Try again later."), + }; + foreach (var (answer, text) in cases) + { + var answers = new Queue>( + [ + () => Responses.Json(Encoding.UTF8.GetBytes(ProbeFixtures.AuthenticatedJson)), + answer, + ]); + using var context = new BunitContext(); + RegisterProbes(context, ScriptedServer.Always(() => answers.Dequeue()())); + + var cut = context.Render(); + cut.WaitForAssertion(() => Assert.True(HasButton(cut, "Sign out"))); + Button(cut, "Sign out").Click(); + cut.WaitForAssertion(() => Assert.Equal(text, cut.Find("[role=alert]").TextContent)); + Assert.DoesNotContain(Ended, cut.Markup, StringComparison.Ordinal); + // The page does not claim a sign-out; it offers a fresh read (a new session and token). + Assert.True(HasButton(cut, "Try again")); + Assert.False(HasButton(cut, "Sign out")); + } + } + + [Fact] + public void AFailedReadIsAnnouncedAndCanBeRetried() + { + var answers = new Queue>( + [ + () => Responses.Text("down", HttpStatusCode.ServiceUnavailable), + () => Responses.Json(Encoding.UTF8.GetBytes(ProbeFixtures.AnonymousJson)), + ]); + var server = ScriptedServer.Always(() => answers.Dequeue()()); + using var context = new BunitContext(); + RegisterProbes(context, server); + + var cut = context.Render(); + cut.WaitForAssertion(() => Assert.Equal("The server is unavailable. Try again later.", cut.Find("[role=alert]").TextContent)); + Button(cut, "Try again").Click(); + cut.WaitForAssertion(() => Assert.Contains("You are not signed in.", cut.Markup, StringComparison.Ordinal)); + Assert.Equal(2, server.Count); + } + + [Fact] + public void AnUnreadableAnswerIsShownAsMalformedAndLeavesNoSession() + { + var server = ScriptedServer.Always(() => Responses.Json(Encoding.UTF8.GetBytes("{\"csrfToken\":\"x\"}"))); + using var context = new BunitContext(); + RegisterProbes(context, server); + + var cut = context.Render(); + cut.WaitForAssertion(() => Assert.Equal("The server answered with something this page cannot read.", cut.Find("[role=alert]").TextContent)); + } + + [Fact] + public async Task LeavingThePageCancelsTheReadThatIsStillInFlight() + { + CancellationToken observed = default; + var server = new ScriptedServer(async (_, token) => + { + observed = token; + await Task.Delay(Timeout.Infinite, token); + return Responses.Json(Encoding.UTF8.GetBytes(ProbeFixtures.AnonymousJson)); + }); + using var context = new BunitContext(); + RegisterProbes(context, server); + + context.Render(); + await WaitUntilAsync(() => server.Count == 1); + Assert.False(observed.IsCancellationRequested); + // Disposing the rendered tree unmounts the page, which must cancel the read in flight. + context.Dispose(); + Assert.True(observed.IsCancellationRequested); + } + + [Fact] + public void AnAnonymousVisitorHasNoControlToTabTo() + { + var server = ScriptedServer.Always(() => Responses.Json(Encoding.UTF8.GetBytes(ProbeFixtures.AnonymousJson))); + using var context = new BunitContext(); + RegisterProbes(context, server); + + var cut = context.Render(); + cut.WaitForAssertion(() => Assert.Contains("You are not signed in.", cut.Markup, StringComparison.Ordinal)); + Assert.Empty(FocusOrder.TabSequence(cut)); + FocusOrder.AssertNoPositiveTabIndex(cut); + } + + [Fact] + public void ASignedInVisitorTabsToSignOutAfterTheSessionStatusAndSignOutIsANativeButton() + { + var server = ScriptedServer.Always(() => Responses.Json(Encoding.UTF8.GetBytes(ProbeFixtures.AuthenticatedJson))); + using var context = new BunitContext(); + RegisterProbes(context, server); + + var cut = context.Render(); + cut.WaitForAssertion(() => Assert.True(HasButton(cut, "Sign out"))); + // Reading order: the live status region holds the session facts, then the one control. + Assert.Equal(new[] { "status", "button" }, FocusOrder.ReadingOrder(cut)); + Assert.Equal(new[] { "button:Sign out" }, FocusOrder.TabSequence(cut)); + // A native button gives Enter and Space activation; type="button" keeps it out of any form submission. + var signOut = Button(cut, "Sign out"); + Assert.Equal("button", signOut.LocalName); + Assert.Equal("button", signOut.GetAttribute("type")); + FocusOrder.AssertNoPositiveTabIndex(cut); + } + + [Fact] + public void AFailedReadAnnouncesItsAlertBeforeTheOnlyControlTryAgain() + { + var server = ScriptedServer.Always(() => Responses.Text("down", HttpStatusCode.ServiceUnavailable)); + using var context = new BunitContext(); + RegisterProbes(context, server); + + var cut = context.Render(); + cut.WaitForAssertion(() => Assert.Equal("The server is unavailable. Try again later.", cut.Find("[role=alert]").TextContent)); + Assert.Equal(new[] { "status", "alert", "button" }, FocusOrder.ReadingOrder(cut)); + Assert.Equal(new[] { "button:Try again" }, FocusOrder.TabSequence(cut)); + FocusOrder.AssertNoPositiveTabIndex(cut); + } + + [Fact] + public async Task SignOutStaysPresentAndFocusableWhileWorkingAndAfterTheSessionEnds() + { + // Focus on Sign out must survive the sign-out (AX-02): the control is aria-disabled while working, never removed. + // Element identity is asserted in a real browser (LocalFocusBrowserTests); bUnit re-parses the markup on each render. + var logout = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var server = new ScriptedServer((request, _) => request.Url.AbsolutePath.EndsWith("/logout", StringComparison.Ordinal) + ? logout.Task + : Task.FromResult(Responses.Json(Encoding.UTF8.GetBytes(ProbeFixtures.AuthenticatedJson)))); + using var context = new BunitContext(); + RegisterProbes(context, server); + + var cut = context.Render(); + cut.WaitForAssertion(() => Assert.True(HasButton(cut, "Sign out"))); + Button(cut, "Sign out").Click(); + cut.WaitForAssertion(() => Assert.True(HasButton(cut, "Signing out…"))); + Assert.Single(cut.FindAll("button")); + Assert.Equal("true", Button(cut, "Signing out…").GetAttribute("aria-disabled")); + Assert.False(Button(cut, "Signing out…").HasAttribute("disabled")); + Assert.Equal(new[] { "button:Signing out…" }, FocusOrder.TabSequence(cut)); + + // A click on the refused control does not post a second sign-out. + await WaitUntilAsync(() => server.Count == 2); + Button(cut, "Signing out…").Click(); + await Task.Delay(50, Xunit.TestContext.Current.CancellationToken); + Assert.Equal(2, server.Count); + + logout.SetResult(Responses.Json(ProbeFixtures.ReceiptBody("happened"))); + cut.WaitForAssertion(() => Assert.Contains(Ended, cut.Markup, StringComparison.Ordinal)); + // The ended state keeps the one control, now offering Try again: a fresh read is the only action left. + Assert.Single(cut.FindAll("button")); + Assert.Equal("false", Button(cut, "Try again").GetAttribute("aria-disabled")); + Assert.Equal(new[] { "button:Try again" }, FocusOrder.TabSequence(cut)); + } + + [Fact] + public void AFailedSignOutKeepsTheControlAndOffersTryAgainInIt() + { + var answers = new Queue>( + [ + () => Responses.Json(Encoding.UTF8.GetBytes(ProbeFixtures.AuthenticatedJson)), + () => Responses.Text("{}", HttpStatusCode.Forbidden), + ]); + using var context = new BunitContext(); + RegisterProbes(context, ScriptedServer.Always(() => answers.Dequeue()())); + + var cut = context.Render(); + cut.WaitForAssertion(() => Assert.True(HasButton(cut, "Sign out"))); + Button(cut, "Sign out").Click(); + cut.WaitForAssertion(() => Assert.Equal("The server refused this request.", cut.Find("[role=alert]").TextContent)); + Assert.Single(cut.FindAll("button")); + Assert.Equal("false", Button(cut, "Try again").GetAttribute("aria-disabled")); + Assert.Equal(new[] { "button:Try again" }, FocusOrder.TabSequence(cut)); + } + + [Fact] + public async Task TryAgainStaysPresentAndDisabledWhileItsReadRunsAndBecomesSignOut() + { + var read = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var calls = 0; + var server = new ScriptedServer((_, _) => + { + calls++; + return calls == 1 + ? Task.FromResult(Responses.Text("down", HttpStatusCode.ServiceUnavailable)) + : read.Task; + }); + using var context = new BunitContext(); + RegisterProbes(context, server); + + var cut = context.Render(); + cut.WaitForAssertion(() => Assert.Equal("The server is unavailable. Try again later.", cut.Find("[role=alert]").TextContent)); + Button(cut, "Try again").Click(); + await WaitUntilAsync(() => server.Count == 2); + cut.WaitForAssertion(() => Assert.Equal("true", Button(cut, "Try again").GetAttribute("aria-disabled"))); + Assert.Single(cut.FindAll("button")); + Assert.Equal(new[] { "button:Try again" }, FocusOrder.TabSequence(cut)); + + read.SetResult(Responses.Json(Encoding.UTF8.GetBytes(ProbeFixtures.AuthenticatedJson))); + cut.WaitForAssertion(() => Assert.Contains("Ada Lovelace", cut.Markup, StringComparison.Ordinal)); + Assert.Single(cut.FindAll("button")); + Assert.Equal("false", Button(cut, "Sign out").GetAttribute("aria-disabled")); + } + + internal static async Task WaitUntilAsync(Func condition) + { + for (var attempt = 0; attempt < 500 && !condition(); attempt++) + await Task.Delay(10); + Assert.True(condition(), "The condition was not reached in time."); + } +} diff --git a/tests/ArcForges.Web.App.Tests/ArcForges.Web.App.Tests.csproj b/tests/ArcForges.Web.App.Tests/ArcForges.Web.App.Tests.csproj new file mode 100644 index 0000000..bae9161 --- /dev/null +++ b/tests/ArcForges.Web.App.Tests/ArcForges.Web.App.Tests.csproj @@ -0,0 +1,16 @@ + + + + Exe + true + + + + + + + + AGPL + AGPL-3.0-only + + diff --git a/tests/ArcForges.Web.App.Tests/ChatPageTests.cs b/tests/ArcForges.Web.App.Tests/ChatPageTests.cs new file mode 100644 index 0000000..32dbba3 --- /dev/null +++ b/tests/ArcForges.Web.App.Tests/ChatPageTests.cs @@ -0,0 +1,310 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Port of the chat cases of tests/unit/app-routes.test.tsx: the Chat page rendered with bUnit over the same-origin gRPC-Web +// probe. The server is a test-only double; every reply is a real gRPC-Web frame sequence from the generated protobuf type. +using ArcForges.Contracts.Hello.V1; +using ArcForges.Web.App.Pages; +using ArcForges.Web.App.Probe; +using ArcForges.Web.App.Tests.Fixtures; +using ArcForges.Web.App.Tests.TestDoubles; +using AngleSharp.Dom; +using Bunit; +using Xunit; + +namespace ArcForges.Web.App.Tests; + +public sealed class ChatPageTests +{ + private const string Unavailable = "The server is unavailable. Try again later."; + + private static IElement SendButton(IRenderedComponent cut) => + cut.FindAll("button").Single(button => button.TextContent.Trim() is "Send" or "Sending…"); + + private static IElement CancelButton(IRenderedComponent cut) => + cut.FindAll("button").Single(button => button.TextContent.Trim() == "Cancel"); + + private static HttpResponseMessage Greeting(string name) => + Responses.Raw(ProbeFixtures.HelloReply($"Hello, {name}!"), "application/grpc-web+proto"); + + private static string NameOf(CapturedRequest request) => + SayHelloRequest.Parser.ParseFrom(request.Body.AsSpan(5).ToArray()).Name; + + private static void WaitIdle(IRenderedComponent cut) => + cut.WaitForAssertion(() => Assert.DoesNotContain("Sending…", cut.Markup, StringComparison.Ordinal)); + + [Fact] + public void ChatSendsNothingUntilAskedThenShowsBothSidesOfOneAnonymousExchange() + { + var server = ScriptedServer.Always(() => Responses.Raw(ProbeFixtures.HelloReply("Hello, ArcForges!"), "application/grpc-web+proto")); + using var context = new BunitContext(); + AccountPageTests.RegisterProbes(context, server); + + var cut = context.Render(); + Assert.Equal(0, server.Count); + SendButton(cut).Click(); + cut.WaitForAssertion(() => Assert.Contains("Hello, ArcForges!", cut.Markup, StringComparison.Ordinal)); + + Assert.Equal(1, server.Count); + var request = Assert.Single(server.Requests); + Assert.Equal($"{ProbeFixtures.Origin.Scheme}://{ProbeFixtures.Origin.Authority}{HelloProbe.HelloApiPath}", request.Url.ToString()); + Assert.False(request.HasCookieHeader); + var items = cut.FindAll("li").Select(item => item.TextContent).ToList(); + Assert.Equal(new[] { "youArcForges", "serverHello, ArcForges!" }, items); + } + + [Fact] + public void ChatShowsTheServersTypedRefusalAsAFixedNoticeAndKeepsWorking() + { + var answers = new Queue>( + [ + () => Responses.Raw( + ProbeFixtures.Trailers("grpc-status: 3\r\ngrpc-message: Name%20must%20not%20be%20empty.\r\n"), + "application/grpc-web+proto"), + () => Responses.Raw(ProbeFixtures.HelloReply("Hello, again!"), "application/grpc-web+proto"), + ]); + using var context = new BunitContext(); + AccountPageTests.RegisterProbes(context, ScriptedServer.Always(() => answers.Dequeue()())); + + var cut = context.Render(); + cut.Find("input").Input(string.Empty); + SendButton(cut).Click(); + cut.WaitForAssertion(() => Assert.Contains("The server rejected the request.", cut.Markup, StringComparison.Ordinal)); + Assert.DoesNotContain("must not be empty", cut.Markup, StringComparison.Ordinal); + + cut.Find("input").Input("again"); + WaitIdle(cut); + SendButton(cut).Click(); + cut.WaitForAssertion(() => Assert.Contains("Hello, again!", cut.Markup, StringComparison.Ordinal)); + } + + [Fact] + public void ChatCanCancelAPendingMessageReportsItAsCancelledAndDoesNotDoubleSend() + { + var server = new ScriptedServer(async (_, token) => + { + await Task.Delay(Timeout.Infinite, token); + return Greeting("unused"); + }); + using var context = new BunitContext(); + AccountPageTests.RegisterProbes(context, server); + + var cut = context.Render(); + SendButton(cut).Click(); + cut.WaitForAssertion(() => Assert.Contains("Sending…", cut.Markup, StringComparison.Ordinal)); + Assert.Equal("true", SendButton(cut).GetAttribute("aria-disabled")); + // A second submit while one is pending does not send again. + cut.Find("form").Submit(); + Assert.Equal(1, server.Count); + + CancelButton(cut).Click(); + cut.WaitForAssertion(() => Assert.Contains("The request was cancelled.", cut.Markup, StringComparison.Ordinal)); + // Cancel stays in the document after the message ends, idle again: aria-disabled and out of the tab order. + Assert.Equal("true", CancelButton(cut).GetAttribute("aria-disabled")); + Assert.Equal("-1", CancelButton(cut).GetAttribute("tabindex")); + Assert.Equal("false", SendButton(cut).GetAttribute("aria-disabled")); + } + + [Fact] + public void SendStaysPresentAndFocusableThroughAPendingMessage() + { + // Focus on Send survives a send: the control is never disabled or removed (AX-02). bUnit re-parses the markup on + // every render, so element identity is asserted in a real browser (tests/browser LocalFocusBrowserTests); here the + // control must be present and reachable at each step. + var answer = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var server = new ScriptedServer((_, _) => answer.Task); + using var context = new BunitContext(); + AccountPageTests.RegisterProbes(context, server); + + var cut = context.Render(); + Assert.Equal("false", SendButton(cut).GetAttribute("aria-disabled")); + SendButton(cut).Click(); + cut.WaitForAssertion(() => Assert.Contains("Sending…", cut.Markup, StringComparison.Ordinal)); + Assert.Single(cut.FindAll("button[type=submit]")); + Assert.False(SendButton(cut).HasAttribute("disabled")); + Assert.Equal("true", SendButton(cut).GetAttribute("aria-disabled")); + Assert.Equal(new[] { "input:Name", "button:Sending…", "button:Cancel" }, FocusOrder.TabSequence(cut)); + + answer.SetResult(Greeting("ArcForges")); + cut.WaitForAssertion(() => Assert.Contains("Hello, ArcForges!", cut.Markup, StringComparison.Ordinal)); + Assert.Single(cut.FindAll("button[type=submit]")); + Assert.Equal("false", SendButton(cut).GetAttribute("aria-disabled")); + Assert.Equal(new[] { "input:Name", "button:Send" }, FocusOrder.TabSequence(cut)); + } + + [Fact] + public void APendingSendIgnoresAClickAndEnterSoItCannotSendTwice() + { + var server = new ScriptedServer(async (_, token) => + { + await Task.Delay(Timeout.Infinite, token); + return Greeting("unused"); + }); + using var context = new BunitContext(); + AccountPageTests.RegisterProbes(context, server); + + var cut = context.Render(); + SendButton(cut).Click(); + cut.WaitForAssertion(() => Assert.Contains("Sending…", cut.Markup, StringComparison.Ordinal)); + // The control stays focusable, so a click on it reaches the handler, which refuses the second send. + SendButton(cut).Click(); + cut.Find("form").Submit(); + Assert.Equal(1, server.Count); + Assert.Single(cut.FindAll("li")); + } + + [Fact] + public void ChatRendersUntrustedNamesAsTextAndKeepsOnlyTheLastTwentyEntries() + { + var echo = new ScriptedServer((request, _) => Task.FromResult(Greeting(NameOf(request)))); + using var context = new BunitContext(); + AccountPageTests.RegisterProbes(context, echo); + + var cut = context.Render(); + cut.Find("input").Input("n"); + for (var index = 0; index < 11; index++) + { + SendButton(cut).Click(); + WaitIdle(cut); + } + Assert.Equal(20, cut.FindAll("li").Count); + Assert.Equal(11, echo.Count); + + cut.Find("input").Input(""); + SendButton(cut).Click(); + cut.WaitForAssertion(() => Assert.Contains("<img src=x onerror=alert(1)>", cut.Markup, StringComparison.Ordinal)); + Assert.Empty(cut.FindAll("img")); + } + + [Fact] + public async Task LeavingTheChatPageCancelsTheMessageThatIsStillPending() + { + // The double hands over the token of the request it is answering; the request is pending until it is cancelled. + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var server = new ScriptedServer(async (_, token) => + { + started.TrySetResult(token); + await Task.Delay(Timeout.Infinite, token); + return Greeting("unused"); + }); + using var context = new BunitContext(); + AccountPageTests.RegisterProbes(context, server); + + var cut = context.Render(); + SendButton(cut).Click(); + var observed = await started.Task.WaitAsync(TimeSpan.FromSeconds(10), Xunit.TestContext.Current.CancellationToken); + Assert.False(observed.IsCancellationRequested); + // Leaving the page disposes the rendered component (bUnit disposes components through DisposeComponentsAsync; the + // context dispose only releases services), and the component cancels the message that is still pending. + await context.DisposeComponentsAsync(); + Assert.True(observed.IsCancellationRequested); + } + + [Fact] + public void ChatTabsFromTheNameFieldToSendAndReachesCancelOnlyWhileAMessageIsPending() + { + var server = new ScriptedServer(async (_, token) => + { + await Task.Delay(Timeout.Infinite, token); + return Greeting("unused"); + }); + using var context = new BunitContext(); + AccountPageTests.RegisterProbes(context, server); + + var cut = context.Render(); + Assert.Equal(new[] { "input:Name", "button:Send" }, FocusOrder.TabSequence(cut)); + FocusOrder.AssertNoPositiveTabIndex(cut); + + SendButton(cut).Click(); + cut.WaitForAssertion(() => Assert.Contains("Sending…", cut.Markup, StringComparison.Ordinal)); + // Send stays reachable while the message is pending (aria-disabled, not disabled); Cancel joins it. + Assert.Equal(new[] { "input:Name", "button:Sending…", "button:Cancel" }, FocusOrder.TabSequence(cut)); + FocusOrder.AssertNoPositiveTabIndex(cut); + + CancelButton(cut).Click(); + cut.WaitForAssertion(() => Assert.Contains("The request was cancelled.", cut.Markup, StringComparison.Ordinal)); + Assert.Equal(new[] { "input:Name", "button:Send" }, FocusOrder.TabSequence(cut)); + } + + [Fact] + public void CancelStaysInTheDocumentWhileIdleAndAClickOnItIsRefused() + { + // Option (a) of the 2026-10-09 adjudication: Cancel is persistent. While idle it is aria-disabled and tabindex -1, so + // it is never a tab stop and never removed; focus cannot fall to the body when a message ends. + var server = ScriptedServer.Always(() => Responses.Raw(ProbeFixtures.HelloReply("Hello, ArcForges!"), "application/grpc-web+proto")); + using var context = new BunitContext(); + AccountPageTests.RegisterProbes(context, server); + + var cut = context.Render(); + var cancel = CancelButton(cut); + Assert.Equal("true", cancel.GetAttribute("aria-disabled")); + Assert.Equal("-1", cancel.GetAttribute("tabindex")); + Assert.False(cancel.HasAttribute("disabled")); + + cancel.Click(); + Assert.Equal(0, server.Count); + Assert.Empty(cut.FindAll("li")); + Assert.Equal("true", CancelButton(cut).GetAttribute("aria-disabled")); + Assert.Equal(new[] { "input:Name", "button:Send" }, FocusOrder.TabSequence(cut)); + } + + [Fact] + public void CancelBecomesTabbableWhileAMessageIsPendingAndReturnsToIdleWithoutBeingRemoved() + { + var server = new ScriptedServer(async (_, token) => + { + await Task.Delay(Timeout.Infinite, token); + return Greeting("unused"); + }); + using var context = new BunitContext(); + AccountPageTests.RegisterProbes(context, server); + + var cut = context.Render(); + SendButton(cut).Click(); + cut.WaitForAssertion(() => Assert.Contains("Sending…", cut.Markup, StringComparison.Ordinal)); + Assert.Equal("false", CancelButton(cut).GetAttribute("aria-disabled")); + Assert.False(CancelButton(cut).HasAttribute("tabindex")); + Assert.Equal(new[] { "input:Name", "button:Sending…", "button:Cancel" }, FocusOrder.TabSequence(cut)); + + CancelButton(cut).Click(); + cut.WaitForAssertion(() => Assert.Contains("The request was cancelled.", cut.Markup, StringComparison.Ordinal)); + Assert.Equal("true", CancelButton(cut).GetAttribute("aria-disabled")); + Assert.Equal("-1", CancelButton(cut).GetAttribute("tabindex")); + Assert.Equal(new[] { "input:Name", "button:Send" }, FocusOrder.TabSequence(cut)); + FocusOrder.AssertNoPositiveTabIndex(cut); + Assert.Equal(1, server.Count); + } + + [Fact] + public void EnterInTheNameFieldSendsThroughTheFormsDefaultButtonAndTheReplyIsAnnouncedInTheTranscript() + { + // The keyboard path of the retired Hello greeting: Enter in the name field submits the form, and the reply is + // announced by the live transcript. Enter activates the form's default button, the first submit button in the form. + var server = ScriptedServer.Always(() => Responses.Raw(ProbeFixtures.HelloReply("Hello, ArcForges!"), "application/grpc-web+proto")); + using var context = new BunitContext(); + AccountPageTests.RegisterProbes(context, server); + + var cut = context.Render(); + var form = cut.Find("form"); + Assert.NotNull(form.QuerySelector("input")); + Assert.Equal("Send", FocusOrder.DefaultButtonOf(form)); + + var transcript = cut.Find("ol.transcript"); + Assert.Equal("polite", transcript.GetAttribute("aria-live")); + Assert.Equal("Messages", transcript.GetAttribute("aria-label")); + + cut.Find("form").Submit(); + cut.WaitForAssertion(() => Assert.Contains("Hello, ArcForges!", cut.Find("ol.transcript").TextContent, StringComparison.Ordinal)); + Assert.Equal(1, server.Count); + } + + [Fact] + public void TheTransportFailureIsUnavailableAndItIsAFixedNotice() + { + var server = new ScriptedServer((_, _) => throw new HttpRequestException("Failed to fetch")); + using var context = new BunitContext(); + AccountPageTests.RegisterProbes(context, server); + + var cut = context.Render(); + SendButton(cut).Click(); + cut.WaitForAssertion(() => Assert.Contains(Unavailable, cut.Markup, StringComparison.Ordinal)); + } +} diff --git a/tests/ArcForges.Web.App.Tests/ExactUnsignedTests.cs b/tests/ArcForges.Web.App.Tests/ExactUnsignedTests.cs new file mode 100644 index 0000000..0e0509a --- /dev/null +++ b/tests/ArcForges.Web.App.Tests/ExactUnsignedTests.cs @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Port of the 64-bit case of tests/unit/app-session.test.ts: canonical decimal text in, the same text out, and no value passes +// through a floating-point number. +using ArcForges.Web.App.Probe; +using Xunit; + +namespace ArcForges.Web.App.Tests; + +public sealed class ExactUnsignedTests +{ + [Fact] + public void SixtyFourBitValuesStayExactCanonicalTextInTheSameTextOut() + { + foreach (var value in new[] + { + "0", + "9007199254740991", + "9007199254740993", + "9223372036854775807", + "18446744073709551615", + }) + { + Assert.True(Exact.TryUnsigned(value, out var exact), value); + Assert.Equal(value, exact.Text); + Assert.Equal(ulong.Parse(value, System.Globalization.CultureInfo.InvariantCulture), exact.Value); + } + } + + [Fact] + public void AFloatingPointNumberCannotHoldTheseValuesApartButTheExactPathCan() + { + // A double cannot tell 9007199254740993 from 9007199254740992; the exact path can. + Assert.Equal((double)9007199254740993UL, (double)9007199254740992UL); + Assert.True(Exact.TryUnsigned("9007199254740993", out var odd)); + Assert.True(Exact.TryUnsigned("9007199254740992", out var even)); + Assert.NotEqual(odd.Value, even.Value); + } + + [Fact] + public void AnythingThatIsNotCanonicalUint64TextIsRefused() + { + foreach (var value in new[] + { + string.Empty, + "01", + "-1", + "+1", + "1.0", + "1e3", + " 1", + "1 ", + "0x10", + "18446744073709551616", + }) + Assert.False(Exact.TryUnsigned(value, out _), value); + Assert.False(Exact.TryUnsigned(null, out _)); + } +} diff --git a/tests/ArcForges.Web.App.Tests/Fixtures/ProbeFixtures.cs b/tests/ArcForges.Web.App.Tests/Fixtures/ProbeFixtures.cs new file mode 100644 index 0000000..f1f1262 --- /dev/null +++ b/tests/ArcForges.Web.App.Tests/Fixtures/ProbeFixtures.cs @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Test-only fixtures for the Account and Chat probes. They are wire documents and frames, byte for byte what the same-origin +// server sends; they are produced with the generated Contracts types and never part of a release graph. +using System.Text; +using System.Text.Json.Nodes; +using ArcForges.Contracts.Hello.V1; +using Google.Protobuf; + +namespace ArcForges.Web.App.Tests.Fixtures; + +/// The fixture documents and frames of the Account and Chat tests. +public static class ProbeFixtures +{ + /// The origin the fixtures are served from (the TypeScript fixture origin). + public static readonly Uri Origin = new("https://account.example.test/"); + + /// The CSRF token of the authenticated bootstrap. + public const string AuthenticatedCsrf = "cccccccccccccccccccccccc"; + + /// The CSRF token of the anonymous bootstrap. + public const string AnonymousCsrf = "aaaaaaaaaaaaaaaaaaaaaaaa"; + + /// The authenticated bootstrap document (the fixture of the TypeScript suite, in wire form). + public const string AuthenticatedJson = + "{\"csrfToken\":\"" + AuthenticatedCsrf + "\",\"authenticated\":true," + + "\"session\":{\"sessionId\":\"6d1d4c2a-62a0-4b86-9d3f-0c6a3d0b5c11\",\"expiresAt\":\"2026-10-03T08:00:00.000000Z\"," + + "\"idleExpiresAt\":\"2026-10-02T20:30:00.000000Z\",\"userId\":\"0f0e6a30-5d1c-4c7e-8b53-5b6b7f9a4a10\"," + + "\"deviceId\":\"8f2a1d77-1c1b-4b0a-9a55-2f1d0e5c7b21\",\"workspaceIds\":[\"3f1d3b1e-2a8c-4c44-a1c8-77a1e8f0b9a1\"," + + "\"a2b84f55-0c2d-4b0d-8e6a-5d6e44a6c7d3\"],\"recoveryGeneration\":\"18446744073709551615\",\"purpose\":\"authenticate\"}," + + "\"profile\":{\"displayName\":\"Ada Lovelace\",\"locale\":\"en-GB\",\"timezone\":\"Europe/London\"," + + "\"revision\":\"9007199254740993\"}}"; + + /// The anonymous bootstrap document. + public const string AnonymousJson = "{\"csrfToken\":\"" + AnonymousCsrf + "\",\"authenticated\":false}"; + + /// A logout receipt with the given effect. + public static byte[] ReceiptBody(string effect = "happened") => + Encoding.UTF8.GetBytes( + "{\"commandId\":\"7c9e6679-7425-40de-944b-e07fc1f90ae7\",\"effect\":\"" + effect + "\"}"); + + /// The authenticated bootstrap as a JSON object that a test can change field by field. + public static JsonObject AuthenticatedNode() => JsonNode.Parse(AuthenticatedJson)!.AsObject(); + + /// The anonymous bootstrap as a JSON object that a test can change field by field. + public static JsonObject AnonymousNode() => JsonNode.Parse(AnonymousJson)!.AsObject(); + + /// The UTF-8 bytes of a JSON object. + public static byte[] Bytes(JsonObject node) => Encoding.UTF8.GetBytes(node.ToJsonString()); + + /// One gRPC-Web frame: a flag byte, a big-endian 32-bit length and the payload. + public static byte[] Frame(ReadOnlySpan payload, byte flag) + { + var frame = new byte[payload.Length + 5]; + frame[0] = flag; + System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(frame.AsSpan(1, 4), checked((uint)payload.Length)); + payload.CopyTo(frame.AsSpan(5)); + return frame; + } + + /// A trailers frame (flag 0x80) carrying the given text. + public static byte[] Trailers(string text) => Frame(Encoding.UTF8.GetBytes(text), 0x80); + + /// A binary gRPC-Web hello answer: the SayHelloResponse data frame and an OK trailer. + public static byte[] HelloReply(string message = "Hello, ArcForges!") + { + var payload = new SayHelloResponse { Message = message }.ToByteArray(); + return Concat(Frame(payload, 0), Trailers("grpc-status: 0\r\n")); + } + + /// The bytes of the given parts, in order. + public static byte[] Concat(params byte[][] parts) + { + var total = 0; + foreach (var part in parts) + total += part.Length; + var bytes = new byte[total]; + var offset = 0; + foreach (var part in parts) + { + part.CopyTo(bytes, offset); + offset += part.Length; + } + return bytes; + } +} diff --git a/tests/ArcForges.Web.App.Tests/FocusOrder.cs b/tests/ArcForges.Web.App.Tests/FocusOrder.cs new file mode 100644 index 0000000..1ca88d2 --- /dev/null +++ b/tests/ArcForges.Web.App.Tests/FocusOrder.cs @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Focus-order and keyboard assertions of the interactive pages (the CI accessibility gate, brief section 6 decision 3). +// bUnit renders the markup but does not move focus, so the keyboard sequence is read from the rendered document: the +// controls a keyboard user can reach, in document order, skipping disabled controls and tabindex -1. Native controls +// (button, a, input) give Enter and Space activation for free, so the tests also pin the element kind and the form's +// default button, which decides what Enter does in a text field. +using System.Globalization; +using AngleSharp.Dom; +using Microsoft.AspNetCore.Components; +using Bunit; +using Xunit; + +namespace ArcForges.Web.App.Tests; + +internal static class FocusOrder +{ + private const string Reachable = "a[href], button, input, select, textarea, [tabindex]"; + + /// The keyboard tab sequence: each reachable control as "tag:name", in document order. + internal static IReadOnlyList TabSequence(IRenderedComponent rendered) + where TComponent : IComponent => + rendered.FindAll(Reachable) + .Where(element => element.GetAttribute("tabindex") != "-1" && !element.HasAttribute("disabled")) + .Select(Describe) + .ToList(); + + /// The document order of the live regions and the controls, for reading-order checks. + internal static IReadOnlyList ReadingOrder(IRenderedComponent rendered) + where TComponent : IComponent => + rendered.FindAll("[role=status], [role=alert], button, input") + .Select(element => element.GetAttribute("role") ?? element.LocalName) + .ToList(); + + /// No element may take a positive tabindex, which would reorder the page away from document order. + internal static void AssertNoPositiveTabIndex(IRenderedComponent rendered) + where TComponent : IComponent + { + foreach (var element in rendered.FindAll("[tabindex]")) + { + var value = element.GetAttribute("tabindex")!; + Assert.True( + int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var index) && index <= 0, + $"A positive tabindex reorders the keyboard sequence: {element.OuterHtml}"); + } + } + + /// The first submit button of a form is its default button: Enter in a text field activates it. + internal static string DefaultButtonOf(IElement form) + { + var button = form.QuerySelectorAll("button") + .First(candidate => (candidate.GetAttribute("type") ?? "submit") == "submit"); + return NameOf(button); + } + + private static string Describe(IElement element) => $"{element.LocalName}:{NameOf(element)}"; + + private static string NameOf(IElement element) + { + if (element.LocalName == "input") + { + var label = element.Closest("label")?.TextContent.Trim(); + return element.GetAttribute("aria-label") ?? (string.IsNullOrEmpty(label) ? "(unnamed)" : label); + } + + return element.TextContent.Trim(); + } +} diff --git a/tests/ArcForges.Web.App.Tests/HelloProbeTests.cs b/tests/ArcForges.Web.App.Tests/HelloProbeTests.cs new file mode 100644 index 0000000..ad3dacb --- /dev/null +++ b/tests/ArcForges.Web.App.Tests/HelloProbeTests.cs @@ -0,0 +1,222 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Port of tests/unit/app-hello.test.ts: the anonymous binary gRPC-Web greeting through the generated Contracts client over +// Grpc.Net.Client.Web. The server is a test-only double; every answer is a real gRPC-Web frame sequence. +using System.Net; +using System.Text; +using ArcForges.Contracts.Hello.V1; +using ArcForges.Web.App.Probe; +using ArcForges.Web.App.Tests.Fixtures; +using ArcForges.Web.App.Tests.TestDoubles; +using Grpc.Core; +using Xunit; + +namespace ArcForges.Web.App.Tests; + +public sealed class HelloProbeTests +{ + private const string GrpcWeb = "application/grpc-web+proto"; + + private static HelloProbe ProbeFor(HttpMessageHandler handler, TimeSpan? deadline = null) => + new(handler, new ProbeOrigin(ProbeFixtures.Origin), deadline ?? HelloProbe.DefaultDeadline); + + private static async Task FailureOf(Func call) + { + var failure = await Assert.ThrowsAsync(call); + return failure.Kind; + } + + private static HttpResponseMessage Answer(byte[] body, string contentType = GrpcWeb, HttpStatusCode status = HttpStatusCode.OK) + { + var response = new HttpResponseMessage(status) { Content = new ByteArrayContent(body) }; + response.Content.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse(contentType); + return response; + } + + [Fact] + public void TheDeadlineIsTenSecondsAndTheHelloPathIsTheSameOriginApiPath() + { + Assert.Equal(TimeSpan.FromSeconds(10), HelloProbe.DefaultDeadline); + Assert.Equal("/api/arcforges.hello.v1.HelloService/SayHello", HelloProbe.HelloApiPath); + } + + [Fact] + public async Task TheGreetingIsOneAnonymousBinaryGrpcWebCallToSameOriginApi() + { + var server = ScriptedServer.Always(() => Answer(ProbeFixtures.HelloReply("Hello, ArcForges 世界!"))); + var reply = await ProbeFor(server).SayHelloAsync("ArcForges 世界", CancellationToken.None); + + Assert.Equal("Hello, ArcForges 世界!", reply); + var seen = Assert.Single(server.Requests); + Assert.Equal($"{ProbeFixtures.Origin.Scheme}://{ProbeFixtures.Origin.Authority}{HelloProbe.HelloApiPath}", seen.Url.ToString()); + Assert.Equal(HttpMethod.Post, seen.Method); + Assert.False(seen.HasCookieHeader); + Assert.False(seen.HasAuthorizationHeader); + // The request frame: one uncompressed protobuf data frame carrying the name. + Assert.Equal((byte)0, seen.Body[0]); + var length = System.Buffers.Binary.BinaryPrimitives.ReadUInt32BigEndian(seen.Body.AsSpan(1, 4)); + Assert.Equal(seen.Body.Length - 5, (int)length); + Assert.Equal("ArcForges 世界", SayHelloRequest.Parser.ParseFrom(seen.Body.AsSpan(5).ToArray()).Name); + } + + [Fact] + public async Task EveryGrpcStatusMapsToAClosedFailureKindAndNoneIsASuccess() + { + var expected = new (StatusCode Code, FailureKind Kind)[] + { + (StatusCode.Cancelled, FailureKind.Unexpected), + (StatusCode.Unknown, FailureKind.Unexpected), + (StatusCode.InvalidArgument, FailureKind.Rejected), + (StatusCode.DeadlineExceeded, FailureKind.Timeout), + (StatusCode.NotFound, FailureKind.Rejected), + (StatusCode.AlreadyExists, FailureKind.Rejected), + (StatusCode.PermissionDenied, FailureKind.Forbidden), + (StatusCode.ResourceExhausted, FailureKind.Limit), + (StatusCode.FailedPrecondition, FailureKind.Rejected), + (StatusCode.Aborted, FailureKind.Unavailable), + (StatusCode.OutOfRange, FailureKind.Rejected), + (StatusCode.Unimplemented, FailureKind.Rejected), + (StatusCode.Internal, FailureKind.Unexpected), + (StatusCode.Unavailable, FailureKind.Unavailable), + (StatusCode.DataLoss, FailureKind.Unexpected), + (StatusCode.Unauthenticated, FailureKind.Unauthenticated), + }; + foreach (var (code, kind) in expected) + { + Assert.True(FailureMapping.FromGrpc(code) == kind, code.ToString()); + // The same status arriving as a real trailers-only gRPC-Web answer through the generated client. + var server = ScriptedServer.Always(() => Answer( + ProbeFixtures.Trailers($"grpc-status: {(int)code}\r\ngrpc-message: Server%20text\r\n"))); + Assert.True(await FailureOf(() => ProbeFor(server).SayHelloAsync("x", CancellationToken.None)) == kind, code.ToString()); + } + } + + [Fact] + public async Task AServerMessageIsNeverEchoedIntoTheFailure() + { + var server = ScriptedServer.Always(() => Answer( + ProbeFixtures.Trailers("grpc-status: 3\r\ngrpc-message: secret-token-123\r\n"))); + var failure = await Assert.ThrowsAsync(() => ProbeFor(server).SayHelloAsync("x", CancellationToken.None)); + Assert.Equal(FailureKind.Rejected, failure.Kind); + Assert.DoesNotContain("secret-token-123", failure.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task HttpStatusesAndNonGrpcAnswersAreTypedFailuresNeverALocalGreeting() + { + var answers = new (string Name, Func Answer, FailureKind Kind)[] + { + ("404", () => Answer("Not found"u8.ToArray(), "text/plain", HttpStatusCode.NotFound), FailureKind.Rejected), + ("405", () => Answer("Method not allowed"u8.ToArray(), "text/plain", (HttpStatusCode)405), FailureKind.Rejected), + ("429", () => Answer("Slow down"u8.ToArray(), "text/plain", (HttpStatusCode)429), FailureKind.Limit), + ("500", () => Answer("Broken"u8.ToArray(), "text/plain", HttpStatusCode.InternalServerError), FailureKind.Unexpected), + ("503", () => Answer([], "text/plain", HttpStatusCode.ServiceUnavailable), FailureKind.Unavailable), + ("504", () => Answer([], "text/plain", HttpStatusCode.GatewayTimeout), FailureKind.Timeout), + ("html", () => Answer("Not an API"u8.ToArray(), "text/html"), FailureKind.Malformed), + ("valid frames under another content type", () => Answer(ProbeFixtures.HelloReply(), "application/json"), FailureKind.Malformed), + ("no content type", () => Raw(ProbeFixtures.HelloReply()), FailureKind.Malformed), + }; + foreach (var (name, answer, kind) in answers) + Assert.True( + await FailureOf(() => ProbeFor(ScriptedServer.Always(answer)).SayHelloAsync("ArcForges", CancellationToken.None)) == kind, + name); + + var network = new FailingTransport(new HttpRequestException("Failed to fetch")); + Assert.True(await FailureOf(() => ProbeFor(network).SayHelloAsync("ArcForges", CancellationToken.None)) == FailureKind.Unavailable); + } + + [Fact] + public async Task MalformedFramesAndStatusNeverResolve() + { + var data = ProbeFixtures.HelloReply("Hello, ArcForges!"); + var messageLength = 5 + (int)System.Buffers.Binary.BinaryPrimitives.ReadUInt32BigEndian(data.AsSpan(1, 4)); + var messageFrame = data[..messageLength]; + var bodies = new (string Name, byte[] Body)[] + { + ("empty body", []), + ("truncated header", messageFrame[..3]), + ("truncated message", messageFrame[..(messageLength - 2)]), + ("no trailers", messageFrame), + ("trailers without a status", ProbeFixtures.Concat(messageFrame, ProbeFixtures.Trailers("x-other: 1\r\n"))), + ("unparsable status", ProbeFixtures.Concat(messageFrame, ProbeFixtures.Trailers("grpc-status: ok\r\n"))), + ( + "compressed frame without negotiated compression", + ProbeFixtures.Concat(new byte[] { 1 }.Concat(messageFrame.Skip(1)).ToArray(), ProbeFixtures.Trailers("grpc-status: 0\r\n"))), + ( + "frame length beyond the body", + ProbeFixtures.Concat([0, 0, 0, 1, 0, 1], ProbeFixtures.Trailers("grpc-status: 0\r\n"))), + ("trailers only, status ok, no message", ProbeFixtures.Trailers("grpc-status: 0\r\n")), + ("random bytes", Enumerable.Range(0, 64).Select(index => (byte)((index * 37 + 11) & 0xff)).ToArray()), + }; + foreach (var (name, body) in bodies) + Assert.True( + await FailureOf(() => ProbeFor(ScriptedServer.Always(() => Answer(body))).SayHelloAsync("ArcForges", CancellationToken.None)) == FailureKind.Malformed, + name); + } + + [Fact] + public async Task AReplyThatIsNotTheGreetingForThisNameIsMalformed() + { + var server = ScriptedServer.Always(() => Answer(ProbeFixtures.HelloReply("Hello, someone else!"))); + Assert.True(await FailureOf(() => ProbeFor(server).SayHelloAsync("ArcForges", CancellationToken.None)) == FailureKind.Malformed); + } + + [Fact] + public async Task TheUsersCancellationAbortsTheRequestAndIsReportedAsCancelled() + { + using var controller = new CancellationTokenSource(); + var transport = new NeverAnswers(); + var pending = ProbeFor(transport).SayHelloAsync("ArcForges", controller.Token); + await transport.Started.Task; + Assert.False(transport.Observed.IsCancellationRequested); + await controller.CancelAsync(); + Assert.True(await FailureOf(() => pending) == FailureKind.Cancelled); + Assert.True(transport.Observed.IsCancellationRequested); + } + + [Fact] + public async Task ACallThatHasAlreadyBeenCancelledNeverReachesASuccess() + { + using var controller = new CancellationTokenSource(); + await controller.CancelAsync(); + var server = ScriptedServer.Always(() => Answer(ProbeFixtures.HelloReply())); + Assert.True(await FailureOf(() => ProbeFor(server).SayHelloAsync("ArcForges", controller.Token)) == FailureKind.Cancelled); + } + + [Fact] + public async Task AServerThatNeverAnswersEndsAtTheDeadlineAsATimeout() + { + // The ten second deadline is the page's; the test shortens it to keep the suite quick and asserts the value above. + var transport = new NeverAnswers(); + var failure = await FailureOf(() => ProbeFor(transport, TimeSpan.FromMilliseconds(200)).SayHelloAsync("ArcForges", CancellationToken.None)); + Assert.True(failure == FailureKind.Timeout); + } + + private static HttpResponseMessage Raw(byte[] body) + { + var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(body) }; + return response; + } + + /// A test-only transport that fails before any answer. + private sealed class FailingTransport(Exception failure) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromException(failure); + } + + /// A test-only transport that never answers and records the token it was given, as a stalled fetch does. + private sealed class NeverAnswers : HttpMessageHandler + { + public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public CancellationToken Observed { get; private set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Observed = cancellationToken; + Started.TrySetResult(); + await Task.Delay(Timeout.Infinite, cancellationToken); + return new HttpResponseMessage(HttpStatusCode.OK); + } + } +} diff --git a/tests/ArcForges.Web.App.Tests/HostPageAccessibilityTests.cs b/tests/ArcForges.Web.App.Tests/HostPageAccessibilityTests.cs new file mode 100644 index 0000000..4e0ff96 --- /dev/null +++ b/tests/ArcForges.Web.App.Tests/HostPageAccessibilityTests.cs @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// WCAG 2.2 AA checks of the App host page and stylesheet (WEB.40 accessibility re-proof, 2026-10-09): the unhandled-error +// banner's Dismiss is a named keyboard button (2.1.1, 4.1.2), no anchor without an href acts as a control, and the +// user-interface boundaries, the disabled-state fill and the focus indicators reach 3:1 against their adjacent colours +// (1.4.11). The host page and stylesheet are read from the source tree. +using System.Globalization; +using System.Text.RegularExpressions; +using AngleSharp.Dom; +using AngleSharp.Html.Parser; +using Xunit; + +namespace ArcForges.Web.App.Tests; + +public sealed class HostPageAccessibilityTests +{ + private const string Paper = "#f4f3ed"; + private const string White = "#ffffff"; + private const string FieldFill = "#fffdf7"; + private const string BannerFill = "#fff3cd"; + private static readonly string Root = RepositoryRoot.Find(); + + private static string AppSource(string relative) => File.ReadAllText(Path.Combine(Root, "src", "ArcForges.Web.App", relative)); + + [Fact] + public void TheErrorBannerDismissIsANamedKeyboardButton() + { + var document = new HtmlParser().ParseDocument(AppSource("wwwroot/index.html")); + var dismiss = Assert.Single(document.QuerySelectorAll("#blazor-error-ui .dismiss")); + Assert.Equal("button", dismiss.LocalName); + Assert.Equal("button", dismiss.GetAttribute("type")); + Assert.Equal("Dismiss", dismiss.GetAttribute("aria-label")); + Assert.False(dismiss.HasAttribute("tabindex")); + } + + [Fact] + public void TheErrorBannerMessageIsAnAlertAndNoTextSitsDirectlyInTheBanner() + { + // axe-core 4.13.0's region rule (WCAG 1.3.1 best practice) flags text and links outside every landmark, region, live region or + // alert (an anchor is exempt only for a same-page fragment target). role="alert" makes its content a region, so the message + // and the Reload link live inside that alert and the banner itself carries no text or link of its own. + var document = new HtmlParser().ParseDocument(AppSource("wwwroot/index.html")); + var banner = Assert.Single(document.QuerySelectorAll("#blazor-error-ui")); + var alert = Assert.Single(banner.QuerySelectorAll("[role=\"alert\"]")); + Assert.Equal("An unhandled error has occurred. Reload", Regex.Replace(alert.TextContent, @"\s+", " ").Trim()); + Assert.Single(alert.QuerySelectorAll("a.reload")); + foreach (var text in banner.ChildNodes.OfType()) + { + Assert.True( + string.IsNullOrWhiteSpace(text.Data), + $"Text \"{text.Data.Trim()}\" sits directly in #blazor-error-ui, outside the alert region."); + } + foreach (var link in banner.QuerySelectorAll("a")) + { + Assert.NotNull(link.Closest("[role=\"alert\"]")); + } + } + + [Fact] + public void NoAnchorWithoutAnHrefIsLeftInTheHostPage() + { + var document = new HtmlParser().ParseDocument(AppSource("wwwroot/index.html")); + Assert.Empty(document.QuerySelectorAll("a:not([href])")); + } + + [Theory] + [InlineData(".field input", "border", White, "text input boundary on the white field")] + [InlineData(".field input", "border", Paper, "text input boundary on the page")] + [InlineData(".input-row input", "border", FieldFill, "text input boundary on the field fill")] + [InlineData(".input-row input", "border", Paper, "text input boundary on the page")] + [InlineData(".button[aria-disabled=\"true\"]", "background", Paper, "disabled button fill on the page")] + [InlineData(".button", "background", Paper, "button fill on the page")] + public void ComponentBoundariesAndStateFillsReachThreeToOne(string selector, string property, string adjacent, string what) + { + var colour = DeclaredColour(AppSource("wwwroot/app.css"), selector, property); + Assert.True( + Contrast.Ratio(colour, adjacent) >= 3.0, + $"{what}: {selector} {property} {colour} against {adjacent} is {Contrast.Ratio(colour, adjacent):0.00}:1"); + } + + [Theory] + [InlineData(Paper, "focus indicator on the page")] + [InlineData(BannerFill, "focus indicator on the error banner")] + public void TheFocusIndicatorReachesThreeToOne(string adjacent, string what) + { + var css = AppSource("wwwroot/app.css"); + var match = Regex.Match(css, @":focus-visible\s*\{[^}]*outline:\s*\d+px solid (#[0-9a-fA-F]{6})", RegexOptions.Singleline); + Assert.True(match.Success, "The :focus-visible outline is not declared."); + var colour = match.Groups[1].Value; + Assert.True(Contrast.Ratio(colour, adjacent) >= 3.0, $"{what}: {colour} against {adjacent} is {Contrast.Ratio(colour, adjacent):0.00}:1"); + } + + /// The first declaration of a property in the block of an exact selector, as a six-digit hex colour. + private static string DeclaredColour(string css, string selector, string property) + { + // The selector may head a selector list ("a, a:hover { ... }"); the first block whose list starts with it is the one read. + var block = Regex.Match(css, @"(?[^}]*)\}"); + Assert.True(block.Success, $"The selector {selector} is not declared."); + var declaration = Regex.Match(block.Groups["body"].Value, @"(?The WCAG 2.x contrast ratio of two six-digit hex colours. + private static class Contrast + { + internal static double Ratio(string first, string second) + { + var a = Luminance(first); + var b = Luminance(second); + return (Math.Max(a, b) + 0.05) / (Math.Min(a, b) + 0.05); + } + + private static double Luminance(string hex) + { + double Channel(int offset) + { + var value = int.Parse(hex.AsSpan(offset, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture) / 255.0; + return value <= 0.03928 ? value / 12.92 : Math.Pow((value + 0.055) / 1.055, 2.4); + } + + return 0.2126 * Channel(1) + 0.7152 * Channel(3) + 0.0722 * Channel(5); + } + } +} diff --git a/tests/ArcForges.Web.App.Tests/ProfileIdentityTests.cs b/tests/ArcForges.Web.App.Tests/ProfileIdentityTests.cs new file mode 100644 index 0000000..2835b07 --- /dev/null +++ b/tests/ArcForges.Web.App.Tests/ProfileIdentityTests.cs @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: AGPL-3.0-only +using Xunit; + +namespace ArcForges.Web.App.Tests; + +public sealed class ProfileIdentityTests +{ + [Fact] + public void ProfileNameIsStable() + { + Assert.Equal("app", ArcForges.Web.App.ProfileIdentity.Name); + } +} diff --git a/tests/ArcForges.Web.App.Tests/RepositoryRoot.cs b/tests/ArcForges.Web.App.Tests/RepositoryRoot.cs new file mode 100644 index 0000000..f7cd00a --- /dev/null +++ b/tests/ArcForges.Web.App.Tests/RepositoryRoot.cs @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: AGPL-3.0-only +namespace ArcForges.Web.App.Tests; + +/// Locates the repository root from the test output directory. +public static class RepositoryRoot +{ + /// Walks up from the test assembly until the repository marker files are found. + public static string Find() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "win.slnx")) + && File.Exists(Path.Combine(directory.FullName, "global.json"))) + return directory.FullName; + directory = directory.Parent; + } + throw new DirectoryNotFoundException("The repository root with win.slnx and global.json was not found."); + } +} diff --git a/tests/ArcForges.Web.App.Tests/SessionProbeTests.cs b/tests/ArcForges.Web.App.Tests/SessionProbeTests.cs new file mode 100644 index 0000000..54891cf --- /dev/null +++ b/tests/ArcForges.Web.App.Tests/SessionProbeTests.cs @@ -0,0 +1,319 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Port of tests/unit/app-session.test.ts: the same-origin session probe (bootstrap, logout, the bounded body and the typed +// failures). The server is a test-only double (TestDoubles/ScriptedServer.cs); the documents are wire fixtures. +using System.Net; +using System.Text; +using ArcForges.Web.App.Probe; +using ArcForges.Web.App.Tests.Fixtures; +using ArcForges.Web.App.Tests.TestDoubles; +using Xunit; + +namespace ArcForges.Web.App.Tests; + +public sealed class SessionProbeTests +{ + private const string Json = "application/json"; + + private static SessionProbe ProbeFor(HttpMessageHandler handler) => + new(handler, new ProbeOrigin(ProbeFixtures.Origin)); + + private static CancellationToken Never => CancellationToken.None; + + private static async Task FailureOf(Func call) + { + var failure = await Assert.ThrowsAsync(call); + return failure.Kind; + } + + private static async Task ReadFailure(HttpMessageHandler handler) => + await FailureOf(() => ProbeFor(handler).ReadSessionAsync(Never)); + + [Fact] + public async Task BootstrapIsASameOriginCookieGetDrivenByTheGeneratedRouteCatalogue() + { + var server = ScriptedServer.Always(() => Responses.Json(Encoding.UTF8.GetBytes(ProbeFixtures.AuthenticatedJson))); + var session = await ProbeFor(server).ReadSessionAsync(Never); + + var seen = Assert.Single(server.Requests); + Assert.Equal($"{ProbeFixtures.Origin.Scheme}://{ProbeFixtures.Origin.Authority}/session/v1/bootstrap", seen.Url.ToString()); + Assert.Equal(HttpMethod.Get, seen.Method); + Assert.Null(seen.CsrfHeader); + Assert.False(seen.HasAuthorizationHeader); + Assert.Equal(Json, seen.AcceptHeader); + Assert.Equal( + new AuthenticatedSession( + ProbeFixtures.AuthenticatedCsrf, + "Ada Lovelace", + "en-GB", + "Europe/London", + "2026-10-03T08:00:00.000000Z", + "2026-10-02T20:30:00.000000Z", + 2, + "18446744073709551615"), + session); + } + + [Fact] + public async Task AnAnonymousAnswerCarriesOnlyTheCsrfToken() + { + var server = ScriptedServer.Always(() => Responses.Json(Encoding.UTF8.GetBytes(ProbeFixtures.AnonymousJson))); + var session = await ProbeFor(server).ReadSessionAsync(Never); + Assert.Equal(new AnonymousSession(ProbeFixtures.AnonymousCsrf), session); + } + + [Fact] + public async Task AnAuthenticatedSessionWithoutAProfileStillPresents() + { + var node = ProbeFixtures.AuthenticatedNode(); + node.Remove("profile"); + var server = ScriptedServer.Always(() => Responses.Json(ProbeFixtures.Bytes(node))); + + var session = Assert.IsType(await ProbeFor(server).ReadSessionAsync(Never)); + Assert.Null(session.DisplayName); + Assert.Null(session.Locale); + } + + [Fact] + public async Task ContradictoryOrInexactBootstrapAnswersAreMalformedNeverASession() + { + var withoutSession = ProbeFixtures.AuthenticatedNode(); + withoutSession.Remove("session"); + + var anonymousWithSession = ProbeFixtures.AnonymousNode(); + anonymousWithSession["session"] = ProbeFixtures.AuthenticatedNode()["session"]!.DeepClone(); + + var anonymousWithProfile = ProbeFixtures.AnonymousNode(); + anonymousWithProfile["profile"] = ProbeFixtures.AuthenticatedNode()["profile"]!.DeepClone(); + + var leadingZero = ProbeFixtures.AuthenticatedNode(); + leadingZero["session"]!["recoveryGeneration"] = "01"; + + var aboveUint64 = ProbeFixtures.AuthenticatedNode(); + aboveUint64["session"]!["recoveryGeneration"] = "18446744073709551616"; + + var cases = new (string Name, byte[] Body)[] + { + ("authenticated without a session", ProbeFixtures.Bytes(withoutSession)), + ("anonymous with a session", ProbeFixtures.Bytes(anonymousWithSession)), + ("anonymous with a profile", ProbeFixtures.Bytes(anonymousWithProfile)), + ("leading zero generation", ProbeFixtures.Bytes(leadingZero)), + ("generation above uint64", ProbeFixtures.Bytes(aboveUint64)), + }; + foreach (var (name, body) in cases) + Assert.True( + await ReadFailure(ScriptedServer.Always(() => Responses.Json(body))) == FailureKind.Malformed, + name); + } + + [Fact] + public async Task TheGeneratedCodecRefusesMalformedUnknownFieldAndWrongTypeDocuments() + { + var bodies = new[] + { + string.Empty, + "{", + "[]", + "null", + "{\"csrfToken\":\"x\"}", + "{\"csrfToken\":\"x\",\"authenticated\":\"yes\"}", + "{\"csrfToken\":\"x\",\"authenticated\":false,\"extra\":1}", + "{\"csrfToken\":\"x\",\"authenticated\":false,\"authenticated\":true}", + "{\"csrfToken\":\"" + new string('x', 20) + "\",\"authenticated\":false", + }; + foreach (var body in bodies) + Assert.True( + await ReadFailure(ScriptedServer.Always(() => Responses.Json(Encoding.UTF8.GetBytes(body)))) == FailureKind.Malformed, + body); + } + + [Fact] + public async Task OnlyAJsonTwoHundredIsReadEveryOtherStatusIsATypedFailureWithAFixedMessage() + { + var statuses = new (HttpStatusCode Status, FailureKind Kind)[] + { + ((HttpStatusCode)301, FailureKind.Malformed), + (HttpStatusCode.BadRequest, FailureKind.Rejected), + (HttpStatusCode.Unauthorized, FailureKind.Unauthenticated), + (HttpStatusCode.Forbidden, FailureKind.Forbidden), + (HttpStatusCode.NotFound, FailureKind.Rejected), + (HttpStatusCode.RequestTimeout, FailureKind.Timeout), + (HttpStatusCode.RequestEntityTooLarge, FailureKind.Limit), + ((HttpStatusCode)429, FailureKind.Limit), + (HttpStatusCode.InternalServerError, FailureKind.Unexpected), + (HttpStatusCode.BadGateway, FailureKind.Unavailable), + (HttpStatusCode.ServiceUnavailable, FailureKind.Unavailable), + (HttpStatusCode.GatewayTimeout, FailureKind.Timeout), + }; + foreach (var (status, kind) in statuses) + { + // A body that would parse as a session proves the status is checked first. + var failure = await Assert.ThrowsAsync(() => + ProbeFor(ScriptedServer.Always(() => Responses.Json(Encoding.UTF8.GetBytes(ProbeFixtures.AuthenticatedJson), status))) + .ReadSessionAsync(Never)); + Assert.True(failure.Kind == kind, status.ToString()); + Assert.DoesNotContain(ProbeFixtures.AuthenticatedCsrf, failure.Message, StringComparison.Ordinal); + } + + foreach (var contentType in new[] { "text/html", "application/x-ndjson", "application/jsonp" }) + Assert.True( + await ReadFailure(ScriptedServer.Always(() => Responses.Raw(Encoding.UTF8.GetBytes(ProbeFixtures.AuthenticatedJson), contentType))) == FailureKind.Malformed, + contentType); + Assert.True( + await ReadFailure(ScriptedServer.Always(() => Responses.Raw(Encoding.UTF8.GetBytes(ProbeFixtures.AuthenticatedJson), null))) == FailureKind.Malformed, + "no content type"); + + var parameters = await ProbeFor(ScriptedServer.Always(() => + Responses.Raw(Encoding.UTF8.GetBytes(ProbeFixtures.AnonymousJson), "application/json; charset=utf-8"))).ReadSessionAsync(Never); + Assert.IsType(parameters); + } + + [Fact] + public async Task TheBodyIsBoundedBeforeItIsParsed() + { + var exact = Encoding.UTF8.GetBytes(ProbeFixtures.AnonymousJson); + var padded = new byte[16384 + 1]; + Array.Fill(padded, (byte)0x20); + exact.CopyTo(padded, 16384 + 1 - exact.Length); + // At the schema bound a document is still read: the padding is JSON whitespace. + var atBound = new byte[16384]; + Array.Fill(atBound, (byte)0x20); + exact.CopyTo(atBound, 16384 - exact.Length); + + Assert.IsType(await ProbeFor(ScriptedServer.Always(() => Responses.Json(atBound))).ReadSessionAsync(Never)); + Assert.True(await ReadFailure(ScriptedServer.Always(() => Responses.Json(padded))) == FailureKind.Malformed); + + // A declared length above the bound is refused without reading, and a stream above it is cut off after at most + // one chunk beyond the bound, for the session and the receipt alike. + foreach (var bound in new[] { 16384, 4096 }) + { + var endless = new CountingStream((byte)0x20, 1024); + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StreamContent(endless), + }; + response.Content.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse(Json); + var handler = ScriptedServer.Always(() => response); + var failure = await FailureOfBound(handler, bound); + Assert.True(failure == FailureKind.Malformed, bound.ToString()); + Assert.True(endless.Disposed, bound.ToString()); + Assert.True(endless.BytesYielded > bound, bound.ToString()); + Assert.True(endless.BytesYielded <= bound + 4096, bound.ToString()); + } + + var declared = Responses.Json(exact); + declared.Content.Headers.Remove("Content-Length"); + declared.Content.Headers.TryAddWithoutValidation("Content-Length", "16385"); + Assert.True(await ReadFailure(ScriptedServer.Always(() => declared)) == FailureKind.Malformed); + + var malformedLength = Responses.Json(exact); + malformedLength.Content.Headers.Remove("Content-Length"); + malformedLength.Content.Headers.TryAddWithoutValidation("Content-Length", "12e3"); + Assert.True(await ReadFailure(ScriptedServer.Always(() => malformedLength)) == FailureKind.Malformed); + } + + [Fact] + public async Task TheStreamBoundIsExactOneByteOverItIsTheFirstByteRefused() + { + foreach (var bound in new[] { 16384, 4096 }) + { + var answer = new CountingStream((byte)0x20, 1); + var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(answer) }; + response.Content.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse(Json); + var handler = ScriptedServer.Always(() => response); + Assert.True(await FailureOfBound(handler, bound) == FailureKind.Malformed, bound.ToString()); + Assert.Equal(bound + 1, answer.Reads); + } + } + + [Fact] + public async Task ABodySplitAcrossChunksReadsAsOneDocumentAFailingStreamIsUnavailable() + { + var bytes = Encoding.UTF8.GetBytes(ProbeFixtures.AuthenticatedJson); + var chunks = new[] { bytes[..7], bytes[7..90], bytes[90..] }; + var answer = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(new ChunkedStream(chunks)) }; + answer.Content.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse(Json); + Assert.IsType(await ProbeFor(ScriptedServer.Always(() => answer)).ReadSessionAsync(Never)); + + var broken = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StreamContent(new ChunkedStream([bytes[..20]], new IOException("connection reset"))), + }; + broken.Content.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse(Json); + Assert.True(await ReadFailure(ScriptedServer.Always(() => broken)) == FailureKind.Unavailable); + } + + [Fact] + public async Task NetworkFailureIsUnavailableAndCancellationIsDistinctFromIt() + { + var failing = new DelegatingFailure(new HttpRequestException("Failed to fetch")); + Assert.True(await ReadFailure(failing) == FailureKind.Unavailable); + + using var controller = new CancellationTokenSource(); + var pending = ProbeFor(new NeverAnswers()).ReadSessionAsync(controller.Token); + await controller.CancelAsync(); + Assert.True(await FailureOf(() => pending) == FailureKind.Cancelled); + + using var already = new CancellationTokenSource(); + await already.CancelAsync(); + Assert.True(await FailureOf(() => ProbeFor(ScriptedServer.Always(() => Responses.Json(Encoding.UTF8.GetBytes(ProbeFixtures.AnonymousJson)))) + .ReadSessionAsync(already.Token)) == FailureKind.Cancelled); + } + + [Fact] + public async Task LogoutSendsTheCsrfTokenAsTheOnlyCredentialHeaderWithNoBody() + { + var server = ScriptedServer.Always(() => Responses.Json(ProbeFixtures.ReceiptBody("happened"))); + await ProbeFor(server).EndSessionAsync(ProbeFixtures.AuthenticatedCsrf, Never); + + var seen = Assert.Single(server.Requests); + Assert.Equal($"{ProbeFixtures.Origin.Scheme}://{ProbeFixtures.Origin.Authority}/session/v1/logout", seen.Url.ToString()); + Assert.Equal(HttpMethod.Post, seen.Method); + // The literal wire name is pinned here, independent of the module constant. + Assert.Equal("X-AF-CSRF", SessionProbe.CsrfHeader, ignoreCase: false); + Assert.Equal(ProbeFixtures.AuthenticatedCsrf, seen.CsrfHeader); + Assert.False(seen.HasAuthorizationHeader); + Assert.Empty(seen.Body); + } + + [Fact] + public async Task LogoutReportsAnEndedSessionARefusedTokenAndAMalformedReceiptDistinctly() + { + static Task Run(HttpResponseMessage response) => + ProbeFor(ScriptedServer.Always(() => response)).EndSessionAsync("t", CancellationToken.None); + + Assert.True(await FailureOf(() => Run(Responses.Text("{}", HttpStatusCode.Unauthorized))) == FailureKind.Unauthenticated); + Assert.True(await FailureOf(() => Run(Responses.Text("{}", HttpStatusCode.Forbidden))) == FailureKind.Forbidden); + Assert.True(await FailureOf(() => Run(Responses.Text("{}", HttpStatusCode.BadRequest))) == FailureKind.Rejected); + Assert.True(await FailureOf(() => Run(Responses.Text("{}", HttpStatusCode.ServiceUnavailable))) == FailureKind.Unavailable); + Assert.True(await FailureOf(() => Run(Responses.Json(ProbeFixtures.ReceiptBody("didNotHappen")))) == FailureKind.Rejected); + Assert.True(await FailureOf(() => Run(Responses.Json(ProbeFixtures.ReceiptBody("unknown")))) == FailureKind.Unexpected); + Assert.True(await FailureOf(() => Run(Responses.Json(Encoding.UTF8.GetBytes("{}")))) == FailureKind.Malformed); + Assert.True(await FailureOf(() => Run(Responses.Json(Encoding.UTF8.GetBytes("{\"commandId\":\"x\"}")))) == FailureKind.Malformed); + Assert.True(await FailureOf(() => Run(Responses.Json(Enumerable.Repeat((byte)0x20, 4097).ToArray()))) == FailureKind.Malformed); + } + + private static async Task FailureOfBound(HttpMessageHandler handler, int bound) + { + // The session route has the 16 KiB bound; the logout route has the 4 KiB receipt bound. + if (bound == 16384) + return await ReadFailure(handler); + return await FailureOf(() => ProbeFor(handler).EndSessionAsync("t", Never)); + } + + /// A test-only transport that fails before any answer. + private sealed class DelegatingFailure(Exception failure) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromException(failure); + } + + /// A test-only transport that never answers and ends only when the caller cancels, as a stalled fetch does. + private sealed class NeverAnswers : HttpMessageHandler + { + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + await Task.Delay(Timeout.Infinite, cancellationToken); + return new HttpResponseMessage(HttpStatusCode.OK); + } + } +} diff --git a/tests/ArcForges.Web.App.Tests/TestDoubles/ScriptedServer.cs b/tests/ArcForges.Web.App.Tests/TestDoubles/ScriptedServer.cs new file mode 100644 index 0000000..b587c29 --- /dev/null +++ b/tests/ArcForges.Web.App.Tests/TestDoubles/ScriptedServer.cs @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// TEST-ONLY DOUBLE. The same-origin server is an external input that these unit tests do not run; this double answers +// with the scripted responses the tests set up, and records each request as it was sent. It is never referenced by the +// application project and never part of a release graph. +using System.Net.Http.Headers; + +namespace ArcForges.Web.App.Tests.TestDoubles; + +/// What the test double observed of one request, captured before the transport disposed it. +public sealed record CapturedRequest( + Uri Url, + HttpMethod Method, + string? CsrfHeader, + bool HasCookieHeader, + bool HasAuthorizationHeader, + string? AcceptHeader, + byte[] Body); + +/// A scripted same-origin server: each request is answered by the given function, and cancellation reaches it. +public sealed class ScriptedServer : HttpMessageHandler +{ + private readonly Func> _answer; + + /// Creates the double with the answer for each request. + public ScriptedServer(Func> answer) + { + _answer = answer; + } + + /// Every request that reached the double, in order. + public List Requests { get; } = []; + + /// The number of requests that reached the double. + public int Count => Requests.Count; + + /// Answers every request with the same response factory. + public static ScriptedServer Always(Func answer) => + new((_, _) => Task.FromResult(answer())); + + /// Records the request as sent, then answers it. + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + var body = request.Content is null ? [] : await request.Content.ReadAsByteArrayAsync(cancellationToken); + var captured = new CapturedRequest( + request.RequestUri ?? throw new InvalidOperationException("The request has no URL."), + request.Method, + request.Headers.TryGetValues("X-AF-CSRF", out var csrf) ? csrf.SingleOrDefault() : null, + request.Headers.Contains("Cookie"), + request.Headers.Contains("Authorization"), + request.Headers.Accept.Count == 0 ? null : request.Headers.Accept.ToString(), + body); + Requests.Add(captured); + return await _answer(captured, cancellationToken); + } +} + +/// +/// A test-only response stream that yields the given chunks, then either ends or fails. It stands for a connection that +/// delivers the body in pieces and may break. +/// +public sealed class ChunkedStream(IReadOnlyList chunks, Exception? failWith = null) : Stream +{ + private int _index; + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public override void Flush() { } + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + if (_index < chunks.Count) + { + var chunk = chunks[_index++]; + var count = Math.Min(chunk.Length, buffer.Length); + chunk.AsSpan(0, count).CopyTo(buffer.Span); + return ValueTask.FromResult(count); + } + if (failWith is not null) + throw failWith; + return ValueTask.FromResult(0); + } +} + +/// +/// A test-only endless response stream: each read yields a fixed number of bytes and counts the reads, and disposing it +/// records that the transfer was cut off. +/// +public sealed class CountingStream(byte fill, int bytesPerRead) : Stream +{ + /// The number of reads made so far. + public long Reads { get; private set; } + + /// The total bytes yielded so far. + public long BytesYielded { get; private set; } + + /// True once the stream was disposed, which is how a cut-off transfer is observed. + public bool Disposed { get; private set; } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public override void Flush() { } + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + Reads++; + var count = Math.Min(bytesPerRead, buffer.Length); + buffer.Span[..count].Fill(fill); + BytesYielded += count; + return ValueTask.FromResult(count); + } + + protected override void Dispose(bool disposing) + { + Disposed = true; + base.Dispose(disposing); + } +} + +/// Helpers that build the test-only response shapes. +public static class Responses +{ + /// A response with a JSON body and the given status. + public static HttpResponseMessage Json(byte[] body, System.Net.HttpStatusCode status = System.Net.HttpStatusCode.OK, string contentType = "application/json") + { + var response = new HttpResponseMessage(status) { Content = new ByteArrayContent(body) }; + response.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType); + return response; + } + + /// A response with a text body and the given status and content type. + public static HttpResponseMessage Text(string body, System.Net.HttpStatusCode status = System.Net.HttpStatusCode.OK, string? contentType = null) + { + var response = new HttpResponseMessage(status) { Content = new ByteArrayContent(System.Text.Encoding.UTF8.GetBytes(body)) }; + if (contentType is not null) + response.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType); + return response; + } + + /// A response with the given bytes and content type, for shapes the test controls exactly. + public static HttpResponseMessage Raw(byte[] body, string? contentType, System.Net.HttpStatusCode status = System.Net.HttpStatusCode.OK) + { + var response = new HttpResponseMessage(status) { Content = new ByteArrayContent(body) }; + if (contentType is not null) + response.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType); + return response; + } +} diff --git a/tests/ArcForges.Web.App.Tests/WasmProfilePolicyTests.cs b/tests/ArcForges.Web.App.Tests/WasmProfilePolicyTests.cs new file mode 100644 index 0000000..a6571a3 --- /dev/null +++ b/tests/ArcForges.Web.App.Tests/WasmProfilePolicyTests.cs @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// The App profile's policy rules (WEB.40 validation; P2-021 item 2): the exact script-src and style-src token sets, no inline +// style, no Virtualize, no JavaScript interop, and RunAOTCompilation off. The host page is read from the built source. +using System.Text.RegularExpressions; +using ArcForges.Web.Ui; +using Xunit; + +namespace ArcForges.Web.App.Tests; + +public sealed class WasmProfilePolicyTests +{ + private static readonly string Root = RepositoryRoot.Find(); + + private static string AppSource(string relative) => File.ReadAllText(Path.Combine(Root, "src", "ArcForges.Web.App", relative)); + + [Fact] + public void TheAppPolicyHasExactlyTheWasmScriptTokensAndSelfOnlyStyles() + { + var policy = WasmContentSecurityPolicy.FromHostPages([AppSource("wwwroot/index.html")]); + var directives = WasmContentSecurityPolicy.ParseDirectives(policy); + + // The host page has no inline script, so the only script tokens are self and the WebAssembly compile token. + Assert.Equal(new[] { "'self'", "'wasm-unsafe-eval'" }, directives["script-src"]); + Assert.Equal(new[] { "'self'" }, directives["style-src"]); + Assert.Equal(new[] { "'self'" }, directives["default-src"]); + Assert.Equal(new[] { "'self'" }, directives["connect-src"]); + Assert.DoesNotContain("'unsafe-inline'", policy, StringComparison.Ordinal); + Assert.DoesNotContain("'unsafe-eval'", policy, StringComparison.Ordinal); + Assert.True(policy.Length < WasmContentSecurityPolicy.HeaderLineBudget); + } + + [Fact] + public void ARequiredInlineScriptAddsItsHashAndNothingElseToScriptSources() + { + const string page = ""; + var directives = WasmContentSecurityPolicy.ParseDirectives(WasmContentSecurityPolicy.FromHostPages([page])); + var sources = directives["script-src"]; + Assert.Equal("'self'", sources[0]); + Assert.Equal("'wasm-unsafe-eval'", sources[1]); + var hash = Assert.Single(sources.Skip(2)); + Assert.Matches("^'sha256-[A-Za-z0-9+/]{43}='$", hash); + } + + [Fact] + public void AnExternalOrUnquotedScriptSourceIsRefused() + { + Assert.Throws(() => WasmContentSecurityPolicy.FromHostPages([""])); + Assert.Throws(() => WasmContentSecurityPolicy.FromHostPages([""])); + Assert.Throws(() => WasmContentSecurityPolicy.FromHostPages([""])); + Assert.Throws(() => WasmContentSecurityPolicy.FromHostPages(["", RegexOptions.Singleline | RegexOptions.IgnoreCase)) + Assert.True(string.IsNullOrWhiteSpace(script.Groups["body"].Value), "The host page has an inline script body."); + } + + [Fact] + public void NoRazorComponentEmitsAnInlineStyleOrUsesVirtualize() + { + var pages = Directory.EnumerateFiles(Path.Combine(Root, "src", "ArcForges.Web.App"), "*.razor", SearchOption.AllDirectories) + .Concat(Directory.EnumerateFiles(Path.Combine(Root, "src", "ArcForges.Web.Ui"), "*.razor", SearchOption.AllDirectories)); + foreach (var file in pages) + { + var source = File.ReadAllText(file); + Assert.True(!Regex.IsMatch(source, "\\sstyle\\s*=", RegexOptions.IgnoreCase), "Inline style attribute: " + file); + Assert.DoesNotContain("Virtualize", source, StringComparison.Ordinal); + } + } + + [Fact] + public void TheAppUsesNoJavaScriptInteropBecauseNoAuditedPointIsNeeded() + { + var sources = Directory.EnumerateFiles(Path.Combine(Root, "src", "ArcForges.Web.App"), "*.cs", SearchOption.AllDirectories) + .Concat(Directory.EnumerateFiles(Path.Combine(Root, "src", "ArcForges.Web.App"), "*.razor", SearchOption.AllDirectories)); + foreach (var file in sources) + { + var source = File.ReadAllText(file); + Assert.DoesNotContain("IJSRuntime", source, StringComparison.Ordinal); + Assert.DoesNotContain("IJSInProcessRuntime", source, StringComparison.Ordinal); + } + } + + [Fact] + public void TheProfileIsStandaloneWebAssemblyWithAheadOfTimeCompilationOff() + { + var project = File.ReadAllText(Path.Combine(Root, "src", "ArcForges.Web.App", "ArcForges.Web.App.csproj")); + Assert.Contains("Sdk=\"Microsoft.NET.Sdk.BlazorWebAssembly\"", project, StringComparison.Ordinal); + Assert.Contains("false", project, StringComparison.Ordinal); + Assert.DoesNotContain("Microsoft.AspNetCore.Components.Server", project, StringComparison.Ordinal); + } +} diff --git a/tests/ArcForges.Web.App.Tests/packages.lock.json b/tests/ArcForges.Web.App.Tests/packages.lock.json new file mode 100644 index 0000000..8f0c9b6 --- /dev/null +++ b/tests/ArcForges.Web.App.Tests/packages.lock.json @@ -0,0 +1,520 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "ArcForges.Build.Policy": { + "type": "Direct", + "requested": "[1.0.0-ci.94.1, )", + "resolved": "1.0.0-ci.94.1", + "contentHash": "PCpeEXikk2m87zhMF6L8tbHe2QrI87mO4Nc5lJBenEDhOePwwV4TkArc6IaM1G11Pm8ojVjurLAIoPW22ozb/A==" + }, + "bunit": { + "type": "Direct", + "requested": "[2.9.0, )", + "resolved": "2.9.0", + "contentHash": "Jz9ywwpjjYHppFDw4bIMkk5cNOy0vN74p3PzJFVIlCZicK8vpAovM5LsuNDLKE4bpwISNKrjwSrsw4SIVzT+0g==", + "dependencies": { + "AngleSharp": "1.7.0", + "AngleSharp.Css": "1.0.1", + "AngleSharp.Diffing": "1.1.1", + "Microsoft.AspNetCore.Components": "10.0.10", + "Microsoft.AspNetCore.Components.Authorization": "10.0.10", + "Microsoft.AspNetCore.Components.Web": "10.0.10", + "Microsoft.AspNetCore.Components.WebAssembly": "10.0.10", + "Microsoft.AspNetCore.Components.WebAssembly.Authentication": "10.0.10", + "Microsoft.Extensions.Caching.Memory": "10.0.10", + "Microsoft.Extensions.Localization.Abstractions": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10" + } + }, + "xunit.v3.mtp-v2": { + "type": "Direct", + "requested": "[4.0.1, )", + "resolved": "4.0.1", + "contentHash": "s88KiWwDDYgOWV3A+ViJiqCe9cLU/Rt6Gb5TfOC7Abv/HKSoj4IHVPcOQk7jPt7HhZHHEts5IuaVpB30eO5B1w==", + "dependencies": { + "xunit.analyzers": "2.1.0", + "xunit.v3.assert": "[4.0.1]", + "xunit.v3.core.mtp-v2": "[4.0.1]" + } + }, + "AngleSharp.Css": { + "type": "Transitive", + "resolved": "1.0.1", + "contentHash": "6S13xNHH+SUGPZd6EO1MhkuDbpEnFroUM6A8DZpLJHQnLRTvtBJb3Ydu65s618E4gWF9FSWmM5jhKk4RIfwT2A==", + "dependencies": { + "AngleSharp": "[1.5.0, 2.0.0)" + } + }, + "AngleSharp.Diffing": { + "type": "Transitive", + "resolved": "1.1.1", + "contentHash": "Uzz5895TpRyMA4t8GExmq4TxXqi3+bZTgb3kJgH+tdwrSuo9CO6Dlmv+Oqyhb2pNRfbnReLUzhlHAuT7c+3jSg==", + "dependencies": { + "AngleSharp": "1.1.2", + "AngleSharp.Css": "1.0.0-beta.144" + } + }, + "ArcForges.Contracts.Foundation": { + "type": "Transitive", + "resolved": "1.0.0-ci.287.1", + "contentHash": "Jwe5Z6K57vDsKmcxQFbbwyQZQUQsOkzYH26ogQIRb/biRtaGxLCtOsNg4felCuNTJt9bwRmC6AGx3eJyCoCk/g==", + "dependencies": { + "Google.Protobuf": "3.36.1" + } + }, + "Google.Protobuf": { + "type": "Transitive", + "resolved": "3.36.1", + "contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ==" + }, + "Grpc.Core.Api": { + "type": "Transitive", + "resolved": "2.84.0", + "contentHash": "p2SOMl6q/GZ4/5MLkgboC/z55g4zKUEruRn/g46QgppjQBVnsLZVBU/9VO7n60ll38G3Eo8Zu9b7x+W01GKFVg==" + }, + "Grpc.Net.Common": { + "type": "Transitive", + "resolved": "2.84.0", + "contentHash": "DPxm/H8G/lDRrz997wM0M2zN/PGqVeZKDakhBbtABEbv6WIW4HI9g3BvntBQT6ZUROuGacMzfyyF9e5kiTVFfQ==", + "dependencies": { + "Grpc.Core.Api": "2.84.0" + } + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==" + }, + "Microsoft.AspNetCore.Authorization": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "FxWC2bc788/51CiSiMkKW5gQyqrSSs1991BhphS6ZARMGii+3Qr355Q9OSB/nBpdHVjIopfegAhreZ6UPpO9WA==", + "dependencies": { + "Microsoft.AspNetCore.Metadata": "10.0.10", + "Microsoft.Extensions.Diagnostics": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10" + } + }, + "Microsoft.AspNetCore.Components": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "wlCBFU9YxqhUc2kVbl/FCGNFSbRjS312WVk9YRVjs/OcZ+Yqa9QsD7z+UcBzNNAbcPpEM98f/PaaD6lHt5rmIg==", + "dependencies": { + "Microsoft.AspNetCore.Authorization": "10.0.10", + "Microsoft.AspNetCore.Components.Analyzers": "10.0.10" + } + }, + "Microsoft.AspNetCore.Components.Analyzers": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "ZBJobkEDv6yWftw3ycZMkuV9YpvJOBmjWpg2UbQ0Ol4THObhKai0PNl/SGFBmpw03JKCPA63RgplQOA1LQbRDg==" + }, + "Microsoft.AspNetCore.Components.Authorization": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "em2xVQkvtn3BHFT3J9pp4K3yDgPnp0S5a+gcGxb6xkjqy+81yDa8XEEaQnkruCVRusFMltORFFmnlrnHa/A90w==", + "dependencies": { + "Microsoft.AspNetCore.Authorization": "10.0.10", + "Microsoft.AspNetCore.Components": "10.0.10" + } + }, + "Microsoft.AspNetCore.Components.Forms": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "09cKk0/t83Y5DN5ne4J9Y+OiLJEQ+XBFGIoD0UXAnae8yQEkFa/OYbX0bBGkx0k5r5ndta/CMdlMk3lCG81R0g==", + "dependencies": { + "Microsoft.AspNetCore.Components": "10.0.10", + "Microsoft.Extensions.Validation": "10.0.10" + } + }, + "Microsoft.AspNetCore.Components.Web": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "8+cyStKhSy94Q2L/2zmgk0H1f+GKvBGEvTImT0qdsCqeNbT7ilGDKTIJ1PiugsPtSNLuUEABUqMpA3rE1yAKFg==", + "dependencies": { + "Microsoft.AspNetCore.Components": "10.0.10", + "Microsoft.AspNetCore.Components.Forms": "10.0.10", + "Microsoft.Extensions.DependencyInjection": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10", + "Microsoft.JSInterop": "10.0.10" + } + }, + "Microsoft.AspNetCore.Components.WebAssembly.Authentication": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "D2GIsqVJ2+tVDViJmbEF4tc+gxhdAl5xkBAMZHWCJV7Pt+oBkU2omBSRbgVTq3N2OgreVNSp7w+FrZNgu97d5A==", + "dependencies": { + "Microsoft.AspNetCore.Components.Authorization": "10.0.10", + "Microsoft.AspNetCore.Components.Web": "10.0.10" + } + }, + "Microsoft.AspNetCore.Metadata": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "nmZYV9OQ69r3EDOj0SW1JLgD4uFHfMNDKb8mk8Mf4y0Enw3AzkMD85zPuAk5FqmEz8PS0WagR/tJCwLg8xpI3Q==" + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "4ZFBNE+jzR+CrWWlhOesnmywCW7pYKT0dxyAQRdL11yJwxe4jvcAu31eorFtEkoFeCDcUTeNssgPv2yaRRptaQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "N1w5H7uK6gCTnCBZAWzE0/EQYSPysij/uYwDqntqBVvBa6bjMmBKitsnEFd6yh/SX3wLm67nO6+OnZ84K+gZWg==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.10", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "plJWK2zpWuuyxI8F8s2scx6Je7N1Ajjs6HvYUGKwRnDMWIVIz9FHwAkiT7ASgrvAOd10T0FPVlh9BzAJJME+jg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "5Vnd2I75DmZCVEjSynIdJ/0EGafgnLQwgR3t2C2/fkjx/nRG+cLwxLLdInoHeCEpkD5K4Ov/g9ZCRYrl4TRsaA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "GqmN2o1CkJvk7uWp+p4CwBYW0w/zfoEbvsiFDbO2G8l1Uz+mrDAbAcZiXhU2lufKPby1cjAUdd5GTWpebYOkOA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10" + } + }, + "Microsoft.Extensions.Configuration.FileExtensions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "ZOhZYwvbXGTgGVRwswIirofEMVHuWdxjdh0JeUZXwaF9cgcjXdz/t0ELtgaevw7ezTyv47yPNCgGreWtLkn3IQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.10", + "Microsoft.Extensions.FileProviders.Physical": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.Configuration.Json": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "uvJ6sHwjgrkMEJOgiC76G0mcZGXerwyyWkwX34EOjCbxKG6TCtfAoqDKAMsCvEBf9HxjlGQEgqsSMOGCmGBf+A==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.10", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.10" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "ANyvsgkNBRvcJh2XLgn8veGmajf+8m0AbKK+HPWdRL1yraSNVVSmQhFntLtdz/C795jxqqup+k05cs/3jZQPOA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "z/2xXlFw2aLGjHyEm6E0tQ+In6VfzQzTrtArbQ2c0TQE16ZbyDCMGPvaUT9I0s8rgy9sRWlU2P9waW37qV04qA==" + }, + "Microsoft.Extensions.Diagnostics": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "Kr/e7lUf4+N8tacbqJ2Ctwe/HarKdAc9ZkgKVVqvtJDBKbez+T/KnUwu82KSlnBp/SrpBcxc7u7xkE2oUZT/5Q==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.10", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.10", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.10" + } + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "9uWiKpeOVac355STyChWR/pliFX/5CeLqChW9kKsaxyDH4EUTZxMkT4Jwp/J/peLm0GBFmSX5c0WCse3yCnq1Q==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10" + } + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "c5zqFCY9DiIpMovLd7/d/CTiEtrMOuQ639dhv3PABtKQIKNQikSHwQt8+N679uii9q+B55lgK28Uv64FOwEu8w==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.FileProviders.Physical": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "jhJAyo38kSrH3ARvWUk0h8itogVnQu2DCZuPo+s0Z+tXes0ugTxMPaHYzap85785eHQmPFqD9TYERqBbtGxn/w==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.10", + "Microsoft.Extensions.FileSystemGlobbing": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.FileSystemGlobbing": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "jSOCVxEwCd4Aq925kJVz1kSO1EpX2OHYKL04qVREXkDU7Ce3pVDdHPYm+fEy8y/th2kJf/DAstRHpJAqoNWP8w==" + }, + "Microsoft.Extensions.Localization.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "j1DygDJR1wP3LG3d7a1rTFPUZcPfhMggcmDEGKhK2iLK0TuAIaW9bHiq1f9k650gdsXsANTm71hEg/k9msNnAQ==" + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "Tf6z5HsL0VDYRTfvsoNrTGHGheCwkTsZBA2FFh5ATJUbkAwug+FFNISJK2gjpUNemlAOoWllAK52HOWCjto3EQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "zkFxGYUvdxAvIKTyXHrmW+Sux53D4SezD9dMyZ6hrwwzPQJNuwCRy1f5W7AvYTqacEGhWF2XderRQG1OvbV8og==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "srnhnk7nE8krBiIXp71LvBmKBtraBONWSRzdjJgRv1Ko9Mp8IVNqv4vIS9hGeVteBig8aQkva9ZG+sC+o5sVcA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "tnBmu/LwF25ZQK+HBNCu2xrwnkKoB/XEbJyooGGoYxHrhvxbSKi7eOFiJ4AXBy/QU4vtCvCJfoi8k9Ej72qzOQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.Configuration.Binder": "10.0.10", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "5wu/GrYVd8mG2DVUw3vFJzF+O336TyTGg/Kmcgw9bfwYhCoFiV5lR5QeEmKecJyrW4W54nMfD3p3589E8a7czQ==" + }, + "Microsoft.Extensions.Validation": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "d77zBZk+Z4uo6hEuLvMY+yMjLkn2T/vX5c2sB0sdoSxQVZk7qvec6SuORp2/uZp7UchX4hNp+VJm4GsoSE0waA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10" + } + }, + "Microsoft.JSInterop": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "VivWvz1iFR3Ntf/e5/xci6o/MF2tcHiprNkhRlL+gMkBoDjQJCioiO+Gwp/DP8scuzqbp3b9C3wh+zuy4pD4AA==" + }, + "Microsoft.JSInterop.WebAssembly": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "FjPAGQWbCP85FWY2Wl9opHSsY+u9nZb8Ui6eTVHEMY/RJateOhW78jEtC4QOQ/6slkS1MKyIAF6ZHIrUR1fX/w==", + "dependencies": { + "Microsoft.JSInterop": "10.0.10" + } + }, + "Microsoft.Testing.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "2.4.0", + "contentHash": "JeP1RFqBa11fWmBk8xEfZcMKr4rxWSyI6OZ+659V069CaMkTEOQBW2UdSSeNz3absOsygcn7JJkzerC4LGnZ9w==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.23.0", + "Microsoft.Testing.Platform": "[2.4.0, 3.0.0)" + } + }, + "Microsoft.Testing.Extensions.TrxReport.Abstractions": { + "type": "Transitive", + "resolved": "2.4.0", + "contentHash": "uRb+4qM42dFDg4kWJZ2kFEcwESNVCRZJjItp5vETorN3rSJGysP617LqYirOcrCYmxg+obPreHMM5JcsNDKtBw==", + "dependencies": { + "Microsoft.Testing.Platform": "[2.4.0, 3.0.0)" + } + }, + "Microsoft.Testing.Platform": { + "type": "Transitive", + "resolved": "2.4.0", + "contentHash": "dp1N3P1ujb0ztwFgqz2o/ItEvq+pm19/AiA+Xq7Zpcy7oPMcxDBZLYGtf0LlU1MveEBJuKVjZhI+LnkfcH/0jQ==" + }, + "Microsoft.Testing.Platform.MSBuild": { + "type": "Transitive", + "resolved": "2.4.0", + "contentHash": "qr5M6h16YHMJLFDcWELFVMMpGte2BUmveBZKT5YoBV+bmuJRPu9bv/Zqke4yQuOEKRNxoAETrG5jr+/6Rnr3Hg==", + "dependencies": { + "Microsoft.Testing.Platform": "[2.4.0, 3.0.0)" + } + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==" + }, + "System.Security.AccessControl": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "IQ4NXP/B3Ayzvw0rDQzVTYsCKyy0Jp9KI6aYcK7UnGVlR9+Awz++TIPCQtPYfLJfOpm8ajowMR09V7quD3sEHw==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "X7QXEcZQGz0G/HL4HUyK+aAvNa/IMGbOCnFIq4jD/Evktq12xANKwzOUr7b08vCmC1LXu/47qHWOdjm3KfaJ0A==" + }, + "xunit.v3.assert": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "nC7d3cY06Oo7hkdwWPZkBR0Ud75xNhgx0P7i0GmMMZC3puCGlc4szFiT30biH03IN/eDnr8h+nv3A1UD17bk/A==" + }, + "xunit.v3.common": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "Qf25TVdadDQYf9zVxSd7L9RNQhuP0jCUHmo96XTmIKLxGefylIEV63jRbXBPFzdXmV09TCdcurk+AQ9LiWS2Hg==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "xunit.v3.core.mtp-v2": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "7qfTfrIfS2wybpSVyRLmhXHbSudD2eNw66LfukixmKsRCTcfvOrLsx0qGL/lObcEh3Z2F4SIXgoFnLIiB8yfjQ==", + "dependencies": { + "Microsoft.Testing.Extensions.Telemetry": "2.4.0", + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.4.0", + "Microsoft.Testing.Platform": "2.4.0", + "Microsoft.Testing.Platform.MSBuild": "2.4.0", + "xunit.v3.extensibility.core": "[4.0.1]", + "xunit.v3.runner.inproc.console": "[4.0.1]" + } + }, + "xunit.v3.extensibility.core": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "J0d5OfFcp920nZxCRIvU14+TZhiSPQR0wvqEMZ3MRiJLPu1VmYlcRNkmcSW0i1DwkB81gAHc+fHUQf/cU64MYg==", + "dependencies": { + "xunit.v3.common": "[4.0.1]" + } + }, + "xunit.v3.runner.common": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "p9AyfBpj2e5Iws8B57SbLiSngg7mEdvegHInKR4T2cjt2mis9h8htVgCnmu//agZO4zri6p6VQOj5EkHHTy3gA==", + "dependencies": { + "Microsoft.Win32.Registry": "[5.0.0]", + "System.Security.AccessControl": "[6.0.1]", + "xunit.v3.common": "[4.0.1]" + } + }, + "xunit.v3.runner.inproc.console": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "Hqwfd6ehMIhPmVWUQ2dgmknzuLFTWeyp8ES1q3D4YR5bQVyiXDcaIoaFwqAz2zgLr49WaW4Mz7VVncP7u/Q8/Q==", + "dependencies": { + "xunit.v3.extensibility.core": "[4.0.1]", + "xunit.v3.runner.common": "[4.0.1]" + } + }, + "arcforges.web.app": { + "type": "Project", + "dependencies": { + "ArcForges.Web.Ui": "[1.0.0, )", + "Grpc.Net.Client": "[2.84.0, )", + "Grpc.Net.Client.Web": "[2.84.0, )", + "Microsoft.AspNetCore.Components.WebAssembly": "[10.0.10, )" + } + }, + "arcforges.web.ui": { + "type": "Project", + "dependencies": { + "ArcForges.Contracts.PublicApi": "[1.0.0-ci.287.1, )" + } + }, + "AngleSharp": { + "type": "CentralTransitive", + "requested": "[1.7.0, )", + "resolved": "1.7.0", + "contentHash": "v1R++46dblGRBo2/fKFUfgtZOaFnDGQhqBM0AarxgZSJuumj1e1vexBbjlTv2hx3Olr3qeoJa2yUoWyv5fuJ5A==" + }, + "ArcForges.Contracts.PublicApi": { + "type": "CentralTransitive", + "requested": "[1.0.0-ci.287.1, )", + "resolved": "1.0.0-ci.287.1", + "contentHash": "/chAZbT/NTs4yyQBV5dgh37/dWBTKt3PHCikIidI9T90LheydsJoCWxKHGC6Tjzw0rqA62rYxnQfXGzKhTea5Q==", + "dependencies": { + "ArcForges.Contracts.Foundation": "1.0.0-ci.287.1", + "Google.Protobuf": "3.36.1", + "Grpc.Core.Api": "2.84.0" + } + }, + "Grpc.Net.Client": { + "type": "CentralTransitive", + "requested": "[2.84.0, )", + "resolved": "2.84.0", + "contentHash": "My6Fms2YCyAbihTBZZ3kLS39iTY2pkMAzGDBTC6XhPOujg70eB07AEJ7tPiW3tZ70bhFzHcc3+d4j3DaIPF3aA==", + "dependencies": { + "Grpc.Net.Common": "2.84.0", + "Microsoft.Extensions.Logging.Abstractions": "8.0.1" + } + }, + "Grpc.Net.Client.Web": { + "type": "CentralTransitive", + "requested": "[2.84.0, )", + "resolved": "2.84.0", + "contentHash": "6KB6IFGx+KfGMaN06nATBVgvgsOEVf12/nJHvdAPsY2mULmWCSn4moFUT8UnCnQ19C3tpGbc2CnB1/e0hVUmaA==" + }, + "Microsoft.AspNetCore.Components.WebAssembly": { + "type": "CentralTransitive", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "0Zib7U6HmGw4Noj/+yJz1YuEJwo3XOV0iLezXDLFsrHTaC8L+KUOR/1+1tX7AAeipDG6jP3gPZOwpirk6G3LYQ==", + "dependencies": { + "Microsoft.AspNetCore.Components.Web": "10.0.10", + "Microsoft.Extensions.Configuration.Binder": "10.0.10", + "Microsoft.Extensions.Configuration.Json": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10", + "Microsoft.JSInterop.WebAssembly": "10.0.10" + } + } + } + } +} diff --git a/tests/ArcForges.Web.Operations.Tests/ArcForges.Web.Operations.Tests.csproj b/tests/ArcForges.Web.Operations.Tests/ArcForges.Web.Operations.Tests.csproj new file mode 100644 index 0000000..ea656cd --- /dev/null +++ b/tests/ArcForges.Web.Operations.Tests/ArcForges.Web.Operations.Tests.csproj @@ -0,0 +1,16 @@ + + + + Exe + true + + + + + + + + AGPL + AGPL-3.0-only + + diff --git a/tests/ArcForges.Web.Operations.Tests/OperationsProfileTests.cs b/tests/ArcForges.Web.Operations.Tests/OperationsProfileTests.cs new file mode 100644 index 0000000..0ac0c61 --- /dev/null +++ b/tests/ArcForges.Web.Operations.Tests/OperationsProfileTests.cs @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// The Operations profile skeleton (WEB.40): the shell route renders, the policy is the same exact token set as the App +// profiles, and the separate-origin boundary shares nothing. No operator feature is tested here (OPS.05 and OPS.11). +using ArcForges.Web.Ui; +using Bunit; +using Xunit; + +namespace ArcForges.Web.Operations.Tests; + +public sealed class OperationsProfileTests +{ + private static readonly string Root = RepositoryRoot.Find(); + + [Fact] + public void TheShellRouteRendersTheSeparateOriginSkeletonAndNoOperatorFeature() + { + using var context = new BunitContext(); + var cut = context.Render(); + Assert.Contains("Operations console", cut.Markup, StringComparison.Ordinal); + Assert.Empty(cut.FindAll("form")); + Assert.Empty(cut.FindAll("button")); + } + + [Fact] + public void ThePolicyHasTheSameExactTokenSetAsTheAppProfiles() + { + var html = File.ReadAllText(Path.Combine(Root, "src", "ArcForges.Web.Operations", "wwwroot", "index.html")); + var directives = WasmContentSecurityPolicy.ParseDirectives(WasmContentSecurityPolicy.FromHostPages([html])); + Assert.Equal(new[] { "'self'", "'wasm-unsafe-eval'" }, directives["script-src"]); + Assert.Equal(new[] { "'self'" }, directives["style-src"]); + var policy = WasmContentSecurityPolicy.FromHostPages([html]); + Assert.DoesNotContain("'unsafe-inline'", policy, StringComparison.Ordinal); + Assert.DoesNotContain("'unsafe-eval'", policy, StringComparison.Ordinal); + } + + [Fact] + public void TheSeparateOriginBoundarySharesNoCookieOrSessionWithAnotherProfile() + { + Assert.Empty(ProfileBoundary.SharedCookieOrigins); + Assert.Equal("own-origin", ProfileBoundary.PolicyScope); + } + + [Fact] + public void TheOperationsProfileIsStandaloneWebAssemblyWithAheadOfTimeCompilationOff() + { + var project = File.ReadAllText(Path.Combine(Root, "src", "ArcForges.Web.Operations", "ArcForges.Web.Operations.csproj")); + Assert.Contains("Sdk=\"Microsoft.NET.Sdk.BlazorWebAssembly\"", project, StringComparison.Ordinal); + Assert.Contains("false", project, StringComparison.Ordinal); + } +} diff --git a/tests/ArcForges.Web.Operations.Tests/ProfileIdentityTests.cs b/tests/ArcForges.Web.Operations.Tests/ProfileIdentityTests.cs new file mode 100644 index 0000000..2470d88 --- /dev/null +++ b/tests/ArcForges.Web.Operations.Tests/ProfileIdentityTests.cs @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: AGPL-3.0-only +using Xunit; + +namespace ArcForges.Web.Operations.Tests; + +public sealed class ProfileIdentityTests +{ + [Fact] + public void ProfileNameIsStable() + { + Assert.Equal("operations", ArcForges.Web.Operations.ProfileIdentity.Name); + } +} diff --git a/tests/ArcForges.Web.Operations.Tests/RepositoryRoot.cs b/tests/ArcForges.Web.Operations.Tests/RepositoryRoot.cs new file mode 100644 index 0000000..a089b5c --- /dev/null +++ b/tests/ArcForges.Web.Operations.Tests/RepositoryRoot.cs @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: AGPL-3.0-only +namespace ArcForges.Web.Operations.Tests; + +/// Locates the repository root from the test output directory. +public static class RepositoryRoot +{ + /// Walks up from the test assembly until the repository marker files are found. + public static string Find() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "win.slnx")) + && File.Exists(Path.Combine(directory.FullName, "global.json"))) + return directory.FullName; + directory = directory.Parent; + } + throw new DirectoryNotFoundException("The repository root with win.slnx and global.json was not found."); + } +} diff --git a/tests/ArcForges.Web.Operations.Tests/packages.lock.json b/tests/ArcForges.Web.Operations.Tests/packages.lock.json new file mode 100644 index 0000000..0145148 --- /dev/null +++ b/tests/ArcForges.Web.Operations.Tests/packages.lock.json @@ -0,0 +1,494 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "ArcForges.Build.Policy": { + "type": "Direct", + "requested": "[1.0.0-ci.94.1, )", + "resolved": "1.0.0-ci.94.1", + "contentHash": "PCpeEXikk2m87zhMF6L8tbHe2QrI87mO4Nc5lJBenEDhOePwwV4TkArc6IaM1G11Pm8ojVjurLAIoPW22ozb/A==" + }, + "bunit": { + "type": "Direct", + "requested": "[2.9.0, )", + "resolved": "2.9.0", + "contentHash": "Jz9ywwpjjYHppFDw4bIMkk5cNOy0vN74p3PzJFVIlCZicK8vpAovM5LsuNDLKE4bpwISNKrjwSrsw4SIVzT+0g==", + "dependencies": { + "AngleSharp": "1.7.0", + "AngleSharp.Css": "1.0.1", + "AngleSharp.Diffing": "1.1.1", + "Microsoft.AspNetCore.Components": "10.0.10", + "Microsoft.AspNetCore.Components.Authorization": "10.0.10", + "Microsoft.AspNetCore.Components.Web": "10.0.10", + "Microsoft.AspNetCore.Components.WebAssembly": "10.0.10", + "Microsoft.AspNetCore.Components.WebAssembly.Authentication": "10.0.10", + "Microsoft.Extensions.Caching.Memory": "10.0.10", + "Microsoft.Extensions.Localization.Abstractions": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10" + } + }, + "xunit.v3.mtp-v2": { + "type": "Direct", + "requested": "[4.0.1, )", + "resolved": "4.0.1", + "contentHash": "s88KiWwDDYgOWV3A+ViJiqCe9cLU/Rt6Gb5TfOC7Abv/HKSoj4IHVPcOQk7jPt7HhZHHEts5IuaVpB30eO5B1w==", + "dependencies": { + "xunit.analyzers": "2.1.0", + "xunit.v3.assert": "[4.0.1]", + "xunit.v3.core.mtp-v2": "[4.0.1]" + } + }, + "AngleSharp.Css": { + "type": "Transitive", + "resolved": "1.0.1", + "contentHash": "6S13xNHH+SUGPZd6EO1MhkuDbpEnFroUM6A8DZpLJHQnLRTvtBJb3Ydu65s618E4gWF9FSWmM5jhKk4RIfwT2A==", + "dependencies": { + "AngleSharp": "[1.5.0, 2.0.0)" + } + }, + "AngleSharp.Diffing": { + "type": "Transitive", + "resolved": "1.1.1", + "contentHash": "Uzz5895TpRyMA4t8GExmq4TxXqi3+bZTgb3kJgH+tdwrSuo9CO6Dlmv+Oqyhb2pNRfbnReLUzhlHAuT7c+3jSg==", + "dependencies": { + "AngleSharp": "1.1.2", + "AngleSharp.Css": "1.0.0-beta.144" + } + }, + "ArcForges.Contracts.Foundation": { + "type": "Transitive", + "resolved": "1.0.0-ci.287.1", + "contentHash": "Jwe5Z6K57vDsKmcxQFbbwyQZQUQsOkzYH26ogQIRb/biRtaGxLCtOsNg4felCuNTJt9bwRmC6AGx3eJyCoCk/g==", + "dependencies": { + "Google.Protobuf": "3.36.1" + } + }, + "Google.Protobuf": { + "type": "Transitive", + "resolved": "3.36.1", + "contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ==" + }, + "Grpc.Core.Api": { + "type": "Transitive", + "resolved": "2.84.0", + "contentHash": "p2SOMl6q/GZ4/5MLkgboC/z55g4zKUEruRn/g46QgppjQBVnsLZVBU/9VO7n60ll38G3Eo8Zu9b7x+W01GKFVg==" + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==" + }, + "Microsoft.AspNetCore.Authorization": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "FxWC2bc788/51CiSiMkKW5gQyqrSSs1991BhphS6ZARMGii+3Qr355Q9OSB/nBpdHVjIopfegAhreZ6UPpO9WA==", + "dependencies": { + "Microsoft.AspNetCore.Metadata": "10.0.10", + "Microsoft.Extensions.Diagnostics": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10" + } + }, + "Microsoft.AspNetCore.Components": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "wlCBFU9YxqhUc2kVbl/FCGNFSbRjS312WVk9YRVjs/OcZ+Yqa9QsD7z+UcBzNNAbcPpEM98f/PaaD6lHt5rmIg==", + "dependencies": { + "Microsoft.AspNetCore.Authorization": "10.0.10", + "Microsoft.AspNetCore.Components.Analyzers": "10.0.10" + } + }, + "Microsoft.AspNetCore.Components.Analyzers": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "ZBJobkEDv6yWftw3ycZMkuV9YpvJOBmjWpg2UbQ0Ol4THObhKai0PNl/SGFBmpw03JKCPA63RgplQOA1LQbRDg==" + }, + "Microsoft.AspNetCore.Components.Authorization": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "em2xVQkvtn3BHFT3J9pp4K3yDgPnp0S5a+gcGxb6xkjqy+81yDa8XEEaQnkruCVRusFMltORFFmnlrnHa/A90w==", + "dependencies": { + "Microsoft.AspNetCore.Authorization": "10.0.10", + "Microsoft.AspNetCore.Components": "10.0.10" + } + }, + "Microsoft.AspNetCore.Components.Forms": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "09cKk0/t83Y5DN5ne4J9Y+OiLJEQ+XBFGIoD0UXAnae8yQEkFa/OYbX0bBGkx0k5r5ndta/CMdlMk3lCG81R0g==", + "dependencies": { + "Microsoft.AspNetCore.Components": "10.0.10", + "Microsoft.Extensions.Validation": "10.0.10" + } + }, + "Microsoft.AspNetCore.Components.Web": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "8+cyStKhSy94Q2L/2zmgk0H1f+GKvBGEvTImT0qdsCqeNbT7ilGDKTIJ1PiugsPtSNLuUEABUqMpA3rE1yAKFg==", + "dependencies": { + "Microsoft.AspNetCore.Components": "10.0.10", + "Microsoft.AspNetCore.Components.Forms": "10.0.10", + "Microsoft.Extensions.DependencyInjection": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10", + "Microsoft.JSInterop": "10.0.10" + } + }, + "Microsoft.AspNetCore.Components.WebAssembly.Authentication": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "D2GIsqVJ2+tVDViJmbEF4tc+gxhdAl5xkBAMZHWCJV7Pt+oBkU2omBSRbgVTq3N2OgreVNSp7w+FrZNgu97d5A==", + "dependencies": { + "Microsoft.AspNetCore.Components.Authorization": "10.0.10", + "Microsoft.AspNetCore.Components.Web": "10.0.10" + } + }, + "Microsoft.AspNetCore.Metadata": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "nmZYV9OQ69r3EDOj0SW1JLgD4uFHfMNDKb8mk8Mf4y0Enw3AzkMD85zPuAk5FqmEz8PS0WagR/tJCwLg8xpI3Q==" + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "4ZFBNE+jzR+CrWWlhOesnmywCW7pYKT0dxyAQRdL11yJwxe4jvcAu31eorFtEkoFeCDcUTeNssgPv2yaRRptaQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "N1w5H7uK6gCTnCBZAWzE0/EQYSPysij/uYwDqntqBVvBa6bjMmBKitsnEFd6yh/SX3wLm67nO6+OnZ84K+gZWg==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.10", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "plJWK2zpWuuyxI8F8s2scx6Je7N1Ajjs6HvYUGKwRnDMWIVIz9FHwAkiT7ASgrvAOd10T0FPVlh9BzAJJME+jg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "5Vnd2I75DmZCVEjSynIdJ/0EGafgnLQwgR3t2C2/fkjx/nRG+cLwxLLdInoHeCEpkD5K4Ov/g9ZCRYrl4TRsaA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "GqmN2o1CkJvk7uWp+p4CwBYW0w/zfoEbvsiFDbO2G8l1Uz+mrDAbAcZiXhU2lufKPby1cjAUdd5GTWpebYOkOA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10" + } + }, + "Microsoft.Extensions.Configuration.FileExtensions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "ZOhZYwvbXGTgGVRwswIirofEMVHuWdxjdh0JeUZXwaF9cgcjXdz/t0ELtgaevw7ezTyv47yPNCgGreWtLkn3IQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.10", + "Microsoft.Extensions.FileProviders.Physical": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.Configuration.Json": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "uvJ6sHwjgrkMEJOgiC76G0mcZGXerwyyWkwX34EOjCbxKG6TCtfAoqDKAMsCvEBf9HxjlGQEgqsSMOGCmGBf+A==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.10", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.10" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "ANyvsgkNBRvcJh2XLgn8veGmajf+8m0AbKK+HPWdRL1yraSNVVSmQhFntLtdz/C795jxqqup+k05cs/3jZQPOA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "z/2xXlFw2aLGjHyEm6E0tQ+In6VfzQzTrtArbQ2c0TQE16ZbyDCMGPvaUT9I0s8rgy9sRWlU2P9waW37qV04qA==" + }, + "Microsoft.Extensions.Diagnostics": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "Kr/e7lUf4+N8tacbqJ2Ctwe/HarKdAc9ZkgKVVqvtJDBKbez+T/KnUwu82KSlnBp/SrpBcxc7u7xkE2oUZT/5Q==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.10", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.10", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.10" + } + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "9uWiKpeOVac355STyChWR/pliFX/5CeLqChW9kKsaxyDH4EUTZxMkT4Jwp/J/peLm0GBFmSX5c0WCse3yCnq1Q==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10" + } + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "c5zqFCY9DiIpMovLd7/d/CTiEtrMOuQ639dhv3PABtKQIKNQikSHwQt8+N679uii9q+B55lgK28Uv64FOwEu8w==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.FileProviders.Physical": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "jhJAyo38kSrH3ARvWUk0h8itogVnQu2DCZuPo+s0Z+tXes0ugTxMPaHYzap85785eHQmPFqD9TYERqBbtGxn/w==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.10", + "Microsoft.Extensions.FileSystemGlobbing": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.FileSystemGlobbing": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "jSOCVxEwCd4Aq925kJVz1kSO1EpX2OHYKL04qVREXkDU7Ce3pVDdHPYm+fEy8y/th2kJf/DAstRHpJAqoNWP8w==" + }, + "Microsoft.Extensions.Localization.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "j1DygDJR1wP3LG3d7a1rTFPUZcPfhMggcmDEGKhK2iLK0TuAIaW9bHiq1f9k650gdsXsANTm71hEg/k9msNnAQ==" + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "Tf6z5HsL0VDYRTfvsoNrTGHGheCwkTsZBA2FFh5ATJUbkAwug+FFNISJK2gjpUNemlAOoWllAK52HOWCjto3EQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "zkFxGYUvdxAvIKTyXHrmW+Sux53D4SezD9dMyZ6hrwwzPQJNuwCRy1f5W7AvYTqacEGhWF2XderRQG1OvbV8og==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "srnhnk7nE8krBiIXp71LvBmKBtraBONWSRzdjJgRv1Ko9Mp8IVNqv4vIS9hGeVteBig8aQkva9ZG+sC+o5sVcA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "tnBmu/LwF25ZQK+HBNCu2xrwnkKoB/XEbJyooGGoYxHrhvxbSKi7eOFiJ4AXBy/QU4vtCvCJfoi8k9Ej72qzOQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.Configuration.Binder": "10.0.10", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "5wu/GrYVd8mG2DVUw3vFJzF+O336TyTGg/Kmcgw9bfwYhCoFiV5lR5QeEmKecJyrW4W54nMfD3p3589E8a7czQ==" + }, + "Microsoft.Extensions.Validation": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "d77zBZk+Z4uo6hEuLvMY+yMjLkn2T/vX5c2sB0sdoSxQVZk7qvec6SuORp2/uZp7UchX4hNp+VJm4GsoSE0waA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10" + } + }, + "Microsoft.JSInterop": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "VivWvz1iFR3Ntf/e5/xci6o/MF2tcHiprNkhRlL+gMkBoDjQJCioiO+Gwp/DP8scuzqbp3b9C3wh+zuy4pD4AA==" + }, + "Microsoft.JSInterop.WebAssembly": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "FjPAGQWbCP85FWY2Wl9opHSsY+u9nZb8Ui6eTVHEMY/RJateOhW78jEtC4QOQ/6slkS1MKyIAF6ZHIrUR1fX/w==", + "dependencies": { + "Microsoft.JSInterop": "10.0.10" + } + }, + "Microsoft.Testing.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "2.4.0", + "contentHash": "JeP1RFqBa11fWmBk8xEfZcMKr4rxWSyI6OZ+659V069CaMkTEOQBW2UdSSeNz3absOsygcn7JJkzerC4LGnZ9w==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.23.0", + "Microsoft.Testing.Platform": "[2.4.0, 3.0.0)" + } + }, + "Microsoft.Testing.Extensions.TrxReport.Abstractions": { + "type": "Transitive", + "resolved": "2.4.0", + "contentHash": "uRb+4qM42dFDg4kWJZ2kFEcwESNVCRZJjItp5vETorN3rSJGysP617LqYirOcrCYmxg+obPreHMM5JcsNDKtBw==", + "dependencies": { + "Microsoft.Testing.Platform": "[2.4.0, 3.0.0)" + } + }, + "Microsoft.Testing.Platform": { + "type": "Transitive", + "resolved": "2.4.0", + "contentHash": "dp1N3P1ujb0ztwFgqz2o/ItEvq+pm19/AiA+Xq7Zpcy7oPMcxDBZLYGtf0LlU1MveEBJuKVjZhI+LnkfcH/0jQ==" + }, + "Microsoft.Testing.Platform.MSBuild": { + "type": "Transitive", + "resolved": "2.4.0", + "contentHash": "qr5M6h16YHMJLFDcWELFVMMpGte2BUmveBZKT5YoBV+bmuJRPu9bv/Zqke4yQuOEKRNxoAETrG5jr+/6Rnr3Hg==", + "dependencies": { + "Microsoft.Testing.Platform": "[2.4.0, 3.0.0)" + } + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==" + }, + "System.Security.AccessControl": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "IQ4NXP/B3Ayzvw0rDQzVTYsCKyy0Jp9KI6aYcK7UnGVlR9+Awz++TIPCQtPYfLJfOpm8ajowMR09V7quD3sEHw==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "X7QXEcZQGz0G/HL4HUyK+aAvNa/IMGbOCnFIq4jD/Evktq12xANKwzOUr7b08vCmC1LXu/47qHWOdjm3KfaJ0A==" + }, + "xunit.v3.assert": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "nC7d3cY06Oo7hkdwWPZkBR0Ud75xNhgx0P7i0GmMMZC3puCGlc4szFiT30biH03IN/eDnr8h+nv3A1UD17bk/A==" + }, + "xunit.v3.common": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "Qf25TVdadDQYf9zVxSd7L9RNQhuP0jCUHmo96XTmIKLxGefylIEV63jRbXBPFzdXmV09TCdcurk+AQ9LiWS2Hg==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "xunit.v3.core.mtp-v2": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "7qfTfrIfS2wybpSVyRLmhXHbSudD2eNw66LfukixmKsRCTcfvOrLsx0qGL/lObcEh3Z2F4SIXgoFnLIiB8yfjQ==", + "dependencies": { + "Microsoft.Testing.Extensions.Telemetry": "2.4.0", + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.4.0", + "Microsoft.Testing.Platform": "2.4.0", + "Microsoft.Testing.Platform.MSBuild": "2.4.0", + "xunit.v3.extensibility.core": "[4.0.1]", + "xunit.v3.runner.inproc.console": "[4.0.1]" + } + }, + "xunit.v3.extensibility.core": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "J0d5OfFcp920nZxCRIvU14+TZhiSPQR0wvqEMZ3MRiJLPu1VmYlcRNkmcSW0i1DwkB81gAHc+fHUQf/cU64MYg==", + "dependencies": { + "xunit.v3.common": "[4.0.1]" + } + }, + "xunit.v3.runner.common": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "p9AyfBpj2e5Iws8B57SbLiSngg7mEdvegHInKR4T2cjt2mis9h8htVgCnmu//agZO4zri6p6VQOj5EkHHTy3gA==", + "dependencies": { + "Microsoft.Win32.Registry": "[5.0.0]", + "System.Security.AccessControl": "[6.0.1]", + "xunit.v3.common": "[4.0.1]" + } + }, + "xunit.v3.runner.inproc.console": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "Hqwfd6ehMIhPmVWUQ2dgmknzuLFTWeyp8ES1q3D4YR5bQVyiXDcaIoaFwqAz2zgLr49WaW4Mz7VVncP7u/Q8/Q==", + "dependencies": { + "xunit.v3.extensibility.core": "[4.0.1]", + "xunit.v3.runner.common": "[4.0.1]" + } + }, + "arcforges.web.operations": { + "type": "Project", + "dependencies": { + "ArcForges.Web.Ui": "[1.0.0, )", + "Microsoft.AspNetCore.Components.WebAssembly": "[10.0.10, )" + } + }, + "arcforges.web.ui": { + "type": "Project", + "dependencies": { + "ArcForges.Contracts.PublicApi": "[1.0.0-ci.287.1, )" + } + }, + "AngleSharp": { + "type": "CentralTransitive", + "requested": "[1.7.0, )", + "resolved": "1.7.0", + "contentHash": "v1R++46dblGRBo2/fKFUfgtZOaFnDGQhqBM0AarxgZSJuumj1e1vexBbjlTv2hx3Olr3qeoJa2yUoWyv5fuJ5A==" + }, + "ArcForges.Contracts.PublicApi": { + "type": "CentralTransitive", + "requested": "[1.0.0-ci.287.1, )", + "resolved": "1.0.0-ci.287.1", + "contentHash": "/chAZbT/NTs4yyQBV5dgh37/dWBTKt3PHCikIidI9T90LheydsJoCWxKHGC6Tjzw0rqA62rYxnQfXGzKhTea5Q==", + "dependencies": { + "ArcForges.Contracts.Foundation": "1.0.0-ci.287.1", + "Google.Protobuf": "3.36.1", + "Grpc.Core.Api": "2.84.0" + } + }, + "Microsoft.AspNetCore.Components.WebAssembly": { + "type": "CentralTransitive", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "0Zib7U6HmGw4Noj/+yJz1YuEJwo3XOV0iLezXDLFsrHTaC8L+KUOR/1+1tX7AAeipDG6jP3gPZOwpirk6G3LYQ==", + "dependencies": { + "Microsoft.AspNetCore.Components.Web": "10.0.10", + "Microsoft.Extensions.Configuration.Binder": "10.0.10", + "Microsoft.Extensions.Configuration.Json": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10", + "Microsoft.JSInterop.WebAssembly": "10.0.10" + } + } + } + } +} diff --git a/tests/ArcForges.Web.Policy.Tests/ArcForges.Web.Policy.Tests.csproj b/tests/ArcForges.Web.Policy.Tests/ArcForges.Web.Policy.Tests.csproj new file mode 100644 index 0000000..7f4bea1 --- /dev/null +++ b/tests/ArcForges.Web.Policy.Tests/ArcForges.Web.Policy.Tests.csproj @@ -0,0 +1,14 @@ + + + + Exe + true + + + + + + AGPL + AGPL-3.0-only + + diff --git a/tests/ArcForges.Web.Policy.Tests/Architecture/ArchitecturePolicy.CSharp.cs b/tests/ArcForges.Web.Policy.Tests/Architecture/ArchitecturePolicy.CSharp.cs new file mode 100644 index 0000000..dacf4c6 --- /dev/null +++ b/tests/ArcForges.Web.Policy.Tests/Architecture/ArchitecturePolicy.CSharp.cs @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: AGPL-3.0-only +using System.Text.RegularExpressions; + +namespace ArcForges.Web.Policy.Tests.Architecture; + +/// +/// The C# source rules: private namespaces, generated wire types only, the two admitted JSON and HTTP surfaces, the +/// desktop DOM prohibition and the release fixture routes. A file is release code when it is under src/. +/// +public static partial class ArchitecturePolicy +{ + private static readonly Regex CSharpUsing = new( + @"^\s*(?:global\s+)?using\s+(?:static\s+)?(?:[\w.]+\s*=\s*)?([\w.]+)\s*;", + RegexOptions.Multiline | RegexOptions.CultureInvariant); + private static readonly Regex RazorUsing = new(@"@using\s+(?:static\s+)?([\w.]+)", RegexOptions.CultureInvariant); + private static readonly Regex WireDeclaration = new( + @"\b(?:class|record|struct|interface)\s+(\w*(?:Request|Response|Dto|Message))\b", RegexOptions.CultureInvariant); + private static readonly Regex JsonApi = new( + @"\bSystem\.Text\.Json\b|\bNewtonsoft\.Json\b|\bJsonSerializer\b|\bJsonNode\b|\bJsonObject\b|\bJsonArray\b|\bJsonDocument\b|\bJsonElement\b|\bUtf8JsonWriter\b", + RegexOptions.CultureInvariant); + private static readonly Regex HttpApi = new( + @"\bHttpClient\b|\bHttpRequestMessage\b|\bHttpResponseMessage\b|\bHttpMethod\b|\b(?:FromJsonAsync|AsJsonAsync|ReadFromJsonAsync|GetStringAsync|GetByteArrayAsync|GetStreamAsync)\b|(?", RegexOptions.CultureInvariant); + + /// The one admitted JSON codec: the generated Contracts JSON context decodes the same-origin documents. + private static readonly string[] JsonAdmissions = ["src/ArcForges.Web.App/Probe/WireJson.cs"]; + + /// + /// The admitted HTTP surfaces: the BrowserSession HTTP exception calls (SessionProbe), the generated gRPC-Web + /// handler of the anonymous greeting (HelloProbe) and the cloud-hello server connection check (ServerConnection). + /// + private static readonly string[] HttpAdmissions = + [ + "src/ArcForges.Web.App/Probe/SessionProbe.cs", + "src/ArcForges.Web.App/Probe/HelloProbe.cs", + "src/ArcForges.Web.Ui/ServerConnection.cs", + ]; + + private static void AuditCSharpSources(PolicyContext context) + { + foreach (var file in context.Find(name => name.EndsWith(".cs", StringComparison.Ordinal) + || name.EndsWith(".razor", StringComparison.Ordinal))) + { + var text = context.Text(file) ?? string.Empty; + var razor = file.EndsWith(".razor", StringComparison.Ordinal); + var code = razor ? RazorComment.Replace(text, " ") : PolicyText.StripCode(text, templates: false); + + foreach (Match use in (razor ? RazorUsing : CSharpUsing).Matches(code)) + if (PrivateNamespace.IsMatch(use.Groups[1].Value)) + context.Report("private-import", file, "Private or server namespace: " + use.Groups[1].Value); + + if (DesktopPath.IsMatch(file) && DesktopApi.IsMatch(code)) + context.Report("desktop-dom", file, "Desktop graphs must not use the DOM or JavaScript interop."); + + if (!file.StartsWith("src/", StringComparison.Ordinal)) + continue; + + foreach (Match declaration in WireDeclaration.Matches(code)) + context.Report("wire-source", file, "Wire shapes must come from the published generated package: " + declaration.Groups[1].Value); + if (!JsonAdmissions.Contains(file, StringComparer.Ordinal) && JsonApi.IsMatch(code)) + context.Report("wire-source", file, "Production wire serialization uses the generated Contracts codecs; JSON needs an explicit admission."); + if (!HttpAdmissions.Contains(file, StringComparer.Ordinal) && HttpApi.IsMatch(code)) + context.Report("wire-source", file, "Business network calls must use the published generated transport."); + if (BlazorServerCode.IsMatch(code)) + context.Report("obsolete-target", file, "Blazor Server render modes and circuits are forbidden in release code."); + foreach (Match route in PageRoute.Matches(code)) + if (FixtureRouteSegment.IsMatch(route.Groups[1].Value)) + context.Report("release-fixture", file, "Fixture or debug route in the release route graph: " + route.Groups[1].Value); + } + } +} diff --git a/tests/ArcForges.Web.Policy.Tests/Architecture/ArchitecturePolicy.MSBuild.cs b/tests/ArcForges.Web.Policy.Tests/Architecture/ArchitecturePolicy.MSBuild.cs new file mode 100644 index 0000000..8ce24bb --- /dev/null +++ b/tests/ArcForges.Web.Policy.Tests/Architecture/ArchitecturePolicy.MSBuild.cs @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: AGPL-3.0-only +using System.Text.RegularExpressions; + +namespace ArcForges.Web.Policy.Tests.Architecture; + +/// +/// The MSBuild rules: obsolete Blazor targets, portable references, production and install commands, restore sources, +/// imports, the Apache-to-AGPL project graph and the release route graph. +/// +public static partial class ArchitecturePolicy +{ + /// Paths that name test, fixture or double code. Such files must never be in the release route graph. + internal static readonly Regex FixturePath = new( + @"(?:^|/)(?:tests?|__tests__|fixtures?|test-?helpers|testdoubles)(?:/|\.)|\.(?:test|spec)\.|(?:^|/)[^/]*(?:Fixtures?|TestDoubles?)\.(?:cs|razor)$", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + + private static readonly Regex ProjectReferenceInclude = new( + "]*?Include=[\"']([^\"']+)[\"']", RegexOptions.CultureInvariant); + private static readonly Regex NonLiteralReference = new(@"[$@%*?;]", RegexOptions.CultureInvariant); + private static readonly Regex BlazorServerTarget = new( + @"AddInteractiveServer|InteractiveServer|Microsoft\.AspNetCore\.Components\.Server\b|Microsoft\.AspNetCore\.Components\.WebAssembly\.Server\b|AddRazorComponents|MapRazorComponents|\bCircuit\b", + RegexOptions.CultureInvariant); + private static readonly Regex DevelopmentServer = new( + "]*?\\bCommand=\"([^\"]*)\"", RegexOptions.CultureInvariant); + private static readonly Regex ExecForbidden = new( + @"\b(?:npm\s+(?:install|i|exec)|npx\b|dotnet\s+(?:restore|add)|(?:vite|react-router|wrangler)\s+(?:dev|serve)|--hmr)\b|\bnpm\s+ci\b(?![^""]*--ignore-scripts)", + RegexOptions.CultureInvariant); + private static readonly Regex DevelopmentBuildCommand = new( + @"(?:\b(?:install|ci|dev|serve)\b|--hmr)", RegexOptions.CultureInvariant); + private static readonly Regex NpmInstallSwitch = new( + "\\s*(?!false\\s*<)[^<]+", RegexOptions.CultureInvariant); + private static readonly Regex BuildCommandElement = new("[^<]*", RegexOptions.CultureInvariant); + private static readonly Regex LockRestoreDisabled = new( + "<(?:RestorePackagesWithLockFile|RestoreLockedMode)>\\s*false\\s*<", RegexOptions.CultureInvariant); + private static readonly Regex AlternateRestoreSource = new( + "<(?:RestoreSources|RestoreAdditionalProjectSources)\\b", RegexOptions.CultureInvariant); + private static readonly Regex UsingInclude = new("]*?Include=\"([^\"]+)\"", RegexOptions.CultureInvariant); + private static readonly Regex LicenceBoundaryValue = new( + "]*)?>([^<]*)", RegexOptions.CultureInvariant); + private static readonly Regex ReleaseItem = new( + "<(?:Compile|Content|None|EmbeddedResource)\\s[^>]*?Include=\"([^\"]+)\"", RegexOptions.CultureInvariant); + private static readonly Regex PrivateNamespace = new( + @"^ArcForges(?:\.[\w]+)*\.(?:Private|Server|LocalRpc|Internal|Operator|AIInternal|Storage)(?:\.|$)|^ArcForges\.Contracts\.(?:Internal|Storage|AIInternal)(?:\.|$)", + RegexOptions.CultureInvariant); + + private static readonly string[] ReleaseRoots = ["src/"]; + + private static void AuditMsBuildProjects(PolicyContext context) + { + var projects = context.Find(name => name.EndsWith(".csproj", StringComparison.Ordinal) + || name.EndsWith(".esproj", StringComparison.Ordinal) + || name.EndsWith(".props", StringComparison.Ordinal) + || name.EndsWith(".targets", StringComparison.Ordinal)); + var targets = context.Find(name => name.EndsWith(".slnx", StringComparison.Ordinal) + || name.EndsWith(".csproj", StringComparison.Ordinal) + || name.EndsWith(".esproj", StringComparison.Ordinal) + || name.EndsWith(".props", StringComparison.Ordinal) + || name.EndsWith(".targets", StringComparison.Ordinal)); + + foreach (var file in targets) + { + var xml = PolicyText.StripXmlComments(context.Text(file) ?? string.Empty); + if (BlazorServerTarget.IsMatch(xml)) + context.Report("obsolete-target", file, "Blazor Server render modes and circuits are forbidden; Blazor WebAssembly standalone is the only Blazor target."); + if (DevelopmentServer.IsMatch(xml)) + context.Report("production-command", file, "A development server (SpaProxy) is not allowed in a production project."); + if (LockRestoreDisabled.IsMatch(xml)) + context.Report("workspace", file, "Implicit restore is refused: restore uses the locked NuGet closure."); + if (AlternateRestoreSource.IsMatch(xml)) + context.Report("workspace", file, "Alternate restore sources are not allowed; the nuget.org source is the only one."); + } + + foreach (var file in projects) + { + var xml = PolicyText.StripXmlComments(context.Text(file) ?? string.Empty); + if (file.EndsWith(".esproj", StringComparison.Ordinal) || file.EndsWith(".props", StringComparison.Ordinal) || file.EndsWith(".targets", StringComparison.Ordinal)) + { + if (NpmInstallSwitch.IsMatch(xml)) + context.Report("production-command", file, "Implicit IDE npm install is not allowed."); + } + foreach (var build in BuildCommandElement.Matches(xml).Select(match => match.Value)) + if (DevelopmentBuildCommand.IsMatch(build)) + context.Report("production-command", file, "The build command installs or starts a development server."); + foreach (Match exec in ExecCommand.Matches(xml)) + if (ExecForbidden.IsMatch(exec.Groups[1].Value)) + context.Report("production-command", file, "An Exec command installs, restores or starts a development server implicitly."); + foreach (Match use in UsingInclude.Matches(xml)) + if (PrivateNamespace.IsMatch(use.Groups[1].Value)) + context.Report("private-import", file, "Private or server namespace: " + use.Groups[1].Value); + foreach (Match package in Regex.Matches(xml, "]*?(?:Include|Update)=\"([^\"]+)\"", RegexOptions.CultureInvariant)) + if (PrivateNamespace.IsMatch(package.Groups[1].Value)) + context.Report("private-import", file, "Private or server package: " + package.Groups[1].Value); + if (!file.EndsWith(".csproj", StringComparison.Ordinal) && !file.EndsWith(".props", StringComparison.Ordinal) && !file.EndsWith(".targets", StringComparison.Ordinal)) + continue; + foreach (Match reference in ProjectReferenceInclude.Matches(xml)) + { + var include = reference.Groups[1].Value; + if (NonLiteralReference.IsMatch(include)) + { + context.Report("computed-import", file, "Nonliteral project reference: " + include); + continue; + } + if (include.EndsWith(".esproj", StringComparison.OrdinalIgnoreCase)) + { + context.Report("portable-reference", file, "Managed graph references esproj: " + include); + continue; + } + var target = ProjectTarget(file, include); + if (!context.Sources.ContainsKey(target) && !target.EndsWith(".esproj", StringComparison.Ordinal)) + context.Report("unresolved-import", file, "Project reference does not resolve: " + include); + } + } + + AuditLicenceBoundaryGraph(context, projects); + } + + /// An Apache-declared project must never reach a project of another boundary through project references. + private static void AuditLicenceBoundaryGraph(PolicyContext context, IReadOnlyList projects) + { + var boundaries = new Dictionary(StringComparer.Ordinal); + var edges = new Dictionary>(StringComparer.Ordinal); + foreach (var project in projects.Where(name => name.EndsWith(".csproj", StringComparison.Ordinal))) + { + var xml = PolicyText.StripXmlComments(context.Text(project) ?? string.Empty); + boundaries[project] = LicenceBoundaryValue.Match(xml).Groups[1].Value; + edges[project] = ProjectReferenceInclude.Matches(xml) + .Select(match => match.Groups[1].Value) + .Where(include => !NonLiteralReference.IsMatch(include) && !include.EndsWith(".esproj", StringComparison.OrdinalIgnoreCase)) + .Select(include => ProjectTarget(project, include)) + .ToList(); + } + foreach (var (project, boundary) in boundaries) + { + if (boundary != "Apache") + continue; + var seen = new HashSet(StringComparer.Ordinal); + var pending = new Stack(edges[project]); + while (pending.Count > 0) + { + var current = pending.Pop(); + if (!seen.Add(current) || current == project) + continue; + if (boundaries.TryGetValue(current, out var reached) && reached != "Apache") + context.Report("sdk-ui", project, "Apache project reaches " + current + " with boundary " + reached); + if (edges.TryGetValue(current, out var next)) + foreach (var target in next) + pending.Push(target); + } + } + } + + /// + /// The release route graph: every project reachable from a source project through project references must be + /// production code, and no file of a reached project may be a test, fixture or double. + /// + private static void AuditReleaseGraph(PolicyContext context) + { + var roots = context.Find(name => name.EndsWith(".csproj", StringComparison.Ordinal) + && ReleaseRoots.Any(prefix => name.StartsWith(prefix, StringComparison.Ordinal))); + var seen = new HashSet(StringComparer.Ordinal); + var pending = new Stack(roots); + while (pending.Count > 0) + { + var project = pending.Pop(); + if (!seen.Add(project)) + continue; + if (FixturePath.IsMatch(project)) + context.Report("release-fixture", project, "Test or fixture project reachable from the release route graph."); + var directory = PolicyText.DirectoryOf(project) + "/"; + var xml = PolicyText.StripXmlComments(context.Text(project) ?? string.Empty); + foreach (Match item in ReleaseItem.Matches(xml)) + if (item.Groups[1].Value.Contains("..", StringComparison.Ordinal) || FixturePath.IsMatch(item.Groups[1].Value)) + context.Report("release-fixture", project, "Release item reaches outside production code: " + item.Groups[1].Value); + foreach (var file in context.Find(name => name.StartsWith(directory, StringComparison.Ordinal) + && (name.EndsWith(".cs", StringComparison.Ordinal) || name.EndsWith(".razor", StringComparison.Ordinal)))) + if (FixturePath.IsMatch(file)) + context.Report("release-fixture", file, "Fixture or test helper in the release route graph."); + foreach (Match reference in ProjectReferenceInclude.Matches(xml)) + { + var include = reference.Groups[1].Value; + if (NonLiteralReference.IsMatch(include) || include.EndsWith(".esproj", StringComparison.OrdinalIgnoreCase)) + continue; + var target = ProjectTarget(project, include); + if (context.Sources.ContainsKey(target)) + pending.Push(target); + } + } + } + + private static string ProjectTarget(string project, string include) => + PolicyText.Join(PolicyText.DirectoryOf(project), include.Replace('\\', '/')); +} diff --git a/tests/ArcForges.Web.Policy.Tests/Architecture/ArchitecturePolicy.TypeScript.cs b/tests/ArcForges.Web.Policy.Tests/Architecture/ArchitecturePolicy.TypeScript.cs new file mode 100644 index 0000000..7cb180c --- /dev/null +++ b/tests/ArcForges.Web.Policy.Tests/Architecture/ArchitecturePolicy.TypeScript.cs @@ -0,0 +1,196 @@ +// SPDX-License-Identifier: AGPL-3.0-only +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; + +namespace ArcForges.Web.Policy.Tests.Architecture; + +/// +/// The TypeScript source rules that remain until the React and TypeScript removal unit. They are the lexical port of +/// the Babel-based GOV.11 rules: specifiers and their resolution, generated wire types only, the fetch and JSON +/// prohibitions, the desktop DOM prohibition and the release route graph from the production roots. Only files +/// reachable from a production root are reported. +/// +public static partial class ArchitecturePolicy +{ + private static readonly string[] TypeScriptExtensions = [".ts", ".tsx", ".js", ".mjs", ".cjs", ".cts", ".mts"]; + private static readonly string[] AssetExtensions = [".css", ".svg", ".png", ".woff", ".woff2"]; + private static readonly string[] ResolutionSuffixes = [".ts", ".tsx", ".js", ".jsx", "/index.ts", "/index.tsx"]; + + private static readonly Regex TsProductionRoot = new( + @"^(?:apps/[^/]+/app|packages/[^/]+/src|worker)/", RegexOptions.CultureInvariant); + private static readonly Regex TsStaticSpecifier = new( + @"\b(?:import|export)\b[^;'""`]*?\bfrom\s*(['""])([^'""\r\n]+)\1", RegexOptions.CultureInvariant); + private static readonly Regex TsBareImport = new( + @"(? Edges { get; } = []; + public List Findings { get; } = []; + } + + private static void AuditTypeScriptSources(PolicyContext context) + { + var workspaces = WorkspaceDirectories(context); + var parsed = new Dictionary(StringComparer.Ordinal); + foreach (var file in context.Find(name => TypeScriptExtensions.Any(extension => name.EndsWith(extension, StringComparison.Ordinal)))) + parsed[file] = ParseTypeScript(context, file, workspaces); + + var reachable = new HashSet(StringComparer.Ordinal); + var pending = new Stack(parsed.Keys.Where(file => TsProductionRoot.IsMatch(file))); + while (pending.Count > 0) + { + var file = pending.Pop(); + if (!reachable.Add(file) || !parsed.TryGetValue(file, out var info)) + continue; + foreach (var edge in info.Edges) + pending.Push(edge); + } + + foreach (var file in reachable.Order(StringComparer.Ordinal)) + { + if (FixturePath.IsMatch(file)) + context.Report("release-fixture", file, "Fixture or test helper reachable from the release route graph."); + if (parsed.TryGetValue(file, out var info)) + foreach (var finding in info.Findings) + context.Report(finding.Rule, finding.File, finding.Detail); + } + } + + private static Dictionary WorkspaceDirectories(PolicyContext context) + { + var workspaces = new Dictionary(StringComparer.Ordinal); + foreach (var manifest in context.Find(name => PolicyText.BaseName(name) == "package.json")) + { + var name = JsonString(PolicyText.ParseObject(context.Text(manifest)), "name"); + if (name.Length > 0) + workspaces[name] = PolicyText.DirectoryOf(manifest); + } + return workspaces; + } + + private static TypeScriptFile ParseTypeScript(PolicyContext context, string file, IReadOnlyDictionary workspaces) + { + var info = new TypeScriptFile(); + var code = PolicyText.StripCode(context.Text(file) ?? string.Empty, templates: true); + + foreach (Match match in TsStaticSpecifier.Matches(code)) + Specifier(context, info, file, match.Groups[2].Value, workspaces); + foreach (Match match in TsBareImport.Matches(code)) + Specifier(context, info, file, match.Groups[2].Value, workspaces); + foreach (var pattern in new[] { TsDynamicImport, TsRequire }) + foreach (Match match in pattern.Matches(code)) + { + var argument = match.Groups[1].Value; + var literal = TsLiteralArgument.Match(argument); + if (literal.Success) + Specifier(context, info, file, literal.Groups[2].Value, workspaces); + else + info.Findings.Add(new PolicyFinding("computed-import", file, "Release imports must be statically resolved.")); + } + + var generated = new HashSet(StringComparer.Ordinal); + foreach (Match import in TsGeneratedImport.Matches(code)) + foreach (var item in import.Groups[1].Value.Split(',')) + { + var parts = item.Split(" as ", StringSplitOptions.TrimEntries); + var local = parts[^1]; + if (local.Length > 0) + generated.Add(local); + } + foreach (Match declaration in TsWireInterface.Matches(code)) + info.Findings.Add(new PolicyFinding("wire-source", file, "Wire shapes must come from the published generated package: " + declaration.Groups[1].Value)); + foreach (Match alias in TsWireAlias.Matches(code)) + { + var target = alias.Groups[2].Value.Trim(); + if (!TsGeneratedAliasTarget.IsMatch(target) && !generated.Contains(target)) + info.Findings.Add(new PolicyFinding("wire-source", file, "Wire shapes must come from the published generated package: " + alias.Groups[1].Value)); + } + if (TsJsonCodec.IsMatch(code)) + info.Findings.Add(new PolicyFinding("wire-source", file, "Production wire serialization uses published generated codecs; JSON needs explicit HTTP exception admission.")); + if (!file.StartsWith("worker/", StringComparison.Ordinal) && TsFetch.IsMatch(code)) + info.Findings.Add(new PolicyFinding("wire-source", file, "Business network calls must use published generated transport.")); + if (TsDesktopPath.IsMatch(file)) + foreach (Match dom in TsDesktopDom.Matches(code)) + info.Findings.Add(new PolicyFinding("desktop-dom", file, dom.Value)); + return info; + } + + private static void Specifier(PolicyContext context, TypeScriptFile info, string file, string value, IReadOnlyDictionary workspaces) + { + if (TsProtobufSpecifier.IsMatch(value)) + info.Findings.Add(new PolicyFinding("wire-source", file, "Production cannot author wire descriptors or codecs.")); + if (NpmPrivateSpecifier.IsMatch(value)) + info.Findings.Add(new PolicyFinding("private-import", file, value)); + if (TsSdkPath.IsMatch(file) && TsSdkSpecifier.IsMatch(value)) + info.Findings.Add(new PolicyFinding("sdk-ui", file, value)); + if (TsDesktopPath.IsMatch(file) && TsDesktopSpecifier.IsMatch(value)) + info.Findings.Add(new PolicyFinding("desktop-dom", file, value)); + + var stem = LocalStem(context, file, value, workspaces); + if (stem is null) + return; + if (stem.StartsWith("../", StringComparison.Ordinal)) + { + info.Findings.Add(new PolicyFinding("private-import", file, "Sibling source escape: " + value)); + return; + } + var stripped = stem.EndsWith(".js", StringComparison.Ordinal) ? stem[..^3] : stem; + var candidates = new[] { stem }.Concat(ResolutionSuffixes.Select(suffix => stripped + suffix)).ToArray(); + var resolved = candidates.FirstOrDefault(candidate => context.Sources.ContainsKey(candidate)); + if (resolved is null) + { + if (!value.Contains("+types/", StringComparison.Ordinal) && !AssetExtensions.Any(extension => value.EndsWith(extension, StringComparison.Ordinal))) + info.Findings.Add(new PolicyFinding("unresolved-import", file, value)); + return; + } + info.Edges.Add(resolved); + } + + private static string? LocalStem(PolicyContext context, string file, string value, IReadOnlyDictionary workspaces) + { + if (value.StartsWith('.')) + return PolicyText.Join(PolicyText.DirectoryOf(file), value); + if (value.StartsWith("~/", StringComparison.Ordinal)) + { + var segments = file.Split('/'); + return PolicyText.Join(string.Join('/', segments.Take(2)) + "/app", value[2..]); + } + foreach (var (name, directory) in workspaces) + { + if (value != name && !value.StartsWith(name + "/", StringComparison.Ordinal)) + continue; + var manifest = PolicyText.ParseObject(context.Text(PolicyText.ManifestPath(directory))); + var exports = manifest?["exports"]; + var key = value == name ? "." : "." + value[name.Length..]; + string? mapped = exports is JsonValue single && single.TryGetValue(out string? text) + ? text + : (exports as JsonObject)?[key]?.ToJsonString().Trim('"'); + if (mapped is not null) + return PolicyText.Join(directory, mapped); + } + return null; + } +} diff --git a/tests/ArcForges.Web.Policy.Tests/Architecture/ArchitecturePolicy.Whitespace.cs b/tests/ArcForges.Web.Policy.Tests/Architecture/ArchitecturePolicy.Whitespace.cs new file mode 100644 index 0000000..6ab1167 --- /dev/null +++ b/tests/ArcForges.Web.Policy.Tests/Architecture/ArchitecturePolicy.Whitespace.cs @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: AGPL-3.0-only +namespace ArcForges.Web.Policy.Tests.Architecture; + +/// +/// The text-format rules of the audited inputs (WEB.40 U5: the formatting gate that replaces Prettier and Biome). The C# +/// formatter is gated separately by dotnet format in CI; this partial enforces what the formatter does not own across the +/// audited C#, MSBuild, npm, lock and thin TypeScript Worker sources: a final newline and no trailing whitespace. A line +/// ending may be LF or CRLF, because a Windows working copy carries CRLF until Git normalises it (.gitattributes eol=lf). +/// +public static partial class ArchitecturePolicy +{ + private static void AuditWhitespace(PolicyContext context) + { + foreach (var (file, text) in context.Sources) + { + if (text.Length == 0) + { + context.Report("final-newline", file, "An audited text file must not be empty."); + continue; + } + + if (!text.EndsWith('\n') || text.EndsWith("\n\n", StringComparison.Ordinal)) + context.Report("final-newline", file, "An audited text file ends with exactly one line feed."); + + var lines = text.Split('\n'); + for (var index = 0; index < lines.Length; index++) + { + var line = lines[index].TrimEnd('\r'); + if (line.Length > 0 && char.IsWhiteSpace(line[^1])) + context.Report("whitespace", file, "Trailing whitespace on line " + (index + 1).ToString(System.Globalization.CultureInfo.InvariantCulture) + "."); + } + } + } +} diff --git a/tests/ArcForges.Web.Policy.Tests/Architecture/ArchitecturePolicy.cs b/tests/ArcForges.Web.Policy.Tests/Architecture/ArchitecturePolicy.cs new file mode 100644 index 0000000..46baf40 --- /dev/null +++ b/tests/ArcForges.Web.Policy.Tests/Architecture/ArchitecturePolicy.cs @@ -0,0 +1,336 @@ +// SPDX-License-Identifier: AGPL-3.0-only +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; + +namespace ArcForges.Web.Policy.Tests.Architecture; + +/// +/// The Web architecture rules of the GOV.11 successor: repository shape, exact pins, npm manifests and scripts, MSBuild +/// targets, C# and TypeScript import and wire rules, desktop scope and the release route graph. Each rule family +/// reports through ; the fixtures in the test project keep one passing and one failing +/// example for every rule. +/// +public static partial class ArchitecturePolicy +{ + internal static readonly Regex ExactVersion = new(@"^\d+\.\d+\.\d+(?:-[\w.-]+)?$", RegexOptions.CultureInvariant); + private static readonly Regex ExactSdk = new(@"^\d+\.\d+\.\d+$", RegexOptions.CultureInvariant); + private static readonly Regex ExactDownload = new(@"^\[\d+\.\d+\.\d+(?:-[\w.-]+)?\]$", RegexOptions.CultureInvariant); + private static readonly Regex NpmManagerPin = new(@"^npm@\d+\.\d+\.\d+$", RegexOptions.CultureInvariant); + private static readonly Regex NpmImplicitInstall = new( + @"\b(?:npm\s+(?:install|i|ci)|npx\b|(?:vite|react-router|wrangler)\s+dev|--hmr)\b", RegexOptions.CultureInvariant); + private static readonly Regex NpmProductionReach = new( + @"\b(?:npm\s+(?:install|i|ci|exec)|npx\b|(?:vite|react-router|wrangler)\s+(?:dev|serve)|--hmr)\b", RegexOptions.CultureInvariant); + private static readonly Regex NpmRunCall = new(@"\bnpm\s+run\s+([\w:-]+)", RegexOptions.CultureInvariant); + private static readonly Regex NpmWorkspaceFlag = new(@"(?:--workspace(?:=|\s+)|-w\s+)([\w@/.-]+)", RegexOptions.CultureInvariant); + private static readonly Regex ManifestSpecialWorkspace = new(@"[*?\\]|^\.", RegexOptions.CultureInvariant); + private static readonly Regex PrivateNpmOwner = new(@"^@arcforges/(?!proto$|api-client$|web-)", RegexOptions.CultureInvariant); + private static readonly Regex NpmPrivateSpecifier = new( + @"^@arcforges/(?!proto$|api-client$|web-)|(?:^|/)(?:private|server|local-rpc|internal)(?:/|$)|^(?:https?:|file:|[A-Za-z]:[\\/])", + RegexOptions.CultureInvariant); + private static readonly string[] DependencySections = + ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]; + private static readonly string[] DevelopmentScripts = ["dev", "preview", "test:e2e"]; + private static readonly string[] NpmLockNames = + ["package-lock.json", "npm-shrinkwrap.json", "yarn.lock", "pnpm-lock.yaml", "bun.lock", "bun.lockb"]; + private static readonly string[] SolutionFiles = ["win.slnx"]; + private static readonly string[] PackageLockOnly = ["package-lock.json"]; + + /// Audits the repository text inputs and returns every refusal, in a stable order. + public static IReadOnlyList Audit(IReadOnlyDictionary sources) + { + var context = new PolicyContext(sources); + AuditWorkspace(context); + AuditPins(context); + AuditNpmManifests(context); + AuditMsBuildProjects(context); + AuditReleaseGraph(context); + AuditCSharpSources(context); + AuditTypeScriptSources(context); + AuditWhitespace(context); + return context.Findings + .OrderBy(finding => finding.File, StringComparer.Ordinal) + .ThenBy(finding => finding.Rule, StringComparer.Ordinal) + .ThenBy(finding => finding.Detail, StringComparer.Ordinal) + .ToArray(); + } + + private static void AuditWorkspace(PolicyContext context) + { + var solutions = context.Find(name => name.EndsWith(".sln", StringComparison.Ordinal) + || name.EndsWith(".slnx", StringComparison.Ordinal)); + if (!solutions.SequenceEqual(SolutionFiles, StringComparer.Ordinal)) + context.Report("workspace", "win.slnx", "Exactly one solution, win.slnx, is allowed."); + + foreach (var required in new[] + { + "global.json", "Directory.Build.props", "Directory.Build.targets", "Directory.Packages.props", + "NuGet.config", ".node-version", + }) + RequireRootFile(context, required); + + foreach (var file in context.Find(name => string.Equals(PolicyText.BaseName(name), "packages.config", StringComparison.OrdinalIgnoreCase))) + context.Report("workspace", file, "Legacy packages.config is not allowed; use central package management."); + + var nugetConfig = context.Text("NuGet.config") ?? string.Empty; + var nugetSources = Regex.Matches(nugetConfig, "]*>", RegexOptions.CultureInvariant) + .Select(match => PolicyText.Attribute(match.Value, "value")) + .ToArray(); + if (!Regex.IsMatch(nugetConfig, "", RegexOptions.CultureInvariant) + || nugetSources.Length != 1 + || nugetSources[0] != "https://api.nuget.org/v3/index.json") + context.Report("workspace", "NuGet.config", "Only the nuget.org source, after a clear, is admitted."); + + var global = PolicyText.ParseObject(context.Text("global.json")); + var sdk = global?["sdk"] as JsonObject; + if (sdk is null + || !ExactSdk.IsMatch(JsonString(sdk, "version")) + || JsonString(sdk, "rollForward") != "disable" + || JsonString(sdk, "allowPrerelease") != "false") + context.Report("pins", "global.json", "An exact .NET SDK pin with rollForward disabled is required."); + + foreach (var project in context.Find(name => name.EndsWith(".csproj", StringComparison.Ordinal))) + if (!context.Sources.ContainsKey(SiblingPath(project, "packages.lock.json"))) + context.Report("workspace", project, "Every C# project needs its sibling packages.lock.json."); + + foreach (var locked in context.Find(name => PolicyText.BaseName(name) == "packages.lock.json")) + if (!HasSiblingProject(context, locked)) + context.Report("workspace", locked, "A packages.lock.json without a sibling C# project is an orphan."); + + var npmLocks = context.Find(name => NpmLockNames.Contains(PolicyText.BaseName(name), StringComparer.Ordinal)); + if (!npmLocks.SequenceEqual(PackageLockOnly, StringComparer.Ordinal)) + context.Report("workspace", "package-lock.json", "One root npm v3 lock, and no alternate npm lock, is allowed."); + var npmLock = PolicyText.ParseObject(context.Text("package-lock.json")); + if (npmLock is null || JsonString(npmLock, "lockfileVersion") != "3") + context.Report("workspace", "package-lock.json", "The npm lock must be lockfileVersion 3."); + + var root = PolicyText.ParseObject(context.Text("package.json")); + if (root is null) + { + context.Report("workspace", "package.json", "The root package.json is missing or malformed."); + return; + } + if (root["workspaces"] is not null and not JsonArray) + { + context.Report("workspace", "package.json", "The root workspaces field must be an array when present."); + return; + } + // The root is the only npm project when the field is absent: the React workspaces are retired (WEB.40 U5), and + // the root package.json holds wrangler and the TypeScript Worker build only. + var workspaceArray = root["workspaces"] as JsonArray ?? new JsonArray(); + var workspaces = workspaceArray.Select(node => node?.GetValue() ?? string.Empty).ToArray(); + var expected = new[] { "package.json" }.Concat(workspaces.Select(name => name + "/package.json")) + .Order(StringComparer.Ordinal).ToArray(); + var manifests = context.Find(name => PolicyText.BaseName(name) == "package.json"); + if (!expected.SequenceEqual(manifests.Order(StringComparer.Ordinal), StringComparer.Ordinal) + || workspaces.Any(name => ManifestSpecialWorkspace.IsMatch(name))) + context.Report("workspace", "package.json", "Workspace manifest inventory must be exact."); + + foreach (var manifest in manifests) + { + var parsed = PolicyText.ParseObject(context.Text(manifest)); + if (parsed is null) + { + context.Report("workspace", manifest, "The npm manifest is malformed."); + continue; + } + if (manifest != "package.json" && parsed["workspaces"] is not null) + context.Report("workspace", manifest, "Nested workspace."); + var entry = npmLock?["packages"]?[PolicyText.DirectoryOf(manifest)] as JsonObject; + foreach (var section in DependencySections) + if (SectionText(parsed, section) != SectionText(entry, section)) + context.Report("workspace", manifest, "Lock differs: " + section); + } + } + + private static void RequireRootFile(PolicyContext context, string name) + { + var found = context.Find(file => string.Equals(PolicyText.BaseName(file), name, StringComparison.OrdinalIgnoreCase)); + if (found.Count != 1 || found[0] != name) + context.Report("workspace", name, "Exactly one root " + name + " is required."); + } + + private static void AuditPins(PolicyContext context) + { + var node = context.Text(".node-version")?.Trim(); + if (node is null || !ExactVersion.IsMatch(node)) + context.Report("pins", ".node-version", "An exact Node pin is required."); + + var root = PolicyText.ParseObject(context.Text("package.json")); + var devDependencies = root?["devDependencies"] as JsonObject; + if (!ExactVersion.IsMatch(JsonString(devDependencies, "typescript")) + || !ExactVersion.IsMatch(JsonString(devDependencies, "wrangler"))) + context.Report("pins", "package.json", "Exact compiler and static generator pins are required."); + + var packageManager = JsonString(root, "packageManager"); + var engines = root?["engines"] as JsonObject; + if (node is null + || !ExactVersion.IsMatch(node) + || !NpmManagerPin.IsMatch(packageManager) + || !JsonString(engines, "node").Contains(node, StringComparison.Ordinal) + || !JsonString(engines, "npm").Contains(packageManager[4..], StringComparison.Ordinal)) + context.Report("pins", "package.json", "Exact Node and npm pins with matching engines are required."); + + foreach (var file in context.Find(name => name.EndsWith(".props", StringComparison.Ordinal) + || name.EndsWith(".csproj", StringComparison.Ordinal) + || name.EndsWith(".targets", StringComparison.Ordinal))) + foreach (Match download in Regex.Matches(context.Text(file) ?? string.Empty, "]*)>", RegexOptions.CultureInvariant)) + if (!ExactDownload.IsMatch(PolicyText.Attribute(download.Value, "Version") ?? string.Empty)) + context.Report("pins", file, "Every PackageDownload must name an exact bracketed version."); + + var central = context.Text("Directory.Packages.props") ?? string.Empty; + if (!Regex.IsMatch(central, "\\s*true\\s*<", RegexOptions.CultureInvariant)) + context.Report("pins", "Directory.Packages.props", "Central package management must be enabled."); + foreach (Match version in Regex.Matches(central, "]*>", RegexOptions.CultureInvariant)) + if (!ExactVersion.IsMatch(PolicyText.Attribute(version.Value, "Version") ?? string.Empty)) + context.Report("pins", "Directory.Packages.props", "Every central PackageVersion must be exact: " + PolicyText.Attribute(version.Value, "Include")); + + foreach (var file in context.Find(name => name.EndsWith(".csproj", StringComparison.Ordinal) + || name.EndsWith(".props", StringComparison.Ordinal) + || name.EndsWith(".targets", StringComparison.Ordinal))) + { + var xml = PolicyText.StripXmlComments(context.Text(file) ?? string.Empty); + if (Regex.IsMatch(xml, "]*\\bVersion\\s*=", RegexOptions.CultureInvariant) + || Regex.IsMatch(xml, "]*>[\\s\\S]*?", RegexOptions.CultureInvariant)) + context.Report("pins", file, "PackageReference must not carry a version; versions are central."); + } + } + + private static void AuditNpmManifests(PolicyContext context) + { + var manifests = context.Find(name => PolicyText.BaseName(name) == "package.json"); + var parsed = manifests.ToDictionary(name => name, name => PolicyText.ParseObject(context.Text(name)), StringComparer.Ordinal); + var workspaceByName = new Dictionary(StringComparer.Ordinal); + foreach (var manifest in manifests) + { + var name = JsonString(parsed[manifest], "name"); + if (name.Length > 0) + workspaceByName[name] = PolicyText.DirectoryOf(manifest); + } + + foreach (var manifest in manifests) + { + var document = parsed[manifest]; + if (document is null) + continue; + foreach (var section in DependencySections) + foreach (var (dependency, version) in Entries(document[section])) + { + var value = version?.ToJsonString().Trim('"') ?? string.Empty; + if (!ExactVersion.IsMatch(value)) + context.Report("pins", manifest, "Nonexact dependency " + dependency); + if (dependency.Contains("blazor", StringComparison.OrdinalIgnoreCase)) + context.Report("obsolete-target", manifest, dependency); + if (PrivateNpmOwner.IsMatch(dependency)) + context.Report("private-import", manifest, dependency); + if (JsonString(document, "license") == "Apache-2.0" + && dependency is "@arcforges/web-ui" or "react-dom") + context.Report("sdk-ui", manifest, dependency); + } + foreach (var (script, command) in Entries(document["scripts"])) + if (!DevelopmentScripts.Contains(script, StringComparer.Ordinal) + && NpmImplicitInstall.IsMatch(command?.ToJsonString().Trim('"') ?? string.Empty)) + context.Report("production-command", manifest, script + ": implicit install or development server"); + if (manifest != "package.json" && document["workspaces"] is not null) + context.Report("workspace", manifest, "Nested workspace."); + } + + foreach (var manifest in manifests) + { + var document = parsed[manifest]; + if (document is null) + continue; + foreach (var (script, _) in Entries(document["scripts"])) + if (!DevelopmentScripts.Contains(script, StringComparer.Ordinal)) + CheckNpmScript(context, parsed, workspaceByName, manifest, script, []); + } + + foreach (var manifest in manifests) + { + var document = parsed[manifest]; + if (document is null || JsonString(document, "license") != "Apache-2.0") + continue; + var seen = new HashSet(StringComparer.Ordinal); + var pending = new Stack(); + pending.Push(manifest); + while (pending.Count > 0) + { + var current = pending.Pop(); + if (!seen.Add(current)) + continue; + var currentDocument = parsed.TryGetValue(current, out var found) ? found : PolicyText.ParseObject(context.Text(current)); + if (JsonString(currentDocument, "license") != "Apache-2.0") + context.Report("sdk-ui", manifest, "Apache workspace reaches " + current); + foreach (var section in DependencySections) + foreach (var (dependency, _) in Entries(currentDocument?[section])) + if (workspaceByName.TryGetValue(dependency, out var directory)) + pending.Push(PolicyText.ManifestPath(directory)); + } + } + } + + private static void CheckNpmScript( + PolicyContext context, + IReadOnlyDictionary manifests, + IReadOnlyDictionary workspaceByName, + string file, + string name, + HashSet ancestors) + { + var key = file + ":" + name; + if (ancestors.Contains(key)) + { + context.Report("production-command", file, "Script cycle: " + key); + return; + } + var scripts = manifests.TryGetValue(file, out var manifest) ? manifest?["scripts"] as JsonObject : null; + if (scripts?[name]?.GetValue() is not { } script) + { + context.Report("production-command", file, "Unknown script: " + name); + return; + } + if (NpmProductionReach.IsMatch(script)) + context.Report("production-command", file, "Production script reaches dev/install: " + name); + var next = new HashSet(ancestors, StringComparer.Ordinal) { key }; + foreach (var command in script.Split(["&&", "||", ";"], StringSplitOptions.None)) + { + var call = NpmRunCall.Match(command); + if (!call.Success) + continue; + var workspace = NpmWorkspaceFlag.Match(command); + string? directory = null; + if (workspace.Success && !workspaceByName.TryGetValue(workspace.Groups[1].Value, out directory)) + { + context.Report("production-command", file, "Unknown script workspace: " + workspace.Groups[1].Value); + continue; + } + var target = workspace.Success ? PolicyText.ManifestPath(directory!) : file; + CheckNpmScript(context, manifests, workspaceByName, target, call.Groups[1].Value, next); + } + } + + /// Returns the JSON text of one dependency section, or the empty object text when it is absent. + private static string SectionText(JsonObject? document, string section) => + document?[section] is { } node ? node.ToJsonString() : "{}"; + + /// Returns a string member, or the literal text of a number or boolean member, or the empty string. + private static string JsonString(JsonNode? document, string name) + { + var node = document?[name]; + if (node is not JsonValue value) + return string.Empty; + return value.TryGetValue(out string? text) ? text ?? string.Empty : value.ToJsonString(); + } + + private static IEnumerable> Entries(JsonNode? node) => + node is JsonObject entries ? entries : Array.Empty>(); + + private static string SiblingPath(string file, string name) + { + var directory = PolicyText.DirectoryOf(file); + return directory.Length == 0 ? name : directory + "/" + name; + } + + private static bool HasSiblingProject(PolicyContext context, string lockPath) => + context.Find(name => name.EndsWith(".csproj", StringComparison.Ordinal) + && PolicyText.DirectoryOf(name) == PolicyText.DirectoryOf(lockPath)).Count > 0; +} diff --git a/tests/ArcForges.Web.Policy.Tests/Architecture/PolicyCore.cs b/tests/ArcForges.Web.Policy.Tests/Architecture/PolicyCore.cs new file mode 100644 index 0000000..b5ac3d0 --- /dev/null +++ b/tests/ArcForges.Web.Policy.Tests/Architecture/PolicyCore.cs @@ -0,0 +1,253 @@ +// SPDX-License-Identifier: AGPL-3.0-only +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using System.Xml.Linq; + +namespace ArcForges.Web.Policy.Tests.Architecture; + +/// One refusal from the Web policy suite (the C# successor of the GOV.11 Node/TypeScript mechanism). +/// The rule family, for example wire-source or production-command. +/// The repository-relative POSIX path of the offending input. +/// A diagnostic for the reviewer. It never contains a secret. +public sealed record PolicyFinding(string Rule, string File, string Detail); + +/// +/// The text inputs of the policy suite, keyed by repository-relative POSIX path in ordinal order. The repository +/// inventory is the tracked and nonignored file set, exactly as the GOV.11 mechanism read it. +/// +public static class PolicySources +{ + private static readonly string[] AuditedExtensions = + [ + ".cs", ".razor", ".csproj", ".fsproj", ".vbproj", ".vcxproj", ".esproj", ".props", ".targets", ".slnx", ".sln", + ".ts", ".tsx", ".js", ".mjs", ".cjs", ".cts", ".mts", ".gradle", ".kts", ".lockfile", + ]; + + private static readonly string[] AuditedNames = + [ + "package.json", "package-lock.json", "packages.lock.json", "global.json", "nuget.config", "packages.config", + ".node-version", ".npmrc", "licence-boundary.json", "yarn.lock", "pnpm-lock.yaml", "bun.lock", "bun.lockb", + "npm-shrinkwrap.json", "cmakelists.txt", "wrangler.json", + ]; + + /// Returns true when the policy suite reads the file. + public static bool IsAudited(string name) + { + var lower = PolicyText.BaseName(name).ToLowerInvariant(); + return AuditedNames.Contains(lower, StringComparer.Ordinal) + || AuditedExtensions.Any(extension => lower.EndsWith(extension, StringComparison.Ordinal)); + } + + /// Reads every audited tracked or untracked-but-not-ignored file of the repository. + public static IReadOnlyDictionary FromRepository(string root) + { + var listed = Git(root, "ls-files", "-z", "--cached", "--others", "--exclude-standard"); + var files = new SortedDictionary(StringComparer.Ordinal); + foreach (var name in listed.Split('\0', StringSplitOptions.RemoveEmptyEntries).Distinct(StringComparer.Ordinal)) + { + if (!IsAudited(name)) + continue; + var path = Path.Combine(root, name.Replace('/', Path.DirectorySeparatorChar)); + files[name] = File.ReadAllText(path, Encoding.UTF8); + } + return files; + } + + /// Runs git with fixed arguments and returns its standard output. + public static string Git(string root, params string[] arguments) + { + var start = new ProcessStartInfo("git") + { + WorkingDirectory = root, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8, + }; + foreach (var argument in arguments) + start.ArgumentList.Add(argument); + using var process = Process.Start(start) ?? throw new InvalidOperationException("git could not be started."); + var errorTask = process.StandardError.ReadToEndAsync(); + var output = process.StandardOutput.ReadToEnd(); + process.WaitForExit(); + var error = errorTask.GetAwaiter().GetResult(); + if (process.ExitCode != 0) + throw new InvalidOperationException("git " + string.Join(' ', arguments) + " failed: " + error); + return output; + } +} + +/// The mutable finding sink shared by the rule families of one audit. +internal sealed class PolicyContext +{ + private readonly List _findings = []; + + public PolicyContext(IReadOnlyDictionary sources) + { + Sources = sources; + } + + public IReadOnlyDictionary Sources { get; } + + public IReadOnlyList Findings => _findings; + + public void Report(string rule, string file, string detail) => + _findings.Add(new PolicyFinding(rule, file, detail)); + + public IReadOnlyList Find(Func predicate) => + Sources.Keys.Where(predicate).Order(StringComparer.Ordinal).ToArray(); + + public string? Text(string file) => Sources.TryGetValue(file, out var text) ? text : null; +} + +/// Path, comment and parse helpers shared by the rule families. +internal static class PolicyText +{ + private static readonly Regex XmlComment = new("", RegexOptions.CultureInvariant); + + public static string BaseName(string file) => file[(file.LastIndexOf('/') + 1)..]; + + public static string DirectoryOf(string file) => + file.IndexOf('/', StringComparison.Ordinal) >= 0 ? file[..file.LastIndexOf('/')] : string.Empty; + + public static string ManifestPath(string directory) => + directory is "" or "." ? "package.json" : directory + "/package.json"; + + /// Resolves a relative path against a directory, keeping leading parent segments. + public static string Join(string directory, string relative) => + Normalize(directory.Length == 0 ? relative : directory + "/" + relative); + + public static string Normalize(string path) + { + var parts = new List(); + foreach (var part in path.Replace('\\', '/').Split('/')) + { + if (part is "" or ".") + continue; + if (part == ".." && parts.Count > 0 && parts[^1] != "..") + parts.RemoveAt(parts.Count - 1); + else + parts.Add(part); + } + return string.Join('/', parts); + } + + /// Removes XML comments, replacing each with a space so that comments cannot join two fragments. + public static string StripXmlComments(string text) => XmlComment.Replace(text, " "); + + /// + /// Removes C-style comments while keeping string and template contents, so that code inside a comment is never + /// read as a dependency and a quoted fake import is still visible as text. + /// + public static string StripCode(string text, bool templates) + { + var builder = new StringBuilder(text.Length); + for (var index = 0; index < text.Length; index++) + { + var current = text[index]; + var next = index + 1 < text.Length ? text[index + 1] : '\0'; + if (current == '/' && next == '/') + { + while (index < text.Length && text[index] != '\n') + index++; + if (index < text.Length) + builder.Append('\n'); + continue; + } + if (current == '/' && next == '*') + { + var end = text.IndexOf("*/", index + 2, StringComparison.Ordinal); + var stop = end < 0 ? text.Length : end + 2; + builder.Append(' '); + index = stop - 1; + continue; + } + if (current == '"' || current == '\'' || (templates && current == '`')) + { + var verbatim = current == '"' && index > 0 && text[index - 1] == '@'; + var multiline = verbatim || (templates && current == '`'); + builder.Append(current); + index++; + while (index < text.Length) + { + var character = text[index]; + if (character == '\n' && !multiline) + { + builder.Append('\n'); + break; + } + if (character == '\\' && !verbatim) + { + builder.Append(character); + if (index + 1 < text.Length) + builder.Append(text[index + 1]); + index += 2; + continue; + } + if (character == current) + { + if (verbatim && index + 1 < text.Length && text[index + 1] == current) + { + builder.Append(character).Append(character); + index += 2; + continue; + } + builder.Append(character); + break; + } + builder.Append(character); + index++; + } + continue; + } + builder.Append(current); + } + return builder.ToString(); + } + + public static JsonObject? ParseObject(string? text) + { + if (text is null) + return null; + try + { + return JsonNode.Parse(text)?.AsObject(); + } + catch (JsonException) + { + return null; + } + } + + public static IEnumerable Values(string xml, string element) => + Regex.Matches(xml, "<" + element + "(?:\\s[^>]*)?>([^<]*)", RegexOptions.CultureInvariant) + .Select(match => match.Groups[1].Value); + + public static bool IsDocument(string? text, out XDocument? document) + { + document = null; + if (text is null) + return false; + try + { + document = XDocument.Parse(text); + return true; + } + catch (System.Xml.XmlException) + { + return false; + } + } + + /// Returns the value of an XML attribute from an element's opening tag text, or null. + public static string? Attribute(string tag, string name) + { + var match = Regex.Match(tag, "\\s" + name + "=\"([^\"]*)\"", RegexOptions.CultureInvariant); + return match.Success ? match.Groups[1].Value : null; + } +} diff --git a/tests/ArcForges.Web.Policy.Tests/ArchitecturePolicyTests.cs b/tests/ArcForges.Web.Policy.Tests/ArchitecturePolicyTests.cs new file mode 100644 index 0000000..67f065c --- /dev/null +++ b/tests/ArcForges.Web.Policy.Tests/ArchitecturePolicyTests.cs @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: AGPL-3.0-only +using ArcForges.Web.Policy.Tests.Architecture; +using ArcForges.Web.Policy.Tests.Fixtures; +using ArcForges.Web.Policy.Tests.Licence; +using Xunit; + +namespace ArcForges.Web.Policy.Tests; + +/// +/// The GOV.11 successor refusal fixtures: the passing repository is admitted, every rule refuses its negative example +/// and the accepted forms are not refused. The fixture identifiers name the rule family and the sub-rule. +/// +public sealed class ArchitecturePolicyTests +{ + public static TheoryData RefusalIdentifiers() + { + var data = new TheoryData(); + foreach (var identifier in PolicyCases.Refusals.Keys.Order(StringComparer.Ordinal)) + data.Add(identifier); + return data; + } + + public static TheoryData AcceptedIdentifiers() + { + var data = new TheoryData(); + foreach (var identifier in PolicyCases.Accepted.Keys.Order(StringComparer.Ordinal)) + data.Add(identifier); + return data; + } + + [Fact] + public void PassingRepositoryIsAdmittedByEveryArchitectureAndLicenceRule() + { + var sources = PolicyBaseline.Create(); + Assert.Empty(ArchitecturePolicy.Audit(sources)); + Assert.Empty(LicencePolicy.Audit(sources)); + } + + [Theory] + [MemberData(nameof(RefusalIdentifiers))] + public void EveryRuleRefusesItsNegativeExample(string identifier) + { + var refusal = PolicyCases.Refusals[identifier]; + var sources = PolicyBaseline.Create(); + refusal.Mutate(sources); + var findings = ArchitecturePolicy.Audit(sources).Concat(LicencePolicy.Audit(sources)).ToArray(); + Assert.Contains(findings, finding => finding.Rule == refusal.Rule); + } + + [Theory] + [MemberData(nameof(AcceptedIdentifiers))] + public void AcceptedFormsAreNotRefused(string identifier) + { + var sources = PolicyBaseline.Create(); + PolicyCases.Accepted[identifier](sources); + Assert.Empty(ArchitecturePolicy.Audit(sources)); + Assert.Empty(LicencePolicy.Audit(sources)); + } + + [Fact] + public void EveryGovernedRuleFamilyHasANegativeExample() + { + var families = new[] + { + "workspace", "pins", "obsolete-target", "portable-reference", "production-command", "sdk-ui", "wire-source", + "private-import", "desktop-dom", "computed-import", "unresolved-import", "release-fixture", "inventory", + "boundary", "spdx", "override", "reference", "owner", "source-reference", "gradle", "whitespace", + "final-newline", + }; + foreach (var family in families) + Assert.Contains(PolicyCases.Refusals.Values, refusal => refusal.Rule == family); + } +} diff --git a/tests/ArcForges.Web.Policy.Tests/Fixtures/PolicyBaseline.cs b/tests/ArcForges.Web.Policy.Tests/Fixtures/PolicyBaseline.cs new file mode 100644 index 0000000..48691d3 --- /dev/null +++ b/tests/ArcForges.Web.Policy.Tests/Fixtures/PolicyBaseline.cs @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: AGPL-3.0-only +using System.Text.Json.Nodes; + +namespace ArcForges.Web.Policy.Tests.Fixtures; + +/// +/// The passing in-memory Web repository of the policy fixtures. It has the same shape as the real repository (one +/// solution, central packages, one locked C# project, one npm workspace, one TypeScript production root) and no +/// finding of any rule. Each refusal fixture changes one copy of it. +/// +public static class PolicyBaseline +{ + private const string Solution = """ + + + + + + """; + + private const string NuGetConfig = """ + + + + + + + + """; + + private const string BuildProps = """ + + + AGPL + AGPL-3.0-only + true + + + """; + + private const string CentralPackages = """ + + + true + + + + + + """; + + private const string UiProject = """ + + + + + + AGPL + AGPL-3.0-only + + + """; + + private const string UiLock = """ + {"version":2,"dependencies":{"net10.0":{"ArcForges.Contracts.PublicApi":{"type":"Direct","requested":"[1.0.0-ci.287.1, )","resolved":"1.0.0-ci.287.1"}}}} + """; + + private const string RootManifest = """ + {"name":"web","workspaces":["apps/site"],"packageManager":"npm@11.19.0","engines":{"node":">=24.21.0 <25","npm":">=11.19.0 <12"},"devDependencies":{"typescript":"7.0.2","wrangler":"4.143.1"},"license":"AGPL-3.0-only","arcforges":{"licenceBoundary":"AGPL"}} + """; + + private const string SiteManifest = """ + {"name":"site","license":"AGPL-3.0-only","arcforges":{"licenceBoundary":"AGPL"}} + """; + + private const string RootLock = """ + {"lockfileVersion":3,"packages":{"":{"devDependencies":{"typescript":"7.0.2","wrangler":"4.143.1"}},"apps/site":{}}} + """; + + private const string Inventory = """ + {"schemaVersion":1,"repository":"Web","spdxLicense":"AGPL-3.0-only","licenceBoundary":"AGPL","projects":[{"path":"apps/site/package.json","kind":"npm"},{"path":"package.json","kind":"npm"},{"path":"src/ArcForges.Web.Ui/ArcForges.Web.Ui.csproj","kind":"msbuild"}]} + """; + + /// Returns a new mutable copy of the passing repository. + public static Dictionary Create() + { + var sources = new Dictionary(StringComparer.Ordinal) + { + ["win.slnx"] = Solution, + ["global.json"] = """{"sdk":{"version":"10.0.401","rollForward":"disable","allowPrerelease":false},"test":{"runner":"Microsoft.Testing.Platform"}}""", + ["NuGet.config"] = NuGetConfig, + ["Directory.Build.props"] = BuildProps, + ["Directory.Build.targets"] = "", + ["Directory.Packages.props"] = CentralPackages, + [".node-version"] = "24.21.0\n", + ["package.json"] = RootManifest, + ["package-lock.json"] = RootLock, + ["apps/site/package.json"] = SiteManifest, + ["apps/site/app/root.ts"] = "import type { Message } from \"@arcforges/proto\";\n\ndocument.title = \"Web DOM is allowed\";\n", + ["eng/policy/licence-boundary.json"] = Inventory, + ["src/ArcForges.Web.Ui/ArcForges.Web.Ui.csproj"] = UiProject, + ["src/ArcForges.Web.Ui/packages.lock.json"] = UiLock, + ["src/ArcForges.Web.Ui/Greeting.cs"] = "namespace ArcForges.Web.Ui;\n\npublic sealed class Greeting\n{\n}\n", + ["src/ArcForges.Web.Ui/Greeting.razor"] = "

Hello

\n", + }; + // A file on disk ends with a line feed; C# raw literals omit the final line break of their content. + foreach (var file in sources.Keys.ToArray()) + if (!sources[file].EndsWith('\n')) + sources[file] += "\n"; + return sources; + } + + /// Edits one JSON file of a copy in place. + public static void EditJson(Dictionary sources, string file, Action edit) + { + var document = JsonNode.Parse(sources[file])!.AsObject(); + edit(document); + sources[file] = document.ToJsonString() + "\n"; + } + + /// Edits one XML-like file of a copy by replacing a fragment, failing when the fragment is absent. + public static void Replace(Dictionary sources, string file, string from, string to) + { + var text = sources[file]; + if (!text.Contains(from, StringComparison.Ordinal)) + throw new InvalidOperationException("The fixture fragment is absent from " + file + ": " + from); + sources[file] = text.Replace(from, to, StringComparison.Ordinal); + } +} diff --git a/tests/ArcForges.Web.Policy.Tests/Fixtures/PolicyCases.cs b/tests/ArcForges.Web.Policy.Tests/Fixtures/PolicyCases.cs new file mode 100644 index 0000000..1b66ac1 --- /dev/null +++ b/tests/ArcForges.Web.Policy.Tests/Fixtures/PolicyCases.cs @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: AGPL-3.0-only +namespace ArcForges.Web.Policy.Tests.Fixtures; + +/// One negative fixture: the rule that must refuse the mutated repository, and the mutation. +public sealed record PolicyCase(string Rule, Action> Mutate); + +/// +/// The negative examples of the GOV.11 successor, one for every rule family and sub-rule the Node/TypeScript mechanism +/// enforced, plus the accepted forms that the successor must not refuse (the passing examples). +/// +public static class PolicyCases +{ + private const string Ui = "src/ArcForges.Web.Ui/ArcForges.Web.Ui.csproj"; + private const string Root = "package.json"; + private const string Site = "apps/site/package.json"; + private const string RootTs = "apps/site/app/root.ts"; + + /// Every refusal case, keyed by its stable identifier. + public static IReadOnlyDictionary Refusals { get; } = new Dictionary(StringComparer.Ordinal) + { + // whitespace and final newline: the text-format rules that replace the removed Prettier and Biome gates + ["whitespace-trailing-space"] = new("whitespace", s => s[Ui] = s[Ui].Replace("\n", " \n", StringComparison.Ordinal)), + ["final-newline-missing"] = new("final-newline", s => s[Ui] = s[Ui].TrimEnd('\n')), + ["final-newline-double"] = new("final-newline", s => s[Ui] += "\n"), + // workspace: one solution, one lock per project, one npm lock and manifest inventory + ["workspace-second-solution"] = new("workspace", s => s["ArcForges.sln"] = "Microsoft Visual Studio Solution File"), + ["workspace-unlocked-project"] = new("workspace", s => s["src/ArcForges.Web.Site/ArcForges.Web.Site.csproj"] = ""), + ["workspace-orphan-lock"] = new("workspace", s => s["tests/Orphan/packages.lock.json"] = "{}"), + ["workspace-packages-config"] = new("workspace", s => s["src/ArcForges.Web.Ui/packages.config"] = ""), + ["workspace-yarn-lock"] = new("workspace", s => s["yarn.lock"] = "alternate"), + ["workspace-nested-manifest"] = new("workspace", s => s["hidden/package.json"] = "{\"name\":\"hidden\"}"), + ["workspace-nested-workspaces"] = new("workspace", s => s["apps/site/package.json"] = "{\"name\":\"site\",\"workspaces\":[\"x\"],\"license\":\"AGPL-3.0-only\",\"arcforges\":{\"licenceBoundary\":\"AGPL\"}}"), + ["workspace-root-workspaces-not-array"] = new("workspace", s => PolicyBaseline.EditJson(s, Root, o => o["workspaces"] = System.Text.Json.Nodes.JsonValue.Create("apps/site"))), + ["workspace-lock-differs"] = new("workspace", s => PolicyBaseline.EditJson(s, Site, o => o["devDependencies"] = new System.Text.Json.Nodes.JsonObject { ["left-pad"] = "1.3.0" })), + ["workspace-restore-without-lock"] = new("workspace", s => PolicyBaseline.Replace(s, "Directory.Build.props", "true", "false")), + ["workspace-alternate-nuget-source"] = new("workspace", s => PolicyBaseline.Replace(s, "NuGet.config", "", "")), + ["workspace-alternate-restore-source"] = new("workspace", s => PolicyBaseline.Replace(s, Ui, "", "https://example.invalid/index.json
")), + + // pins: exact SDK, Node and npm, central versions, no inline versions, exact downloads + ["pins-sdk-floating"] = new("pins", s => PolicyBaseline.Replace(s, "global.json", "\"version\":\"10.0.401\"", "\"version\":\"10.0.*\"")), + ["pins-sdk-roll-forward"] = new("pins", s => PolicyBaseline.Replace(s, "global.json", "\"rollForward\":\"disable\"", "\"rollForward\":\"latestMinor\"")), + ["pins-inline-version"] = new("pins", s => PolicyBaseline.Replace(s, Ui, "", "")), + ["pins-floating-central-version"] = new("pins", s => PolicyBaseline.Replace(s, "Directory.Packages.props", "Version=\"1.0.0-ci.287.1\"", "Version=\"1.0.*\"")), + ["pins-central-management-off"] = new("pins", s => PolicyBaseline.Replace(s, "Directory.Packages.props", "true", "false")), + ["pins-download-unbracketed"] = new("pins", s => PolicyBaseline.Replace(s, Ui, "
", "
")), + ["pins-npm-caret"] = new("pins", s => PolicyBaseline.EditJson(s, Root, o => ((System.Text.Json.Nodes.JsonObject)o["devDependencies"]!)["typescript"] = "^7.0.2")), + ["pins-package-manager-floating"] = new("pins", s => PolicyBaseline.EditJson(s, Root, o => o["packageManager"] = "npm@latest")), + ["pins-node-version-floating"] = new("pins", s => s[".node-version"] = "24\n"), + + // obsolete-target: Blazor WebAssembly standalone only + ["obsolete-blazor-server-package"] = new("obsolete-target", s => PolicyBaseline.Replace(s, Ui, "", "")), + ["obsolete-interactive-server-markup"] = new("obsolete-target", s => s["src/ArcForges.Web.Ui/Greeting.razor"] = "@rendermode InteractiveServer\n

Hello

\n"), + ["obsolete-circuit-code"] = new("obsolete-target", s => s["src/ArcForges.Web.Ui/Host.cs"] = "namespace ArcForges.Web.Ui;\npublic static class Host { public static void Start(IServiceCollection services) => services.AddInteractiveServerComponents(); }\n"), + ["obsolete-npm-blazor-dependency"] = new("obsolete-target", s => PolicyBaseline.EditJson(s, Site, o => o["dependencies"] = new System.Text.Json.Nodes.JsonObject { ["blazor-server-kit"] = "1.0.0" })), + + // portable-reference and production-command + ["portable-esproj-reference"] = new("portable-reference", s => PolicyBaseline.Replace(s, Ui, "
", "")), + ["production-esproj-install"] = new("production-command", s => s["ArcForges.Web.esproj"] = "true"), + ["production-esproj-dev-build"] = new("production-command", s => s["ArcForges.Web.esproj"] = "npm run dev"), + ["production-exec-npm-install"] = new("production-command", s => PolicyBaseline.Replace(s, "Directory.Build.targets", "", "")), + ["production-exec-dotnet-restore"] = new("production-command", s => PolicyBaseline.Replace(s, "Directory.Build.targets", "", "")), + ["production-spa-proxy"] = new("production-command", s => PolicyBaseline.Replace(s, Ui, "", "npm run dev")), + ["production-script-vite-dev"] = new("production-command", s => PolicyBaseline.EditJson(s, Root, o => o["scripts"] = new System.Text.Json.Nodes.JsonObject { ["build"] = "vite dev" })), + ["production-script-install"] = new("production-command", s => PolicyBaseline.EditJson(s, Root, o => o["scripts"] = new System.Text.Json.Nodes.JsonObject { ["build"] = "npm install && vite build" })), + ["production-script-cycle"] = new("production-command", s => PolicyBaseline.EditJson(s, Root, o => o["scripts"] = new System.Text.Json.Nodes.JsonObject { ["build"] = "npm run other", ["other"] = "npm run build" })), + ["production-script-unknown"] = new("production-command", s => PolicyBaseline.EditJson(s, Root, o => o["scripts"] = new System.Text.Json.Nodes.JsonObject { ["build"] = "npm run missing" })), + + // sdk-ui: an Apache project never reaches an AGPL project + ["sdk-apache-csharp-reaches-agpl"] = new("sdk-ui", s => s["packages/sdk/Sdk.csproj"] = "ApacheApache-2.0"), + ["sdk-apache-npm-reaches-agpl"] = new("sdk-ui", s => s["packages/sdk/package.json"] = "{\"name\":\"@arcforges/web-sdk\",\"license\":\"Apache-2.0\",\"dependencies\":{\"site\":\"1.0.0\"}}"), + ["sdk-ts-imports-web-ui"] = new("sdk-ui", s => s["packages/sdk/src/index.ts"] = "export * from \"@arcforges/web-ui\";\n"), + + // wire-source: generated wire types only, the admitted codec and transport + ["wire-csharp-dto-declaration"] = new("wire-source", s => s["src/ArcForges.Web.Ui/LoginRequest.cs"] = "namespace ArcForges.Web.Ui;\npublic sealed record LoginRequest(string Name);\n"), + ["wire-csharp-json-codec"] = new("wire-source", s => s["src/ArcForges.Web.Ui/Codec.cs"] = "namespace ArcForges.Web.Ui;\npublic static class Codec { public static string Text() => System.Text.Json.JsonSerializer.Serialize(1); }\n"), + ["wire-csharp-direct-http"] = new("wire-source", s => s["src/ArcForges.Web.Ui/Fetcher.cs"] = "namespace ArcForges.Web.Ui;\npublic static class Fetcher { public static HttpClient Make() => new HttpClient(); }\n"), + ["wire-ts-json-codec"] = new("wire-source", s => s["apps/site/app/codec.ts"] = "export const text = JSON.stringify({ a: 1 });\n"), + ["wire-ts-direct-fetch"] = new("wire-source", s => s["apps/site/app/net.ts"] = "export const reply = fetch(\"/api\");\n"), + ["wire-ts-declared-request"] = new("wire-source", s => s["apps/site/app/model.ts"] = "export interface LoginRequest { token: string }\n"), + ["wire-ts-protobuf-codegen"] = new("wire-source", s => s["apps/site/app/codecs.ts"] = "import { messageDesc } from \"@bufbuild/protobuf/codegen\";\nexport const d = messageDesc;\n"), + + // private-import: no private, server or local-RPC namespace or package + // The import text is split so that the raw fixture file never contains the contiguous private import that the + // dependency gate scans for; the fixture value at run time is the same refused import. + ["private-csharp-namespace"] = new("private-import", s => s["src/ArcForges.Web.Ui/Bridge.cs"] = "using ArcForges" + ".Contracts.Internal;\nnamespace ArcForges.Web.Ui;\npublic static class Bridge { }\n"), + ["private-package-reference"] = new("private-import", s => PolicyBaseline.Replace(s, Ui, "", "")), + ["private-npm-scope"] = new("private-import", s => PolicyBaseline.EditJson(s, Site, o => o["dependencies"] = new System.Text.Json.Nodes.JsonObject { ["@arcforges/operator-client"] = "1.0.0" })), + ["private-ts-package"] = new("private-import", s => s[RootTs] = "import \"@arcforges/operator-client\";\n"), + ["private-ts-local-rpc"] = new("private-import", s => s[RootTs] = "type T = import(\"@arcforges/local-rpc\").T;\n"), + ["private-ts-server-path"] = new("private-import", s => s[RootTs] = "const x = import(\"../server/private.ts\");\n"), + ["private-ts-sibling-escape"] = new("private-import", s => s[RootTs] = "import \"../../../../outside\";\n"), + + // desktop-dom: the desktop graph has no DOM or JavaScript interop + ["desktop-ts-dom"] = new("desktop-dom", s => s["packages/desktop/src/index.ts"] = "document.createElement('div');\n"), + ["desktop-csharp-js-interop"] = new("desktop-dom", s => s["desktop/Shell.cs"] = "namespace Desktop;\npublic sealed class Shell { public Microsoft.JSInterop.IJSRuntime? Js { get; set; } }\n"), + + // computed-import and unresolved-import + ["computed-ts-dynamic"] = new("computed-import", s => s[RootTs] = "const x = import(variable);\n"), + ["computed-csproj-reference"] = new("computed-import", s => PolicyBaseline.Replace(s, Ui, "", "")), + ["unresolved-ts-relative"] = new("unresolved-import", s => s[RootTs] = "import \"./missing\";\n"), + ["unresolved-csproj-reference"] = new("unresolved-import", s => PolicyBaseline.Replace(s, Ui, "", "")), + + // release-fixture: no test, fixture, double or debug route in the release route graph + ["release-project-reference-tests"] = new("release-fixture", s => + { + s["tests/ArcForges.Web.Policy.Tests/ArcForges.Web.Policy.Tests.csproj"] = ""; + PolicyBaseline.Replace(s, Ui, "", ""); + }), + ["release-csharp-test-double"] = new("release-fixture", s => s["src/ArcForges.Web.Ui/TestDoubles/Scripted.cs"] = "namespace ArcForges.Web.Ui.TestDoubles;\npublic sealed class Scripted { }\n"), + ["release-debug-route"] = new("release-fixture", s => s["src/ArcForges.Web.Ui/Greeting.razor"] = "@page \"/fixtures/greeting\"\n

Hello

\n"), + ["release-ts-test-helper"] = new("release-fixture", s => + { + s[RootTs] = "import \"../bridge\";\n"; + s["apps/site/bridge.ts"] = "import \"../../tests/helper\";\nexport const b = 1;\n"; + s["tests/helper.ts"] = "export const fake = true;\n"; + }), + ["release-ts-fixture-file"] = new("release-fixture", s => + { + s[RootTs] = "import \"./fixtures/secret\";\n"; + s["apps/site/app/fixtures/secret.ts"] = "export const fake = true;\n"; + }), + + // licence-boundary: inventory, boundary, overrides, references and first-party owners + ["licence-inventory-drift"] = new("inventory", s => s["unregistered/package.json"] = "{\"name\":\"unregistered\",\"license\":\"AGPL-3.0-only\",\"arcforges\":{\"licenceBoundary\":\"AGPL\"}}"), + ["licence-missing-boundary"] = new("boundary", s => PolicyBaseline.Replace(s, Ui, "AGPL", string.Empty)), + ["licence-apache-spdx"] = new("spdx", s => PolicyBaseline.Replace(s, Ui, "AGPL-3.0-only", "Apache-2.0")), + ["licence-props-override"] = new("override", s => PolicyBaseline.Replace(s, "Directory.Build.props", "AGPL", "Apache")), + ["licence-escaped-reference"] = new("reference", s => PolicyBaseline.Replace(s, Ui, "", "")), + ["licence-comment-split-boundary"] = new("boundary", s => PolicyBaseline.Replace(s, Ui, "AGPL", "oundary>AGPL")), + ["licence-npm-unknown-owner"] = new("owner", s => PolicyBaseline.EditJson(s, Site, o => o["dependencies"] = new System.Text.Json.Nodes.JsonObject { ["@arcforges/unknown"] = "1.0.0" })), + ["licence-npm-alias-owner"] = new("owner", s => PolicyBaseline.EditJson(s, Site, o => o["dependencies"] = new System.Text.Json.Nodes.JsonObject { ["alias"] = "npm:@arcforges/unknown@1.0.0" })), + ["licence-npm-unpublished-source"] = new("source-reference", s => PolicyBaseline.EditJson(s, Site, o => o["dependencies"] = new System.Text.Json.Nodes.JsonObject { ["local-lib"] = "file:../lib" })), + ["licence-locked-unknown-project"] = new("owner", s => PolicyBaseline.Replace(s, "src/ArcForges.Web.Ui/packages.lock.json", "\"ArcForges.Contracts.PublicApi\":{\"type\":\"Direct\"", "\"ArcForges.Contracts.Unknown\":{\"type\":\"Project\"},\"ArcForges.Contracts.PublicApi\":{\"type\":\"Direct\"")), + ["licence-locked-unknown-transitive"] = new("owner", s => PolicyBaseline.Replace(s, "src/ArcForges.Web.Ui/packages.lock.json", "\"ArcForges.Contracts.PublicApi\":{\"type\":\"Direct\"", "\"ArcForges.Contracts.Unknown\":{\"type\":\"Transitive\"},\"ArcForges.Contracts.PublicApi\":{\"type\":\"Direct\"")), + ["licence-lock-unknown-owner"] = new("owner", s => s["package-lock.json"] = "{\"lockfileVersion\":3,\"packages\":{\"node_modules/@arcforges/unknown\":{}}}"), + ["licence-gradle-declaration"] = new("gradle", s => s["build.gradle.kts"] = "extra[\"spdxLicense\"] = \"AGPL-3.0-only\"\nextra[\"licenceBoundary\"] = \"Apache\"\n"), + }; + + /// Accepted forms: each mutation keeps the repository admitted, so the audit must report nothing. + public static IReadOnlyDictionary>> Accepted { get; } = new Dictionary>>(StringComparer.Ordinal) + { + ["whitespace-crlf-working-copy"] = s => s[Ui] = s[Ui].Replace("\n", "\r\n", StringComparison.Ordinal), + ["blazor-webassembly-standalone"] = s => PolicyBaseline.Replace(s, Ui, "", ""), + ["npm-ci-with-ignored-scripts-exec"] = s => PolicyBaseline.Replace(s, "Directory.Build.targets", "", ""), + ["admitted-json-codec"] = s => s["src/ArcForges.Web.App/Probe/WireJson.cs"] = "using System.Text.Json;\nnamespace ArcForges.Web.App.Probe;\npublic static class WireJson { public static object? Read(ReadOnlySpan bytes) => JsonSerializer.Deserialize(bytes); }\n", + ["admitted-http-probe"] = s => s["src/ArcForges.Web.App/Probe/SessionProbe.cs"] = "namespace ArcForges.Web.App.Probe;\npublic sealed class SessionProbe { public System.Net.Http.HttpRequestMessage Request() => new(System.Net.Http.HttpMethod.Get, \"/\"); }\n", + ["generated-alias-type"] = s => s["apps/site/app/alias.ts"] = "import type { SayHelloRequest } from \"@arcforges/proto\";\ntype HelloRequest = SayHelloRequest;\ntype OtherRequest = import(\"@arcforges/proto\").SayHelloRequest;\nexport type Keep = HelloRequest | OtherRequest;\n", + ["comments-and-strings-are-not-code"] = s => s[RootTs] = "// import x from \"@arcforges/private\";\nconst text = \"document @arcforges/private\";\ndocument.title = text;\n", + ["comment-mentions-json-and-http"] = s => s["src/ArcForges.Web.Ui/Notes.cs"] = "namespace ArcForges.Web.Ui;\n// JsonSerializer and new HttpClient are described here, not used.\npublic static class Notes { }\n", + }; +} diff --git a/tests/ArcForges.Web.Policy.Tests/Licence/LicencePolicy.cs b/tests/ArcForges.Web.Policy.Tests/Licence/LicencePolicy.cs new file mode 100644 index 0000000..0d6bfa5 --- /dev/null +++ b/tests/ArcForges.Web.Policy.Tests/Licence/LicencePolicy.cs @@ -0,0 +1,274 @@ +// SPDX-License-Identifier: AGPL-3.0-only +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using ArcForges.Web.Policy.Tests.Architecture; + +namespace ArcForges.Web.Policy.Tests.Licence; + +/// +/// The licence-boundary rules of the GOV.11 successor, over the same inventory file (eng/policy/licence-boundary.json). +/// The inventory must match the tracked project files exactly; each project declares the AGPL boundary itself; imported +/// overrides, escaped or unregistered references, non-literal references, unknown first-party owners and unpublished +/// sources are refused. Comments are removed before any value is read, so a comment cannot join two fragments. +/// +public static class LicencePolicy +{ + private const string InventoryPath = "eng/policy/licence-boundary.json"; + private static readonly string[] MsBuildExtensions = [".csproj", ".fsproj", ".vbproj", ".vcxproj", ".esproj"]; + private static readonly string[] ImportedExtensions = [".props", ".targets"]; + private static readonly string[] SectionNames = ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]; + private static readonly string[] InventoryKeys = ["licenceBoundary", "projects", "repository", "schemaVersion", "spdxLicense"]; + + private static readonly HashSet NpmOwners = new(StringComparer.Ordinal) + { + "@arcforges/proto", "@arcforges/api-client", "@arcforges/contract-fixtures", "@arcforges/ai-internal", + "@arcforges/ai", "@arcforges/cloud-workspace", "@arcforges/web-workspace", "@arcforges/web-app", + "@arcforges/web-site", "@arcforges/web-ui", + }; + + private static readonly HashSet ArcForgesOwners = new(StringComparer.Ordinal) + { + "arcforges.contracts.publicapi", "arcforges.contracts.foundation", "arcforges.build.policy", + }; + + private static readonly HashSet GithubOwners = new(StringComparer.Ordinal) + { + "io.github.arcforges:contracts-proto", "io.github.arcforges:contracts-client", + "io.github.arcforges:contracts-connect-client", "io.github.arcforges:contract-fixtures", + }; + + private static readonly Regex ProjectInclude = new( + "]*?Include=[\"']([^\"']+)[\"']", RegexOptions.CultureInvariant); + private static readonly Regex PackageIdentity = new( + "]*?(?:Include|Update)=[\"']([^\"']+)[\"']", RegexOptions.CultureInvariant); + private static readonly Regex NonLiteral = new(@"[$@*?;]", RegexOptions.CultureInvariant); + private static readonly Regex UnpublishedSource = new(@"^(?:file:|link:|git\+|\.\.?/)", RegexOptions.CultureInvariant); + private static readonly Regex GradleSource = new(@"includeBuild\s*\(|mavenLocal\s*\(", RegexOptions.CultureInvariant); + private static readonly Regex GradleProject = new(@"\bproject\(\s*[""']:", RegexOptions.CultureInvariant); + private static readonly Regex GradleOwner = new(@"io\.github\.arcforges:[A-Za-z0-9_.-]+", RegexOptions.CultureInvariant); + private static readonly Regex GradleFile = new(@"^(?:build|settings)\.gradle(?:\.kts)?$", RegexOptions.CultureInvariant); + + /// Audits the licence boundary of every audited project and returns every refusal. + public static IReadOnlyList Audit(IReadOnlyDictionary sources) + { + var findings = new List(); + void Report(string rule, string file, string detail) => findings.Add(new PolicyFinding(rule, file, detail)); + + var policy = PolicyText.ParseObject(sources.GetValueOrDefault(InventoryPath)); + if (policy is null) + { + Report("inventory", InventoryPath, "The licence inventory is missing or malformed."); + return findings; + } + if (!policy.Select(entry => entry.Key).Order(StringComparer.Ordinal).SequenceEqual(InventoryKeys, StringComparer.Ordinal) + || Scalar(policy, "schemaVersion") != "1" + || Scalar(policy, "repository") != "Web" + || Scalar(policy, "spdxLicense") != "AGPL-3.0-only" + || Scalar(policy, "licenceBoundary") != "AGPL") + Report("inventory", InventoryPath, "The licence inventory header is not the Web AGPL boundary."); + + var actual = sources.Keys + .Select(name => (Path: name, Kind: Kind(name))) + .Where(row => row.Kind is not null) + .Select(row => (row.Path, Kind: row.Kind!)) + .OrderBy(row => row.Path, StringComparer.Ordinal) + .ToArray(); + if (actual.Length == 0) + Report("inventory", InventoryPath, "No licence-bearing project is tracked."); + var declared = (policy["projects"] as JsonArray ?? new JsonArray()) + .Select(node => node as JsonObject) + .Where(row => row is not null) + .Select(row => (Path: Scalar(row, "path"), Kind: Scalar(row, "kind"))) + .OrderBy(row => row.Path, StringComparer.Ordinal) + .ToArray(); + if (!declared.SequenceEqual(actual)) + Report("inventory", InventoryPath, "Project licence inventory drift."); + + var npmNames = new Dictionary(StringComparer.Ordinal); + var projectNames = new HashSet(StringComparer.Ordinal); + var msbuildPaths = new HashSet(StringComparer.Ordinal); + foreach (var (path, kind) in actual) + { + if (kind == "msbuild") + { + msbuildPaths.Add(path); + projectNames.Add(PolicyText.BaseName(path)[..PolicyText.BaseName(path).LastIndexOf('.')].ToLowerInvariant()); + } + if (kind == "npm") + { + var name = Scalar(PolicyText.ParseObject(sources[path]), "name"); + if (name.Length > 0 && !npmNames.TryAdd(name, path)) + Report("inventory", path, "Duplicate npm identity: " + name); + } + } + + void CheckPackage(string name, string file) + { + var lower = name.ToLowerInvariant(); + if ((lower.StartsWith("@arcforges/", StringComparison.Ordinal) && !NpmOwners.Contains(name)) + || (lower.StartsWith("arcforges.", StringComparison.Ordinal) && !ArcForgesOwners.Contains(lower)) + || (lower.StartsWith("io.github.arcforges:", StringComparison.Ordinal) && !GithubOwners.Contains(name))) + Report("owner", file, "Unknown first-party package owner: " + name); + } + + foreach (var (path, kind) in actual) + { + var text = sources[path]; + switch (kind) + { + case "npm": + AuditManifest(path, text, npmNames, CheckPackage, Report); + break; + case "msbuild": + var xml = PolicyText.StripXmlComments(text); + ExpectSingle(PolicyText.Values(xml, "PackageLicenseExpression"), "AGPL-3.0-only", () => Report("spdx", path, "Incorrect SPDX licence expression.")); + ExpectSingle(PolicyText.Values(xml, "LicenceBoundary"), "AGPL", () => Report("boundary", path, "Incorrect licence boundary.")); + foreach (var name in PolicyText.Values(xml, "AssemblyName").Concat(PolicyText.Values(xml, "PackageId"))) + if (name.Length > 0) + projectNames.Add(name.ToLowerInvariant()); + break; + case "gradle": + foreach (var (key, value) in new[] { ("spdxLicense", "AGPL-3.0-only"), ("licenceBoundary", "AGPL") }) + if (!Regex.Matches(text, "extra\\[\"" + key + "\"\\]\\s*=\\s*\"([^\"\\n]+)\"", RegexOptions.CultureInvariant) + .Select(match => match.Groups[1].Value) + .SequenceEqual(new[] { value }, StringComparer.Ordinal)) + Report("gradle", path, "Incorrect Gradle declaration: " + key); + break; + default: + Report("build-system", path, "A new build system needs a reviewed licence verifier."); + break; + } + } + + foreach (var file in sources.Keys.Order(StringComparer.Ordinal)) + { + var text = sources[file]; + var name = PolicyText.BaseName(file); + if (MsBuildExtensions.Any(extension => name.EndsWith(extension, StringComparison.Ordinal)) + || ImportedExtensions.Any(extension => name.EndsWith(extension, StringComparison.Ordinal))) + { + var xml = PolicyText.StripXmlComments(text); + if (!PolicyText.Values(xml, "PackageLicenseExpression").All(value => value == "AGPL-3.0-only") + || !PolicyText.Values(xml, "LicenceBoundary").All(value => value == "AGPL")) + Report("override", file, "Imported licence override."); + foreach (Match reference in ProjectInclude.Matches(xml)) + { + var include = reference.Groups[1].Value; + if (NonLiteral.IsMatch(include)) + { + Report("reference", file, "Nonliteral reference: " + include); + continue; + } + var target = PolicyText.Join(PolicyText.DirectoryOf(file), include.Replace('\\', '/')); + if (!msbuildPaths.Contains(target)) + Report("reference", file, "Escaped/unregistered reference: " + include); + } + foreach (Match package in PackageIdentity.Matches(xml)) + CheckPackage(package.Groups[1].Value, file); + } + else if (name == "package-lock.json") + { + foreach (var (key, entry) in Entries(PolicyText.ParseObject(text)?["packages"])) + { + var identity = Scalar(entry as JsonObject, "name"); + if (identity.Length == 0) + identity = key.Split("node_modules/", StringSplitOptions.None).Last(); + if (!npmNames.ContainsKey(identity)) + CheckPackage(identity, file); + } + } + else if (name == "packages.lock.json") + { + foreach (var (_, framework) in Entries(PolicyText.ParseObject(text)?["dependencies"])) + foreach (var (dependency, entry) in Entries(framework)) + if (string.Equals(Scalar(entry as JsonObject, "type"), "project", StringComparison.OrdinalIgnoreCase)) + { + if (!projectNames.Contains(dependency.ToLowerInvariant())) + Report("owner", file, "Unknown locked project: " + dependency); + } + else + { + CheckPackage(dependency, file); + } + } + else if (file.EndsWith(".lockfile", StringComparison.Ordinal)) + { + foreach (var line in text.Split('\n').Where(line => line.Length > 0 && !line.StartsWith('#'))) + CheckPackage(string.Join(':', line.Split(':').Take(2)), file); + } + else if (GradleFile.IsMatch(name)) + { + if (GradleSource.IsMatch(text)) + Report("gradle", file, "Unpublished Gradle source."); + if (GradleProject.IsMatch(text)) + Report("gradle", file, "New Gradle project graph requires licence review."); + foreach (Match owner in GradleOwner.Matches(text)) + CheckPackage(owner.Value, file); + } + } + return findings; + } + + private static void AuditManifest( + string path, + string text, + IReadOnlyDictionary npmNames, + Action checkPackage, + Action report) + { + var manifest = PolicyText.ParseObject(text); + if (manifest is null) + { + report("inventory", path, "The npm manifest is malformed."); + return; + } + if (Scalar(manifest, "license") != "AGPL-3.0-only") + report("spdx", path, "Incorrect SPDX licence expression."); + if (manifest["arcforges"] is not JsonObject boundary || boundary.Count != 1 || Scalar(boundary, "licenceBoundary") != "AGPL") + report("boundary", path, "Incorrect licence boundary."); + foreach (var section in SectionNames) + foreach (var (name, node) in Entries(manifest[section])) + { + var version = node?.ToJsonString().Trim('"') ?? string.Empty; + if (UnpublishedSource.IsMatch(version)) + report("source-reference", path, "Unpublished source reference: " + name); + if (!npmNames.ContainsKey(name)) + checkPackage(name, path); + if (version.StartsWith("npm:", StringComparison.Ordinal)) + { + var alias = version[4..]; + var end = alias.IndexOf('@', 1); + checkPackage(end < 0 ? alias : alias[..end], path); + } + } + } + + private static void ExpectSingle(IEnumerable values, string expected, Action report) + { + var list = values.ToArray(); + if (list.Length != 1 || list[0] != expected) + report(); + } + + private static IEnumerable> Entries(JsonNode? node) => + node is JsonObject entries ? entries : Array.Empty>(); + + private static string Scalar(JsonNode? node, string name) + { + if (node?[name] is not JsonValue value) + return string.Empty; + return value.TryGetValue(out string? text) ? text ?? string.Empty : value.ToJsonString(); + } + + private static string? Kind(string file) + { + var name = PolicyText.BaseName(file); + if (name == "package.json") + return "npm"; + if (name is "build.gradle.kts" or "build.gradle") + return "gradle"; + if (name == "CMakeLists.txt") + return "cmake"; + return MsBuildExtensions.Any(extension => name.EndsWith(extension, StringComparison.Ordinal)) ? "msbuild" : null; + } +} diff --git a/tests/ArcForges.Web.Policy.Tests/NamingScannerTests.cs b/tests/ArcForges.Web.Policy.Tests/NamingScannerTests.cs new file mode 100644 index 0000000..baf7416 --- /dev/null +++ b/tests/ArcForges.Web.Policy.Tests/NamingScannerTests.cs @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: AGPL-3.0-only +using System.Diagnostics; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json.Nodes; +using Xunit; + +namespace ArcForges.Web.Policy.Tests; + +/// +/// The forbidden-term scanner of WP-05.02 in the C# policy suite. The scanner and its product-name policy are a byte copy +/// of the published Contracts naming authority (ArcForges.Contracts.Validation 1.0.0-ci.287.1, tools/naming) vendored +/// under eng/naming; this suite verifies the pinned asset digests and runs the scanner under python -I on an isolated +/// local Git fixture, once for the current terms and once for every forbidden term. +/// +public sealed class NamingScannerTests +{ + private const string Clean = "ArcForges ArcScope"; + + [Fact] + public void PublishedNamingAuthorityMatchesTheAdmittedCandidate() + { + var root = RepositoryRoot.Find(); + var candidate = JsonNode.Parse(File.ReadAllText(Path.Combine(root, "eng", "policy", "naming-candidate.json")))!.AsObject(); + var package = NamingRoot(root); + Assert.Equal("ArcForges.Contracts.Validation", candidate["package"]!.GetValue()); + Assert.Equal("1.0.0-ci.287.1", candidate["version"]!.GetValue()); + Assert.Equal("ca45f36cccbdd31380f76b8f6cdecc958fdcb430", candidate["sourceCommit"]!.GetValue()); + foreach (var (asset, expected) in candidate["assets"]!.AsObject()) + Assert.Equal(expected!.GetValue(), Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(Path.Combine(package, asset)))).ToLowerInvariant()); + } + + [Fact] + public void PublishedScannerAcceptsCurrentTermsAndDetectsEveryForbiddenTerm() + { + var root = RepositoryRoot.Find(); + var package = NamingRoot(root); + var script = Path.Combine(package, "tools", "naming", "eng", "check_naming.py"); + var policy = JsonNode.Parse(File.ReadAllText(Path.Combine(package, "tools", "naming", "eng", "policy", "product-names.json")))!.AsObject(); + + var repository = Path.Combine(Path.GetTempPath(), "web-naming-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(repository); + try + { + Git(repository, "init", "-q"); + Git(repository, "remote", "add", "origin", "https://github.com/ArcForges/Web.git"); + Git(repository, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "-c", "commit.gpgsign=false", "commit", "--allow-empty", "-qm", "fixture"); + var probe = Path.Combine(repository, "probe.txt"); + + File.WriteAllText(probe, Clean, Encoding.UTF8); + var accepted = Scan(script, repository); + Assert.Equal(0, accepted.ExitCode); + + foreach (var term in policy["forbiddenNames"]!.AsArray()) + { + var name = term!["name"]!.GetValue(); + File.WriteAllText(probe, name, Encoding.UTF8); + var refused = Scan(script, repository); + Assert.True(refused.ExitCode == 1, "Forbidden term was not refused: " + name); + Assert.Contains("\"kind\": \"forbidden content\"", refused.Output, StringComparison.Ordinal); + } + } + finally + { + DeleteFixtureRepository(repository); + } + } + + /// Deletes the fixture repository. Git writes its object files read-only on Windows, so they are made writable first. + private static void DeleteFixtureRepository(string repository) + { + foreach (var file in Directory.EnumerateFiles(repository, "*", SearchOption.AllDirectories)) + File.SetAttributes(file, FileAttributes.Normal); + Directory.Delete(repository, recursive: true); + } + + private static string NamingRoot(string root) + { + var directory = Path.Combine(root, "eng", "naming"); + Assert.True(Directory.Exists(directory), "The vendored naming authority is missing: eng/naming"); + return directory; + } + + /// + /// The interpreter name for the scanner. Windows images name it python; the GitHub Ubuntu image provides python3 + /// and does not guarantee a python alias. + /// + private static string PythonInterpreter() => OperatingSystem.IsWindows() ? "python" : "python3"; + + private static (int ExitCode, string Output) Scan(string script, string repository) + { + var start = new ProcessStartInfo(PythonInterpreter()) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8, + }; + start.ArgumentList.Add("-I"); + start.ArgumentList.Add(script); + start.ArgumentList.Add("--repository"); + start.ArgumentList.Add("Web=" + repository); + using var process = Process.Start(start) ?? throw new InvalidOperationException("python could not be started."); + var errorTask = process.StandardError.ReadToEndAsync(); + var output = process.StandardOutput.ReadToEnd(); + process.WaitForExit(); + errorTask.GetAwaiter().GetResult(); + return (process.ExitCode, output); + } + + private static void Git(string repository, params string[] arguments) + { + var start = new ProcessStartInfo("git") { WorkingDirectory = repository, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true }; + foreach (var argument in arguments) + start.ArgumentList.Add(argument); + using var process = Process.Start(start) ?? throw new InvalidOperationException("git could not be started."); + var errorTask = process.StandardError.ReadToEndAsync(); + process.StandardOutput.ReadToEnd(); + process.WaitForExit(); + errorTask.GetAwaiter().GetResult(); + Assert.Equal(0, process.ExitCode); + } +} diff --git a/tests/ArcForges.Web.Policy.Tests/NuGetAdmission.cs b/tests/ArcForges.Web.Policy.Tests/NuGetAdmission.cs new file mode 100644 index 0000000..e0c70e2 --- /dev/null +++ b/tests/ArcForges.Web.Policy.Tests/NuGetAdmission.cs @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: AGPL-3.0-only +using System.Text.Json.Nodes; + +namespace ArcForges.Web.Policy.Tests; + +/// Compares the restored NuGet closure with the admitted closure in eng/policy (WEB.40 successor check). +public static class NuGetAdmission +{ + private static readonly string[] PermissiveLicences = ["MIT", "Apache-2.0", "BSD-2-Clause", "BSD-3-Clause", "ISC"]; + + /// + /// File-level copyleft licences that are admitted only for a row marked testOnly (WEB.40 2026-10-09 adjudication: axe-core, + /// the accessibility re-proof). A testOnly row must not be restored by any product project (see the policy tests). + /// + private static readonly string[] TestOnlyLicences = ["MPL-2.0"]; + + /// Returns every refusal reason; an empty list means the closure is admitted. + public static IReadOnlyList Violations(JsonObject admitted, IReadOnlyDictionary restored) + { + var violations = new List(); + foreach (var key in restored.Keys.Where(key => !admitted.ContainsKey(key))) + violations.Add("Unadmitted NuGet package: " + key); + foreach (var key in admitted.Select(entry => entry.Key).Where(key => !restored.ContainsKey(key))) + violations.Add("Admitted package missing from the restored closure: " + key); + foreach (var (key, row) in admitted.Select(entry => (entry.Key, entry.Value!.AsObject()))) + { + if (restored.TryGetValue(key, out var content) && row["contentHash"]?.GetValue() != content) + violations.Add("Content hash differs from the admitted record: " + key); + var licence = row["licence"]?.GetValue() ?? string.Empty; + var firstParty = key.StartsWith("arcforges.", StringComparison.Ordinal); + var testOnly = row["testOnly"]?.GetValue() == true; + if (!PermissiveLicences.Contains(licence, StringComparer.Ordinal) + && !(firstParty && licence == "AGPL-3.0-only") + && !(testOnly && TestOnlyLicences.Contains(licence, StringComparer.Ordinal))) + violations.Add("Forbidden or unreviewed licence " + licence + ": " + key); + } + return violations; + } +} diff --git a/tests/ArcForges.Web.Policy.Tests/NuGetClosureAdmissionTests.cs b/tests/ArcForges.Web.Policy.Tests/NuGetClosureAdmissionTests.cs new file mode 100644 index 0000000..769c0bb --- /dev/null +++ b/tests/ArcForges.Web.Policy.Tests/NuGetClosureAdmissionTests.cs @@ -0,0 +1,320 @@ +// SPDX-License-Identifier: AGPL-3.0-only +using System.Security.Cryptography; +using System.Text.Json.Nodes; +using Xunit; + +namespace ArcForges.Web.Policy.Tests; + +public sealed class NuGetClosureAdmissionTests +{ + [Fact] + public void RestoredClosureMatchesTheAdmittedRecords() + { + var root = RepositoryRoot.Find(); + var admitted = AdmittedClosure(root); + var restored = RestoredClosure(root); + Assert.Empty(NuGetAdmission.Violations(admitted, restored)); + } + + [Fact] + public void EveryAdmittedRowHasSourceAndDigestEvidence() + { + var admitted = AdmittedClosure(RepositoryRoot.Find()); + Assert.NotEmpty(admitted); + foreach (var entry in admitted) + { + var row = entry.Value!.AsObject(); + var digest = row["nuspecSha256"]?.GetValue() ?? string.Empty; + Assert.Matches("^[0-9a-f]{64}$", digest); + var source = row["source"]?.GetValue() ?? string.Empty; + Assert.StartsWith("https://api.nuget.org/v3-flatcontainer/", source, StringComparison.Ordinal); + } + } + + [Fact] + public void EveryAdmittedNuspecDigestIsTheRestoredNuspecDigest() + { + // Convention: the SHA-256 of the .nuspec entry exactly as the package stores it, UTF-8 BOM included. The CI source + // job restores every project except the local browser project from nuget.org, so each package it restores must be + // present here and must match; browser-only test packages are verified wherever the local folder holds them. + var root = RepositoryRoot.Find(); + var policy = PolicyDocument(root); + var packages = NuGetPackagesFolder(); + var restoredByCi = CiRestoredKeys(root); + var packsRoot = DotNetPacksFolder(); + var verified = 0; + foreach (var section in new[] { "nugetClosure", "nugetPackDownloads" }) + { + foreach (var entry in policy[section]!.AsObject()) + { + var expected = entry.Value!.AsObject()["nuspecSha256"]!.GetValue(); + var mustBePresent = section == "nugetPackDownloads" || restoredByCi.Contains(entry.Key); + if (TryRestoredNuspecDigest(packages, entry.Key, out var actual)) + { + Assert.Equal(expected, actual); + verified++; + } + else if (!SatisfiedByPacksFolder(packsRoot, entry.Key, out _)) + { + // A row with no NuGet copy is skipped only through an explicit PacksFolderRows entry for this platform. + Assert.False(mustBePresent, $"Admitted package {entry.Key} is not in the NuGet packages folder; run the locked restore first."); + } + } + } + Assert.True(verified > 0, "No admitted nuspec was verified against the restored packages."); + } + + [Fact] + public void PacksFolderRowsAreAdmittedPackDownloads() + { + var policy = PolicyDocument(RepositoryRoot.Find()); + var downloads = policy["nugetPackDownloads"]!.AsObject(); + foreach (var row in PacksFolderRows) + { + Assert.True(downloads.ContainsKey(row.Key), $"{row.Key} is not an admitted pack-download row."); + Assert.False(string.IsNullOrWhiteSpace(row.Reason), $"{row.Key} has no recorded reason."); + } + } + + [Fact] + public void ContractsIdentityNuspecDigestsAreTheRestoredNuspecDigests() + { + var root = RepositoryRoot.Find(); + var receipt = ActiveReceipt(root); + var identity = receipt["con07Identity"]!.AsObject(); + var version = identity["version"]!.GetValue().ToLowerInvariant(); + var packages = NuGetPackagesFolder(); + foreach (var package in identity["packages"]!.AsArray()) + { + var row = package!.AsObject(); + var key = row["id"]!.GetValue().ToLowerInvariant() + "/" + version; + if (TryRestoredNuspecDigest(packages, key, out var actual)) + Assert.Equal(row["nuspecSha256"]!.GetValue(), actual); + } + } + + [Fact] + public void AdmittedNuspecRowsEqualTheActiveReceipt() + { + var root = RepositoryRoot.Find(); + var policy = PolicyDocument(root); + var receipt = ActiveReceipt(root); + Assert.True(JsonNode.DeepEquals(policy["nugetClosure"], receipt["nugetClosure"]), "Policy and receipt nugetClosure rows differ."); + Assert.True(JsonNode.DeepEquals(policy["nugetPackDownloads"], receipt["nugetPackDownloads"]), "Policy and receipt nugetPackDownloads rows differ."); + } + + [Fact] + public void ChangedContentHashIsRefused() + { + var admitted = AdmittedClosure(RepositoryRoot.Find()); + var restored = admitted.ToDictionary(entry => entry.Key, entry => entry.Value!["contentHash"]!.GetValue()); + var first = restored.Keys.First(); + restored[first] = "changed-hash"; + Assert.Contains(NuGetAdmission.Violations(admitted, restored), message => message.StartsWith("Content hash differs", StringComparison.Ordinal)); + } + + [Fact] + public void UnadmittedPackageIsRefused() + { + var admitted = AdmittedClosure(RepositoryRoot.Find()); + var restored = admitted.ToDictionary(entry => entry.Key, entry => entry.Value!["contentHash"]!.GetValue()); + restored["unadmitted.example/1.0.0"] = "hash"; + Assert.Contains(NuGetAdmission.Violations(admitted, restored), message => message.StartsWith("Unadmitted NuGet package", StringComparison.Ordinal)); + } + + [Fact] + public void CopyleftThirdPartyLicenceIsRefused() + { + var admitted = new JsonObject + { + ["third.party/1.0.0"] = new JsonObject { ["contentHash"] = "h", ["licence"] = "GPL-3.0-only" }, + }; + var restored = new Dictionary { ["third.party/1.0.0"] = "h" }; + Assert.Contains(NuGetAdmission.Violations(admitted, restored), message => message.StartsWith("Forbidden or unreviewed licence", StringComparison.Ordinal)); + } + + [Fact] + public void TestOnlyCopyleftIsAdmittedOnlyForATestOnlyRow() + { + var restored = new Dictionary { ["axe.test/1.0.0"] = "h" }; + var testOnly = new JsonObject + { + ["axe.test/1.0.0"] = new JsonObject { ["contentHash"] = "h", ["licence"] = "MPL-2.0", ["testOnly"] = true }, + }; + Assert.Empty(NuGetAdmission.Violations(testOnly, restored)); + + var productionCopyleft = new JsonObject + { + ["axe.test/1.0.0"] = new JsonObject { ["contentHash"] = "h", ["licence"] = "MPL-2.0" }, + }; + Assert.Contains(NuGetAdmission.Violations(productionCopyleft, restored), message => message.StartsWith("Forbidden or unreviewed licence", StringComparison.Ordinal)); + + var testOnlyGpl = new JsonObject + { + ["axe.test/1.0.0"] = new JsonObject { ["contentHash"] = "h", ["licence"] = "GPL-3.0-only", ["testOnly"] = true }, + }; + Assert.Contains(NuGetAdmission.Violations(testOnlyGpl, restored), message => message.StartsWith("Forbidden or unreviewed licence", StringComparison.Ordinal)); + } + + [Fact] + public void TestOnlyPackagesAreRestoredByNoProductProject() + { + var root = RepositoryRoot.Find(); + var testOnly = AdmittedClosure(root) + .Where(entry => entry.Value!.AsObject()["testOnly"]?.GetValue() == true) + .Select(entry => entry.Key.Split('/')[0]) + .ToList(); + Assert.NotEmpty(testOnly); + + var productLocks = Directory.EnumerateFiles(root, "packages.lock.json", SearchOption.AllDirectories) + .Where(path => !path.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.Ordinal) + && !path.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.Ordinal)) + .Where(path => !Path.GetRelativePath(root, path).StartsWith($"tests{Path.DirectorySeparatorChar}", StringComparison.Ordinal)); + foreach (var lockPath in productLocks) + { + var text = File.ReadAllText(lockPath); + foreach (var id in testOnly) + Assert.DoesNotContain($"\"{id}\": {{", text, StringComparison.OrdinalIgnoreCase); + } + } + + /// + /// The admitted pack-download rows that the SDK resolves from its own packs folder on one platform, so the locked restore + /// does not write their NuGet copy there. The SDK adds the implicit Mono browser-wasm runtime pack as a NuGet download only + /// when the packs folder lacks the bundled runtime version. The explicit PackageDownload rows are always restored to NuGet. + /// Every other row is required in the NuGet packages folder on every platform. A row is skipped only when its packs copy + /// holds the admitted version and its content marker, so a missing or empty pack still fails. + /// + private static readonly PacksFolderRow[] PacksFolderRows = + [ + new( + Key: "microsoft.netcore.app.runtime.mono.browser-wasm/10.0.12", + Platform: "Windows", + PacksFolder: "Microsoft.NETCore.App.Runtime.Mono.browser-wasm", + Version: "10.0.12", + Marker: Path.Combine("runtimes", "browser-wasm", "native", "dotnet.native.wasm"), + Reason: "The hosted windows-2025-vs2026 image (runner image 20260925.250.1, CI job 113727560820) preinstalls the wasm.tools workload into C:\\Program Files\\dotnet, which holds this pack at the bundled version 10.0.12 (the same run's build lists the 10.0.12 Emscripten workload packs in that folder), so the SDK adds no NuGet download. A local machine whose packs folder lacks 10.0.12 restores the NuGet copy and it is verified by digest there."), + new( + Key: "microsoft.netcore.app.runtime.mono.browser-wasm/10.0.12", + Platform: "Linux", + PacksFolder: "Microsoft.NETCore.App.Runtime.Mono.browser-wasm", + Version: "10.0.12", + Marker: Path.Combine("runtimes", "browser-wasm", "native", "dotnet.native.wasm"), + Reason: "The Ubuntu hosted image carries no WebAssembly workload, so this row is checked there and not satisfied: the SDK adds the implicit pack as a NuGet download, restores it and the test verifies its digest. The NuGet copy is skipped only when a 10.0.12 packs copy with the marker exists locally, and in that case the SDK does not download the pack either."), + ]; + + private sealed record PacksFolderRow(string Key, string Platform, string PacksFolder, string Version, string Marker, string Reason); + + private static bool SatisfiedByPacksFolder(string packsRoot, string key, out string reason) + { + reason = string.Empty; + foreach (var row in PacksFolderRows) + { + if (row.Key != key || row.Platform != CurrentPlatform()) + continue; + // The folder alone is not evidence of an installed pack: the content marker must exist at the admitted version. + if (!File.Exists(Path.Combine(packsRoot, row.PacksFolder, row.Version, row.Marker))) + return false; + reason = row.Reason; + return true; + } + return false; + } + + private static string CurrentPlatform() => OperatingSystem.IsWindows() ? "Windows" : OperatingSystem.IsLinux() ? "Linux" : "Other"; + + private static string DotNetPacksFolder() + { + // The SDK reads NetCoreTargetingPackRoot when it is set, the same environment property that overrides its packs + // folder. Otherwise the packs folder is /packs, where the dotnet root holds the running shared runtime + // (/shared/Microsoft.NETCore.App//). The restoring SDK and the test host share that root. + var configured = Environment.GetEnvironmentVariable("NetCoreTargetingPackRoot"); + if (!string.IsNullOrWhiteSpace(configured)) + return configured; + var runtime = System.Runtime.InteropServices.RuntimeEnvironment.GetRuntimeDirectory(); + return Path.GetFullPath(Path.Combine(runtime, "..", "..", "..", "packs")); + } + + private static JsonObject AdmittedClosure(string root) + { + return PolicyDocument(root)["nugetClosure"]!.AsObject(); + } + + private static JsonObject PolicyDocument(string root) + { + return JsonNode.Parse(File.ReadAllText(Path.Combine(root, "eng", "policy", "dependency-policy.json")))!.AsObject(); + } + + private static JsonObject ActiveReceipt(string root) + { + var reviewRecord = PolicyDocument(root)["reviewRecord"]!.GetValue(); + return JsonNode.Parse(File.ReadAllText(Path.Combine(root, reviewRecord)))!.AsObject(); + } + + private static string NuGetPackagesFolder() + { + var configured = Environment.GetEnvironmentVariable("NUGET_PACKAGES"); + return string.IsNullOrWhiteSpace(configured) + ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".nuget", "packages") + : configured; + } + + private static bool TryRestoredNuspecDigest(string packages, string key, out string digest) + { + var parts = key.Split('/'); + var nuspec = Path.Combine(packages, parts[0], parts[1], parts[0] + ".nuspec"); + digest = string.Empty; + if (!File.Exists(nuspec)) + return false; + digest = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(nuspec))).ToLowerInvariant(); + return true; + } + + private static HashSet CiRestoredKeys(string root) + { + // The source job restores every project except the local opt-in browser project under tests/browser. + var browser = "tests" + Path.DirectorySeparatorChar + "browser" + Path.DirectorySeparatorChar; + var keys = new HashSet(StringComparer.Ordinal); + foreach (var lockPath in Directory.EnumerateFiles(root, "packages.lock.json", SearchOption.AllDirectories) + .Where(path => !path.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.Ordinal) + && !path.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.Ordinal) + && !Path.GetRelativePath(root, path).StartsWith(browser, StringComparison.Ordinal))) + { + var document = JsonNode.Parse(File.ReadAllText(lockPath))!.AsObject(); + foreach (var framework in document["dependencies"]!.AsObject()) + foreach (var package in framework.Value!.AsObject()) + { + var entry = package.Value!.AsObject(); + if (entry["type"]!.GetValue() == "Project") + continue; + keys.Add(package.Key.ToLowerInvariant() + "/" + entry["resolved"]!.GetValue()); + } + } + Assert.NotEmpty(keys); + return keys; + } + + private static IReadOnlyDictionary RestoredClosure(string root) + { + var closure = new Dictionary(StringComparer.Ordinal); + foreach (var lockPath in Directory.EnumerateFiles(root, "packages.lock.json", SearchOption.AllDirectories) + .Where(path => !path.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.Ordinal) + && !path.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.Ordinal))) + { + var document = JsonNode.Parse(File.ReadAllText(lockPath))!.AsObject(); + foreach (var framework in document["dependencies"]!.AsObject()) + foreach (var package in framework.Value!.AsObject()) + { + var entry = package.Value!.AsObject(); + if (entry["type"]!.GetValue() == "Project") + continue; + var key = package.Key.ToLowerInvariant() + "/" + entry["resolved"]!.GetValue(); + var hash = entry["contentHash"]!.GetValue(); + if (closure.TryGetValue(key, out var seen)) + Assert.Equal(seen, hash); + closure[key] = hash; + } + } + return closure; + } +} diff --git a/tests/ArcForges.Web.Policy.Tests/NuGetPackDownloadAdmissionTests.cs b/tests/ArcForges.Web.Policy.Tests/NuGetPackDownloadAdmissionTests.cs new file mode 100644 index 0000000..73521f9 --- /dev/null +++ b/tests/ArcForges.Web.Policy.Tests/NuGetPackDownloadAdmissionTests.cs @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: AGPL-3.0-only +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using Xunit; + +namespace ArcForges.Web.Policy.Tests; + +/// +/// Checks the browser-wasm pack downloads (WEB.40 section 10). NuGet records PackageDownload items outside +/// packages.lock.json, so each declared item must be admitted at the exact version, and every admitted download must +/// be either declared in a project or added implicitly by the SDK. +/// +public sealed class NuGetPackDownloadAdmissionTests +{ + private static readonly Regex DeclaredDownload = new( + "[^\"]+)\"\\s+Version=\"\\[(?[^\\]]+)\\]\"", + RegexOptions.CultureInvariant); + + private static readonly string[] PermissiveLicences = ["MIT", "Apache-2.0", "BSD-2-Clause", "BSD-3-Clause", "ISC"]; + + [Fact] + public void EveryDeclaredPackDownloadIsAdmittedAtTheExactVersion() + { + var root = RepositoryRoot.Find(); + var admitted = AdmittedDownloads(root); + var declared = DeclaredDownloads(root); + Assert.NotEmpty(declared); + foreach (var entry in declared) + Assert.True(admitted.ContainsKey(entry.Key), $"Unadmitted PackageDownload {entry.Key} in {entry.Value}."); + } + + [Fact] + public void EveryAdmittedPackDownloadIsDeclaredOrAddedImplicitlyBySdk() + { + var root = RepositoryRoot.Find(); + var admitted = AdmittedDownloads(root); + var declared = DeclaredDownloads(root).Select(entry => entry.Key).ToHashSet(StringComparer.Ordinal); + foreach (var key in admitted.Select(entry => entry.Key)) + { + if (declared.Contains(key)) + continue; + var declaredBy = admitted[key]?["declaredBy"]?.GetValue() ?? string.Empty; + Assert.StartsWith("implicit:", declaredBy, StringComparison.Ordinal); + } + } + + [Fact] + public void EveryAdmittedPackDownloadHasAContentHashSourceAndPermissiveLicence() + { + var admitted = AdmittedDownloads(RepositoryRoot.Find()); + Assert.NotEmpty(admitted); + foreach (var entry in admitted) + { + var row = entry.Value?.AsObject() ?? throw new InvalidDataException("Empty admission row: " + entry.Key); + Assert.Matches("^[A-Za-z0-9+/]{86}==$", row["contentHash"]?.GetValue() ?? string.Empty); + Assert.Matches("^[0-9a-f]{64}$", row["nuspecSha256"]?.GetValue() ?? string.Empty); + Assert.StartsWith("https://api.nuget.org/v3-flatcontainer/", row["source"]?.GetValue() ?? string.Empty, StringComparison.Ordinal); + var licence = row["licence"]?.GetValue() ?? string.Empty; + Assert.Contains(licence, PermissiveLicences); + Assert.EndsWith("/10.0.12", entry.Key, StringComparison.Ordinal); + } + } + + [Fact] + public void AspNetCoreBrowserWasmPackIsNotAdmitted() + { + Assert.False(AdmittedDownloads(RepositoryRoot.Find()).ContainsKey("microsoft.aspnetcore.app.runtime.browser-wasm/10.0.12")); + } + + private static JsonObject AdmittedDownloads(string root) + { + var policy = JsonNode.Parse(File.ReadAllText(Path.Combine(root, "eng", "policy", "dependency-policy.json")))!.AsObject(); + return policy["nugetPackDownloads"]!.AsObject(); + } + + private static List> DeclaredDownloads(string root) + { + var declared = new List>(); + foreach (var project in Directory.EnumerateFiles(root, "*.csproj", SearchOption.AllDirectories) + .Where(path => !path.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.Ordinal) + && !path.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.Ordinal))) + { + foreach (Match match in DeclaredDownload.Matches(File.ReadAllText(project))) + declared.Add(new KeyValuePair( + match.Groups["id"].Value.ToLowerInvariant() + "/" + match.Groups["version"].Value, + Path.GetRelativePath(root, project))); + } + return declared; + } +} diff --git a/tests/ArcForges.Web.Policy.Tests/RepositoryPolicyTests.cs b/tests/ArcForges.Web.Policy.Tests/RepositoryPolicyTests.cs new file mode 100644 index 0000000..16df48e --- /dev/null +++ b/tests/ArcForges.Web.Policy.Tests/RepositoryPolicyTests.cs @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: AGPL-3.0-only +using ArcForges.Web.Policy.Tests.Architecture; +using ArcForges.Web.Policy.Tests.Licence; +using Xunit; + +namespace ArcForges.Web.Policy.Tests; + +/// The policy gate over the real Web repository: the tracked and nonignored inputs must pass every rule. +public sealed class RepositoryPolicyTests +{ + [Fact] + public void TheRepositoryPassesEveryArchitectureRule() + { + var sources = PolicySources.FromRepository(RepositoryRoot.Find()); + Assert.NotEmpty(sources); + Assert.Empty(ArchitecturePolicy.Audit(sources)); + } + + [Fact] + public void TheRepositoryPassesEveryLicenceBoundaryRule() + { + var sources = PolicySources.FromRepository(RepositoryRoot.Find()); + Assert.Empty(LicencePolicy.Audit(sources)); + } +} diff --git a/tests/ArcForges.Web.Policy.Tests/RepositoryRoot.cs b/tests/ArcForges.Web.Policy.Tests/RepositoryRoot.cs new file mode 100644 index 0000000..a58fa98 --- /dev/null +++ b/tests/ArcForges.Web.Policy.Tests/RepositoryRoot.cs @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: AGPL-3.0-only +namespace ArcForges.Web.Policy.Tests; + +/// Locates the repository root from the test output directory. +public static class RepositoryRoot +{ + /// Walks up from the test assembly until the repository marker files are found. + public static string Find() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "win.slnx")) + && File.Exists(Path.Combine(directory.FullName, "global.json"))) + return directory.FullName; + directory = directory.Parent; + } + throw new DirectoryNotFoundException("The repository root with win.slnx and global.json was not found."); + } +} diff --git a/tests/ArcForges.Web.Policy.Tests/packages.lock.json b/tests/ArcForges.Web.Policy.Tests/packages.lock.json new file mode 100644 index 0000000..1cf7f05 --- /dev/null +++ b/tests/ArcForges.Web.Policy.Tests/packages.lock.json @@ -0,0 +1,132 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "ArcForges.Build.Policy": { + "type": "Direct", + "requested": "[1.0.0-ci.94.1, )", + "resolved": "1.0.0-ci.94.1", + "contentHash": "PCpeEXikk2m87zhMF6L8tbHe2QrI87mO4Nc5lJBenEDhOePwwV4TkArc6IaM1G11Pm8ojVjurLAIoPW22ozb/A==" + }, + "xunit.v3.mtp-v2": { + "type": "Direct", + "requested": "[4.0.1, )", + "resolved": "4.0.1", + "contentHash": "s88KiWwDDYgOWV3A+ViJiqCe9cLU/Rt6Gb5TfOC7Abv/HKSoj4IHVPcOQk7jPt7HhZHHEts5IuaVpB30eO5B1w==", + "dependencies": { + "xunit.analyzers": "2.1.0", + "xunit.v3.assert": "[4.0.1]", + "xunit.v3.core.mtp-v2": "[4.0.1]" + } + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==" + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, + "Microsoft.Testing.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "2.4.0", + "contentHash": "JeP1RFqBa11fWmBk8xEfZcMKr4rxWSyI6OZ+659V069CaMkTEOQBW2UdSSeNz3absOsygcn7JJkzerC4LGnZ9w==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.23.0", + "Microsoft.Testing.Platform": "[2.4.0, 3.0.0)" + } + }, + "Microsoft.Testing.Extensions.TrxReport.Abstractions": { + "type": "Transitive", + "resolved": "2.4.0", + "contentHash": "uRb+4qM42dFDg4kWJZ2kFEcwESNVCRZJjItp5vETorN3rSJGysP617LqYirOcrCYmxg+obPreHMM5JcsNDKtBw==", + "dependencies": { + "Microsoft.Testing.Platform": "[2.4.0, 3.0.0)" + } + }, + "Microsoft.Testing.Platform": { + "type": "Transitive", + "resolved": "2.4.0", + "contentHash": "dp1N3P1ujb0ztwFgqz2o/ItEvq+pm19/AiA+Xq7Zpcy7oPMcxDBZLYGtf0LlU1MveEBJuKVjZhI+LnkfcH/0jQ==" + }, + "Microsoft.Testing.Platform.MSBuild": { + "type": "Transitive", + "resolved": "2.4.0", + "contentHash": "qr5M6h16YHMJLFDcWELFVMMpGte2BUmveBZKT5YoBV+bmuJRPu9bv/Zqke4yQuOEKRNxoAETrG5jr+/6Rnr3Hg==", + "dependencies": { + "Microsoft.Testing.Platform": "[2.4.0, 3.0.0)" + } + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==" + }, + "System.Security.AccessControl": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "IQ4NXP/B3Ayzvw0rDQzVTYsCKyy0Jp9KI6aYcK7UnGVlR9+Awz++TIPCQtPYfLJfOpm8ajowMR09V7quD3sEHw==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "X7QXEcZQGz0G/HL4HUyK+aAvNa/IMGbOCnFIq4jD/Evktq12xANKwzOUr7b08vCmC1LXu/47qHWOdjm3KfaJ0A==" + }, + "xunit.v3.assert": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "nC7d3cY06Oo7hkdwWPZkBR0Ud75xNhgx0P7i0GmMMZC3puCGlc4szFiT30biH03IN/eDnr8h+nv3A1UD17bk/A==" + }, + "xunit.v3.common": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "Qf25TVdadDQYf9zVxSd7L9RNQhuP0jCUHmo96XTmIKLxGefylIEV63jRbXBPFzdXmV09TCdcurk+AQ9LiWS2Hg==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "xunit.v3.core.mtp-v2": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "7qfTfrIfS2wybpSVyRLmhXHbSudD2eNw66LfukixmKsRCTcfvOrLsx0qGL/lObcEh3Z2F4SIXgoFnLIiB8yfjQ==", + "dependencies": { + "Microsoft.Testing.Extensions.Telemetry": "2.4.0", + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.4.0", + "Microsoft.Testing.Platform": "2.4.0", + "Microsoft.Testing.Platform.MSBuild": "2.4.0", + "xunit.v3.extensibility.core": "[4.0.1]", + "xunit.v3.runner.inproc.console": "[4.0.1]" + } + }, + "xunit.v3.extensibility.core": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "J0d5OfFcp920nZxCRIvU14+TZhiSPQR0wvqEMZ3MRiJLPu1VmYlcRNkmcSW0i1DwkB81gAHc+fHUQf/cU64MYg==", + "dependencies": { + "xunit.v3.common": "[4.0.1]" + } + }, + "xunit.v3.runner.common": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "p9AyfBpj2e5Iws8B57SbLiSngg7mEdvegHInKR4T2cjt2mis9h8htVgCnmu//agZO4zri6p6VQOj5EkHHTy3gA==", + "dependencies": { + "Microsoft.Win32.Registry": "[5.0.0]", + "System.Security.AccessControl": "[6.0.1]", + "xunit.v3.common": "[4.0.1]" + } + }, + "xunit.v3.runner.inproc.console": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "Hqwfd6ehMIhPmVWUQ2dgmknzuLFTWeyp8ES1q3D4YR5bQVyiXDcaIoaFwqAz2zgLr49WaW4Mz7VVncP7u/Q8/Q==", + "dependencies": { + "xunit.v3.extensibility.core": "[4.0.1]", + "xunit.v3.runner.common": "[4.0.1]" + } + } + } + } +} diff --git a/tests/ArcForges.Web.Site.Tests/ArcForges.Web.Site.Tests.csproj b/tests/ArcForges.Web.Site.Tests/ArcForges.Web.Site.Tests.csproj new file mode 100644 index 0000000..a46b9a7 --- /dev/null +++ b/tests/ArcForges.Web.Site.Tests/ArcForges.Web.Site.Tests.csproj @@ -0,0 +1,16 @@ + + + + Exe + true + + + + + + + + AGPL + AGPL-3.0-only + + diff --git a/tests/ArcForges.Web.Site.Tests/ContentSecurityPolicyTests.cs b/tests/ArcForges.Web.Site.Tests/ContentSecurityPolicyTests.cs new file mode 100644 index 0000000..d63d922 --- /dev/null +++ b/tests/ArcForges.Web.Site.Tests/ContentSecurityPolicyTests.cs @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: AGPL-3.0-only +using System.Security.Cryptography; +using System.Text; +using ArcForges.Web.Site; +using Xunit; + +namespace ArcForges.Web.Site.Tests; + +public sealed class ContentSecurityPolicyTests +{ + [Fact] + public void PagesWithoutScriptsGetTheSelfOnlyScriptSource() + { + var policy = SiteContentSecurityPolicy.FromPages(["plain"]); + + Assert.Equal(SiteOutputTests.ExpectedPolicy, policy); + } + + [Fact] + public void AnInlineScriptBodyIsAddedAsItsSha256Hash() + { + const string body = "console.log(1);"; + var hash = Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes(body))); + + var policy = SiteContentSecurityPolicy.FromPages([$""]); + + Assert.Contains($"script-src 'self' 'sha256-{hash}';", policy, StringComparison.Ordinal); + Assert.DoesNotContain("unsafe-inline", policy, StringComparison.Ordinal); + } + + [Fact] + public void HashesAreSortedAndDeduplicatedAcrossPages() + { + var one = Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes("a()"))); + var two = Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes("b()"))); + + var policy = SiteContentSecurityPolicy.FromPages(["", ""]); + + var sorted = new[] { $"'sha256-{one}'", $"'sha256-{two}'" }.OrderBy(token => token, StringComparer.Ordinal); + Assert.Contains("script-src 'self' " + string.Join(' ', sorted) + ";", policy, StringComparison.Ordinal); + } + + [Fact] + public void AnEmptyInlineScriptIsNotHashed() + { + var policy = SiteContentSecurityPolicy.FromPages([""]); + + Assert.Equal(SiteOutputTests.ExpectedPolicy, policy); + } + + [Theory] + [InlineData("")] + [InlineData("")] + [InlineData("")] + [InlineData("")] + public void AnExternalScriptIsRefused(string html) + { + var error = Assert.Throws(() => SiteContentSecurityPolicy.FromPages([html])); + Assert.Contains("external script", error.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData(""]); + + Assert.DoesNotContain("unsafe-inline", policy, StringComparison.Ordinal); + Assert.DoesNotContain("unsafe-eval", policy, StringComparison.Ordinal); + Assert.DoesNotContain("wasm-unsafe-eval", policy, StringComparison.Ordinal); + Assert.True(policy.Length < SiteContentSecurityPolicy.HeaderLineBudget); + } + + [Fact] + public void APolicyThatExceedsTheHeaderBudgetIsRefusedByTheHeaderRenderer() + { + var oversized = "default-src 'self'; " + new string('x', SiteContentSecurityPolicy.HeaderLineBudget); + + Assert.Throws(() => SiteSecurityHeaders.Render(oversized)); + } +} diff --git a/tests/ArcForges.Web.Site.Tests/GreetingTests.cs b/tests/ArcForges.Web.Site.Tests/GreetingTests.cs new file mode 100644 index 0000000..21988c9 --- /dev/null +++ b/tests/ArcForges.Web.Site.Tests/GreetingTests.cs @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Port of tests/unit/hello.test.tsx (the greeting part: the published protobuf round trip). +using ArcForges.Web.Ui; +using Xunit; + +namespace ArcForges.Web.Site.Tests; + +public sealed class GreetingTests +{ + public static TheoryData InvalidNames => + [ + " ", + "", + new string('x', 81), + "hi" + (char)0, + "tab" + '\t', + "del" + (char)127, + string.Concat(Enumerable.Repeat("🌍", 81)), + ]; + + [Fact] + public void PublishedProtobufRoundTripPreservesUnicodeNames() + { + Assert.Equal("Hello, 世界 🌍!", HelloGreeting.Greet(" 世界 🌍 ")); + var longest = string.Concat(Enumerable.Repeat("🌍", 80)); + Assert.Equal("Hello, " + longest + "!", HelloGreeting.Greet(longest)); + } + + [Theory] + [MemberData(nameof(InvalidNames))] + public void InvalidNamesAreRefusedWithTheExactFailureText(string invalid) + { + var error = Assert.Throws(() => HelloGreeting.Greet(invalid)); + Assert.Equal("Enter a name between 1 and 80 characters, without control characters.", error.Message); + } + + [Fact] + public void TheInitialGreetingIsTheReactGreetingForTheDefaultName() + { + Assert.Equal("World", HelloGreeting.DefaultName); + Assert.Equal("Hello, World!", HelloGreeting.Greet(HelloGreeting.DefaultName)); + } + + [Fact] + public void UnpairedSurrogateIsEncodedAsTheReplacementCharacterLikeTheBrowserEncoder() + { + var lone = "a" + (char)0xD800 + "b"; + Assert.Equal("Hello, a�b!", HelloGreeting.Greet(lone)); + } +} diff --git a/tests/ArcForges.Web.Site.Tests/GrpcWebFixture.cs b/tests/ArcForges.Web.Site.Tests/GrpcWebFixture.cs new file mode 100644 index 0000000..17c5a93 --- /dev/null +++ b/tests/ArcForges.Web.Site.Tests/GrpcWebFixture.cs @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Test-only gRPC-Web fixture (the port of tests/fixtures/grpc-web.ts). It encodes and decodes the frames +// independently of the product framing code, so the tests do not check the framing with itself. +using System.Buffers.Binary; +using System.Text; +using ArcForges.Contracts.Hello.V1; +using Google.Protobuf; + +namespace ArcForges.Web.Site.Tests; + +internal static class GrpcWebFixture +{ + public const byte DataFlag = 0; + public const byte TrailersFlag = 0x80; + + public static byte[] Frame(byte[] payload, byte flag) + { + var bytes = new byte[payload.Length + 5]; + bytes[0] = flag; + BinaryPrimitives.WriteUInt32BigEndian(bytes.AsSpan(1, 4), (uint)payload.Length); + payload.CopyTo(bytes, 5); + return bytes; + } + + /// Returns the name of one uncompressed SayHello request frame, and fails for any other shape. + public static string DecodeHelloRequestName(byte[] bytes) + { + if (bytes.Length < 5 || bytes[0] != DataFlag || BinaryPrimitives.ReadUInt32BigEndian(bytes.AsSpan(1, 4)) != bytes.Length - 5) + throw new InvalidOperationException("Expected one uncompressed protobuf request frame"); + return SayHelloRequest.Parser.ParseFrom(bytes.AsSpan(5).ToArray()).Name; + } + + /// A unary OK reply: one data frame with the message, then the trailers frame with grpc-status 0. + public static byte[] HelloResponse(string message = "Hello, ArcForges!") + { + var data = Frame(new SayHelloResponse { Message = message }.ToByteArray(), DataFlag); + var trailers = Frame(Encoding.UTF8.GetBytes("grpc-status: 0\r\n"), TrailersFlag); + return [.. data, .. trailers]; + } + + public static byte[] Concat(params byte[][] parts) + { + var bytes = new List(); + foreach (var part in parts) + bytes.AddRange(part); + return bytes.ToArray(); + } +} diff --git a/tests/ArcForges.Web.Site.Tests/GrpcWebFramesTests.cs b/tests/ArcForges.Web.Site.Tests/GrpcWebFramesTests.cs new file mode 100644 index 0000000..1b7b82e --- /dev/null +++ b/tests/ArcForges.Web.Site.Tests/GrpcWebFramesTests.cs @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Failure cases of the unary gRPC-Web reply, ported from the malformed-frame and status cases of tests/unit/app-hello.test.ts. +using ArcForges.Contracts.Hello.V1; +using ArcForges.Web.Ui; +using Google.Protobuf; +using Xunit; + +namespace ArcForges.Web.Site.Tests; + +public sealed class GrpcWebFramesTests +{ + [Fact] + public void AWellFormedUnaryReplyYieldsTheMessageBytes() + { + var message = new SayHelloResponse { Message = "Hello, ArcForges!" }.ToByteArray(); + + var decoded = GrpcWebFrames.DecodeUnaryReply(GrpcWebFixture.HelloResponse()); + + Assert.Equal(message, decoded); + } + + [Fact] + public void FramesEncodedByTheProductAreReadBackByTheFixture() + { + var payload = new SayHelloRequest { Name = "ArcForges" }.ToByteArray(); + + var frame = GrpcWebFrames.Encode(payload, GrpcWebFrames.DataFlag); + + Assert.Equal("ArcForges", GrpcWebFixture.DecodeHelloRequestName(frame)); + } + + public static TheoryData MalformedReplies + { + get + { + var data = GrpcWebFixture.Frame(new SayHelloResponse { Message = "Hello, ArcForges!" }.ToByteArray(), GrpcWebFixture.DataFlag); + var trailersOk = GrpcWebFixture.Frame("grpc-status: 0\r\n"u8.ToArray(), GrpcWebFixture.TrailersFlag); + return new TheoryData + { + { "empty body", [] }, + { "truncated header", data[..3] }, + { "truncated message", data[..^2] }, + { "no trailers", data }, + { "trailers only", trailersOk }, + { "trailers without a status", GrpcWebFixture.Concat(data, GrpcWebFixture.Frame("x-other: 1\r\n"u8.ToArray(), GrpcWebFixture.TrailersFlag)) }, + { "unparsable status", GrpcWebFixture.Concat(data, GrpcWebFixture.Frame("grpc-status: ok\r\n"u8.ToArray(), GrpcWebFixture.TrailersFlag)) }, + { "non-OK status", GrpcWebFixture.Concat(data, GrpcWebFixture.Frame("grpc-status: 3\r\n"u8.ToArray(), GrpcWebFixture.TrailersFlag)) }, + { "compressed frame", GrpcWebFixture.Concat([1, .. data[1..]], trailersOk) }, + { "frame length beyond the body", GrpcWebFixture.Concat([0, 0, 0, 0, 200, .. "x"u8.ToArray()], trailersOk) }, + { "bytes after the trailers", GrpcWebFixture.Concat(data, trailersOk, [0]) }, + { "data frame after the trailers", GrpcWebFixture.Concat(data, trailersOk, data) }, + }; + } + } + + [Theory] + [MemberData(nameof(MalformedReplies))] + public void MalformedOrNonOkRepliesAreRefusedAndNeverResolve(string name, byte[] body) + { + var error = Assert.Throws(() => GrpcWebFrames.DecodeUnaryReply(body)); + Assert.False(string.IsNullOrEmpty(error.Message), name); + } + + [Fact] + public Task AMessageThatIsNotAProtobufSayHelloResponseIsRefusedByTheClient() + { + var garbage = GrpcWebFixture.Concat( + GrpcWebFixture.Frame([0xFF, 0xFF, 0xFF], GrpcWebFixture.DataFlag), + GrpcWebFixture.Frame("grpc-status: 0\r\n"u8.ToArray(), GrpcWebFixture.TrailersFlag)); + + var client = new ServerConnectionClient(new HttpMessageHandlerStub(garbage)); + + return Assert.ThrowsAsync(() => client.CheckAsync(new Uri("https://arcforges.com"), TestContext.Current.CancellationToken)); + } + + /// Answers one fixed binary body with the gRPC-Web media type. + private sealed class HttpMessageHandlerStub(byte[] body) : System.Net.Http.HttpMessageHandler + { + protected override Task SendAsync(System.Net.Http.HttpRequestMessage request, CancellationToken cancellationToken) + { + var response = new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + Content = new System.Net.Http.ByteArrayContent(body), + }; + response.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(GrpcWebFrames.MediaType); + return Task.FromResult(response); + } + } +} diff --git a/tests/ArcForges.Web.Site.Tests/ProfileIdentityTests.cs b/tests/ArcForges.Web.Site.Tests/ProfileIdentityTests.cs new file mode 100644 index 0000000..edd3d35 --- /dev/null +++ b/tests/ArcForges.Web.Site.Tests/ProfileIdentityTests.cs @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: AGPL-3.0-only +using Xunit; + +namespace ArcForges.Web.Site.Tests; + +public sealed class ProfileIdentityTests +{ + [Fact] + public void ProfileNameIsStable() + { + Assert.Equal("site", ArcForges.Web.Site.ProfileIdentity.Name); + } +} diff --git a/tests/ArcForges.Web.Site.Tests/ServerConnectionTests.cs b/tests/ArcForges.Web.Site.Tests/ServerConnectionTests.cs new file mode 100644 index 0000000..d860bc5 --- /dev/null +++ b/tests/ArcForges.Web.Site.Tests/ServerConnectionTests.cs @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Port of tests/unit/cloud-hello.test.ts (the server connection check) plus its failure and cancellation cases. +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using ArcForges.Web.Ui; +using Xunit; + +namespace ArcForges.Web.Site.Tests; + +public sealed class ServerConnectionTests +{ + private static readonly Uri Origin = new("https://arcforges.com"); + + [Fact] + public async Task ConnectionUsesThePublishedBinaryClientSameOriginApiAndNoCredentials() + { + var handler = new ScriptedHandler(_ => Reply(HttpStatusCode.OK, GrpcWebFixture.HelloResponse(), GrpcWebFrames.MediaType)); + var client = new ServerConnectionClient(handler); + + var message = await client.CheckAsync(Origin, TestContext.Current.CancellationToken); + + Assert.Equal("Hello, ArcForges!", message); + var seen = handler.Last ?? throw new InvalidOperationException("No request was sent."); + Assert.Equal("https://arcforges.com" + ServerConnectionClient.HelloApiPath, seen.Uri.AbsoluteUri); + Assert.Equal(HttpMethod.Post, seen.Method); + Assert.Contains("application/grpc-web+proto", seen.ContentType ?? string.Empty, StringComparison.Ordinal); + Assert.False(seen.HasAuthorization); + Assert.False(seen.HasCookie); + Assert.Equal("ArcForges", GrpcWebFixture.DecodeHelloRequestName(seen.Body)); + } + + [Fact] + public async Task TheSameCheckFromAnotherOriginPathUsesTheRootApiPath() + { + var handler = new ScriptedHandler(_ => Reply(HttpStatusCode.OK, GrpcWebFixture.HelloResponse(), GrpcWebFrames.MediaType)); + var client = new ServerConnectionClient(handler); + + await client.CheckAsync(new Uri("https://arcforges.com/hello/?name=ignored"), TestContext.Current.CancellationToken); + + Assert.Equal("https://arcforges.com" + ServerConnectionClient.HelloApiPath, handler.Last?.Uri.AbsoluteUri); + } + + [Fact] + public async Task MissingApiAndUnexpectedServerOutputNeverBecomeASuccessfulGreeting() + { + var answers = new (string Name, Func Answer)[] + { + ("404", () => new HttpResponseMessage(HttpStatusCode.NotFound) { Content = new StringContent("Not found") }), + ("405", () => new HttpResponseMessage(HttpStatusCode.MethodNotAllowed) { Content = new StringContent("Method not allowed") }), + ("html", () => Reply(HttpStatusCode.OK, Encoding.UTF8.GetBytes("Not an API"), "text/html")), + ("other service", () => Reply(HttpStatusCode.OK, GrpcWebFixture.HelloResponse("Hello, different service!"), GrpcWebFrames.MediaType)), + ("no content type", () => new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(GrpcWebFixture.HelloResponse()) }), + ("redirect refused", () => new HttpResponseMessage(HttpStatusCode.Redirect) { Headers = { Location = new Uri("https://other.example/") } }), + ("server error", () => new HttpResponseMessage(HttpStatusCode.InternalServerError)), + }; + foreach (var (name, answer) in answers) + { + var client = new ServerConnectionClient(new ScriptedHandler(_ => answer())); + var error = await Assert.ThrowsAsync(() => client.CheckAsync(Origin, TestContext.Current.CancellationToken)); + Assert.False(string.IsNullOrEmpty(error.Message), name); + } + } + + [Fact] + public async Task AnUnreachableServerIsATypedFailureNotAGreeting() + { + var client = new ServerConnectionClient(new ScriptedHandler(_ => throw new HttpRequestException("network down"))); + + await Assert.ThrowsAsync(() => client.CheckAsync(Origin, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task TheUsersCancellationAbortsTheRequestAndIsNotReportedAsAGreeting() + { + using var cancellation = new CancellationTokenSource(); + var handler = new ScriptedHandler(_ => throw new InvalidOperationException("the request must not complete"), waitForCancellation: true); + var client = new ServerConnectionClient(handler); + var pending = client.CheckAsync(Origin, cancellation.Token); + + await cancellation.CancelAsync(); + + await Assert.ThrowsAnyAsync(() => pending); + Assert.True(handler.WasCancelled); + } + + [Fact] + public async Task AlreadyCancelledCheckNeverReachesASuccess() + { + using var cancellation = new CancellationTokenSource(); + await cancellation.CancelAsync(); + var client = new ServerConnectionClient(new ScriptedHandler(_ => Reply(HttpStatusCode.OK, GrpcWebFixture.HelloResponse(), GrpcWebFrames.MediaType))); + + await Assert.ThrowsAnyAsync(() => client.CheckAsync(Origin, cancellation.Token)); + } + + [Fact] + public void TheDeadlineIsTenSecondsAsInTheReactExample() + { + Assert.Equal(TimeSpan.FromSeconds(10), ServerConnectionClient.Deadline); + } + + [Fact] + public async Task AReplyBeyondTheCostLimitIsRefusedBeforeItIsParsed() + { + var oversized = new byte[ServerConnectionClient.MaximumReplyBytes + 1]; + var client = new ServerConnectionClient(new ScriptedHandler(_ => Reply(HttpStatusCode.OK, oversized, GrpcWebFrames.MediaType))); + + var error = await Assert.ThrowsAsync(() => client.CheckAsync(Origin, TestContext.Current.CancellationToken)); + Assert.Contains("larger than the limit", error.Message, StringComparison.Ordinal); + } + + private static HttpResponseMessage Reply(HttpStatusCode status, byte[] body, string mediaType) + { + var response = new HttpResponseMessage(status) { Content = new ByteArrayContent(body) }; + response.Content.Headers.ContentType = new MediaTypeHeaderValue(mediaType); + return response; + } + + private sealed record Captured(Uri Uri, HttpMethod Method, string? ContentType, bool HasAuthorization, bool HasCookie, byte[] Body); + + /// Answers each request with a scripted reply and records what was sent. + private sealed class ScriptedHandler(Func respond, bool waitForCancellation = false) : HttpMessageHandler + { + public Captured? Last { get; private set; } + + public bool WasCancelled { get; private set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var body = request.Content is null ? [] : await request.Content.ReadAsByteArrayAsync(cancellationToken); + Last = new Captured( + request.RequestUri ?? throw new InvalidOperationException("No request URI."), + request.Method, + request.Content?.Headers.ContentType?.MediaType, + request.Headers.Authorization is not null, + request.Headers.Contains("Cookie"), + body); + if (waitForCancellation) + { + try + { + await Task.Delay(Timeout.Infinite, cancellationToken); + } + catch (OperationCanceledException) + { + WasCancelled = true; + throw; + } + } + return respond(request); + } + } +} diff --git a/tests/ArcForges.Web.Site.Tests/SiteOutputTests.cs b/tests/ArcForges.Web.Site.Tests/SiteOutputTests.cs new file mode 100644 index 0000000..d69de49 --- /dev/null +++ b/tests/ArcForges.Web.Site.Tests/SiteOutputTests.cs @@ -0,0 +1,247 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Page inventory, determinism, no-script readability and the exact headers and policy of the static public Site. +using System.Net; +using System.Text; +using System.Text.RegularExpressions; +using ArcForges.Web.Site; +using Xunit; + +namespace ArcForges.Web.Site.Tests; + +public sealed class SiteOutputTests +{ + /// The exact public Site policy: no WebAssembly, no unsafe-inline, no unsafe-eval and no external origin. + internal const string ExpectedPolicy = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'"; + + private const string ExpectedHeaders = + "/*\n" + + " Content-Security-Policy: " + ExpectedPolicy + "\n" + + " X-Content-Type-Options: nosniff\n" + + " Referrer-Policy: no-referrer\n" + + " Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()\n" + + " X-Frame-Options: DENY\n" + + " X-Robots-Tag: noindex, nofollow\n" + + " Cache-Control: public, no-cache, no-transform\n" + + "/assets/*\n" + + " ! Cache-Control\n" + + " Cache-Control: public, max-age=31536000, immutable, no-transform\n" + + "/__build.json\n" + + " ! Cache-Control\n" + + " Cache-Control: no-store, no-transform\n" + + "/__build-info.json\n" + + " ! Cache-Control\n" + + " Cache-Control: no-store, no-transform\n"; + + private static readonly string[] HtmlPaths = ["index.html", "hello/index.html", "cloud-hello/index.html"]; + + private static readonly Regex StylesheetPath = new(@"^assets/site\.[0-9a-f]{16}\.css$", RegexOptions.CultureInvariant); + + private static readonly Regex EventHandlerAttribute = new(@"\son[a-z]+\s*=", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + + [Fact] + public async Task TheOutputIsExactlyThePublicPageSetAndItsHeadersAndAssets() + { + var site = await SiteBuilder.BuildAsync(new SiteOptions(), TestContext.Current.CancellationToken); + + var paths = site.Files.Select(file => file.Path).ToArray(); + var stylesheet = Assert.Single(paths, path => StylesheetPath.IsMatch(path)); + Assert.Equal( + new[] { "404.css", "404.html", "_headers", "cloud-hello/index.html", "favicon.svg", "hello/index.html", "index.html", "robots.txt", stylesheet }.OrderBy(path => path, StringComparer.Ordinal), + paths); + Assert.Equal(paths.OrderBy(path => path, StringComparer.Ordinal), paths); + } + + [Fact] + public async Task TwoBuildsOfTheSameInputsProduceTheSameBytes() + { + var first = await SiteBuilder.BuildAsync(new SiteOptions { SourceRef = new string('a', 40) }, TestContext.Current.CancellationToken); + var second = await SiteBuilder.BuildAsync(new SiteOptions { SourceRef = new string('a', 40) }, TestContext.Current.CancellationToken); + + Assert.Equal(first.Files.Select(file => file.Path), second.Files.Select(file => file.Path)); + foreach (var (left, right) in first.Files.Zip(second.Files)) + { + Assert.Equal(left.Path, right.Path); + Assert.Equal(left.Sha256, right.Sha256); + Assert.True(left.Content.AsSpan().SequenceEqual(right.Content), left.Path); + } + } + + [Theory] + [MemberData(nameof(HtmlFiles))] + public async Task EveryPageIsReadableWithScriptingDisabled(string path) + { + var html = await PageHtml(path); + + Assert.DoesNotContain("world.", html, StringComparison.Ordinal); + Assert.Contains("Every good thing starts somewhere.", html, StringComparison.Ordinal); + Assert.Contains("Welcome to the first page of ArcForges Web.", html, StringComparison.Ordinal); + Assert.Contains("href=\"/hello\"", html, StringComparison.Ordinal); + Assert.Contains("Make it your hello", html, StringComparison.Ordinal); + Assert.Contains("A beginning you can explore.", html, StringComparison.Ordinal); + Assert.Contains("This small preview has one job: say hello.", html, StringComparison.Ordinal); + Assert.Contains("aria-labelledby=\"intro-title\"", html, StringComparison.Ordinal); + } + + [Fact] + public async Task TheHelloPageIsTheReactPrerenderWithItsControlsDisabledAndTheDefaultGreeting() + { + var html = await PageHtml("hello/index.html"); + + Assert.Equal("Your hello — ArcForges", Title(html)); + Assert.Contains("A hello, just for you.", html, StringComparison.Ordinal); + Assert.Contains("Start with your name. This example runs in your browser, and your name stays on this page.", html, StringComparison.Ordinal); + Assert.Contains("", html, StringComparison.Ordinal); + Assert.Matches(new Regex("]*id=\"hello-name\"[^>]*disabled[^>]*value=\"World\"", RegexOptions.CultureInvariant), html); + Assert.Contains("aria-invalid=\"false\"", html, StringComparison.Ordinal); + Assert.Contains("aria-describedby=\"name-hint\"", html, StringComparison.Ordinal); + Assert.Contains("Up to 80 characters. No account needed.", html, StringComparison.Ordinal); + Assert.DoesNotContain("role=\"alert\"", html, StringComparison.Ordinal); + Assert.Contains("The example greeting is shown below. Enable JavaScript to personalize it.", html, StringComparison.Ordinal); + Assert.Contains("Your greeting", html, StringComparison.Ordinal); + Assert.Contains("Hello, World!", html, StringComparison.Ordinal); + Assert.Contains("href=\"/cloud-hello/\"", html, StringComparison.Ordinal); + Assert.Matches(new Regex("]*disabled[^>]*>Say hello", RegexOptions.CultureInvariant), html); + } + + [Fact] + public async Task TheCloudHelloPageIsTheReactPrerenderWithNoRequestSent() + { + var html = await PageHtml("cloud-hello/index.html"); + + Assert.Equal("Server connection — ArcForges", Title(html)); + Assert.Contains("A hello, from the server.", html, StringComparison.Ordinal); + Assert.Contains("Send a fixed “ArcForges” greeting to check the server connection.", html, StringComparison.Ordinal); + Assert.Contains("Nothing is sent until you choose to connect.", html, StringComparison.Ordinal); + Assert.Matches(new Regex("]*disabled[^>]*>Check connection", RegexOptions.CultureInvariant), html); + Assert.Contains("Enable JavaScript to check the server connection.", html, StringComparison.Ordinal); + Assert.Contains("Server response", html, StringComparison.Ordinal); + Assert.Contains("No request sent yet.", html, StringComparison.Ordinal); + Assert.Contains("href=\"/hello/\"", html, StringComparison.Ordinal); + Assert.DoesNotContain("api/", html, StringComparison.Ordinal); + } + + [Fact] + public async Task EveryPageLinksTheShellNavigationAndTheSourceLinkOfTheBuild() + { + foreach (var path in HtmlPaths) + { + var html = await PageHtml(path); + Assert.Contains("href=\"#main\"", html, StringComparison.Ordinal); + Assert.Contains("aria-label=\"ArcForges home\"", html, StringComparison.Ordinal); + Assert.Contains("aria-label=\"Main navigation\"", html, StringComparison.Ordinal); + Assert.Contains("href=\"https://github.com/ArcForges/Web\"", html, StringComparison.Ordinal); + Assert.Contains("href=\"/license.txt\"", html, StringComparison.Ordinal); + Assert.Contains("href=\"/third-party-notices.txt\"", html, StringComparison.Ordinal); + Assert.Contains("A small beginning. Built in the open.", html, StringComparison.Ordinal); + } + } + + [Fact] + public async Task TheSourceLinkPointsAtTheBuildCommitWhenItIsKnown() + { + var commit = new string('f', 40); + var site = await SiteBuilder.BuildAsync(new SiteOptions { SourceRef = commit }, TestContext.Current.CancellationToken); + + var html = Encoding.UTF8.GetString(site.Find("index.html")!.Content); + Assert.Contains("href=\"https://github.com/ArcForges/Web/tree/" + commit + "\"", html, StringComparison.Ordinal); + Assert.DoesNotContain("href=\"https://github.com/ArcForges/Web\"", html, StringComparison.Ordinal); + } + + [Theory] + [InlineData("")] + [InlineData("ABCDEF0123456789ABCDEF0123456789ABCDEF01")] + [InlineData("0123456789abcdef0123456789abcdef0123456")] + [InlineData("0123456789abcdef0123456789abcdef012345678")] + [InlineData("0123456789abcdef0123456789abcdef01234\"567")] + public void ASourceRefThatIsNotAFullLowerCaseCommitIsRefused(string sourceRef) + { + Assert.Throws(() => SiteBuilder.SourceUrl(sourceRef)); + } + + [Fact] + public async Task EveryPageLinksTheContentHashedStylesheetThatTheSiteEmits() + { + var site = await SiteBuilder.BuildAsync(new SiteOptions(), TestContext.Current.CancellationToken); + + var stylesheet = site.Files.Single(file => StylesheetPath.IsMatch(file.Path)); + foreach (var path in HtmlPaths) + { + var html = Encoding.UTF8.GetString(site.Find(path)!.Content); + Assert.Contains("Page not found — ArcForges", notFound, StringComparison.Ordinal); + Assert.Contains("

Page not found.

", notFound, StringComparison.Ordinal); + Assert.Contains("This page is not part of the preview.", notFound, StringComparison.Ordinal); + Assert.Contains("href=\"/\"", notFound, StringComparison.Ordinal); + Assert.DoesNotContain(" HtmlFiles => new(HtmlPaths); + + private static async Task PageHtml(string path) + { + var site = await SiteBuilder.BuildAsync(new SiteOptions(), TestContext.Current.CancellationToken); + return Encoding.UTF8.GetString(site.Find(path)?.Content ?? throw new InvalidOperationException("Missing page: " + path)); + } + + private static string Title(string html) + { + var match = Regex.Match(html, "(.*?)", RegexOptions.Singleline | RegexOptions.CultureInvariant); + Assert.True(match.Success, "No title element."); + return WebUtility.HtmlDecode(match.Groups[1].Value); + } +} diff --git a/tests/ArcForges.Web.Site.Tests/SiteParity/HtmlNormaliser.cs b/tests/ArcForges.Web.Site.Tests/SiteParity/HtmlNormaliser.cs new file mode 100644 index 0000000..b43620f --- /dev/null +++ b/tests/ArcForges.Web.Site.Tests/SiteParity/HtmlNormaliser.cs @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Normalised-DOM comparison of the public pages (WEB.40 parity gate, 2026-10-09 adjudication). Both sides are parsed with +// the HTML5 parser (AngleSharp), so void-element syntax, attribute-name case, entity form, boolean attribute spelling and +// text-node splitting do not count as differences. Scripts, module preloads and comments are not part of the rendered +// structure and are removed, which is the TB-01 change; every other difference is reported. +using System.Text; +using AngleSharp; +using AngleSharp.Dom; +using AngleSharp.Html.Parser; + +namespace ArcForges.Web.Site.Tests.SiteParity; + +/// The normalised structure of one HTML document, one line per start tag, end tag or text run. +public sealed record NormalisedHtml(IReadOnlyList Lines, int RemovedScripts, int RemovedModulePreloads, int RemovedComments); + +internal static class HtmlNormaliser +{ + /// The placeholder that replaces a content-hashed stylesheet name. Its bytes are compared separately. + internal const string StylesheetPlaceholder = "/assets/.css"; + + private static readonly HtmlParser Parser = new(); + + internal static NormalisedHtml Normalise(string html) + { + ArgumentNullException.ThrowIfNull(html); + var document = Parser.ParseDocument(html); + var state = new Walk(); + if (document.DocumentElement is { } root) + { + state.Element(root); + } + return new NormalisedHtml(state.Lines, state.RemovedScripts, state.RemovedModulePreloads, state.RemovedComments); + } + + /// The line-level difference between two normalised documents, in an LCS order; empty when they are equal. + internal static IReadOnlyList Difference(IReadOnlyList reference, IReadOnlyList candidate) + { + ArgumentNullException.ThrowIfNull(reference); + ArgumentNullException.ThrowIfNull(candidate); + var n = reference.Count; + var m = candidate.Count; + var common = new int[n + 1, m + 1]; + for (var i = n - 1; i >= 0; i--) + { + for (var j = m - 1; j >= 0; j--) + { + common[i, j] = string.Equals(reference[i], candidate[j], StringComparison.Ordinal) + ? common[i + 1, j + 1] + 1 + : Math.Max(common[i + 1, j], common[i, j + 1]); + } + } + var result = new List(); + int a = 0, b = 0; + while (a < n && b < m) + { + if (string.Equals(reference[a], candidate[b], StringComparison.Ordinal)) + { + a++; + b++; + } + else if (common[a + 1, b] >= common[a, b + 1]) + { + result.Add("- " + reference[a++]); + } + else + { + result.Add("+ " + candidate[b++]); + } + } + while (a < n) + { + result.Add("- " + reference[a++]); + } + while (b < m) + { + result.Add("+ " + candidate[b++]); + } + return result; + } + + private sealed class Walk + { + private static readonly char[] Whitespace = [' ', '\t', '\n', '\r', '\f']; + + /// The void elements have no end tag and no children, so their syntax (with or without the slash) is not compared. + private static readonly HashSet VoidElements = new(StringComparer.Ordinal) + { + "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr", + }; + + internal List Lines { get; } = []; + internal int RemovedScripts { get; private set; } + internal int RemovedModulePreloads { get; private set; } + internal int RemovedComments { get; private set; } + + internal void Element(IElement element) + { + if (element.LocalName == "script") + { + RemovedScripts++; + return; + } + if (element.LocalName == "link" && HasToken(element.GetAttribute("rel"), "modulepreload")) + { + RemovedModulePreloads++; + return; + } + + Lines.Add("<" + element.LocalName + Attributes(element) + ">"); + if (VoidElements.Contains(element.LocalName)) + { + return; + } + var text = new StringBuilder(); + foreach (var child in element.ChildNodes) + { + switch (child) + { + case IText: + text.Append(child.TextContent); + break; + case IElement childElement: + Flush(text); + Element(childElement); + break; + case IComment: + RemovedComments++; + break; + } + } + Flush(text); + Lines.Add(""); + } + + private void Flush(StringBuilder text) + { + var value = string.Join(' ', text.ToString().Split(Whitespace, StringSplitOptions.RemoveEmptyEntries)); + text.Clear(); + if (value.Length > 0) + { + Lines.Add("#text " + value); + } + } + + private static string Attributes(IElement element) + { + var isStylesheet = HasToken(element.GetAttribute("rel"), "stylesheet"); + var builder = new StringBuilder(); + foreach (var attribute in element.Attributes.OrderBy(attribute => attribute.Name, StringComparer.Ordinal)) + { + var value = attribute.Value; + if (attribute.Name == "class") + { + value = string.Join(' ', value.Split(Whitespace, StringSplitOptions.RemoveEmptyEntries)); + } + else if (isStylesheet && attribute.Name == "href" && IsHashedStylesheet(value)) + { + value = StylesheetPlaceholder; + } + builder.Append(' ').Append(attribute.Name).Append("=\"").Append(value).Append('"'); + } + return builder.ToString(); + } + + private static bool IsHashedStylesheet(string href) => + href.StartsWith("/assets/", StringComparison.Ordinal) && href.EndsWith(".css", StringComparison.Ordinal); + + private static bool HasToken(string? list, string token) => + list is not null && list.Split(Whitespace, StringSplitOptions.RemoveEmptyEntries).Contains(token, StringComparer.OrdinalIgnoreCase); + } +} diff --git a/tests/ArcForges.Web.Site.Tests/SiteParityTests.cs b/tests/ArcForges.Web.Site.Tests/SiteParityTests.cs new file mode 100644 index 0000000..2f464be --- /dev/null +++ b/tests/ArcForges.Web.Site.Tests/SiteParityTests.cs @@ -0,0 +1,316 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// WEB.40 parity gate for the public Site (brief section 10, 2026-10-09 adjudication). The byte gate is replaced by a +// normalised-DOM gate: the recorded React prerender and the C# Site are parsed as HTML and compared structurally, and every +// non-HTML asset stays byte-identical. The normaliser is tested on fixtures in CI. The comparison against the recorded React +// prerender needs that local output, so it runs only when ARCFORGES_SITE_PARITY_REFERENCE names it (a local opt-in run that +// never happens in CI). The result is recorded in docs/web-40-site-parity.md. +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using ArcForges.Web.Site.Tests.SiteParity; +using Xunit; + +namespace ArcForges.Web.Site.Tests; + +public sealed class SiteParityTests +{ + /// The directory holding the recorded React prerender (for example artifacts/parity/react). + internal const string ReferenceVariable = "ARCFORGES_SITE_PARITY_REFERENCE"; + + /// An optional file that receives the parity report of a local run. + internal const string ReportVariable = "ARCFORGES_SITE_PARITY_REPORT"; + + /// The source commit that the React prerender was built from (VITE_SOURCE_REF), so the source links match. + internal const string ReferenceSourceRef = "80900a1430c0fbb483e4a220e20980e3c18728b3"; + + private static readonly string[] HtmlPaths = ["index.html", "hello/index.html", "cloud-hello/index.html", "404.html"]; + + private static readonly string[] ByteIdenticalPaths = ["404.css", "favicon.svg", "robots.txt"]; + + /// One SHA-256 source of the React policy, with its leading space (the policy lists them after 'self'). + private static readonly Regex ScriptHash = new(@" 'sha256-[A-Za-z0-9+/=]+'", RegexOptions.CultureInvariant); + + private static readonly Regex ReferenceStylesheet = new(@"href=""/(assets/[^""]+\.css)""", RegexOptions.CultureInvariant); + + [Fact] + public void NormalisationAcceptsEquivalentSerialisationsAndRemovesHydrationNodes() + { + // The React serialiser and the Razor HtmlRenderer spell the same page differently; none of these may be a difference. + var react = """ + Your hello — ArcForges + +

Hello, World!

+ + """; + var csharp = """ + + + + + Your hello — ArcForges + + + +

Hello, World!

+ + + + + """; + + var reference = HtmlNormaliser.Normalise(react); + var candidate = HtmlNormaliser.Normalise(csharp); + + Assert.Equal(reference.Lines, candidate.Lines); + Assert.Empty(HtmlNormaliser.Difference(reference.Lines, candidate.Lines)); + Assert.Equal(1, reference.RemovedScripts); + Assert.Equal(1, reference.RemovedModulePreloads); + Assert.True(reference.RemovedComments >= 3); + Assert.Equal(0, candidate.RemovedScripts + candidate.RemovedModulePreloads + candidate.RemovedComments); + } + + [Fact] + public void NormalisationReportsTextAttributeAndStructuralDifferences() + { + var reference = HtmlNormaliser.Normalise("

Hello

Hello example
"); + + var text = HtmlNormaliser.Normalise("

Hi

Hello example
"); + var textDifference = HtmlNormaliser.Difference(reference.Lines, text.Lines); + Assert.Contains("- #text Hello", textDifference); + Assert.Contains("+ #text Hi", textDifference); + + var attribute = HtmlNormaliser.Normalise("

Hello

Hello example
"); + Assert.Contains("+ ", HtmlNormaliser.Difference(reference.Lines, attribute.Lines)); + + var structure = HtmlNormaliser.Normalise("

Hello

"); + Assert.NotEmpty(HtmlNormaliser.Difference(reference.Lines, structure.Lines)); + } + + [Fact] + public void AScriptThatEnablesAControlIsRemovedAndTheStaticControlStaysDisabled() + { + // The retired live control: the React page disabled the button until a script enabled it. Removing the script leaves + // the control disabled in the static markup, so the only difference is the removed script (the retirement). + var react = "
"; + var csharp = "
"; + + var reference = HtmlNormaliser.Normalise(react); + var candidate = HtmlNormaliser.Normalise(csharp); + + Assert.Empty(HtmlNormaliser.Difference(reference.Lines, candidate.Lines)); + Assert.Equal(1, reference.RemovedScripts); + } + + [Fact] + public void OnlyAHashedStylesheetHrefIsAPlaceholderAndOtherHrefsAreCompared() + { + var reference = HtmlNormaliser.Normalise(""); + var candidate = HtmlNormaliser.Normalise(""); + Assert.Equal(reference.Lines, candidate.Lines); + Assert.Contains(reference.Lines, line => line.Contains(HtmlNormaliser.StylesheetPlaceholder, StringComparison.Ordinal)); + + // The anchor is nested under , so it is not Lines[0]; its own line must keep the href verbatim. + var anchor = HtmlNormaliser.Normalise("
download"); + Assert.Contains("", anchor.Lines); + Assert.DoesNotContain(anchor.Lines, line => line.Contains(HtmlNormaliser.StylesheetPlaceholder, StringComparison.Ordinal)); + } + + [Fact] + public void TheOnlyContentSecurityPolicyDifferenceIsTheReactScriptHashes() + { + // The React policy listed one SHA-256 per inline script body; the C# Site has no inline script, so script-src is 'self'. + var reference = " Content-Security-Policy: default-src 'self'; script-src 'self' 'sha256-aaaa=' 'sha256-bbbb/+='; style-src 'self'"; + var candidate = " Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'"; + + Assert.Equal(candidate, WithoutScriptHashes(reference)); + Assert.Empty(HeaderDifferences([reference], [candidate])); + Assert.NotEmpty(HeaderDifferences([" Referrer-Policy: no-referrer"], [" Referrer-Policy: origin"])); + Assert.NotEmpty(HeaderDifferences([reference], [" Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'"])); + } + + [Fact] + public void AMatchedStylesheetPairIsExcusedOnItsOwnSidesAndNothingElseIs() + { + var reference = new[] { "404.css", "__spa-fallback.html", "assets/entry.client-C6nGypqo.js", "assets/root-BPtceTrQ.css", "index.html" }; + var candidate = new[] { "404.css", "assets/site.d4912dbd6dc5e22b.css", "index.html" }; + + var comparison = CompareFileSets(reference, candidate, "assets/root-BPtceTrQ.css", "assets/site.d4912dbd6dc5e22b.css"); + + Assert.Empty(comparison.Failures); + Assert.Equal(new[] { "__spa-fallback.html", "assets/entry.client-C6nGypqo.js" }, comparison.ReferenceOnly); + Assert.Empty(comparison.CandidateOnly); + } + + [Fact] + public void AnUnmatchedStylesheetStillFailsOnEitherSide() + { + var reference = new[] { "assets/root-BPtceTrQ.css", "index.html" }; + var candidate = new[] { "assets/site.d4912dbd6dc5e22b.css", "index.html" }; + + // The React page names a stylesheet that the reference does not hold, so no pair is matched. + var unnamed = CompareFileSets(reference, candidate, "assets/missing.css", "assets/site.d4912dbd6dc5e22b.css"); + Assert.Contains(unnamed.Failures, failure => failure.Contains("is not a reference file", StringComparison.Ordinal)); + + // The candidate names a stylesheet that it does not hold. + var absent = CompareFileSets(reference, candidate, "assets/root-BPtceTrQ.css", "assets/site.nothere.css"); + Assert.Contains(absent.Failures, failure => failure.Contains("has no stylesheet file", StringComparison.Ordinal)); + + // The candidate has a second stylesheet that no reference stylesheet pairs with. + var extra = new[] { "assets/extra.css", "assets/site.d4912dbd6dc5e22b.css", "index.html" }; + var second = CompareFileSets(reference, extra, "assets/root-BPtceTrQ.css", "assets/site.d4912dbd6dc5e22b.css"); + Assert.Equal(new[] { "assets/extra.css" }, second.CandidateOnly); + Assert.Contains("The candidate has files the reference does not.", second.Failures); + } + + [Fact] + public void AReferenceStylesheetThatIsNotTheMatchedPairStillFailsTheRuntimeRule() + { + var reference = new[] { "__spa-fallback.html", "assets/entry.client-C6nGypqo.js", "assets/root-BPtceTrQ.css", "assets/unpaired.css", "index.html" }; + var candidate = new[] { "assets/site.d4912dbd6dc5e22b.css", "index.html" }; + + var comparison = CompareFileSets(reference, candidate, "assets/root-BPtceTrQ.css", "assets/site.d4912dbd6dc5e22b.css"); + + Assert.Equal(new[] { "__spa-fallback.html", "assets/entry.client-C6nGypqo.js", "assets/unpaired.css" }, comparison.ReferenceOnly); + Assert.Contains("The reference-only files are not exactly the React runtime files.", comparison.Failures); + } + + [Fact] + public void ANonIdenticalStylesheetPairIsNotTheSameBytes() + { + // The file-set check excuses the pair's names only; the bytes of the pair are compared by SameBytes. + Assert.True(SameBytes(Encoding.UTF8.GetBytes("a{color:red}"), Encoding.UTF8.GetBytes("a{color:red}"))); + Assert.False(SameBytes(Encoding.UTF8.GetBytes("a{color:red}"), Encoding.UTF8.GetBytes("a{color:blue}"))); + Assert.False(SameBytes(Encoding.UTF8.GetBytes("a{color:red}"), null)); + } + + [Fact] + public async Task TheCSharpSiteAgreesWithTheRecordedReactPrerenderWhenItIsNamed() + { + var reference = Environment.GetEnvironmentVariable(ReferenceVariable); + if (string.IsNullOrWhiteSpace(reference) || Environment.GetEnvironmentVariable("CI") is not null) + Assert.Skip($"Local parity run only: set {ReferenceVariable} to the recorded React prerender directory; never on CI."); + var root = Path.GetFullPath(reference); + Assert.True(Directory.Exists(root), $"The React prerender directory does not exist: {root}"); + + var site = await SiteBuilder.BuildAsync(new SiteOptions { SourceRef = ReferenceSourceRef }, TestContext.Current.CancellationToken); + var report = new StringBuilder(); + var failures = new List(); + + // File inventory. The reference-only files are the React runtime (hydration bundles and the SPA fallback). The candidate + // has no file the reference lacks, except its content-hashed stylesheet, which is one matched pair with the reference + // stylesheet named by the React page. The pair is excused on its own side only; its bytes are compared below. + var referenceFiles = Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories) + .Select(path => Path.GetRelativePath(root, path).Replace('\\', '/')) + .OrderBy(path => path, StringComparer.Ordinal) + .ToList(); + var candidateFiles = site.Files.Select(file => file.Path).OrderBy(path => path, StringComparer.Ordinal).ToList(); + var stylesheet = ReferenceStylesheet.Match(File.ReadAllText(Path.Combine(root, "index.html"), Encoding.UTF8)).Groups[1].Value; + var candidateStylesheet = Assert.Single(candidateFiles, path => path.StartsWith("assets/site.", StringComparison.Ordinal)); + var inventory = CompareFileSets(referenceFiles, candidateFiles, stylesheet, candidateStylesheet); + report.AppendLine($"Reference files: {referenceFiles.Count}. Candidate files: {candidateFiles.Count}."); + report.AppendLine($"Reference-only (React runtime, intended): {string.Join(", ", inventory.ReferenceOnly)}"); + report.AppendLine($"Candidate-only: {(inventory.CandidateOnly.Count == 0 ? "none" : string.Join(", ", inventory.CandidateOnly))}"); + report.AppendLine($"Stylesheet pair: {stylesheet} and {candidateStylesheet}."); + failures.AddRange(inventory.Failures); + + // HTML pages: each page is a document, and its normalised structure must be equal. + foreach (var path in HtmlPaths) + { + var candidateFile = site.Find(path) ?? throw new InvalidOperationException($"The Site has no {path}."); + var referenceHtml = File.ReadAllText(Path.Combine(root, path), Encoding.UTF8); + var candidateHtml = Encoding.UTF8.GetString(candidateFile.Content); + if (!referenceHtml.StartsWith("", StringComparison.OrdinalIgnoreCase) || !candidateHtml.StartsWith("", StringComparison.OrdinalIgnoreCase)) + failures.Add($"{path} is not an HTML5 document in both outputs."); + var normalisedReference = HtmlNormaliser.Normalise(referenceHtml); + var normalisedCandidate = HtmlNormaliser.Normalise(candidateHtml); + var difference = HtmlNormaliser.Difference(normalisedReference.Lines, normalisedCandidate.Lines); + report.AppendLine($"{path}: {normalisedCandidate.Lines.Count} normalised lines; React removed {normalisedReference.RemovedScripts} scripts, {normalisedReference.RemovedModulePreloads} module preloads, {normalisedReference.RemovedComments} comments; differences {difference.Count}."); + foreach (var line in difference) + report.AppendLine($" {line}"); + if (difference.Count > 0) + failures.Add($"{path} differs after normalisation."); + } + + // Non-HTML assets: byte-identical, including the stylesheet pair whose name is content-hashed. + foreach (var path in ByteIdenticalPaths) + { + var same = SameBytes(File.ReadAllBytes(Path.Combine(root, path)), site.Find(path)?.Content); + report.AppendLine($"{path}: {(same ? "byte identical" : "DIFFERENT BYTES")}"); + if (!same) + failures.Add($"{path} is not byte-identical."); + } + var stylesheetSame = SameBytes(File.ReadAllBytes(Path.Combine(root, stylesheet)), site.Find(candidateStylesheet)?.Content); + report.AppendLine($"{stylesheet} against {candidateStylesheet}: {(stylesheetSame ? "byte identical" : "DIFFERENT BYTES")}"); + if (!stylesheetSame) + failures.Add("The stylesheet bytes are not identical."); + + // Security headers: every line equal, except the Content-Security-Policy, whose only difference is the script hashes. + var referenceHeaders = File.ReadAllLines(Path.Combine(root, "_headers"), Encoding.UTF8); + var candidateHeaders = Encoding.UTF8.GetString(site.Find("_headers")!.Content).TrimEnd('\n').Split('\n'); + var headerDifferences = HeaderDifferences(referenceHeaders, candidateHeaders); + report.AppendLine($"_headers: {candidateHeaders.Length} lines; differences {headerDifferences.Count}."); + foreach (var line in headerDifferences) + report.AppendLine($" {line}"); + if (headerDifferences.Count > 0) + failures.Add("The security headers differ beyond the script hashes."); + + var text = report.ToString(); + var reportPath = Environment.GetEnvironmentVariable(ReportVariable); + if (!string.IsNullOrWhiteSpace(reportPath)) + File.WriteAllText(reportPath, text, new UTF8Encoding(false)); + Assert.True(failures.Count == 0, text + string.Join(Environment.NewLine, failures)); + } + + /// The file-set verdict of the parity gate: what remains on each side once the matched stylesheet pair is excused. + internal sealed record FileSetComparison(IReadOnlyList ReferenceOnly, IReadOnlyList CandidateOnly, IReadOnlyList Failures); + + /// + /// Compares two sorted file inventories. The stylesheet the React page names and the candidate's content-hashed stylesheet + /// are one matched pair, excused on their own side only, and only when both files exist. Everything else stays strict: the + /// reference-only files must be exactly the React runtime files, and the candidate may have no other file the reference lacks. + /// + internal static FileSetComparison CompareFileSets( + IReadOnlyList referenceFiles, + IReadOnlyList candidateFiles, + string referenceStylesheet, + string candidateStylesheet) + { + var failures = new List(); + if (!referenceFiles.Contains(referenceStylesheet, StringComparer.Ordinal)) + failures.Add($"The React page names a stylesheet that is not a reference file: '{referenceStylesheet}'."); + if (!candidateFiles.Contains(candidateStylesheet, StringComparer.Ordinal)) + failures.Add($"The candidate has no stylesheet file '{candidateStylesheet}'."); + + var referenceOnly = referenceFiles + .Except(candidateFiles, StringComparer.Ordinal) + .Where(path => !string.Equals(path, referenceStylesheet, StringComparison.Ordinal)) + .ToList(); + var candidateOnly = candidateFiles + .Except(referenceFiles, StringComparer.Ordinal) + .Where(path => !string.Equals(path, candidateStylesheet, StringComparison.Ordinal)) + .ToList(); + var expectedReferenceOnly = referenceFiles + .Where(path => path == "__spa-fallback.html" || (path.StartsWith("assets/", StringComparison.Ordinal) && path.EndsWith(".js", StringComparison.Ordinal))) + .ToList(); + if (!referenceOnly.SequenceEqual(expectedReferenceOnly, StringComparer.Ordinal)) + failures.Add("The reference-only files are not exactly the React runtime files."); + if (candidateOnly.Count > 0) + failures.Add("The candidate has files the reference does not."); + return new FileSetComparison(referenceOnly, candidateOnly, failures); + } + + internal static bool SameBytes(byte[] reference, byte[]? candidate) => + candidate is not null && SHA256.HashData(reference).AsSpan().SequenceEqual(SHA256.HashData(candidate)); + + /// The header lines that differ, after the React script hashes are removed from its policy. + internal static IReadOnlyList HeaderDifferences(IReadOnlyList reference, IReadOnlyList candidate) + { + var normalisedReference = reference.Select(WithoutScriptHashes).ToList(); + var normalisedCandidate = candidate.Select(WithoutScriptHashes).ToList(); + return HtmlNormaliser.Difference(normalisedReference, normalisedCandidate); + } + + /// A header line with each script hash removed. Only the Content-Security-Policy line can carry one. + internal static string WithoutScriptHashes(string line) => + line.StartsWith(" Content-Security-Policy:", StringComparison.Ordinal) ? ScriptHash.Replace(line, string.Empty) : line; +} diff --git a/tests/ArcForges.Web.Site.Tests/packages.lock.json b/tests/ArcForges.Web.Site.Tests/packages.lock.json new file mode 100644 index 0000000..4883249 --- /dev/null +++ b/tests/ArcForges.Web.Site.Tests/packages.lock.json @@ -0,0 +1,179 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "AngleSharp": { + "type": "Direct", + "requested": "[1.7.0, )", + "resolved": "1.7.0", + "contentHash": "v1R++46dblGRBo2/fKFUfgtZOaFnDGQhqBM0AarxgZSJuumj1e1vexBbjlTv2hx3Olr3qeoJa2yUoWyv5fuJ5A==" + }, + "ArcForges.Build.Policy": { + "type": "Direct", + "requested": "[1.0.0-ci.94.1, )", + "resolved": "1.0.0-ci.94.1", + "contentHash": "PCpeEXikk2m87zhMF6L8tbHe2QrI87mO4Nc5lJBenEDhOePwwV4TkArc6IaM1G11Pm8ojVjurLAIoPW22ozb/A==" + }, + "xunit.v3.mtp-v2": { + "type": "Direct", + "requested": "[4.0.1, )", + "resolved": "4.0.1", + "contentHash": "s88KiWwDDYgOWV3A+ViJiqCe9cLU/Rt6Gb5TfOC7Abv/HKSoj4IHVPcOQk7jPt7HhZHHEts5IuaVpB30eO5B1w==", + "dependencies": { + "xunit.analyzers": "2.1.0", + "xunit.v3.assert": "[4.0.1]", + "xunit.v3.core.mtp-v2": "[4.0.1]" + } + }, + "ArcForges.Contracts.Foundation": { + "type": "Transitive", + "resolved": "1.0.0-ci.287.1", + "contentHash": "Jwe5Z6K57vDsKmcxQFbbwyQZQUQsOkzYH26ogQIRb/biRtaGxLCtOsNg4felCuNTJt9bwRmC6AGx3eJyCoCk/g==", + "dependencies": { + "Google.Protobuf": "3.36.1" + } + }, + "Google.Protobuf": { + "type": "Transitive", + "resolved": "3.36.1", + "contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ==" + }, + "Grpc.Core.Api": { + "type": "Transitive", + "resolved": "2.84.0", + "contentHash": "p2SOMl6q/GZ4/5MLkgboC/z55g4zKUEruRn/g46QgppjQBVnsLZVBU/9VO7n60ll38G3Eo8Zu9b7x+W01GKFVg==" + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==" + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, + "Microsoft.Testing.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "2.4.0", + "contentHash": "JeP1RFqBa11fWmBk8xEfZcMKr4rxWSyI6OZ+659V069CaMkTEOQBW2UdSSeNz3absOsygcn7JJkzerC4LGnZ9w==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.23.0", + "Microsoft.Testing.Platform": "[2.4.0, 3.0.0)" + } + }, + "Microsoft.Testing.Extensions.TrxReport.Abstractions": { + "type": "Transitive", + "resolved": "2.4.0", + "contentHash": "uRb+4qM42dFDg4kWJZ2kFEcwESNVCRZJjItp5vETorN3rSJGysP617LqYirOcrCYmxg+obPreHMM5JcsNDKtBw==", + "dependencies": { + "Microsoft.Testing.Platform": "[2.4.0, 3.0.0)" + } + }, + "Microsoft.Testing.Platform": { + "type": "Transitive", + "resolved": "2.4.0", + "contentHash": "dp1N3P1ujb0ztwFgqz2o/ItEvq+pm19/AiA+Xq7Zpcy7oPMcxDBZLYGtf0LlU1MveEBJuKVjZhI+LnkfcH/0jQ==" + }, + "Microsoft.Testing.Platform.MSBuild": { + "type": "Transitive", + "resolved": "2.4.0", + "contentHash": "qr5M6h16YHMJLFDcWELFVMMpGte2BUmveBZKT5YoBV+bmuJRPu9bv/Zqke4yQuOEKRNxoAETrG5jr+/6Rnr3Hg==", + "dependencies": { + "Microsoft.Testing.Platform": "[2.4.0, 3.0.0)" + } + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==" + }, + "System.Security.AccessControl": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "IQ4NXP/B3Ayzvw0rDQzVTYsCKyy0Jp9KI6aYcK7UnGVlR9+Awz++TIPCQtPYfLJfOpm8ajowMR09V7quD3sEHw==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "X7QXEcZQGz0G/HL4HUyK+aAvNa/IMGbOCnFIq4jD/Evktq12xANKwzOUr7b08vCmC1LXu/47qHWOdjm3KfaJ0A==" + }, + "xunit.v3.assert": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "nC7d3cY06Oo7hkdwWPZkBR0Ud75xNhgx0P7i0GmMMZC3puCGlc4szFiT30biH03IN/eDnr8h+nv3A1UD17bk/A==" + }, + "xunit.v3.common": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "Qf25TVdadDQYf9zVxSd7L9RNQhuP0jCUHmo96XTmIKLxGefylIEV63jRbXBPFzdXmV09TCdcurk+AQ9LiWS2Hg==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "xunit.v3.core.mtp-v2": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "7qfTfrIfS2wybpSVyRLmhXHbSudD2eNw66LfukixmKsRCTcfvOrLsx0qGL/lObcEh3Z2F4SIXgoFnLIiB8yfjQ==", + "dependencies": { + "Microsoft.Testing.Extensions.Telemetry": "2.4.0", + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.4.0", + "Microsoft.Testing.Platform": "2.4.0", + "Microsoft.Testing.Platform.MSBuild": "2.4.0", + "xunit.v3.extensibility.core": "[4.0.1]", + "xunit.v3.runner.inproc.console": "[4.0.1]" + } + }, + "xunit.v3.extensibility.core": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "J0d5OfFcp920nZxCRIvU14+TZhiSPQR0wvqEMZ3MRiJLPu1VmYlcRNkmcSW0i1DwkB81gAHc+fHUQf/cU64MYg==", + "dependencies": { + "xunit.v3.common": "[4.0.1]" + } + }, + "xunit.v3.runner.common": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "p9AyfBpj2e5Iws8B57SbLiSngg7mEdvegHInKR4T2cjt2mis9h8htVgCnmu//agZO4zri6p6VQOj5EkHHTy3gA==", + "dependencies": { + "Microsoft.Win32.Registry": "[5.0.0]", + "System.Security.AccessControl": "[6.0.1]", + "xunit.v3.common": "[4.0.1]" + } + }, + "xunit.v3.runner.inproc.console": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "Hqwfd6ehMIhPmVWUQ2dgmknzuLFTWeyp8ES1q3D4YR5bQVyiXDcaIoaFwqAz2zgLr49WaW4Mz7VVncP7u/Q8/Q==", + "dependencies": { + "xunit.v3.extensibility.core": "[4.0.1]", + "xunit.v3.runner.common": "[4.0.1]" + } + }, + "arcforges.web.site": { + "type": "Project", + "dependencies": { + "ArcForges.Web.Ui": "[1.0.0, )" + } + }, + "arcforges.web.ui": { + "type": "Project", + "dependencies": { + "ArcForges.Contracts.PublicApi": "[1.0.0-ci.287.1, )" + } + }, + "ArcForges.Contracts.PublicApi": { + "type": "CentralTransitive", + "requested": "[1.0.0-ci.287.1, )", + "resolved": "1.0.0-ci.287.1", + "contentHash": "/chAZbT/NTs4yyQBV5dgh37/dWBTKt3PHCikIidI9T90LheydsJoCWxKHGC6Tjzw0rqA62rYxnQfXGzKhTea5Q==", + "dependencies": { + "ArcForges.Contracts.Foundation": "1.0.0-ci.287.1", + "Google.Protobuf": "3.36.1", + "Grpc.Core.Api": "2.84.0" + } + } + } + } +} diff --git a/tests/ArcForges.Web.Tooling.Tests/ArcForges.Web.Tooling.Tests.csproj b/tests/ArcForges.Web.Tooling.Tests/ArcForges.Web.Tooling.Tests.csproj new file mode 100644 index 0000000..732f0c0 --- /dev/null +++ b/tests/ArcForges.Web.Tooling.Tests/ArcForges.Web.Tooling.Tests.csproj @@ -0,0 +1,15 @@ + + + + Exe + true + + + + + + + AGPL + AGPL-3.0-only + + diff --git a/tests/ArcForges.Web.Tooling.Tests/ProfileBudgetTests.cs b/tests/ArcForges.Web.Tooling.Tests/ProfileBudgetTests.cs new file mode 100644 index 0000000..f9329a1 --- /dev/null +++ b/tests/ArcForges.Web.Tooling.Tests/ProfileBudgetTests.cs @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: AGPL-3.0-only +using System.IO.Compression; +using System.Text; +using ArcForges.Web.Tooling.Profiles; +using Xunit; + +namespace ArcForges.Web.Tooling.Tests; + +/// +/// The Blazor profile budgets (eng/policy/profile-budgets.json). They replace the React budgets of apps/app. Each case has +/// a passing and a failing example; the one-for-one successor of tests/unit/app-proof.test.ts budget cases. +/// +public sealed class ProfileBudgetTests +{ + private static readonly string BudgetPath = Path.Combine(RepositoryRoot.Find(), "eng", "policy", "profile-budgets.json"); + + private static string Document(string profiles = "", int regression = 10, string extra = "") + { + var metrics = string.Join(", ", ProfileMeasure.Metrics.Select(metric => $"\"{metric}\": 1000")); + return "{\"schema\": 1, \"regressionPercent\": " + regression + ", \"profiles\": {" + + (profiles.Length > 0 ? profiles : $"\"account\": {{{metrics}}}, \"chat\": {{{metrics}}}") + + "}, \"interactionBudgets\": {\"status\": \"re-baseline-pending\"}" + extra + "}"; + } + + private static ProfileMeasure Measure(long value) => new( + (int)value, + value, + value, + value, + value, + value, + value, + value); + + [Fact] + public void TheReviewedBudgetFileCoversBothProfilesWithPositiveMeasuredBaselines() + { + var budgets = ProfileBudgets.Parse(File.ReadAllBytes(BudgetPath)); + + Assert.Equal(10, budgets.RegressionPercent); + Assert.Equal(["account", "chat"], budgets.Profiles.Keys.OrderBy(key => key, StringComparer.Ordinal)); + foreach (var profile in budgets.Profiles.Values) + foreach (var metric in ProfileMeasure.Metrics) + Assert.True(profile[metric] > 0, metric); + } + + [Fact] + public void TheBudgetFileRefusesAMissingOrExtraField() + { + Assert.Throws(() => ProfileBudgets.Parse(Encoding.UTF8.GetBytes( + "{\"schema\": 1, \"regressionPercent\": 10, \"profiles\": {}}"))); + Assert.Throws(() => ProfileBudgets.Parse(Encoding.UTF8.GetBytes(Document(extra: ", \"unreviewed\": 1")))); + } + + [Fact] + public void TheBudgetFileRefusesAProfileOutsideTheBundleOrAMissingMetric() + { + var metrics = string.Join(", ", ProfileMeasure.Metrics.Skip(1).Select(metric => $"\"{metric}\": 1000")); + var missingOne = Document(profiles: $"\"account\": {{{metrics}}}, \"chat\": {{{metrics}}}"); + Assert.Throws(() => ProfileBudgets.Parse(Encoding.UTF8.GetBytes(missingOne))); + + var extraProfile = Document(profiles: "\"account\": {}, \"chat\": {}, \"operations\": {}"); + Assert.Throws(() => ProfileBudgets.Parse(Encoding.UTF8.GetBytes(extraProfile))); + } + + [Fact] + public void ABaselineMustBeAPositiveMeasuredValue() + { + var metrics = string.Join(", ", ProfileMeasure.Metrics.Skip(1).Select(metric => $"\"{metric}\": 1")); + var zero = Document(profiles: $"\"account\": {{\"initialRequests\": 0, {metrics}}}, \"chat\": {{{metrics}, \"initialRequests\": 1}}"); + Assert.Throws(() => ProfileBudgets.Parse(Encoding.UTF8.GetBytes(zero))); + } + + [Fact] + public void TheRegressionAllowsExactlyTenPercentGrowthAndRoundsDown() + { + Assert.Equal(1100, ProfileBudgets.Limit(1000, 10)); + Assert.Equal(1098, ProfileBudgets.Limit(999, 10)); + Assert.Equal(1000, ProfileBudgets.Limit(1000, 0)); + } + + [Fact] + public void AMeasureAtTheLimitPassesAndOneByteAboveItFailsNamingTheMetric() + { + var budgets = ProfileBudgets.Parse(Encoding.UTF8.GetBytes(Document())); + + var report = ProfileBudget.Check(Measure(1000), budgets); + Assert.Contains("account.totalGzip: 1000 (baseline 1000, limit 1100)", report, StringComparison.Ordinal); + + var at = Measure(1100); + Assert.Contains("limit 1100", ProfileBudget.Check(at, budgets), StringComparison.Ordinal); + + var above = Measure(1101); + var error = Assert.Throws(() => ProfileBudget.Check(above, budgets)); + Assert.Contains("account.initialRequests", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void TheGzipMeasureIsTheLengthOfTheCompressedBytesAndIsStable() + { + var bytes = Encoding.UTF8.GetBytes(string.Concat(Enumerable.Repeat("blazor profile ", 500))); + + var first = ProfileBudget.GzipLength(bytes); + var second = ProfileBudget.GzipLength(bytes); + + Assert.Equal(first, second); + Assert.True(first > 0 && first < bytes.Length); + using var output = new MemoryStream(); + using (var gzip = new GZipStream(output, CompressionLevel.SmallestSize, leaveOpen: true)) + gzip.Write(bytes); + Assert.Equal(output.Length, first); + } + + [Fact] + public void TheMeasureCountsTheInitialFilesAndIgnoresPrecompressedSiblings() + { + var root = Path.Combine(Path.GetTempPath(), "arcforges-budget-" + Guid.NewGuid().ToString("N")); + try + { + var wwwroot = Path.Combine(root, "wwwroot"); + Directory.CreateDirectory(Path.Combine(wwwroot, "_framework")); + File.WriteAllText(Path.Combine(wwwroot, "index.html"), ""); + File.WriteAllText(Path.Combine(wwwroot, "app.css"), "body{margin:0}"); + File.WriteAllText(Path.Combine(wwwroot, "favicon.svg"), ""); + File.WriteAllText(Path.Combine(wwwroot, "_framework", "dotnet.native.b6l13xorvf.js"), "export {};"); + File.WriteAllText(Path.Combine(wwwroot, "_framework", "ArcForges.Web.App.w1s8cjv5ju.wasm"), "wasm"); + File.WriteAllText(Path.Combine(wwwroot, "_framework", "icudt_CJK.tjcz0u77k5.dat"), "data"); + File.WriteAllText(Path.Combine(wwwroot, "_framework", "ArcForges.Web.App.w1s8cjv5ju.wasm.gz"), "precompressed"); + + var measure = ProfileBudget.Measure(root); + + Assert.Equal(6, measure.InitialRequests); + Assert.Equal(Encoding.UTF8.GetBytes("").Length, measure.HtmlBytes); + Assert.Equal(ProfileBudget.GzipLength(Encoding.UTF8.GetBytes("body{margin:0}")), measure.InitialCssGzip); + Assert.Equal(ProfileBudget.GzipLength(Encoding.UTF8.GetBytes("export {};")), measure.InitialJsGzip); + Assert.Equal(ProfileBudget.GzipLength(Encoding.UTF8.GetBytes("wasm")), measure.InitialWasmGzip); + Assert.Equal(ProfileBudget.GzipLength(Encoding.UTF8.GetBytes("data")), measure.InitialDataGzip); + Assert.Equal(ProfileBudget.GzipLength(Encoding.UTF8.GetBytes("")), measure.InitialOtherGzip); + Assert.Equal( + measure.InitialCssGzip + measure.InitialJsGzip + measure.InitialWasmGzip + measure.InitialDataGzip + measure.InitialOtherGzip, + measure.TotalGzip); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void AMeasureWithoutAShellOrWwwrootIsRefused() + { + var root = Path.Combine(Path.GetTempPath(), "arcforges-budget-" + Guid.NewGuid().ToString("N")); + try + { + Directory.CreateDirectory(root); + Assert.Throws(() => ProfileBudget.Measure(root)); + Directory.CreateDirectory(Path.Combine(root, "wwwroot")); + Assert.Throws(() => ProfileBudget.Measure(root)); + } + finally + { + Directory.Delete(root, recursive: true); + } + } +} diff --git a/tests/ArcForges.Web.Tooling.Tests/ProfileBundleArchiveTests.cs b/tests/ArcForges.Web.Tooling.Tests/ProfileBundleArchiveTests.cs new file mode 100644 index 0000000..fcc97e4 --- /dev/null +++ b/tests/ArcForges.Web.Tooling.Tests/ProfileBundleArchiveTests.cs @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: AGPL-3.0-only +using System.Security.Cryptography; +using System.Text; +using ArcForges.Web.Tooling.Profiles; +using Xunit; + +namespace ArcForges.Web.Tooling.Tests; + +/// +/// The archive of the Blazor profile bundle (the successor of the bundle archive cases of tests/unit/app-bundle.test.ts): +/// deterministic bytes, a digest-named file, an exact read-back, and refusal of unsafe, duplicate, damaged or trailing data. +/// +public sealed class ProfileBundleArchiveTests +{ + private static TarArchive.Entry Entry(string path, string text) => new(path, Encoding.UTF8.GetBytes(text)); + + [Fact] + public void TheArchiveIsDeterministicAndReadsBackExactlyWhatWasWritten() + { + var entries = new[] { Entry("manifest.json", "{}"), Entry("account/index.html", ""), Entry("_headers", "/*\n") }; + + var first = TarArchive.Write(entries); + var second = TarArchive.Write(entries); + var read = TarArchive.Read(first); + + Assert.Equal(first, second); + Assert.Equal(entries.Select(entry => entry.Path), read.Select(entry => entry.Path)); + Assert.Equal(entries.Select(entry => entry.Bytes), read.Select(entry => entry.Bytes)); + } + + [Fact] + public void TheBundleNameIsTheDigestOfItsOwnArchive() + { + var archive = TarArchive.Write([Entry("manifest.json", "{}")]); + var digest = Convert.ToHexString(SHA256.HashData(archive)).ToLowerInvariant(); + + Assert.Equal("web-profiles-" + digest + ".tar", ProfileBundle.BundleName(digest)); + Assert.Matches("^web-profiles-[a-f0-9]{64}\\.tar$", ProfileBundle.BundleName(digest)); + } + + [Theory] + [InlineData("../escape.txt")] + [InlineData("/absolute.txt")] + public void AnUnsafePathIsRefusedWhenWriting(string path) + { + Assert.Throws(() => TarArchive.Write([Entry(path, "x")])); + } + + [Fact] + public void ADuplicatePathIsRefusedWhenWriting() + { + Assert.Throws(() => TarArchive.Write([Entry("a.txt", "one"), Entry("a.txt", "two")])); + } + + [Fact] + public void DataAfterTheTerminatorIsRefusedWhenReading() + { + var archive = TarArchive.Write([Entry("manifest.json", "{}")]); + var trailing = archive.Concat(new byte[] { 0x41 }).ToArray(); + + Assert.Throws(() => TarArchive.Read(trailing)); + } + + [Fact] + public void ATruncatedArchiveIsRefusedWhenReading() + { + var archive = TarArchive.Write([Entry("manifest.json", "{\"schema\":1}")]); + var truncated = archive[..(archive.Length / 2)]; + + Assert.ThrowsAny(() => TarArchive.Read(truncated)); + } +} diff --git a/tests/ArcForges.Web.Tooling.Tests/ProfileBundlePublishTests.cs b/tests/ArcForges.Web.Tooling.Tests/ProfileBundlePublishTests.cs new file mode 100644 index 0000000..62ca74a --- /dev/null +++ b/tests/ArcForges.Web.Tooling.Tests/ProfileBundlePublishTests.cs @@ -0,0 +1,374 @@ +// SPDX-License-Identifier: AGPL-3.0-only +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using ArcForges.Web.Tooling.Profiles; +using Xunit; + +namespace ArcForges.Web.Tooling.Tests; + +/// +/// The bundle build refusals of the Blazor profile bundle, ported from the two-client merge cases of +/// tests/unit/app-bundle.test.ts. The Blazor application is one publish that serves both profile routes, so each +/// refusal is exercised on a synthetic publish directory with a recorded static web assets manifest: a path that +/// collides with a generated profile or bundle path, a shell that is missing, bytes that differ from the recorded +/// integrity at one path, a file the manifest does not name, a manifest that names a missing file, a missing +/// manifest, and an unfingerprinted framework file. The positive cases are the deterministic digest, the one-archive +/// write and the tamper refusal of a verified archive. +/// +public sealed class ProfileBundlePublishTests +{ + private const string Shell = + "
Loading
" + + ""; + + private static byte[] Utf8(string text) => Encoding.UTF8.GetBytes(text); + + private static Dictionary StandardFiles() => new(StringComparer.Ordinal) + { + ["index.html"] = Utf8(Shell), + ["_framework/blazor.webassembly.js"] = Utf8("// loader"), + ["_framework/dotnet.js"] = Utf8("// dotnet loader"), + ["_framework/dotnet.runtime.a1b2c3d4e5.js"] = Utf8("export {};"), + ["app.css"] = Utf8("body{margin:0}"), + }; + + /// A temporary publish directory. The recorded map is what the static web assets manifest names. + private sealed class Publish : IDisposable + { + public Publish(IReadOnlyDictionary files, IReadOnlyDictionary? recorded = null, bool writeManifest = true, bool writeWwwroot = true) + { + Root = Path.Combine(Path.GetTempPath(), "arcforges-bundle-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Root); + if (!writeWwwroot) + return; + var wwwroot = Path.Combine(Root, "wwwroot"); + Directory.CreateDirectory(wwwroot); + foreach (var (path, bytes) in files) + { + var target = Path.Combine(wwwroot, path.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(target)!); + File.WriteAllBytes(target, bytes); + } + if (writeManifest) + WriteManifest(recorded ?? files); + } + + public string Root { get; } + + public string Location => Root; + + private void WriteManifest(IReadOnlyDictionary recorded) + { + var endpoints = new System.Text.Json.Nodes.JsonArray(); + foreach (var (path, bytes) in recorded.OrderBy(pair => pair.Key, StringComparer.Ordinal)) + { + var integrity = "sha256-" + Convert.ToBase64String(SHA256.HashData(bytes)); + endpoints.Add(new System.Text.Json.Nodes.JsonObject + { + ["Route"] = path, + ["AssetFile"] = path, + ["EndpointProperties"] = new System.Text.Json.Nodes.JsonArray(new System.Text.Json.Nodes.JsonObject + { + ["Name"] = "integrity", + ["Value"] = integrity, + }), + }); + } + var document = new System.Text.Json.Nodes.JsonObject + { + ["Version"] = 1, + ["Endpoints"] = endpoints, + }; + File.WriteAllText(System.IO.Path.Combine(Root, "app.staticwebassets.endpoints.json"), document.ToJsonString()); + } + + public void Dispose() + { + if (Directory.Exists(Root)) + Directory.Delete(Root, recursive: true); + } + } + + private static ProfileBundleResult BuildStandard() + { + using var publish = new Publish(StandardFiles()); + return ProfileBundle.Build(publish.Location); + } + + [Fact] + public void AStandardPublishBuildsADigestNamedBundleThatVerifies() + { + using var publish = new Publish(StandardFiles()); + + var bundle = ProfileBundle.Build(publish.Location); + var summary = ProfileBundle.Verify(bundle.Archive, bundle.Digest); + var paths = TarArchive.Read(bundle.Archive).Select(entry => entry.Path).ToArray(); + + Assert.Equal(ProfileBundle.BundleName(bundle.Digest), bundle.Name); + Assert.Matches("^web-profiles-[a-f0-9]{64}\\.tar$", bundle.Name); + Assert.Equal(bundle.Members, summary.Members); + Assert.Equal(ProfileBundle.ManifestPath, paths[0]); + Assert.Contains("account/index.html", paths); + Assert.Contains("chat/index.html", paths); + Assert.Contains(ProfileBundle.HeadersPath, paths); + Assert.Contains("app.css", paths); + Assert.DoesNotContain(ProfileBundle.ShellPath, paths); + } + + [Fact] + public void TheSameBytesGiveTheSameBundleAndAChangedFileGivesADifferentOne() + { + using var first = new Publish(StandardFiles()); + using var second = new Publish(StandardFiles()); + var changedFiles = StandardFiles(); + changedFiles["app.css"] = Utf8("body{margin:1px}"); + using var changed = new Publish(changedFiles); + + var original = ProfileBundle.Build(first.Location); + + Assert.Equal(original.Digest, ProfileBundle.Build(second.Location).Digest); + Assert.NotEqual(original.Digest, ProfileBundle.Build(changed.Location).Digest); + } + + [Theory] + [InlineData("account/index.html")] + [InlineData("chat/index.html")] + [InlineData("chat/extra.html")] + [InlineData("_headers")] + [InlineData("manifest.json")] + public void APublishThatCarriesAGeneratedOrReservedBundlePathIsRefused(string path) + { + var files = StandardFiles(); + files[path] = Utf8("reserved"); + using var publish = new Publish(files); + + var error = Assert.Throws(() => ProfileBundle.Build(publish.Location)); + + Assert.Contains("reserved bundle path: " + path, error.Message, StringComparison.Ordinal); + } + + [Fact] + public void APublishWithoutItsApplicationShellIsRefused() + { + var files = StandardFiles(); + files.Remove("index.html"); + using var publish = new Publish(files); + + var error = Assert.Throws(() => ProfileBundle.Build(publish.Location)); + + Assert.Contains("no application shell", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void APublishWithoutItsWwwrootDirectoryIsRefused() + { + using var publish = new Publish(new Dictionary(), writeWwwroot: false); + + var error = Assert.Throws(() => ProfileBundle.Build(publish.Location)); + + Assert.Contains("no wwwroot directory", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void AFileWhoseBytesDifferFromItsRecordedIntegrityIsRefused() + { + // Two builds that fill one path with different bytes: the manifest records the original bytes of app.css. + var recorded = StandardFiles(); + var files = StandardFiles(); + files["app.css"] = Utf8("body{margin:2px}"); + using var publish = new Publish(files, recorded); + + var error = Assert.Throws(() => ProfileBundle.Build(publish.Location)); + + Assert.Contains("Served bytes differ from the build's integrity: app.css", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void AFileThatTheStaticWebAssetsManifestDoesNotNameIsRefused() + { + var files = StandardFiles(); + files["extra.txt"] = Utf8("unlisted"); + var recorded = StandardFiles(); + using var publish = new Publish(files, recorded); + + var error = Assert.Throws(() => ProfileBundle.Build(publish.Location)); + + Assert.Contains("not named by the build's static web assets manifest", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void AManifestThatNamesAMissingFileIsRefused() + { + var recorded = StandardFiles(); + recorded["ghost.css"] = Utf8("body{}"); + using var publish = new Publish(StandardFiles(), recorded); + + var error = Assert.Throws(() => ProfileBundle.Build(publish.Location)); + + Assert.Contains("The manifest names a missing file: ghost.css", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void APublishWithoutItsStaticWebAssetsManifestIsRefused() + { + using var publish = new Publish(StandardFiles(), writeManifest: false); + + var error = Assert.Throws(() => ProfileBundle.Build(publish.Location)); + + Assert.Contains("exactly one static web assets manifest", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void AnUnfingerprintedFrameworkFileFailsTheBuildBeforeAnyBundleIsWritten() + { + var files = StandardFiles(); + files["_framework/app.js"] = Utf8("export {};"); + using var publish = new Publish(files); + + var error = Assert.Throws(() => ProfileBundle.Build(publish.Location)); + + Assert.Contains("Unfingerprinted framework file: _framework/app.js", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void WritingABundleLeavesOneDigestNamedArchiveAndKeepsOtherFiles() + { + using var publish = new Publish(StandardFiles()); + var output = Path.Combine(Path.GetTempPath(), "arcforges-bundle-out-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(output); + try + { + File.WriteAllText(Path.Combine(output, "web-profiles-" + new string('0', 64) + ".tar"), "stale"); + File.WriteAllText(Path.Combine(output, "unrelated.txt"), "kept"); + var bundle = ProfileBundle.Build(publish.Location); + + ProfileBundle.Write(output, bundle); + + var names = Directory.EnumerateFiles(output).Select(file => Path.GetFileName(file)!).Order(StringComparer.Ordinal).ToArray(); + Assert.Equal(new[] { "unrelated.txt", bundle.Name }.Order(StringComparer.Ordinal).ToArray(), names); + } + finally + { + Directory.Delete(output, recursive: true); + } + } + + [Fact] + public void AnArchiveWhoseMemberChangedIsRefusedByItsManifest() + { + var bundle = BuildStandard(); + var entries = TarArchive.Read(bundle.Archive) + .Select(entry => new TarArchive.Entry(entry.Path, (byte[])entry.Bytes.Clone())) + .ToList(); + var target = entries.Single(entry => entry.Path == "app.css"); + target.Bytes[0] ^= 1; + var tampered = TarArchive.Write(entries); + + var error = Assert.Throws(() => ProfileBundle.Verify(tampered)); + + Assert.Contains("Content of app.css", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void AnArchiveWithAnExtraMemberIsRefusedByItsManifest() + { + var bundle = BuildStandard(); + var entries = TarArchive.Read(bundle.Archive).ToList(); + entries.Add(new TarArchive.Entry("zz-extra.txt", Utf8("x"))); + var extra = TarArchive.Write(entries); + + var error = Assert.Throws(() => ProfileBundle.Verify(extra)); + + Assert.Contains("does not hold exactly the manifest's files", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void AnArchiveWhoseDigestDoesNotMatchIsRefused() + { + var bundle = BuildStandard(); + + var error = Assert.Throws(() => ProfileBundle.Verify(bundle.Archive, new string('a', 64))); + + Assert.Contains("Bundle digest mismatch.", error.Message, StringComparison.Ordinal); + } + + private static byte[] Gzip(byte[] bytes) + { + using var output = new MemoryStream(); + using (var gzip = new System.IO.Compression.GZipStream(output, System.IO.Compression.CompressionLevel.Optimal)) + gzip.Write(bytes); + return output.ToArray(); + } + + private static byte[] Brotli(byte[] bytes) + { + using var output = new MemoryStream(); + using (var brotli = new System.IO.Compression.BrotliStream(output, System.IO.Compression.CompressionLevel.Optimal)) + brotli.Write(bytes); + return output.ToArray(); + } + + [Fact] + public void TheShellEncodingsAreNotServedAtTheRootAndEveryOtherFileIsKept() + { + // The encodings are real Brotli and gzip streams of the shell, because the static graph check decodes them. + var withEncodings = StandardFiles(); + withEncodings["index.html.br"] = Brotli(Utf8(Shell)); + withEncodings["index.html.gz"] = Gzip(Utf8(Shell)); + using var plain = new Publish(StandardFiles()); + using var encoded = new Publish(withEncodings); + + var bundle = ProfileBundle.Build(encoded.Location); + var served = TarArchive.Read(bundle.Archive); + var reference = TarArchive.Read(ProfileBundle.Build(plain.Location).Archive); + + Assert.DoesNotContain("index.html.br", served.Select(entry => entry.Path)); + Assert.DoesNotContain("index.html.gz", served.Select(entry => entry.Path)); + Assert.DoesNotContain(ProfileBundle.ShellPath, served.Select(entry => entry.Path)); + // Without the encodings, every served entry after the manifest is the same path and the same bytes. + Assert.Equal(reference.Skip(1).Select(entry => entry.Path), served.Skip(1).Select(entry => entry.Path)); + Assert.Equal(reference.Skip(1).Select(entry => entry.Bytes), served.Skip(1).Select(entry => entry.Bytes)); + ProfileBundle.Verify(bundle.Archive, bundle.Digest); + } + + [Fact] + public void AnArchiveThatServesAShellEncodingAtTheRootIsRefused() + { + var bundle = BuildStandard(); + var entries = TarArchive.Read(bundle.Archive).ToList(); + var encoding = Gzip(Utf8(Shell)); + var manifest = System.Text.Json.Nodes.JsonNode.Parse(entries[0].Bytes)!.AsObject(); + // The forged manifest lists the encoding with its real digest, in the ordinal order the verifier requires. + var rows = new SortedDictionary(StringComparer.Ordinal); + foreach (var pair in manifest["files"]!.AsObject()) + rows[pair.Key] = pair.Value!.DeepClone(); + rows["index.html.gz"] = new System.Text.Json.Nodes.JsonObject + { + ["sha256"] = Convert.ToHexString(SHA256.HashData(encoding)).ToLowerInvariant(), + ["bytes"] = encoding.Length, + }; + var files = new System.Text.Json.Nodes.JsonObject(); + foreach (var (path, row) in rows) + files[path] = row; + var forgedManifest = new System.Text.Json.Nodes.JsonObject + { + ["schema"] = ProfileBundle.Schema, + ["profiles"] = manifest["profiles"]!.DeepClone(), + ["files"] = files, + }; + var forgedEntries = new List + { + new(ProfileBundle.ManifestPath, Encoding.UTF8.GetBytes(forgedManifest.ToJsonString())), + }; + forgedEntries.AddRange(entries.Skip(1).Append(new TarArchive.Entry("index.html.gz", encoding)) + .OrderBy(entry => entry.Path, StringComparer.Ordinal)); + var forged = TarArchive.Write(forgedEntries); + + var error = Assert.Throws(() => ProfileBundle.Verify(forged)); + + Assert.Contains("encodings must not be served at the root", error.Message, StringComparison.Ordinal); + } +} diff --git a/tests/ArcForges.Web.Tooling.Tests/ProfileBundleRulesTests.cs b/tests/ArcForges.Web.Tooling.Tests/ProfileBundleRulesTests.cs new file mode 100644 index 0000000..efdbe48 --- /dev/null +++ b/tests/ArcForges.Web.Tooling.Tests/ProfileBundleRulesTests.cs @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: AGPL-3.0-only +using ArcForges.Web.Tooling.Profiles; +using Xunit; + +namespace ArcForges.Web.Tooling.Tests; + +/// +/// The rules of the Blazor profile bundle that the Cloud proof deployment consumes: the framework cache rule and the +/// fingerprint requirement behind it. Each rule has a passing and a failing example. +/// +public sealed class ProfileBundleRulesTests +{ + private static readonly IReadOnlyDictionary Policies = new Dictionary(StringComparer.Ordinal) + { + ["account"] = "default-src 'self'", + ["chat"] = "default-src 'self'", + }; + + [Fact] + public void FingerprintedFrameworkFilesAndTheirPrecompressedSiblingsAreAccepted() + { + ProfileBundle.RequireFingerprintedFramework( + [ + "_framework/dotnet.native.b6l13xorvf.js", + "_framework/dotnet.native.b6l13xorvf.js.br", + "_framework/dotnet.runtime.v06hirbjsv.js", + "_framework/ArcForges.Web.App.w1s8cjv5ju.wasm", + "_framework/ArcForges.Web.App.w1s8cjv5ju.wasm.gz", + "_framework/icudt_CJK.tjcz0u77k5.dat", + "index.html", + "app.css", + ]); + } + + [Fact] + public void TheTwoUnfingerprintedLoadersAreAcceptedAndRevalidated() + { + ProfileBundle.RequireFingerprintedFramework( + [ + "_framework/blazor.webassembly.js", + "_framework/blazor.webassembly.js.gz", + "_framework/dotnet.js", + ]); + Assert.Equal(["_framework/blazor.webassembly.js", "_framework/dotnet.js"], ProfileBundle.UnfingerprintedFrameworkLoaders); + } + + [Theory] + [InlineData("_framework/app.js")] + [InlineData("_framework/ArcForges.Web.App.wasm")] + [InlineData("_framework/Foo.Bar.wasm")] + [InlineData("_framework/blazor.boot.json")] + [InlineData("_framework/ArcForges.Web.App.w1s8cjv5j.wasm")] + [InlineData("_framework/ArcForges.Web.App.W1S8CJV5JU.wasm")] + [InlineData("_framework/nested/ArcForges.Web.App.w1s8cjv5ju.wasm")] + public void AnUnfingerprintedFrameworkFileFailsTheBuild(string path) + { + var error = Assert.Throws(() => ProfileBundle.RequireFingerprintedFramework([path])); + Assert.Contains("Unfingerprinted framework file", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void TheHeadersCarryTheImmutableFrameworkRuleAndTheLoaderOverrides() + { + var headers = ProfileBundle.HeadersText(Policies); + + Assert.Contains( + "/_framework/*\n ! Cache-Control\n Cache-Control: public, max-age=31536000, immutable, no-transform\n", + headers, + StringComparison.Ordinal); + Assert.Contains( + "/_framework/blazor.webassembly.js\n ! Cache-Control\n Cache-Control: public, no-cache, no-transform\n", + headers, + StringComparison.Ordinal); + Assert.Contains( + "/_framework/dotnet.js\n ! Cache-Control\n Cache-Control: public, no-cache, no-transform\n", + headers, + StringComparison.Ordinal); + } + + [Fact] + public void TheHeadersAllowNoUnsafeScriptAndNameEachProfilePathOnce() + { + var headers = ProfileBundle.HeadersText(Policies); + + Assert.DoesNotContain("unsafe-eval", headers, StringComparison.Ordinal); + Assert.DoesNotContain("unsafe-inline", headers, StringComparison.Ordinal); + Assert.DoesNotContain("/index.html", headers, StringComparison.Ordinal); + Assert.Equal(2, headers.Split("/account/*").Length - 1 + (headers.Split("/chat/*").Length - 1)); + } + + [Fact] + public void AProfilePolicyBeyondTheHeaderLineBudgetIsRefused() + { + var oversized = new Dictionary(StringComparer.Ordinal) + { + ["account"] = new string('a', ProfileBundle.HeaderLineBudget), + ["chat"] = "default-src 'self'", + }; + + Assert.Throws(() => ProfileBundle.HeadersText(oversized)); + } +} diff --git a/tests/ArcForges.Web.Tooling.Tests/RepositoryRoot.cs b/tests/ArcForges.Web.Tooling.Tests/RepositoryRoot.cs new file mode 100644 index 0000000..a165ac6 --- /dev/null +++ b/tests/ArcForges.Web.Tooling.Tests/RepositoryRoot.cs @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: AGPL-3.0-only +namespace ArcForges.Web.Tooling.Tests; + +/// Locates the repository root from the test output directory. +public static class RepositoryRoot +{ + /// Walks up from the test assembly until the repository marker files are found. + public static string Find() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "win.slnx")) + && File.Exists(Path.Combine(directory.FullName, "global.json"))) + return directory.FullName; + directory = directory.Parent; + } + throw new DirectoryNotFoundException("The repository root with win.slnx and global.json was not found."); + } +} diff --git a/tests/ArcForges.Web.Tooling.Tests/SiteArchiveTests.cs b/tests/ArcForges.Web.Tooling.Tests/SiteArchiveTests.cs new file mode 100644 index 0000000..18b684a --- /dev/null +++ b/tests/ArcForges.Web.Tooling.Tests/SiteArchiveTests.cs @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: AGPL-3.0-only +using System.Security.Cryptography; +using ArcForges.Web.Site; +using ArcForges.Web.Tooling.Candidate; +using ArcForges.Web.Tooling.Profiles; +using Xunit; + +namespace ArcForges.Web.Tooling.Tests; + +/// +/// The Site release asset (web-site-<sha256>.tar): two builds of the same Site are the same bytes, the name is the +/// digest of those bytes, and the archive holds exactly the Site's own files in ordinal path order. The candidate seal and +/// the refusal of a tampered archive are covered by tests/provenance/csharp-candidate.test.ts on a sealed candidate. +/// +public sealed class SiteArchiveTests +{ + private const string SourceRef = "0123456789abcdef0123456789abcdef01234567"; + + private static async Task BuildSiteAsync() => + await SiteBuilder.BuildAsync(new SiteOptions { SourceRef = SourceRef }); + + private static string Digest(byte[] bytes) => Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); + + [Fact] + public async Task TwoBuildsOfTheSiteGiveTheSameArchiveBytes() + { + var first = SiteArchive.Build((await BuildSiteAsync()).Files); + var second = SiteArchive.Build((await BuildSiteAsync()).Files); + + Assert.Equal(first, second); + } + + [Fact] + public async Task TheArchiveIsNamedByTheDigestOfItsOwnBytes() + { + var archive = SiteArchive.Build((await BuildSiteAsync()).Files); + var name = SiteArchive.Name(Digest(archive)); + + Assert.Matches("^web-site-[a-f0-9]{64}\\.tar$", name); + Assert.True(SiteArchive.IsName(name)); + Assert.False(SiteArchive.IsName("web-site-" + new string('a', 64) + ".tar.gz")); + Assert.False(SiteArchive.IsName("web-profiles-" + Digest(archive) + ".tar")); + } + + [Fact] + public async Task TheArchiveHoldsEverySiteFileInOrderAndNothingElse() + { + var site = await BuildSiteAsync(); + var expected = site.Files.OrderBy(file => file.Path, StringComparer.Ordinal).ToArray(); + + var entries = TarArchive.Read(SiteArchive.Build(site.Files)); + + Assert.Equal(expected.Select(file => file.Path), entries.Select(entry => entry.Path)); + Assert.Equal(expected.Select(file => file.Content), entries.Select(entry => entry.Bytes)); + } + + [Fact] + public async Task AChangedSiteFileChangesTheArchiveAndItsName() + { + var site = await BuildSiteAsync(); + var original = SiteArchive.Build(site.Files); + var changed = site.Files + .Select(file => file.Path == "robots.txt" ? file with { Content = [.. file.Content, (byte)'#'] } : file) + .ToArray(); + + var other = SiteArchive.Build(changed); + + Assert.NotEqual(original, other); + Assert.NotEqual(SiteArchive.Name(Digest(original)), SiteArchive.Name(Digest(other))); + } +} diff --git a/tests/ArcForges.Web.Tooling.Tests/packages.lock.json b/tests/ArcForges.Web.Tooling.Tests/packages.lock.json new file mode 100644 index 0000000..02b5f03 --- /dev/null +++ b/tests/ArcForges.Web.Tooling.Tests/packages.lock.json @@ -0,0 +1,179 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "ArcForges.Build.Policy": { + "type": "Direct", + "requested": "[1.0.0-ci.94.1, )", + "resolved": "1.0.0-ci.94.1", + "contentHash": "PCpeEXikk2m87zhMF6L8tbHe2QrI87mO4Nc5lJBenEDhOePwwV4TkArc6IaM1G11Pm8ojVjurLAIoPW22ozb/A==" + }, + "xunit.v3.mtp-v2": { + "type": "Direct", + "requested": "[4.0.1, )", + "resolved": "4.0.1", + "contentHash": "s88KiWwDDYgOWV3A+ViJiqCe9cLU/Rt6Gb5TfOC7Abv/HKSoj4IHVPcOQk7jPt7HhZHHEts5IuaVpB30eO5B1w==", + "dependencies": { + "xunit.analyzers": "2.1.0", + "xunit.v3.assert": "[4.0.1]", + "xunit.v3.core.mtp-v2": "[4.0.1]" + } + }, + "ArcForges.Contracts.Foundation": { + "type": "Transitive", + "resolved": "1.0.0-ci.287.1", + "contentHash": "Jwe5Z6K57vDsKmcxQFbbwyQZQUQsOkzYH26ogQIRb/biRtaGxLCtOsNg4felCuNTJt9bwRmC6AGx3eJyCoCk/g==", + "dependencies": { + "Google.Protobuf": "3.36.1" + } + }, + "Google.Protobuf": { + "type": "Transitive", + "resolved": "3.36.1", + "contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ==" + }, + "Grpc.Core.Api": { + "type": "Transitive", + "resolved": "2.84.0", + "contentHash": "p2SOMl6q/GZ4/5MLkgboC/z55g4zKUEruRn/g46QgppjQBVnsLZVBU/9VO7n60ll38G3Eo8Zu9b7x+W01GKFVg==" + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==" + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, + "Microsoft.Testing.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "2.4.0", + "contentHash": "JeP1RFqBa11fWmBk8xEfZcMKr4rxWSyI6OZ+659V069CaMkTEOQBW2UdSSeNz3absOsygcn7JJkzerC4LGnZ9w==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.23.0", + "Microsoft.Testing.Platform": "[2.4.0, 3.0.0)" + } + }, + "Microsoft.Testing.Extensions.TrxReport.Abstractions": { + "type": "Transitive", + "resolved": "2.4.0", + "contentHash": "uRb+4qM42dFDg4kWJZ2kFEcwESNVCRZJjItp5vETorN3rSJGysP617LqYirOcrCYmxg+obPreHMM5JcsNDKtBw==", + "dependencies": { + "Microsoft.Testing.Platform": "[2.4.0, 3.0.0)" + } + }, + "Microsoft.Testing.Platform": { + "type": "Transitive", + "resolved": "2.4.0", + "contentHash": "dp1N3P1ujb0ztwFgqz2o/ItEvq+pm19/AiA+Xq7Zpcy7oPMcxDBZLYGtf0LlU1MveEBJuKVjZhI+LnkfcH/0jQ==" + }, + "Microsoft.Testing.Platform.MSBuild": { + "type": "Transitive", + "resolved": "2.4.0", + "contentHash": "qr5M6h16YHMJLFDcWELFVMMpGte2BUmveBZKT5YoBV+bmuJRPu9bv/Zqke4yQuOEKRNxoAETrG5jr+/6Rnr3Hg==", + "dependencies": { + "Microsoft.Testing.Platform": "[2.4.0, 3.0.0)" + } + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==" + }, + "System.Security.AccessControl": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "IQ4NXP/B3Ayzvw0rDQzVTYsCKyy0Jp9KI6aYcK7UnGVlR9+Awz++TIPCQtPYfLJfOpm8ajowMR09V7quD3sEHw==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "X7QXEcZQGz0G/HL4HUyK+aAvNa/IMGbOCnFIq4jD/Evktq12xANKwzOUr7b08vCmC1LXu/47qHWOdjm3KfaJ0A==" + }, + "xunit.v3.assert": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "nC7d3cY06Oo7hkdwWPZkBR0Ud75xNhgx0P7i0GmMMZC3puCGlc4szFiT30biH03IN/eDnr8h+nv3A1UD17bk/A==" + }, + "xunit.v3.common": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "Qf25TVdadDQYf9zVxSd7L9RNQhuP0jCUHmo96XTmIKLxGefylIEV63jRbXBPFzdXmV09TCdcurk+AQ9LiWS2Hg==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "xunit.v3.core.mtp-v2": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "7qfTfrIfS2wybpSVyRLmhXHbSudD2eNw66LfukixmKsRCTcfvOrLsx0qGL/lObcEh3Z2F4SIXgoFnLIiB8yfjQ==", + "dependencies": { + "Microsoft.Testing.Extensions.Telemetry": "2.4.0", + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.4.0", + "Microsoft.Testing.Platform": "2.4.0", + "Microsoft.Testing.Platform.MSBuild": "2.4.0", + "xunit.v3.extensibility.core": "[4.0.1]", + "xunit.v3.runner.inproc.console": "[4.0.1]" + } + }, + "xunit.v3.extensibility.core": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "J0d5OfFcp920nZxCRIvU14+TZhiSPQR0wvqEMZ3MRiJLPu1VmYlcRNkmcSW0i1DwkB81gAHc+fHUQf/cU64MYg==", + "dependencies": { + "xunit.v3.common": "[4.0.1]" + } + }, + "xunit.v3.runner.common": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "p9AyfBpj2e5Iws8B57SbLiSngg7mEdvegHInKR4T2cjt2mis9h8htVgCnmu//agZO4zri6p6VQOj5EkHHTy3gA==", + "dependencies": { + "Microsoft.Win32.Registry": "[5.0.0]", + "System.Security.AccessControl": "[6.0.1]", + "xunit.v3.common": "[4.0.1]" + } + }, + "xunit.v3.runner.inproc.console": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "Hqwfd6ehMIhPmVWUQ2dgmknzuLFTWeyp8ES1q3D4YR5bQVyiXDcaIoaFwqAz2zgLr49WaW4Mz7VVncP7u/Q8/Q==", + "dependencies": { + "xunit.v3.extensibility.core": "[4.0.1]", + "xunit.v3.runner.common": "[4.0.1]" + } + }, + "arcforges.web.site": { + "type": "Project", + "dependencies": { + "ArcForges.Web.Ui": "[1.0.0, )" + } + }, + "arcforges.web.tooling": { + "type": "Project", + "dependencies": { + "ArcForges.Web.Site": "[1.0.0, )" + } + }, + "arcforges.web.ui": { + "type": "Project", + "dependencies": { + "ArcForges.Contracts.PublicApi": "[1.0.0-ci.287.1, )" + } + }, + "ArcForges.Contracts.PublicApi": { + "type": "CentralTransitive", + "requested": "[1.0.0-ci.287.1, )", + "resolved": "1.0.0-ci.287.1", + "contentHash": "/chAZbT/NTs4yyQBV5dgh37/dWBTKt3PHCikIidI9T90LheydsJoCWxKHGC6Tjzw0rqA62rYxnQfXGzKhTea5Q==", + "dependencies": { + "ArcForges.Contracts.Foundation": "1.0.0-ci.287.1", + "Google.Protobuf": "3.36.1", + "Grpc.Core.Api": "2.84.0" + } + } + } + } +} diff --git a/tests/ArcForges.Web.Ui.Tests/ArcForges.Web.Ui.Tests.csproj b/tests/ArcForges.Web.Ui.Tests/ArcForges.Web.Ui.Tests.csproj new file mode 100644 index 0000000..a08be19 --- /dev/null +++ b/tests/ArcForges.Web.Ui.Tests/ArcForges.Web.Ui.Tests.csproj @@ -0,0 +1,16 @@ + + + + Exe + true + + + + + + + + AGPL + AGPL-3.0-only + + diff --git a/tests/ArcForges.Web.Ui.Tests/CloudHelloExampleTests.cs b/tests/ArcForges.Web.Ui.Tests/CloudHelloExampleTests.cs new file mode 100644 index 0000000..ea9818b --- /dev/null +++ b/tests/ArcForges.Web.Ui.Tests/CloudHelloExampleTests.cs @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// The cloud-hello control states: the fixed greeting on success and the exact failure text on any error. +using Bunit; +using Xunit; + +namespace ArcForges.Web.Ui.Tests; + +public sealed class CloudHelloExampleTests +{ + private const string FailureText = "The server is unavailable or returned an unexpected response. Try again later."; + + [Fact] + public void TheCheckShowsTheServerGreetingOnSuccess() + { + using var context = new BunitContext(); + var cut = context.Render(parameters => parameters.Add(component => component.CheckConnection, _ => Task.FromResult("Hello, ArcForges!"))); + + cut.Find("button").Click(); + + cut.WaitForAssertion(() => Assert.Equal("Hello, ArcForges!", cut.Find("[role=status] p").TextContent)); + } + + [Fact] + public void AnyFailureShowsTheExactFailureTextAsAnAlert() + { + using var context = new BunitContext(); + var cut = context.Render(parameters => parameters.Add(component => component.CheckConnection, _ => Task.FromException(new ServerConnectionException("refused")))); + + cut.Find("button").Click(); + + cut.WaitForAssertion(() => Assert.Equal(FailureText, cut.Find("[role=alert] p").TextContent)); + } + + [Fact] + public void TheControlDoesNothingWhenNoCheckIsSupplied() + { + using var context = new BunitContext(); + + var cut = context.Render(); + + Assert.Equal("No request sent yet.", cut.Find("[role=status] p").TextContent); + Assert.Equal("Check connection", cut.Find("button").TextContent.Trim()); + cut.Find("button").Click(); + Assert.Equal("No request sent yet.", cut.Find("[role=status] p").TextContent); + } +} diff --git a/tests/ArcForges.Web.Ui.Tests/HelloExampleTests.cs b/tests/ArcForges.Web.Ui.Tests/HelloExampleTests.cs new file mode 100644 index 0000000..8075999 --- /dev/null +++ b/tests/ArcForges.Web.Ui.Tests/HelloExampleTests.cs @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Port of tests/unit/hello.test.tsx (the form part): validation, recovery and untrusted names rendered as text. +using Bunit; +using Xunit; + +namespace ArcForges.Web.Ui.Tests; + +public sealed class HelloExampleTests +{ + [Fact] + public void FormValidatesRecoversAndRendersUntrustedNamesAsText() + { + using var context = new BunitContext(); + var cut = context.Render(); + var input = cut.Find("#hello-name"); + + input.Input(string.Empty); + cut.Find("form").Submit(); + Assert.Equal("Enter a name between 1 and 80 characters, without control characters.", cut.Find("[role=alert]").TextContent); + + input.Input(""); + cut.Find("form").Submit(); + Assert.Empty(cut.FindAll("[role=alert]")); + Assert.Equal("Hello, !", cut.Find("[role=status] p").TextContent); + Assert.Empty(cut.FindAll("img")); + } + + [Fact] + public void TheControlsAreEnabledAfterTheFirstInteractiveRenderAndTheDefaultGreetingIsShown() + { + using var context = new BunitContext(); + + var cut = context.Render(); + + Assert.Null(cut.Find("#hello-name").GetAttribute("disabled")); + Assert.Equal("Hello, World!", cut.Find("[role=status] p").TextContent); + Assert.Equal("false", cut.Find("#hello-name").GetAttribute("aria-invalid")); + Assert.Equal("name-hint", cut.Find("#hello-name").GetAttribute("aria-describedby")); + } + + [Fact] + public void AnInvalidNameMarksTheFieldAndDescribesTheError() + { + using var context = new BunitContext(); + var cut = context.Render(); + + cut.Find("#hello-name").Input(" "); + cut.Find("form").Submit(); + + Assert.Equal("true", cut.Find("#hello-name").GetAttribute("aria-invalid")); + Assert.Equal("name-error name-hint", cut.Find("#hello-name").GetAttribute("aria-describedby")); + } +} diff --git a/tests/ArcForges.Web.Ui.Tests/ProfileIdentityTests.cs b/tests/ArcForges.Web.Ui.Tests/ProfileIdentityTests.cs new file mode 100644 index 0000000..0226af7 --- /dev/null +++ b/tests/ArcForges.Web.Ui.Tests/ProfileIdentityTests.cs @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: AGPL-3.0-only +using Xunit; + +namespace ArcForges.Web.Ui.Tests; + +public sealed class ProfileIdentityTests +{ + [Fact] + public void ProfileNameIsStable() + { + Assert.Equal("ui", ArcForges.Web.Ui.ProfileIdentity.Name); + } +} diff --git a/tests/ArcForges.Web.Ui.Tests/WasmContentSecurityPolicyTests.cs b/tests/ArcForges.Web.Ui.Tests/WasmContentSecurityPolicyTests.cs new file mode 100644 index 0000000..fe9727d --- /dev/null +++ b/tests/ArcForges.Web.Ui.Tests/WasmContentSecurityPolicyTests.cs @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// The shared WebAssembly profile policy (WEB.40; P2-021 item 2): positive and negative fixtures for the exact token set. +using ArcForges.Web.Ui; +using Xunit; + +namespace ArcForges.Web.Ui.Tests; + +public sealed class WasmContentSecurityPolicyTests +{ + private const string HostPage = "
"; + + [Fact] + public void ScriptSourcesAreExactlySelfAndWasmUnsafeEvalWithoutHashesForAPageWithoutInlineScript() + { + var directives = WasmContentSecurityPolicy.ParseDirectives(WasmContentSecurityPolicy.FromHostPages([HostPage])); + Assert.Equal(new[] { "'self'", "'wasm-unsafe-eval'" }, directives["script-src"]); + } + + [Fact] + public void StyleSourcesAreSelfOnlyAndTheDefaultIsSelf() + { + var directives = WasmContentSecurityPolicy.ParseDirectives(WasmContentSecurityPolicy.FromHostPages([HostPage])); + Assert.Equal(new[] { "'self'" }, directives["style-src"]); + Assert.Equal(new[] { "'self'" }, directives["default-src"]); + Assert.Equal(new[] { "'none'" }, directives["object-src"]); + } + + [Fact] + public void NoUnsafeTokenAppearsInAnyDirective() + { + var policy = WasmContentSecurityPolicy.FromHostPages([HostPage, ""]); + Assert.DoesNotContain("'unsafe-inline'", policy, StringComparison.Ordinal); + Assert.DoesNotContain("'unsafe-eval'", policy, StringComparison.Ordinal); + } + + [Fact] + public void AnInlineScriptAddsExactlyItsSha256HashToScriptSources() + { + var page = ""; + var script = WasmContentSecurityPolicy.ParseDirectives(WasmContentSecurityPolicy.FromHostPages([page]))["script-src"]; + Assert.Equal(3, script.Count); + Assert.Equal("'self'", script[0]); + Assert.Equal("'wasm-unsafe-eval'", script[1]); + Assert.Equal( + "'sha256-" + Convert.ToBase64String(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes("window.start = 1;"))) + "'", + script[2]); + } + + [Fact] + public void AnAbsoluteExternalScriptIsRefused() + { + Assert.Throws(() => WasmContentSecurityPolicy.FromHostPages([""])); + Assert.Throws(() => WasmContentSecurityPolicy.FromHostPages([""])); + } + + [Fact] + public void AnUnquotedOrUnterminatedScriptSourceIsRefused() + { + Assert.Throws(() => WasmContentSecurityPolicy.FromHostPages([""])); + Assert.Throws(() => WasmContentSecurityPolicy.FromHostPages([""])); + Assert.Throws(() => WasmContentSecurityPolicy.FromHostPages([""); - await input.press("Enter"); - await expect(page.getByRole("alert")).toHaveCount(0); - await expect(page.getByRole("status")).toContainText(""); - expect(requests).toEqual([]); - expect(errors).toEqual([]); -}); -test("public content and navigation work without JavaScript", async ({ browser, baseURL }) => { - const context = await browser.newContext({ javaScriptEnabled: false }); - const page = await context.newPage(); - await page.goto(`${baseURL}/`); - await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); - await page.getByRole("link", { name: "Make it your hello" }).click(); - await expect(page.getByRole("status")).toContainText("Hello, World!"); - await expect(page.getByLabel("Your name")).toBeDisabled(); - await expect(page.getByRole("button", { name: "Say hello" })).toBeDisabled(); - await page.getByRole("link", { name: "Check the server connection" }).click(); - await expect(page.getByRole("button", { name: "Check connection" })).toBeDisabled(); - await context.close(); -}); -test("pages are accessible and fit narrow screens", async ({ page }, info) => { - for (const route of ["/", "/hello/", "/cloud-hello/"]) { - const pageName = route === "/" ? "home" : route.split("/")[1]; - await page.goto(route); - const results = await new AxeBuilder({ page }) - .withTags(["wcag2a", "wcag2aa", "wcag21aa"]) - .analyze(); - expect(results.violations).toEqual([]); - await page.setViewportSize({ width: 360, height: 800 }); - expect( - await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), - ).toBe(true); - await page.screenshot({ - path: info.outputPath(`${pageName}-mobile.png`), - fullPage: true, - }); - await page.setViewportSize({ width: 1440, height: 1000 }); - await page.screenshot({ - path: info.outputPath(`${pageName}-desktop.png`), - fullPage: true, - }); - } -}); -test("server connection page makes no automatic API calls", async ({ page }) => { - const requests: string[] = []; - const errors: string[] = []; - page.on("request", (request) => { - if (new URL(request.url()).pathname.startsWith("/api/")) requests.push(request.url()); - }); - page.on("pageerror", (error) => errors.push(error.message)); - await page.goto("/cloud-hello/"); - await expect(page.getByRole("heading", { level: 1 })).toContainText("from the server"); - await expect(page.getByRole("button", { name: "Check connection" })).toBeEnabled(); - await expect(page.getByRole("status")).toContainText("No request sent yet"); - expect(requests).toEqual([]); - expect(errors).toEqual([]); -}); -test("static delivery has security/cache headers and real missing-page and asset responses", async ({ - request, -}) => { - const home = await request.get("/"); - expect(home.status()).toBe(200); - expect(home.headers()["content-security-policy"]).toContain("script-src 'self' 'sha256-"); - expect(home.headers()["content-security-policy"]).not.toMatch(/unsafe-inline|unsafe-eval/); - expect(home.headers()["x-content-type-options"]).toBe("nosniff"); - expect(home.headers()["cache-control"]).toContain("no-transform"); - const html = await home.text(); - const asset = html.match(/href="(\/assets\/[^"]+\.css)"/)?.[1]; - expect(asset).toBeDefined(); - expect((await request.get(asset ?? "")).headers()["cache-control"]).toContain("immutable"); - expect((await request.get("/__build.json")).headers()["cache-control"]).toContain("no-store"); - for (const path of ["/no-such-page", "/assets/missing.js", "/__spa-fallback.html"]) { - const missing = await request.get(path); - expect(missing.status()).toBe(404); - expect(await missing.text()).toContain("Page not found"); - } -}); diff --git a/tests/fixtures/app-probe.ts b/tests/fixtures/app-probe.ts deleted file mode 100644 index 260d130..0000000 --- a/tests/fixtures/app-probe.ts +++ /dev/null @@ -1,78 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -// Test-only fixtures for the Account/Chat profile probes. Never part of a release graph. -// They produce wire shapes with the published generated codecs; they do not call a C# server. -import { - serializeBrowserBootstrapResponseJson, - serializeBrowserReceiptJson, -} from "@arcforges/api-client"; - -export const origin = "https://account.example.test"; - -export const authenticated = { - csrfToken: "c".repeat(24), - authenticated: true, - session: { - sessionId: "6d1d4c2a-62a0-4b86-9d3f-0c6a3d0b5c11", - expiresAt: "2026-10-03T08:00:00.000000Z", - idleExpiresAt: "2026-10-02T20:30:00.000000Z", - userId: "0f0e6a30-5d1c-4c7e-8b53-5b6b7f9a4a10", - deviceId: "8f2a1d77-1c1b-4b0a-9a55-2f1d0e5c7b21", - workspaceIds: ["3f1d3b1e-2a8c-4c44-a1c8-77a1e8f0b9a1", "a2b84f55-0c2d-4b0d-8e6a-5d6e44a6c7d3"], - recoveryGeneration: "18446744073709551615", - purpose: "authenticate", - }, - profile: { - displayName: "Ada Lovelace", - locale: "en-GB", - timezone: "Europe/London", - revision: "9007199254740993", - }, -} as const; - -export const anonymous = { - csrfToken: "a".repeat(24), - authenticated: false, -} as const; - -export function bootstrapBody(value: unknown): Uint8Array { - return serializeBrowserBootstrapResponseJson( - value as Parameters[0], - ); -} - -export function receiptBody(effect = "happened"): Uint8Array { - return serializeBrowserReceiptJson({ - commandId: "7c9e6679-7425-40de-944b-e07fc1f90ae7", - effect, - }); -} - -export function jsonResponse(body: Uint8Array | string, init: ResponseInit = {}): Response { - return new Response(body as BodyInit, { - status: 200, - ...init, - headers: { "content-type": "application/json", ...(init.headers as object | undefined) }, - }); -} - -/** A response whose body is delivered in the given chunks, then optionally fails. */ -export function streamed( - chunks: Uint8Array[], - failWith?: Error, - init: ResponseInit = {}, -): Response { - let index = 0; - const body = new ReadableStream({ - pull(controller) { - const chunk = chunks[index++]; - if (chunk) controller.enqueue(chunk); - else if (failWith) controller.error(failWith); - else controller.close(); - }, - }); - return new Response(body, { - status: 200, - ...init, - headers: { "content-type": "application/json", ...(init.headers as object | undefined) }, - }); -} diff --git a/tests/fixtures/grpc-web.ts b/tests/fixtures/grpc-web.ts deleted file mode 100644 index 12afc4c..0000000 --- a/tests/fixtures/grpc-web.ts +++ /dev/null @@ -1,34 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -// Test-only gRPC-Web fixture. Never included in the deployed static candidate. -import { SayHelloRequestSchema, SayHelloResponseSchema } from "@arcforges/proto"; -import { create, fromBinary, toBinary } from "@bufbuild/protobuf"; - -export function frame(payload: Uint8Array, flag: number) { - const bytes = new Uint8Array(payload.length + 5); - bytes[0] = flag; - new DataView(bytes.buffer).setUint32(1, payload.length); - bytes.set(payload, 5); - return bytes; -} - -export function decodeHelloRequest(bytes: Uint8Array) { - if ( - bytes.length < 5 || - bytes[0] !== 0 || - new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(1) !== bytes.length - 5 - ) - throw new Error("Expected one uncompressed protobuf request frame"); - return fromBinary(SayHelloRequestSchema, bytes.slice(5)); -} - -export function helloResponse(message = "Hello, ArcForges!") { - const data = frame( - toBinary(SayHelloResponseSchema, create(SayHelloResponseSchema, { message })), - 0, - ); - const trailers = frame(new TextEncoder().encode("grpc-status: 0\r\n"), 0x80); - const body = new Uint8Array(data.length + trailers.length); - body.set(data); - body.set(trailers, data.length); - return body; -} diff --git a/tests/provenance/build-identity.test.ts b/tests/provenance/build-identity.test.ts index 3e4b0e6..02e4bee 100644 --- a/tests/provenance/build-identity.test.ts +++ b/tests/provenance/build-identity.test.ts @@ -108,7 +108,10 @@ test("published ContractSet preserves protobuf and JSON schema axes independentl const f = fixture(); const receipt = JSON.parse( readFileSync( - new URL("../../node_modules/@arcforges/api-client/build-identity.json", import.meta.url), + new URL( + "../../eng/contracts/ArcForges.Contracts.PublicApi/1.0.0-ci.287.1/build-identity.json", + import.meta.url, + ), "utf8", ), ); diff --git a/tests/provenance/candidate.test.ts b/tests/provenance/candidate.test.ts deleted file mode 100644 index 0b7e090..0000000 --- a/tests/provenance/candidate.test.ts +++ /dev/null @@ -1,230 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -import assert from "node:assert/strict"; -import { cp, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { test } from "node:test"; -import { candidate, digest, files, json, save, seal, verify } from "../../tooling/project.ts"; - -// A positive case must consume a real built candidate. There is no synthetic positive shortcut. -const baseline = await verify(); -test("the actual compiled candidate has the complete browser and legal closure", async () => { - const receipt = await json(path.join(candidate, "provenance/receipt.json")); - assert.equal(receipt.parsedModules, 303); - assert.equal(receipt.emittedModules, 136); - const bom = await json(path.join(candidate, "runtime-sbom.cdx.json")); - const names = new Set(bom.components.map((component: { name: string }) => component.name)); - for (const name of [ - "react", - "react-dom", - "react-router", - "scheduler", - "vite", - "rolldown", - "tailwindcss", - "turbo-stream", - "protobuf-wkt", - ]) - assert(names.has(name), `Missing emitted origin: ${name}`); - assert(!names.has("isbot") && !names.has("cookie-es")); -}); - -async function reseal(root: string) { - const receipt = await json(path.join(root, "provenance/receipt.json")); - receipt.members = Object.fromEntries( - await Promise.all( - (await files(root)) - .filter((file) => !["manifest.json", "provenance/receipt.json"].includes(file)) - .map(async (file) => [file, digest(await readFile(path.join(root, file)))]), - ), - ); - await save(path.join(root, "provenance/receipt.json"), receipt); - await seal(root, { source: baseline.source, version: baseline.version, dirty: baseline.dirty }); -} -function rejects(name: string, change: (root: string) => Promise, reason: RegExp) { - test(name, async () => { - const root = await mkdtemp(path.join(tmpdir(), "web-candidate-provenance-")); - try { - await cp(candidate, root, { recursive: true }); - await change(root); - await reseal(root); - await assert.rejects(verify(root, baseline.source), reason); - } finally { - assert.equal(path.dirname(root), tmpdir()); - assert(path.basename(root).startsWith("web-candidate-provenance-")); - await rm(root, { recursive: true, force: true }); - } - }); -} -async function edit(root: string, file: string, change: (text: string) => string) { - const full = path.join(root, file); - await writeFile(full, change(await readFile(full, "utf8"))); -} -rejects( - "reject executable changes after both manifests are resealed", - async (root) => { - const file = (await files(root)).find((file) => - /^assets\/assets\/entry.client-.*\.js$/u.test(file), - ); - assert(file); - await edit(root, file, (text) => `${text}\nfetch('/unreviewed');`); - }, - /independently reviewed two-build oracle/u, -); -rejects( - "reject added executable modules with plausible generated names", - async (root) => { - await writeFile(path.join(root, "assets/assets/extra-12345678.js"), "console.log('new');"); - }, - /Unclassified compiled browser resource/u, -); -rejects( - "reject omitted compiled modules", - async (root) => { - const file = (await files(root)).find((file) => /\/hello_pb-.*\.js$/u.test(file)); - assert(file); - await rm(path.join(root, file)); - }, - /independently reviewed two-build oracle/u, -); -rejects( - "reject added static files", - async (root) => { - await writeFile(path.join(root, "assets/new.txt"), "unreviewed"); - }, - /Unclassified public member/u, -); -rejects( - "reject HTML with a changed inline script", - async (root) => { - await edit(root, "assets/index.html", (text) => - text.replace("", ""), - ); - }, - /independently reviewed two-build oracle/u, -); -rejects( - "reject a forged Router fingerprint", - async (root) => { - const file = (await files(root)).find((file) => /\/manifest-.*\.js$/u.test(file)); - assert(file); - await edit(root, file, (text) => text.replace('"version":"', '"version":"0')); - }, - /Router manifest fingerprint mismatch/u, -); -rejects( - "reject a changed module graph", - async (root) => { - const graph = await json(path.join(root, "provenance/browser-graph.json")); - graph.modules[0].imports = []; - graph.modules[0].id = "foreign-source.js"; - await save(path.join(root, "provenance/browser-graph.json"), graph); - }, - /Candidate graph differs/u, -); -rejects( - "reject a substituted profile", - async (root) => { - const value = await json(path.join(root, "provenance/profile.json")); - value.templates = {}; - await save(path.join(root, "provenance/profile.json"), value); - }, - /Candidate substituted/u, -); -rejects( - "reject a modified used record", - async (root) => { - const file = "provenance/records/browser-resources-r2.json"; - const value = await json(path.join(root, file)); - value.review.rationale = "forged"; - await save(path.join(root, file), value); - }, - /Candidate record changed/u, -); -rejects( - "reject omitted original legal terms", - async (root) => { - await edit(root, "assets/third-party-notices.txt", (text) => - text.replaceAll("Evan Wallace", "Removed Author"), - ); - }, - /Required full third-party notices changed/u, -); -rejects( - "reject reformatting of immutable candidate records", - async (root) => { - await edit(root, "provenance/records/browser-resources-r2.json", (text) => `${text}\n`); - }, - /Candidate record bytes changed/u, -); -rejects( - "reject omitted full AGPL legal body", - async (root) => { - await writeFile(path.join(root, "assets/license.txt"), "AGPL-3.0-only\n"); - }, - /Full AGPL legal text changed/u, -); -rejects( - "reject weakened CSP after resealing", - async (root) => { - await edit(root, "assets/_headers", (text) => - text.replace("default-src 'self'", "default-src *"), - ); - }, - /Security\/cache headers changed/u, -); -rejects( - "reject browser SBOM dependency-edge tampering", - async (root) => { - const value = await json(path.join(root, "runtime-sbom.cdx.json")); - value.dependencies = []; - await save(path.join(root, "runtime-sbom.cdx.json"), value); - }, - /Emitted browser SBOM identities\/edges changed/u, -); -rejects( - "reject build SBOM identity tampering", - async (root) => { - const value = await json(path.join(root, "sbom.cdx.json")); - value.metadata.component.name = "foreign-checkout"; - await save(path.join(root, "sbom.cdx.json"), value); - }, - /Build SBOM identities\/edges changed/u, -); -rejects( - "reject changed Contracts source identity", - async (root) => { - await edit(root, "contracts/proto/source.json", (text) => - text.replace("1fb1dfaaaaa7a9f2f4c64a6e1c6a2b7de47d67b0", "a".repeat(40)), - ); - }, - /Published Contracts member changed/u, -); -rejects( - "reject added candidate-private files", - async (root) => { - await writeFile(path.join(root, "private-extra.txt"), "unclassified"); - }, - /Unclassified candidate member/u, -); - -rejects( - "reject build metadata mutation even after both manifests are resealed", - async (root) => { - const file = path.join(root, "assets/__build-info.json"); - const value = await json(file); - value.build.buildId = "another-run"; - await save(file, value); - }, - /Built identity differs/u, -); -rejects( - "reject axis mutation even after both manifests are resealed", - async (root) => { - const file = path.join(root, "assets/__build-info.json"); - const value = await json(file); - value.axes.ContractSet.values[0].version = "999"; - await save(file, value); - }, - /Built identity differs/u, -); diff --git a/tests/provenance/cloudflare-delivery.test.ts b/tests/provenance/cloudflare-delivery.test.ts new file mode 100644 index 0000000..93d3888 --- /dev/null +++ b/tests/provenance/cloudflare-delivery.test.ts @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// The deployment helpers that remain in Node (tooling/cloudflare.ts) and the CI version rule. Ported from the vitest file +// tests/unit/delivery.test.ts; the candidate seal and CSP cases retire to tests/provenance/csharp-candidate.test.ts and the +// C# Site suite, which verify the same bytes. +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + deploymentUrl, + requireCustomDomain, + waitForDelivery, + waitForIdentity, +} from "../../tooling/cloudflare.ts"; +import { releaseVersion } from "../../tooling/project.ts"; + +test("CI versions are unique across runs and reruns", () => { + assert.equal(releaseVersion("10", "2"), "0.1.0-ci.10.2"); + assert.equal(releaseVersion(undefined, undefined), "0.1.0-local"); + assert.throws(() => releaseVersion("1", "bad")); +}); + +test("live verification tolerates propagation with read-only requests", async () => { + const identity = { source: "a".repeat(40), version: "0.1.0-ci.1.1" }; + let calls = 0; + await waitForIdentity(deploymentUrl, identity, { + attempts: 3, + pause: async () => {}, + fetcher: async (_input, init) => { + assert.equal(init?.method, undefined); + calls++; + return Response.json(calls < 2 ? { ...identity, version: "old" } : identity); + }, + }); + assert.equal(calls, 2); + await assert.rejects( + waitForIdentity(deploymentUrl, identity, { + attempts: 2, + pause: async () => {}, + fetcher: async () => Response.json({ version: "old" }), + }), + /No redeployment/u, + ); + await assert.rejects( + waitForIdentity("https://other.example.com", identity), + /Unexpected deployment host/u, + ); +}); + +test("deployment requires the custom domain to belong to this production Worker", () => { + const domain = { + hostname: "arcforges.com", + service: "arcforges-web", + environment: "production", + }; + assert.doesNotThrow(() => requireCustomDomain([domain])); + for (const domains of [ + [], + [{ ...domain, service: "another-worker" }], + [{ ...domain, hostname: "other.example.com" }], + [{ ...domain, environment: "staging" }], + ]) + assert.throws(() => requireCustomDomain(domains), /Attach arcforges\.com/u); +}); + +test("asset verification waits for headers as well as identity and fails boundedly", async () => { + let reads = 0; + await waitForDelivery( + async (signal) => { + assert.equal(signal.aborted, false); + reads++; + if (reads === 1) throw new Error("Missing no-transform: /404.css"); + }, + { timeoutMs: 1000, intervalMs: 0 }, + ); + assert.equal(reads, 2); + await assert.rejects( + waitForDelivery( + async () => { + throw new Error("Deployed bytes differ: /hello/"); + }, + { timeoutMs: 10, intervalMs: 0 }, + ), + /no redeployment was attempted\. Error: Deployed bytes differ: \/hello\//u, + ); +}); diff --git a/tests/provenance/csharp-candidate.test.ts b/tests/provenance/csharp-candidate.test.ts new file mode 100644 index 0000000..1ad64ad --- /dev/null +++ b/tests/provenance/csharp-candidate.test.ts @@ -0,0 +1,472 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Determinism, seal and security tests of the C# candidate and the Blazor profile bundle. They run the built tool +// (tools/ArcForges.Web.Tooling, Release) on temporary copies. Build and verification only: nothing is uploaded. +// Build the tool first: dotnet build tools/ArcForges.Web.Tooling/ArcForges.Web.Tooling.csproj -c Release --no-restore +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + access, + cp, + mkdtemp, + readdir, + readFile, + rename, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import { expectedIdentity } from "../../tooling/build-identity.ts"; +import { toolLibrary } from "../../tooling/candidate.ts"; +import { root, workerScript } from "../../tooling/project.ts"; + +// A missing tool build fails here with the build command, not later with a spawn error for every test. +await access(toolLibrary).catch(() => { + throw new Error(`The C# tool is not built at ${toolLibrary}. Build it first (see the header of this file).`); +}); + +const sha = (bytes: Uint8Array | string) => createHash("sha256").update(bytes).digest("hex"); +/** + * expectedIdentity reads the producer environment from process.env. This suite builds a local candidate, so the identity + * is read without any GITHUB_* variable, whatever the runner sets (the CI job sets GITHUB_ACTIONS=true, and a CI identity + * needs a CI-shaped version). The variables are restored before this returns. + */ +function localIdentity(version: string) { + const saved = Object.entries(process.env).filter(([name]) => name.startsWith("GITHUB_")); + for (const [name] of saved) delete process.env[name]; + try { + return expectedIdentity(version); + } finally { + for (const [name, value] of saved) process.env[name] = value; + } +} +const scratch = await mkdtemp(path.join(tmpdir(), "arcforges-candidate-")); +const identityRecord = localIdentity("0.1.0-local"); +const source = identityRecord.build.sourceCommit; +const dirty = identityRecord.build.dirty; +const workerPath = path.join(scratch, "index.js"); +const identityPath = path.join(scratch, "build-info.json"); +await writeFile(workerPath, await workerScript()); +await writeFile(identityPath, `${JSON.stringify(identityRecord, null, 2)}\n`); +const candidateDirectory = (name: string) => path.join(scratch, name); + +interface ToolResult { + status: number; + output: string; +} + +function tool(args: string[]): ToolResult { + const result = spawnSync("dotnet", [toolLibrary, ...args], { + cwd: root, + encoding: "utf8", + windowsHide: true, + timeout: 600000, + }); + return { status: result.status ?? -1, output: `${result.stdout ?? ""}${result.stderr ?? ""}` }; +} + +function build( + out: string, + options: { worker?: string; identity?: string; dirtyFlag?: string } = {}, +): ToolResult { + return tool([ + "candidate", + "build", + "--out", + out, + "--repo", + root, + "--source-ref", + source, + "--version", + "0.1.0-local", + "--identity", + options.identity ?? identityPath, + "--worker", + options.worker ?? workerPath, + "--dirty", + options.dirtyFlag ?? String(dirty), + ]); +} + +function verify(directory: string, expectedSource?: string): ToolResult { + const args = ["candidate", "verify", "--dir", directory, "--repo", root]; + if (expectedSource) args.push("--expected-source", expectedSource); + return tool(args); +} + +function expectRefusal(result: ToolResult, message: string) { + assert.notEqual(result.status, 0, `Expected a refusal containing: ${message}\n${result.output}`); + assert( + result.output.includes(message), + `Wrong refusal, expected "${message}":\n${result.output}`, + ); +} + +async function listFiles(directory: string, prefix = ""): Promise { + const found: string[] = []; + for (const entry of await readdir(path.join(directory, prefix), { withFileTypes: true })) { + const relative = `${prefix}${entry.name}`; + if (entry.isDirectory()) found.push(...(await listFiles(directory, `${relative}/`))); + else found.push(relative); + } + return found.sort(); +} + +async function snapshot(directory: string): Promise> { + const result = new Map(); + for (const file of await listFiles(directory)) + result.set(file, sha(await readFile(path.join(directory, file)))); + return result; +} + +/** Recomputes the receipt and the seal over the current files, so only the deeper rules can refuse a tampered candidate. */ +async function reseal(directory: string) { + const receiptPath = path.join(directory, "provenance/receipt.json"); + const receipt = JSON.parse(await readFile(receiptPath, "utf8")); + const members: Record = {}; + for (const file of await listFiles(directory)) + if (file !== "manifest.json" && file !== "provenance/receipt.json") + members[file] = sha(await readFile(path.join(directory, file))); + receipt.members = members; + await writeFile(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`); + const manifestPath = path.join(directory, "manifest.json"); + const manifest = JSON.parse(await readFile(manifestPath, "utf8")); + const files: Record = {}; + for (const file of await listFiles(directory)) + if (file !== "manifest.json") files[file] = sha(await readFile(path.join(directory, file))); + manifest.files = files; + await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); +} + +async function copyCandidate(from: string, name: string): Promise { + const to = candidateDirectory(name); + await cp(from, to, { recursive: true }); + return to; +} + +const baseline = candidateDirectory("baseline"); +const repeat = candidateDirectory("repeat"); +const built = build(baseline); +if (built.status !== 0) throw new Error(`Baseline candidate build failed:\n${built.output}`); + +test("two builds of the same inputs are the same bytes and verify against the source", async () => { + const second = build(repeat); + assert.equal(second.status, 0, second.output); + assert.deepEqual(await snapshot(repeat), await snapshot(baseline), "Candidate builds differ"); + const passed = verify(baseline, source); + assert.equal(passed.status, 0, passed.output); + assert.match(passed.output, /Candidate verified: 0\.1\.0-local/u); +}); + +test("a changed public page is refused by the seal", async () => { + const dir = await copyCandidate(baseline, "page-changed"); + await writeFile( + path.join(dir, "assets/index.html"), + `${await readFile(path.join(dir, "assets/index.html"), "utf8")}`, + ); + expectRefusal(verify(dir, source), "Candidate changed: assets/index.html"); +}); + +test("an added public file is refused by the seal", async () => { + const dir = await copyCandidate(baseline, "file-added"); + await writeFile(path.join(dir, "assets/extra.txt"), "unreviewed"); + expectRefusal(verify(dir, source), "Candidate file set changed"); +}); + +test("a removed required member is refused", async () => { + const dir = await copyCandidate(baseline, "member-removed"); + await rm(path.join(dir, "assets/404.html")); + expectRefusal(verify(dir, source), "Candidate file set changed"); +}); + +test("a candidate for another source is refused", async () => { + expectRefusal(verify(baseline, "b".repeat(40)), "Candidate source does not match this run"); +}); + +test("a Worker that differs from the reviewed emission is refused at build time", async () => { + const worker = path.join(scratch, "other-worker.js"); + await writeFile(worker, "export default { fetch() { return new Response('x'); } };\n"); + expectRefusal(build(candidateDirectory("worker-refused"), { worker }), "Worker script changed"); +}); + +test("an identity of another commit or another dirty state is refused at build time", async () => { + const other = path.join(scratch, "other-identity.json"); + const commit = JSON.parse(await readFile(identityPath, "utf8")); + commit.build.sourceCommit = "a".repeat(40); + await writeFile(other, JSON.stringify(commit)); + expectRefusal( + build(candidateDirectory("identity-commit"), { identity: other }), + "another source commit", + ); + + const flipped = path.join(scratch, "flipped-identity.json"); + const state = JSON.parse(await readFile(identityPath, "utf8")); + state.build.dirty = !dirty; + await writeFile(flipped, JSON.stringify(state)); + expectRefusal( + build(candidateDirectory("identity-dirty"), { identity: flipped }), + "another dirty state", + ); +}); + +test("an existing output directory is refused, so a stale candidate cannot be sealed", async () => { + expectRefusal(build(baseline), "Refusing to write into an existing path"); +}); + +test("a resealed public page change is refused by the regenerated Site comparison", async () => { + const dir = await copyCandidate(baseline, "resealed-page"); + const file = path.join(dir, "assets/hello/index.html"); + await writeFile(file, `${await readFile(file, "utf8")}`); + await reseal(dir); + expectRefusal( + verify(dir, source), + "Public Site bytes differ from the regenerated Site: hello/index.html", + ); +}); + +test("a resealed policy loosened to unsafe-inline is refused by the regenerated headers", async () => { + const dir = await copyCandidate(baseline, "resealed-headers"); + const headers = path.join(dir, "assets/_headers"); + await writeFile( + headers, + (await readFile(headers, "utf8")).replace( + "script-src 'self'", + "script-src 'self' 'unsafe-inline'", + ), + ); + await reseal(dir); + expectRefusal( + verify(dir, source), + "Public Site bytes differ from the regenerated Site: _headers", + ); +}); + +test("a resealed wrangler change that enables workers.dev is refused", async () => { + const dir = await copyCandidate(baseline, "resealed-wrangler"); + const config = JSON.parse(await readFile(path.join(dir, "wrangler.json"), "utf8")); + config.workers_dev = true; + await writeFile(path.join(dir, "wrangler.json"), `${JSON.stringify(config, null, 2)}\n`); + await reseal(dir); + expectRefusal(verify(dir, source), "workers.dev must stay disabled"); +}); + +test("a resealed Worker replacement is refused by the pinned digest", async () => { + const dir = await copyCandidate(baseline, "resealed-worker"); + await writeFile( + path.join(dir, "worker/index.js"), + "export default { fetch: () => fetch('https://example.invalid') };\n", + ); + await reseal(dir); + expectRefusal(verify(dir, source), "Worker script changed from the reviewed source"); +}); + +test("a resealed licence change is refused against the repository", async () => { + const dir = await copyCandidate(baseline, "resealed-licence"); + await writeFile(path.join(dir, "assets/license.txt"), "Not the AGPL.\n"); + await reseal(dir); + expectRefusal(verify(dir, source), "Full AGPL legal text changed"); +}); + +test("a resealed identity for another source is refused", async () => { + const dir = await copyCandidate(baseline, "resealed-identity"); + const info = JSON.parse(await readFile(path.join(dir, "assets/__build-info.json"), "utf8")); + info.build.sourceCommit = "c".repeat(40); + await writeFile(path.join(dir, "assets/__build-info.json"), `${JSON.stringify(info, null, 2)}\n`); + await reseal(dir); + expectRefusal(verify(dir, source), "The build identity names another source commit"); +}); + +// The bundle tests consume the publish of ArcForges.Web.App made by the local proof (artifacts/publish/app). Without it, +// the tests are skipped rather than run against a synthetic bundle. +const publish = path.join(root, "artifacts/publish/app"); +const havePublish = await access(path.join(publish, "wwwroot/index.html")).then( + () => true, + () => false, +); + +async function buildBundle(out: string): Promise<{ status: number; output: string; name: string }> { + const result = tool(["profiles", "bundle", "--publish", publish, "--out", out]); + const name = + (await listFiles(out)).find((file) => /^web-profiles-[0-9a-f]{64}\.tar$/u.test(file)) ?? ""; + return { ...result, name }; +} + +test( + "the profile bundle keeps its name pattern, layout and reviewed headers, and verifies", + { skip: !havePublish }, + async () => { + const out = candidateDirectory("bundle"); + const first = await buildBundle(out); + assert.equal(first.status, 0, first.output); + assert.match(first.name, /^web-profiles-[0-9a-f]{64}\.tar$/u); + const archive = path.join(out, first.name); + const digest = first.name.slice("web-profiles-".length, -".tar".length); + const verified = tool(["profiles", "verify", "--bundle", archive, "--expected-digest", digest]); + assert.equal(verified.status, 0, verified.output); + const repeated = await buildBundle(candidateDirectory("bundle-repeat")); + assert.equal(repeated.name, first.name, "Bundle builds differ"); + }, +); + +test( + "a bundle with a changed byte is refused by its digest and by its content hash", + { skip: !havePublish }, + async () => { + const out = candidateDirectory("bundle-tamper"); + const built = await buildBundle(out); + const archive = path.join(out, built.name); + const bytes = await readFile(archive); + const flipped = Buffer.from(bytes); + // The first content byte of the last entry (its header is one block before it, and the archive ends with two zero + // blocks and the padding of the last file), so the header stays valid and only the content changes. + const contentByte = bytes.length - 1024 - 512; + flipped[contentByte] = (flipped[contentByte] ?? 0) ^ 0x01; + const tampered = path.join(out, "tampered.tar"); + await writeFile(tampered, flipped); + const digest = built.name.slice("web-profiles-".length, -".tar".length); + expectRefusal( + tool(["profiles", "verify", "--bundle", tampered, "--expected-digest", digest]), + "Bundle digest mismatch", + ); + expectRefusal(tool(["profiles", "verify", "--bundle", tampered]), "Content of"); + }, +); + +test( + "a truncated bundle is refused by the strict archive reader", + { skip: !havePublish }, + async () => { + const out = candidateDirectory("bundle-truncated"); + const built = await buildBundle(out); + const bytes = await readFile(path.join(out, built.name)); + const truncated = path.join(out, "truncated.tar"); + await writeFile(truncated, bytes.subarray(0, bytes.length - 1024)); + const result = tool(["profiles", "verify", "--bundle", truncated]); + assert.notEqual(result.status, 0, result.output); + assert.match(result.output, /terminator|truncated|Not a bundle archive|Data after/u); + }, +); + +test("the static Site candidate exists in the seal with its required pages", async () => { + for (const page of [ + "assets/index.html", + "assets/hello/index.html", + "assets/cloud-hello/index.html", + "assets/404.html", + ]) + await stat(path.join(baseline, page)); + const sbom = JSON.parse(await readFile(path.join(baseline, "sbom.cdx.json"), "utf8")); + assert.equal(sbom.bomFormat, "CycloneDX"); + assert(sbom.components.length > 0, "The build SBOM lists no packages"); + const runtime = JSON.parse(await readFile(path.join(baseline, "runtime-sbom.cdx.json"), "utf8")); + assert.deepEqual(runtime.components, [], "The static Site must ship no runtime dependency"); +}); + +test("the seal lists every member and no member is a link or a source map", async () => { + const manifest = JSON.parse(await readFile(path.join(baseline, "manifest.json"), "utf8")); + assert.deepEqual( + Object.keys(manifest.files).sort(), + (await listFiles(baseline)).filter((file) => file !== "manifest.json"), + ); + assert( + Object.keys(manifest.files).every((file) => !file.endsWith(".map")), + "A source map is published", + ); +}); + +// The Site release asset. The candidate seals one deterministic archive of the Site beside the deployed assets; the seal +// records its digest, its name is that digest, and the verifier refuses any other bytes. +const siteArchiveName = async (directory: string) => + (await listFiles(directory)).find((file) => /^web-site-[0-9a-f]{64}\.tar$/u.test(file)) ?? ""; + +/** Flips the low bit of the first content byte: the first entry's content starts after its 512-byte header. */ +function flipContent(bytes: Buffer): Buffer { + const flipped = Buffer.from(bytes); + flipped[512] = (flipped[512] ?? 0) ^ 0x01; + return flipped; +} + +/** The entry names of a plain ustar archive, read from the 512-byte headers (no content parsing beyond the size field). */ +function tarNames(archive: Buffer): string[] { + const names: string[] = []; + for (let offset = 0; offset + 512 <= archive.length; ) { + const head = archive.subarray(offset, offset + 512); + if (head.every((byte) => byte === 0)) break; + names.push(head.subarray(0, 100).toString("latin1").replace(/\0[\s\S]*$/u, "")); + const size = Number.parseInt( + head.subarray(124, 135).toString("latin1").replace(/\0[\s\S]*$/u, "").trim(), + 8, + ); + offset += 512 + Math.ceil(size / 512) * 512; + } + return names; +} + +test("the seal records one Site archive named by its digest, outside the deployed assets", async () => { + const name = await siteArchiveName(baseline); + assert.match(name, /^web-site-[0-9a-f]{64}\.tar$/u); + const bytes = await readFile(path.join(baseline, name)); + assert.equal(name, `web-site-${sha(bytes)}.tar`, "The Site archive name is not its digest"); + const manifest = JSON.parse(await readFile(path.join(baseline, "manifest.json"), "utf8")); + assert.equal(manifest.files[name], sha(bytes), "The seal does not record the Site archive digest"); + assert(!name.startsWith("assets/"), "The Site archive must stay outside the deployed assets"); + assert.deepEqual( + await readFile(path.join(repeat, name)), + bytes, + "Two candidate builds disagree on the Site archive", + ); + assert.equal(tarNames(bytes)[0], "404.css", "The Site archive holds the Site files in ordinal order"); +}); + +test("a Site archive changed after sealing is refused by the seal", async () => { + const dir = await copyCandidate(baseline, "site-archive-changed"); + const name = await siteArchiveName(dir); + await writeFile(path.join(dir, name), flipContent(await readFile(path.join(dir, name)))); + expectRefusal(verify(dir, source), `Candidate changed: ${name}`); +}); + +test("a resealed Site archive with other bytes is refused by the regenerated Site", async () => { + const dir = await copyCandidate(baseline, "site-archive-resealed"); + const name = await siteArchiveName(dir); + const tampered = flipContent(await readFile(path.join(dir, name))); + await rm(path.join(dir, name)); + await writeFile(path.join(dir, `web-site-${sha(tampered)}.tar`), tampered); + await reseal(dir); + expectRefusal(verify(dir, source), "The Site archive differs from the regenerated Site."); +}); + +test("a resealed Site archive under a name that is not its digest is refused", async () => { + const dir = await copyCandidate(baseline, "site-archive-renamed"); + const name = await siteArchiveName(dir); + await rename(path.join(dir, name), path.join(dir, `web-site-${"0".repeat(64)}.tar`)); + await reseal(dir); + expectRefusal(verify(dir, source), "The Site archive name does not match its digest."); +}); + +test("a second Site archive beside the sealed one is refused", async () => { + const dir = await copyCandidate(baseline, "site-archive-second"); + const name = await siteArchiveName(dir); + await writeFile(path.join(dir, `web-site-${"f".repeat(64)}.tar`), await readFile(path.join(dir, name))); + await reseal(dir); + expectRefusal(verify(dir, source), "The candidate must hold exactly one Site archive."); +}); + +test( + "the profile bundle does not serve the application shell's encodings at the root", + { skip: !havePublish }, + async () => { + const out = candidateDirectory("bundle-root-names"); + const built = await buildBundle(out); + assert.equal(built.status, 0, built.output); + const names = tarNames(await readFile(path.join(out, built.name))); + assert.equal(names[0], "manifest.json"); + for (const shell of ["index.html", "index.html.br", "index.html.gz"]) + assert(!names.includes(shell), `The bundle serves ${shell} at the root`); + for (const served of ["account/index.html", "chat/index.html", "_headers"]) + assert(names.includes(served), `The bundle does not hold ${served}`); + }, +); diff --git a/tests/unit/lock-provenance.test.ts b/tests/provenance/lock-provenance.test.ts similarity index 75% rename from tests/unit/lock-provenance.test.ts rename to tests/provenance/lock-provenance.test.ts index acf75c4..3f7d1bb 100644 --- a/tests/unit/lock-provenance.test.ts +++ b/tests/provenance/lock-provenance.test.ts @@ -1,5 +1,9 @@ // SPDX-License-Identifier: AGPL-3.0-only -import { expect, test } from "vitest"; +// The npm lock provenance rule (tooling/project.ts verifyLockProvenance). Ported from the vitest file +// tests/unit/lock-provenance.test.ts to node:test. The repository has no npm workspaces after the React removal, so a +// workspace link is refused in every form. +import assert from "node:assert/strict"; +import { test } from "node:test"; import { verifyLockProvenance } from "../../tooling/project.ts"; const registry = { @@ -10,7 +14,7 @@ const bundled = { inBundle: true }; test("accepts the bundled optional dependency layout reported by Dependabot", () => { // Preserve the failing layout as a fixture even if a future Tailwind release stops bundling it. - expect(() => + assert.doesNotThrow(() => verifyLockProvenance({ "node_modules/@tailwindcss/oxide-wasm32-wasi": { resolved: @@ -20,17 +24,17 @@ test("accepts the bundled optional dependency layout reported by Dependabot", () }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": bundled, }), - ).not.toThrow(); + ); }); test("nested bundled dependencies inherit the enclosing registry artifact", () => { - expect(() => + assert.doesNotThrow(() => verifyLockProvenance({ "node_modules/outer": registry, "node_modules/outer/node_modules/@scope/inner": bundled, "node_modules/outer/node_modules/@scope/inner/node_modules/leaf": bundled, }), - ).not.toThrow(); + ); }); test("rejects orphaned bundles, workspace ancestors and incomplete or foreign artifacts", () => { @@ -58,20 +62,21 @@ test("rejects orphaned bundles, workspace ancestors and incomplete or foreign ar }, }, ]) - expect(() => verifyLockProvenance(packages)).toThrow(); + assert.throws(() => verifyLockProvenance(packages)); }); -test("ordinary downloads and workspace links keep their provenance requirements", () => { - expect(() => +test("ordinary downloads keep their provenance requirements and workspace links are refused", () => { + assert.doesNotThrow(() => verifyLockProvenance({ "node_modules/outer": registry, - "node_modules/@arcforges/web-site": { link: true, resolved: "apps/site" }, }), - ).not.toThrow(); + ); for (const entry of [ { resolved: registry.resolved }, { ...registry, resolved: "https://example.com/outer.tgz" }, { link: true, resolved: "../other-repo" }, + { link: true, resolved: "apps/site" }, + { link: true, resolved: "packages/ui" }, ]) - expect(() => verifyLockProvenance({ "node_modules/outer": entry })).toThrow(); + assert.throws(() => verifyLockProvenance({ "node_modules/outer": entry })); }); diff --git a/tests/unit/app-bundle.test.ts b/tests/unit/app-bundle.test.ts deleted file mode 100644 index 6b72735..0000000 --- a/tests/unit/app-bundle.test.ts +++ /dev/null @@ -1,252 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -import { createHash } from "node:crypto"; -import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import { afterEach, expect, test } from "vitest"; -import { - buildBundle, - bundleName, - headersFile, - manifestName, - mergeProfiles, - profileCsp, - readTar, - verifyBundle, - writeBundle, - writeTar, -} from "../../apps/app/scripts/bundle.ts"; -import { pageFile, servedFile } from "../../apps/app/scripts/measure.ts"; - -const roots: string[] = []; -afterEach(() => { - for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); -}); -const sha256 = (value: Uint8Array | string) => createHash("sha256").update(value).digest("hex"); - -function page(profile: string, script = `window.profile = "${profile}";`) { - return ``; -} - -function client(profile: "account" | "chat", extra: Record = {}) { - const root = mkdtempSync(join(tmpdir(), "cloud71-")); - roots.push(root); - const files: Record = { - [pageFile(profile)]: page(profile), - // The fallback shell the build writes at the root is never part of a bundle. - "index.html": "", - "__spa-fallback.html": "", - ".vite/manifest.json": "{}", - "favicon.svg": "", - "robots.txt": "User-agent: *\n", - "assets/entry.js": "export const entry = 1;", - "assets/root.css": "body{color:red}", - [`assets/${profile}-1234.js`]: `export const own = "${profile}";`, - ...extra, - }; - for (const [name, content] of Object.entries(files)) { - mkdirSync(dirname(join(root, name)), { recursive: true }); - writeFileSync(join(root, name), content); - } - return root; -} - -const clients = () => ({ account: client("account"), chat: client("chat") }); -const measure = (_client: string, profile: string) => ({ digest: sha256(`build ${profile}`) }); - -test("a request path maps to a served file and the root is not a page", () => { - expect(servedFile("/")).toBeUndefined(); - expect(servedFile("/index.html")).toBeUndefined(); - expect(servedFile("/__spa-fallback.html")).toBeUndefined(); - expect(servedFile("/.vite/manifest.json")).toBeUndefined(); - expect(servedFile("/chat/")).toBe("chat/index.html"); - expect(servedFile("/account/")).toBe("account/index.html"); - expect(servedFile("/assets/entry.js")).toBe("assets/entry.js"); - expect(servedFile("/favicon.svg")).toBe("favicon.svg"); -}); - -test("the merged tree holds each profile's page, one copy of the shared files and no fallback shell", () => { - const merged = mergeProfiles(clients()); - expect([...merged.keys()].sort()).toEqual([ - "account/index.html", - "assets/account-1234.js", - "assets/chat-1234.js", - "assets/entry.js", - "assets/root.css", - "chat/index.html", - "favicon.svg", - "robots.txt", - ]); -}); - -test("a path that two builds fill with different bytes refuses the bundle", () => { - expect(() => - mergeProfiles({ - account: client("account"), - chat: client("chat", { "assets/entry.js": "export const entry = 2;" }), - }), - ).toThrow(/different bytes/u); - expect(() => - mergeProfiles({ - account: client("account"), - chat: client("chat", { "favicon.svg": "other" }), - }), - ).toThrow(/favicon\.svg/u); -}); - -test("a build without its own page or with a foreign page is refused", () => { - const missing = client("chat"); - rmSync(join(missing, "chat/index.html")); - expect(() => mergeProfiles({ account: client("account"), chat: missing })).toThrow( - /chat has no chat\/index\.html/u, - ); - expect(() => - mergeProfiles({ - account: client("account", { "chat/index.html": page("chat") }), - chat: client("chat"), - }), - ).toThrow(/foreign path/u); - expect(() => - mergeProfiles({ - account: client("account", { "account/extra.html": "" }), - chat: client("chat"), - }), - ).toThrow(/foreign path/u); -}); - -test("each profile's policy is derived from its own page and applies to its own path only", () => { - const accountPolicy = profileCsp(page("account")); - const chatPolicy = profileCsp(page("chat")); - const hash = (script: string) => - `'sha256-${createHash("sha256").update(script).digest("base64")}'`; - expect(accountPolicy).toContain(hash('window.profile = "account";')); - expect(accountPolicy).not.toContain(hash('window.profile = "chat";')); - expect(chatPolicy).toContain(hash('window.profile = "chat";')); - expect(accountPolicy).toContain("connect-src 'self'"); - expect(accountPolicy).not.toContain("unsafe-inline"); - // The bytes a browser executes use LF; a CRLF page hashes to the same policy. - expect(profileCsp(page("account").replaceAll(";<", ";\r\n<"))).toBe( - profileCsp(page("account").replaceAll(";<", ";\n<")), - ); - const headers = headersFile({ account: accountPolicy, chat: chatPolicy }); - const blocks = headers.split(/\n(?=\/)/u); - const own = (path: string) => blocks.find((block) => block.startsWith(`${path}\n`)) ?? ""; - expect(own("/account/*")).toContain(`Content-Security-Policy: ${accountPolicy}`); - expect(own("/account/*")).not.toContain(chatPolicy); - expect(own("/chat/*")).toContain(`Content-Security-Policy: ${chatPolicy}`); - expect(own("/chat/*")).toContain("X-Frame-Options: DENY"); - expect(own("/*")).not.toContain("Content-Security-Policy"); - expect(own("/*")).toContain("X-Content-Type-Options: nosniff"); - expect(own("/assets/*")).toContain("! Cache-Control"); - expect(own("/assets/*")).toContain("max-age=31536000, immutable"); - expect(headers.split("\n").every((line) => line.length < 2000)).toBe(true); - expect(() => headersFile({ account: accountPolicy })).toThrow(/No policy for chat/u); - expect(() => headersFile({ account: "x".repeat(1800), chat: chatPolicy })).toThrow( - /header line budget/u, - ); -}); - -test("the archive is deterministic and reads back exactly what was written", () => { - const entries = [ - { path: manifestName, bytes: Buffer.from("{}\n") }, - { path: "assets/a.js", bytes: Buffer.alloc(512, 7) }, - { path: "assets/b.js", bytes: Buffer.alloc(0) }, - { path: "z.txt", bytes: Buffer.from("tail") }, - ]; - const first = writeTar(entries); - expect(sha256(writeTar(entries))).toBe(sha256(first)); - expect(first.length % 512).toBe(0); - const back = readTar(first); - expect(back.map((entry) => entry.path)).toEqual(entries.map((entry) => entry.path)); - for (const [index, entry] of back.entries()) - expect(Buffer.compare(Buffer.from(entry.bytes), entries[index]?.bytes ?? Buffer.alloc(0))).toBe( - 0, - ); - // No timestamp, owner or mode varies: the first header carries zeros and the fixed mode. - expect(first.subarray(100, 108).toString("ascii")).toBe("0000644\0"); - expect(first.subarray(136, 148).toString("ascii")).toBe("00000000000\0"); -}); - -test("the archive writer and reader refuse unsafe paths, links, damage and trailing data", () => { - for (const path of ["../x", "/abs", "a//b", "a/", "a b", "", "a/../b", "x".repeat(101)]) - expect(() => writeTar([{ path, bytes: Buffer.alloc(0) }]), path).toThrow(); - expect(() => - writeTar([ - { path: "a", bytes: Buffer.alloc(0) }, - { path: "a", bytes: Buffer.alloc(0) }, - ]), - ).toThrow(/Duplicate/u); - const good = writeTar([{ path: "a.txt", bytes: Buffer.from("hello") }]); - const symlink = Buffer.from(good); - symlink.write("2", 156, "ascii"); - expect(() => readTar(symlink)).toThrow(/plain regular file/u); - const flipped = Buffer.from(good); - flipped[0] = (flipped[0] ?? 0) ^ 1; - expect(() => readTar(flipped)).toThrow(); - expect(() => readTar(good.subarray(0, good.length - 512))).toThrow(); - expect(() => readTar(Buffer.concat([good, Buffer.alloc(512, 1)]))).toThrow(); - expect(() => readTar(Buffer.concat([good, Buffer.alloc(512)]))).toThrow(/after the terminator/u); - expect(() => readTar(Buffer.alloc(10))).toThrow(/Not a bundle/u); -}); - -test("equal content is the same bundle and its name is the digest of its own bytes", () => { - const first = buildBundle(clients(), measure); - const second = buildBundle(clients(), measure); - expect(second.digest).toBe(first.digest); - expect(first.digest).toBe(sha256(first.archive)); - expect(first.name).toBe(bundleName(first.digest)); - expect(first.name).toMatch(/^web-profiles-[0-9a-f]{64}\.tar$/u); - expect(Object.keys(first.manifest.files)).toEqual(Object.keys(first.manifest.files).sort()); - expect(first.manifest.files._headers?.sha256).toBe( - sha256(readTar(first.archive).find((entry) => entry.path === "_headers")?.bytes ?? ""), - ); - expect(first.manifest.profiles.account?.page).toBe("account/index.html"); - expect(first.manifest.profiles.chat?.csp).toBe(profileCsp(page("chat"))); - expect(readTar(first.archive)[0]?.path).toBe(manifestName); - const changed = buildBundle( - { - account: client("account", { "assets/root.css": "body{color:blue}" }), - chat: client("chat", { "assets/root.css": "body{color:blue}" }), - }, - measure, - ); - expect(changed.digest).not.toBe(first.digest); - const otherBuild = buildBundle(clients(), (_client, profile) => ({ - digest: sha256(`x ${profile}`), - })); - expect(otherBuild.digest).not.toBe(first.digest); -}); - -test("verification checks the digest, the exact file set and every file against the manifest", () => { - const bundle = buildBundle(clients(), measure); - expect(verifyBundle(bundle.archive, bundle.digest).schema).toBe(1); - expect(() => verifyBundle(bundle.archive, sha256("another"))).toThrow(/digest mismatch/u); - // A content change that keeps the size, with the new digest expected, is still caught by the manifest. - const entries = readTar(bundle.archive).map((entry) => ({ - path: entry.path, - bytes: Buffer.from(entry.bytes), - })); - const target = entries.find((entry) => entry.path === "assets/entry.js"); - expect(target).toBeDefined(); - if (!target) return; - target.bytes[0] = (target.bytes[0] ?? 0) ^ 1; - const tampered = writeTar(entries); - expect(() => verifyBundle(tampered, sha256(tampered))).toThrow(/Content of assets\/entry\.js/u); - const extra = writeTar([...entries, { path: "assets/zz.js", bytes: Buffer.from("x") }]); - expect(() => verifyBundle(extra, sha256(extra))).toThrow(/exactly the manifest's files/u); - const reordered = writeTar([ - entries[1] as (typeof entries)[0], - entries[0] as (typeof entries)[0], - ]); - expect(() => verifyBundle(reordered, sha256(reordered))).toThrow(/manifest must be the first/u); -}); - -test("writing a bundle leaves exactly one digest-named archive in the output directory", () => { - const out = mkdtempSync(join(tmpdir(), "cloud71-out-")); - roots.push(out); - writeFileSync(join(out, `web-profiles-${"0".repeat(64)}.tar`), "stale"); - writeFileSync(join(out, "unrelated.txt"), "kept"); - const written = writeBundle(clients(), out, measure); - expect(readdirSync(out).sort()).toEqual([written.name, "unrelated.txt"].sort()); - expect(sha256(readFileSync(join(out, written.name)))).toBe(written.digest); -}); diff --git a/tests/unit/app-hello.test.ts b/tests/unit/app-hello.test.ts deleted file mode 100644 index bf44ebc..0000000 --- a/tests/unit/app-hello.test.ts +++ /dev/null @@ -1,316 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -import { createPublicGrpcWebTransport } from "@arcforges/api-client"; -import { - EventService, - EventServicePollRequestSchema, - EventServicePollResponseSchema, - EventSchema, - EventServicePollValueSchema, - ResponseMetaSchema, - RevisionSchema, -} from "@arcforges/proto"; -import { create, fromBinary, toBinary } from "@bufbuild/protobuf"; -import { Code, createClient } from "@connectrpc/connect"; -import { afterEach, expect, test, vi } from "vitest"; -import { failureFromCode, helloApiPath, sayHello } from "../../apps/app/app/probe/hello.ts"; -import { type FailureKind, ProbeFailure } from "../../apps/app/app/probe/failure.ts"; -import { decodeHelloRequest, frame, helloResponse } from "../fixtures/grpc-web.ts"; - -const origin = "https://chat.example.test"; -const grpcWeb = { "content-type": "application/grpc-web+proto" }; -const signal = () => new AbortController().signal; -const trailers = (text: string) => frame(new TextEncoder().encode(text), 0x80); -const concat = (...parts: Uint8Array[]) => { - const bytes = new Uint8Array(parts.reduce((total, part) => total + part.length, 0)); - let offset = 0; - for (const part of parts) { - bytes.set(part, offset); - offset += part.length; - } - return bytes; -}; - -afterEach(() => { - vi.useRealTimers(); -}); - -async function kindOf(task: Promise): Promise { - const error = await task.then( - () => undefined, - (reason: unknown) => reason, - ); - expect(error).toBeInstanceOf(ProbeFailure); - return (error as ProbeFailure).kind; -} - -test("the greeting is one anonymous binary gRPC-Web call to same-origin /api", async () => { - let seen: Request | undefined; - const reply = await sayHello(origin, "ArcForges 世界", signal(), async (input, init) => { - seen = new Request(input, init); - return new Response(helloResponse("Hello, ArcForges 世界!") as BodyInit, { headers: grpcWeb }); - }); - expect(reply).toBe("Hello, ArcForges 世界!"); - expect(seen?.url).toBe(`${origin}${helloApiPath}`); - expect(seen?.method).toBe("POST"); - expect(seen?.headers.get("content-type")).toContain("application/grpc-web+proto"); - expect(seen?.credentials).toBe("omit"); - expect(seen?.redirect).toBe("error"); - expect(seen?.headers.has("authorization")).toBe(false); - expect(seen?.headers.has("cookie")).toBe(false); - expect(decodeHelloRequest(new Uint8Array(await (seen as Request).arrayBuffer())).name).toBe( - "ArcForges 世界", - ); -}); - -test("every gRPC status maps to a closed failure kind and none is a success", async () => { - const expected: Record = { - [Code.Canceled]: "unexpected", - [Code.Unknown]: "unexpected", - [Code.InvalidArgument]: "rejected", - [Code.DeadlineExceeded]: "timeout", - [Code.NotFound]: "rejected", - [Code.AlreadyExists]: "rejected", - [Code.PermissionDenied]: "forbidden", - [Code.ResourceExhausted]: "limit", - [Code.FailedPrecondition]: "rejected", - [Code.Aborted]: "unavailable", - [Code.OutOfRange]: "rejected", - [Code.Unimplemented]: "rejected", - [Code.Internal]: "unexpected", - [Code.Unavailable]: "unavailable", - [Code.DataLoss]: "unexpected", - [Code.Unauthenticated]: "unauthenticated", - }; - for (const [code, kind] of Object.entries(expected)) { - expect(failureFromCode(Number(code) as Code), `code ${code}`).toBe(kind); - // The same status arriving as a real trailers-only gRPC-Web answer through the generated client. - const actual = await kindOf( - sayHello(origin, "x", signal(), async () => { - return new Response( - concat(trailers(`grpc-status: ${code}\r\ngrpc-message: Server%20text\r\n`)) as BodyInit, - { headers: grpcWeb }, - ); - }), - ); - expect(actual, `status ${code}`).toBe(kind); - } -}); - -test("a server message is never echoed into the failure", async () => { - const error = await sayHello( - origin, - "x", - signal(), - async () => - new Response(trailers("grpc-status: 3\r\ngrpc-message: secret-token-123\r\n") as BodyInit, { - headers: grpcWeb, - }), - ).then( - () => undefined, - (reason: unknown) => reason as ProbeFailure, - ); - expect(error?.kind).toBe("rejected"); - expect(error?.message).not.toContain("secret-token-123"); -}); - -test("HTTP statuses and non-gRPC answers are typed failures, never a local greeting", async () => { - const answers: [string, () => Response, FailureKind][] = [ - ["404", () => new Response("Not found", { status: 404 }), "rejected"], - ["405", () => new Response("Method not allowed", { status: 405 }), "rejected"], - ["429", () => new Response("Slow down", { status: 429 }), "limit"], - ["500", () => new Response("Broken", { status: 500 }), "unexpected"], - ["503", () => new Response(null, { status: 503 }), "unavailable"], - ["504", () => new Response(null, { status: 504 }), "timeout"], - [ - "html", - () => new Response("Not an API", { headers: { "content-type": "text/html" } }), - "malformed", - ], - [ - "valid frames under another content type", - () => - new Response(helloResponse() as BodyInit, { - headers: { "content-type": "application/json" }, - }), - "malformed", - ], - ["no content type", () => new Response(helloResponse() as BodyInit), "malformed"], - ]; - for (const [name, answer, kind] of answers) - expect(await kindOf(sayHello(origin, "ArcForges", signal(), async () => answer())), name).toBe( - kind, - ); - expect( - await kindOf( - sayHello(origin, "ArcForges", signal(), async () => { - throw new TypeError("Failed to fetch"); - }), - ), - ).toBe("unavailable"); -}); - -test("malformed frames and status never resolve", async () => { - const data = helloResponse("Hello, ArcForges!"); - const messageFrameLength = 5 + new DataView(data.buffer).getUint32(1); - const messageFrame = data.slice(0, messageFrameLength); - const bodies: [string, Uint8Array][] = [ - ["empty body", new Uint8Array()], - ["truncated header", messageFrame.slice(0, 3)], - ["truncated message", messageFrame.slice(0, messageFrameLength - 2)], - ["no trailers", messageFrame], - ["trailers without a status", concat(messageFrame, trailers("x-other: 1\r\n"))], - ["unparsable status", concat(messageFrame, trailers("grpc-status: ok\r\n"))], - [ - "compressed frame without negotiated compression", - concat(Uint8Array.of(1, ...messageFrame.slice(1)), trailers("grpc-status: 0\r\n")), - ], - [ - "frame length beyond the body", - concat(Uint8Array.of(0, 0, 0, 1, 0, 1), trailers("grpc-status: 0\r\n")), - ], - ["trailers only, status ok, no message", trailers("grpc-status: 0\r\n")], - ["random bytes", Uint8Array.from({ length: 64 }, (_, index) => (index * 37 + 11) & 0xff)], - ]; - for (const [name, bytes] of bodies) - expect( - await kindOf( - sayHello( - origin, - "ArcForges", - signal(), - async () => new Response(bytes as BodyInit, { headers: grpcWeb }), - ), - ), - name, - ).toBe("malformed"); -}); - -test("a reply that is not the greeting for this name is malformed", async () => { - expect( - await kindOf( - sayHello( - origin, - "ArcForges", - signal(), - async () => - new Response(helloResponse("Hello, someone else!") as BodyInit, { headers: grpcWeb }), - ), - ), - ).toBe("malformed"); -}); - -test("the user's cancellation aborts the request and is reported as cancelled", async () => { - const controller = new AbortController(); - let transportSignal: AbortSignal | undefined; - const pending = sayHello( - origin, - "ArcForges", - controller.signal, - (_input, init) => - new Promise((_resolve, reject) => { - transportSignal = init?.signal ?? undefined; - init?.signal?.addEventListener("abort", () => - reject(new DOMException("aborted", "AbortError")), - ); - }), - ); - await vi.waitFor(() => expect(transportSignal).toBeDefined()); - expect(transportSignal?.aborted).toBe(false); - controller.abort(); - expect(await kindOf(pending)).toBe("cancelled"); - expect(transportSignal?.aborted).toBe(true); -}); - -test("a call that has already been cancelled never reaches a success", async () => { - const controller = new AbortController(); - controller.abort(); - expect( - await kindOf( - sayHello(origin, "ArcForges", controller.signal, async (_input, init) => { - if (init?.signal?.aborted) throw new DOMException("aborted", "AbortError"); - return new Response(helloResponse() as BodyInit, { headers: grpcWeb }); - }), - ), - ).toBe("cancelled"); -}); - -test("a server that never answers ends at the ten second deadline as a timeout", async () => { - vi.useFakeTimers(); - const pending = kindOf( - sayHello( - origin, - "ArcForges", - signal(), - (_input, init) => - new Promise((_resolve, reject) => { - init?.signal?.addEventListener("abort", () => - reject(new DOMException("aborted", "AbortError")), - ); - }), - ), - ); - await vi.advanceTimersByTimeAsync(9_999); - let settled = false; - void pending.then(() => { - settled = true; - }); - await vi.advanceTimersByTimeAsync(0); - expect(settled).toBe(false); - await vi.advanceTimersByTimeAsync(2); - expect(await pending).toBe("timeout"); -}); - -test("the generated EventService client keeps 64-bit values exact over binary gRPC-Web", async () => { - // Fixture-labelled: the Cloud probe does not serve EventService. This exercises the generated client, - // transport and bigint mapping with values a JavaScript number cannot hold. - const seq = 9007199254740993n; - const generation = 18446744073709551615n; - const entitlement = 9223372036854775807n; - const revision = 9007199254740995n; - const response = create(EventServicePollResponseSchema, { - meta: create(ResponseMetaSchema, { - resultRev: create(RevisionSchema, { value: revision }), - entitlementVersion: entitlement, - recoveryGeneration: generation, - }), - outcome: { - case: "value", - value: create(EventServicePollValueSchema, { - events: [create(EventSchema, { subscriptionKey: "k", seq })], - }), - }, - }); - let request: Request | undefined; - const transport = createPublicGrpcWebTransport({ - baseUrl: `${origin}/api`, - fetch: async (input, init) => { - request = new Request(input, init); - return new Response( - concat( - frame(toBinary(EventServicePollResponseSchema, response), 0), - trailers("grpc-status: 0\r\n"), - ) as BodyInit, - { headers: grpcWeb }, - ); - }, - }); - const reply = await createClient(EventService, transport).poll({ - cursor: "c", - limit: 4294967295, - }); - expect(request?.url).toBe(`${origin}/api/arcforges.events.v1.EventService/Poll`); - const sent = fromBinary( - EventServicePollRequestSchema, - new Uint8Array(await (request as Request).arrayBuffer()).slice(5), - ); - expect(sent.cursor).toBe("c"); - expect(sent.limit).toBe(4294967295); - expect(reply.meta?.recoveryGeneration).toBe(generation); - expect(reply.meta?.entitlementVersion).toBe(entitlement); - expect(reply.meta?.resultRev?.value).toBe(revision); - expect(reply.outcome.case).toBe("value"); - const events = reply.outcome.case === "value" ? reply.outcome.value.events : []; - expect(events[0]?.seq).toBe(seq); - expect(typeof events[0]?.seq).toBe("bigint"); - expect(String(events[0]?.seq)).toBe("9007199254740993"); -}); diff --git a/tests/unit/app-profiles.test.ts b/tests/unit/app-profiles.test.ts deleted file mode 100644 index 9e32022..0000000 --- a/tests/unit/app-profiles.test.ts +++ /dev/null @@ -1,248 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import { afterEach, expect, test } from "vitest"; -import { activeProfile, isProfile, profiles } from "../../apps/app/profile.ts"; -import { - type Budgets, - checkBudgets, - initialResources, - measureProfile, - toBaseline, - verifyProfile, -} from "../../apps/app/scripts/measure.ts"; - -const roots: string[] = []; -afterEach(() => { - for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); -}); - -function build( - profile: "account" | "chat", - extra: Record = {}, - drop: string[] = [], -) { - const root = mkdtempSync(join(tmpdir(), "prf08-")); - roots.push(root); - const own = profile === "account" ? "/session/v1/bootstrap" : "application/grpc-web"; - const files: Record = { - [`${profile}/index.html`]: `
source`, - "favicon.svg": "", - "assets/entry.js": `export const entry = "${"e".repeat(500)}";`, - "assets/route.js": `export const route = "${own}${"r".repeat(800)}";`, - "assets/root.css": `body{color:red}${"/*x*/".repeat(100)}`, - // The build also writes the single-page-app fallback shell at the root; it is not a page of either profile. - "index.html": - "", - "__spa-fallback.html": "", - ".vite/manifest.json": "{}", - ...extra, - }; - for (const name of drop) delete files[name]; - for (const [name, content] of Object.entries(files)) { - mkdirSync(dirname(join(root, name)), { recursive: true }); - writeFileSync(join(root, name), content); - } - return root; -} - -test("only the two named profiles exist and a build must name one", () => { - expect([...profiles]).toEqual(["account", "chat"]); - expect(isProfile("account")).toBe(true); - expect(isProfile("chat")).toBe(true); - for (const value of ["", "Account", "operator", undefined, 1, null]) - expect(isProfile(value)).toBe(false); - expect(activeProfile({ ARCFORGES_PROFILE: "chat" })).toBe("chat"); - expect(() => activeProfile({})).toThrow(/ARCFORGES_PROFILE/); - expect(() => activeProfile({ ARCFORGES_PROFILE: "site" })).toThrow(/ARCFORGES_PROFILE/); -}); - -test("initial resources are the preloaded modules, script sources and stylesheets, not anchors", () => { - const found = initialResources(readFileSync(join(build("chat"), "chat/index.html"), "utf8")); - expect(found.js).toEqual(["/assets/entry.js", "/assets/route.js"]); - expect(found.css).toEqual(["/assets/root.css"]); - expect(found.references).not.toContain("https://example.test/outside"); - expect(found.references).toContain("/favicon.svg"); -}); - -test("measurement counts only served files and requests, weighs gzip and fingerprints content", () => { - const root = build("chat"); - const measured = measureProfile(root, "chat"); - expect(measured.files).toBe(5); - expect(measured.initialRequests).toBe(4); - expect(measured.initialJs.bytes).toBe( - readFileSync(join(root, "assets/entry.js")).length + - readFileSync(join(root, "assets/route.js")).length, - ); - expect(measured.initialJs.gzip).toBeLessThan(measured.initialJs.bytes); - expect(measured.initialJs.gzip).toBeGreaterThan(0); - expect(measured.initialCss.bytes).toBe(readFileSync(join(root, "assets/root.css")).length); - expect(measured.totalJs).toEqual(measured.initialJs); - expect(measured.htmlBytes).toBe(readFileSync(join(root, "chat/index.html")).length); - const same = measureProfile(build("chat"), "chat"); - expect(same.digest).toBe(measured.digest); - writeFileSync( - join(root, "assets/route.js"), - `export const route = "application/grpc-web${"r".repeat(799)}";`, - ); - expect(measureProfile(root, "chat").digest).not.toBe(measured.digest); - const renamed = build("chat", { "assets/extra.js": "export {};" }); - expect(measureProfile(renamed, "chat").digest).not.toBe(measured.digest); - expect(measureProfile(renamed, "chat").totalJs.bytes).toBeGreaterThan(measured.totalJs.bytes); - expect(measureProfile(renamed, "chat").initialJs).toEqual(measured.initialJs); -}); - -test("a build with a missing initial resource, no entry page or a foreign reference is refused", () => { - expect(() => measureProfile(build("chat", {}, ["assets/route.js"]), "chat")).toThrow( - /missing from the build/, - ); - expect(() => measureProfile(build("chat", {}, ["chat/index.html"]), "chat")).toThrow( - /no prerendered chat\/index.html/, - ); - const absolute = build("chat", { - "chat/index.html": - '', - }); - expect(() => measureProfile(absolute, "chat")).toThrow(/Not same-origin/); - const protocolRelative = build("chat", { - "chat/index.html": '', - }); - expect(() => measureProfile(protocolRelative, "chat")).toThrow(/Not same-origin/); -}); - -test("the structural gates pass a clean profile and name every violation otherwise", () => { - expect(verifyProfile(build("account"), "account")).toEqual([]); - expect(verifyProfile(build("chat"), "chat")).toEqual([]); - expect(verifyProfile(build("chat"), "operator")).toEqual(["Unknown profile operator"]); - expect(verifyProfile(build("chat", { "assets/route.js.map": "{}" }), "chat")).toEqual([ - "Source map shipped: assets/route.js.map", - ]); - expect(verifyProfile(build("chat", { "node_modules/x.js": "" }), "chat")).toEqual([ - "Private file shipped: node_modules/x.js", - ]); - expect(verifyProfile(build("chat", { ".env": "A=1" }), "chat")).toEqual([ - "Private file shipped: .env", - ]); - expect(verifyProfile(build("chat", {}, ["chat/index.html"]), "chat")).toEqual([ - "No prerendered chat/index.html", - ]); - expect( - verifyProfile( - build("chat", { - "chat/index.html": - '', - }), - "chat", - ), - ).toEqual(["Reference is not same-origin: assets/route.js"]); - expect( - verifyProfile( - build("chat", { - "chat/index.html": - '', - }), - "chat", - ), - ).toEqual(["Reference is not same-origin: //cdn.example.test/a.js"]); -}); - -test("one profile's wire code must not reach the other profile", () => { - const crossed = build("chat", { "assets/extra.js": 'export const p = "/session/v1/bootstrap";' }); - expect(verifyProfile(crossed, "chat")).toEqual([ - "Profile chat contains the other profile's wire marker", - ]); - const crossedAccount = build("account", { - "assets/extra.js": 'export const p = "application/grpc-web";', - }); - expect(verifyProfile(crossedAccount, "account")).toEqual([ - "Profile account contains the other profile's wire marker", - ]); - expect(verifyProfile(build("chat", { "assets/route.js": "export {};" }), "chat")).toEqual([ - "Profile chat lost its own wire marker", - ]); - expect(verifyProfile(build("account", { "assets/route.js": "export {};" }), "account")).toEqual([ - "Profile account lost its own wire marker", - ]); -}); - -const budgets = (): Budgets => ({ - schema: 1, - regressionPercent: 10, - profiles: { - chat: { - initialRequests: 10, - initialJsGzip: 1000, - initialCssGzip: 200, - totalGzip: 1500, - htmlBytes: 3000, - }, - }, -}); - -test("the regression budget allows exactly ten percent growth per metric and catches a deliberate regression", () => { - const measured = measureProfile(build("chat"), "chat"); - const actual = toBaseline(measured); - expect(Object.keys(actual).sort()).toEqual( - ["htmlBytes", "initialCssGzip", "initialJsGzip", "initialRequests", "totalGzip"].sort(), - ); - expect(actual).toEqual({ - initialRequests: measured.initialRequests, - initialJsGzip: measured.initialJs.gzip, - initialCssGzip: measured.initialCss.gzip, - totalGzip: measured.totalJs.gzip + measured.totalCss.gzip, - htmlBytes: measured.htmlBytes, - }); - expect(actual.htmlBytes).toBeGreaterThan(0); - const exact: Budgets = { schema: 1, regressionPercent: 10, profiles: { chat: { ...actual } } }; - expect(checkBudgets(measured, exact)).toEqual([]); - // A baseline whose ten percent ceiling equals the measurement passes; one byte lower fails. - for (const key of Object.keys(actual) as (keyof typeof actual)[]) { - const value = actual[key]; - const baseline = Math.ceil((value * 100) / 110); - const at: Budgets = { - schema: 1, - regressionPercent: 10, - profiles: { chat: { ...actual, [key]: baseline } }, - }; - expect(Math.floor((baseline * 110) / 100)).toBeGreaterThanOrEqual(value); - expect(checkBudgets(measured, at), key).toEqual([]); - const below: Budgets = { - schema: 1, - regressionPercent: 10, - profiles: { chat: { ...actual, [key]: Math.floor((value * 100) / 110) - 1 } }, - }; - const problems = checkBudgets(measured, below); - expect(problems, key).toHaveLength(1); - expect(problems[0]).toContain(key); - } - const regressed = build("chat", { - "assets/entry.js": `export const entry = "${Array.from({ length: 4000 }, (_, i) => i * 7919).join(",")}";`, - }); - expect( - checkBudgets(measureProfile(regressed, "chat"), { - ...budgets(), - profiles: { chat: { ...actual } }, - }).length, - ).toBeGreaterThan(0); -}); - -test("a budget needs a baseline for the profile it judges", () => { - expect(checkBudgets(measureProfile(build("account"), "account"), budgets())).toEqual([ - "No baseline for profile account", - ]); -}); - -test("the committed baseline covers both profiles with positive measured values", () => { - const committed = JSON.parse( - readFileSync(new URL("../../apps/app/budgets.json", import.meta.url), "utf8"), - ) as Budgets; - expect(committed.schema).toBe(1); - expect(committed.regressionPercent).toBe(10); - expect(Object.keys(committed.profiles).sort()).toEqual([...profiles]); - for (const baseline of Object.values(committed.profiles)) - for (const value of Object.values(baseline)) { - expect(Number.isInteger(value)).toBe(true); - expect(value).toBeGreaterThan(0); - } -}); diff --git a/tests/unit/app-proof.test.ts b/tests/unit/app-proof.test.ts deleted file mode 100644 index bd1893a..0000000 --- a/tests/unit/app-proof.test.ts +++ /dev/null @@ -1,203 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -import { createHash, generateKeyPairSync, verify } from "node:crypto"; -import { expect, test } from "vitest"; -import { - assertProofOrigin, - checkInteractionBudgets, - forwardTarget, - isOwnLoopbackRequest, - forwardedHeaders, - type InteractionBudgets, - nextCookie, - operatorMessage, - signOperatorRequest, - summarize, -} from "../../apps/app/scripts/proof-lib.ts"; - -test("an operator request is signed over the exact message the Worker verifies", () => { - const { privateKey, publicKey } = generateKeyPairSync("ed25519"); - const body = new TextEncoder().encode('{"a":1}'); - const header = signOperatorRequest( - privateKey, - "POST", - "proof.example.test", - "/proof/v1/session/issue", - body, - { nowSeconds: 1_790_000_000, nonce: "AAAAAAAAAAAAAAAAAAAAAA" }, - ); - const match = /^AF-Operator t=(\d{10}),n=([A-Za-z0-9_-]{22}),s=([A-Za-z0-9_-]{86})$/u.exec( - header, - ); - expect(match).not.toBeNull(); - const [, time = "", nonce = "", signature = ""] = match ?? []; - const digest = createHash("sha256").update(body).digest("hex"); - const message = operatorMessage( - "POST", - "proof.example.test", - "/proof/v1/session/issue", - time, - nonce, - digest, - ); - expect(message).toBe( - `AF-OPERATOR-V2\nPOST\nproof.example.test\n/proof/v1/session/issue\n1790000000\nAAAAAAAAAAAAAAAAAAAAAA\n${digest}`, - ); - expect(verify(null, Buffer.from(message), publicKey, Buffer.from(signature, "base64url"))).toBe( - true, - ); - // A different host, path or body does not verify. - for (const other of [ - operatorMessage("POST", "other.example.test", "/proof/v1/session/issue", time, nonce, digest), - operatorMessage("POST", "proof.example.test", "/proof/v1/exact", time, nonce, digest), - operatorMessage( - "POST", - "proof.example.test", - "/proof/v1/session/issue", - time, - nonce, - "0".repeat(64), - ), - ]) - expect(verify(null, Buffer.from(other), publicKey, Buffer.from(signature, "base64url"))).toBe( - false, - ); -}); - -test("the forwarder replaces Origin, injects only the session cookie and drops every other header", () => { - const headers = forwardedHeaders( - { - origin: "http://127.0.0.1:5000", - cookie: "other=1; __Host-af_session=page", - authorization: "Bearer leak", - accept: "application/json", - "x-af-csrf": "token", - "content-type": "application/grpc-web+proto", - "x-grpc-web": "1", - host: "127.0.0.1:5000", - }, - "https://proof.example.test", - "handle", - ); - expect(headers).toEqual({ - origin: "https://proof.example.test", - accept: "application/json", - "x-af-csrf": "token", - "content-type": "application/grpc-web+proto", - "x-grpc-web": "1", - cookie: "__Host-af_session=handle", - }); - expect( - forwardedHeaders({ cookie: "__Host-af_session=page" }, "https://p.test", undefined), - ).toEqual({ - origin: "https://p.test", - }); -}); - -test("the cookie jar follows the server's Set-Cookie like a browser", () => { - const set = "__Host-af_session=abc; Path=/; Secure; HttpOnly; SameSite=Strict"; - expect(nextCookie(undefined, [set])).toBe("abc"); - expect(nextCookie("abc", [])).toBe("abc"); - expect(nextCookie("abc", ["other=1; Path=/"])).toBe("abc"); - expect(nextCookie("abc", ["__Host-af_session=; Path=/; Secure; Max-Age=0"])).toBeUndefined(); - expect( - nextCookie("abc", ["__Host-af_session=zzz; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT"]), - ).toBeUndefined(); - expect(nextCookie("abc", ["__Host-af_session=def; Path=/; Max-Age=60"])).toBe("def"); -}); - -test("summaries are exact for odd and even sample counts", () => { - expect(summarize([30, 10, 20])).toEqual({ samples: 3, minMs: 10, medianMs: 20, maxMs: 30 }); - expect(summarize([10, 20, 30, 41])).toEqual({ samples: 4, minMs: 10, medianMs: 25, maxMs: 41 }); - expect(() => summarize([])).toThrow(); -}); - -const budgets: InteractionBudgets = { - schema: 1, - basis: "test", - minimumSamples: 3, - interactions: { a: { medianCeilingMs: 100, maxCeilingMs: 200 } }, -}; - -test("interaction budgets pass at the ceiling and fail one millisecond above it", () => { - const ok = { a: { samples: 3, minMs: 1, medianMs: 100, maxMs: 200 } }; - expect(checkInteractionBudgets(ok, budgets)).toEqual([]); - expect(checkInteractionBudgets({ a: { ...ok.a, medianMs: 101 } }, budgets)).toEqual([ - "a: median 101 ms over 100 ms", - ]); - expect(checkInteractionBudgets({ a: { ...ok.a, maxMs: 201 } }, budgets)).toEqual([ - "a: max 201 ms over 200 ms", - ]); - expect(checkInteractionBudgets({ a: { ...ok.a, samples: 2 } }, budgets)).toEqual([ - "a: 2 samples, 3 required", - ]); - expect(checkInteractionBudgets({}, budgets)).toEqual(["a: not measured"]); - expect(checkInteractionBudgets({ ...ok, b: ok.a }, budgets)).toEqual([ - "b: measured but has no budget", - ]); -}); - -test("only the three exact routes are forwarded, to fixed targets", () => { - expect(forwardTarget("/session/v1/bootstrap")).toBe("/session/v1/bootstrap"); - expect(forwardTarget("/session/v1/logout")).toBe("/session/v1/logout"); - expect(forwardTarget("/api/arcforges.hello.v1.HelloService/SayHello")).toBe( - "/api/arcforges.hello.v1.HelloService/SayHello", - ); - for (const other of [ - "/api/", - "/api/healthz", - "/api/arcforges.hello.v1.HelloService/SayHello/", - "/api/arcforges.hello.v1.HelloService/SayHello2", - "/session/v1/", - "/session/v1/bootstrap/", - "/session/v1/bootstrap/../logout", - "/proof/v1/session/issue", - "/assets", - "/", - "", - ]) - expect(forwardTarget(other)).toBeUndefined(); -}); - -test("a live run targets the proof origin unless another https origin is opted in", () => { - expect(assertProofOrigin(undefined, undefined)).toBe("https://proof.arcforges.com"); - expect(assertProofOrigin("https://proof.arcforges.com", undefined)).toBe( - "https://proof.arcforges.com", - ); - expect(() => assertProofOrigin("https://arcforges.com", undefined)).toThrow( - /unless PROOF_ALLOW_OTHER_ORIGIN=1/u, - ); - expect(() => assertProofOrigin("https://arcforges.com", "yes")).toThrow(); - expect(assertProofOrigin("https://other.example.test", "1")).toBe("https://other.example.test"); - expect(() => assertProofOrigin("http://proof.arcforges.com", "1")).toThrow(/https origin/u); - expect(() => assertProofOrigin("https://proof.arcforges.com/x", "1")).toThrow(/https origin/u); -}); - -test("the loopback server answers only its own Host and own Origin", () => { - expect(isOwnLoopbackRequest({ host: "127.0.0.1:5000" }, 5000)).toBe(true); - expect( - isOwnLoopbackRequest({ host: "127.0.0.1:5000", origin: "http://127.0.0.1:5000" }, 5000), - ).toBe(true); - expect( - isOwnLoopbackRequest({ host: "127.0.0.1:5000", origin: "https://evil.example" }, 5000), - ).toBe(false); - expect(isOwnLoopbackRequest({ host: "rebound.example:5000" }, 5000)).toBe(false); - expect(isOwnLoopbackRequest({ host: "127.0.0.1:5001" }, 5000)).toBe(false); - expect(isOwnLoopbackRequest({}, 5000)).toBe(false); -}); - -test("every allow-listed header is forwarded, including the gRPC-Web deadline", () => { - const headers = forwardedHeaders( - { "grpc-timeout": "10000m", "x-user-agent": "ua" }, - "https://p.test", - undefined, - ); - expect(headers["grpc-timeout"]).toBe("10000m"); - expect(headers["x-user-agent"]).toBe("ua"); -}); - -test("an empty value or Max-Age=0 clears the cookie on its own", () => { - expect(nextCookie("abc", ["__Host-af_session=; Path=/"])).toBeUndefined(); - expect(nextCookie("abc", ["__Host-af_session=zzz; Path=/; Max-Age=0"])).toBeUndefined(); - expect(nextCookie("abc", ["__Host-af_session=zzz; Path=/; max-age=0; Secure"])).toBeUndefined(); - expect(nextCookie("abc", ["__Host-af_session=zzz; Path=/; Max-Age=01"])).toBe("zzz"); -}); diff --git a/tests/unit/app-routes.test.tsx b/tests/unit/app-routes.test.tsx deleted file mode 100644 index da08841..0000000 --- a/tests/unit/app-routes.test.tsx +++ /dev/null @@ -1,257 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -// @vitest-environment jsdom -import "@testing-library/jest-dom/vitest"; -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { afterEach, beforeEach, expect, test, vi } from "vitest"; -import Account from "../../apps/app/app/routes/account"; -import Chat from "../../apps/app/app/routes/chat"; -import { failureText } from "../../apps/app/app/probe/failure.ts"; -import { csrfHeader } from "../../apps/app/app/probe/session.ts"; -import { frame, helloResponse } from "../fixtures/grpc-web.ts"; -import { - anonymous, - authenticated, - bootstrapBody, - jsonResponse, - receiptBody, -} from "../fixtures/app-probe.ts"; - -const grpcWeb = { "content-type": "application/grpc-web+proto" }; -let fetcher: ReturnType>; - -beforeEach(() => { - fetcher = vi.fn(); - window.fetch = fetcher as unknown as typeof fetch; -}); -afterEach(() => { - cleanup(); - vi.useRealTimers(); -}); - -const requestOf = (call: number) => { - const [input, init] = fetcher.mock.calls[call] as [RequestInfo | URL, RequestInit | undefined]; - return new Request(input, init); -}; - -test("account reads the session once on open and presents it as text with the exact generation", async () => { - fetcher.mockResolvedValue(jsonResponse(bootstrapBody(authenticated))); - render(); - expect(screen.getByText("Checking your session…")).toBeVisible(); - await screen.findByText("Ada Lovelace"); - expect(fetcher).toHaveBeenCalledTimes(1); - expect(requestOf(0).url).toBe(`${window.location.origin}/session/v1/bootstrap`); - expect(screen.getByText("18446744073709551615")).toHaveClass("exact"); - expect(screen.getByText("en-GB")).toBeVisible(); - expect(screen.getByText("Europe/London")).toBeVisible(); - expect(screen.getByText(authenticated.session.expiresAt)).toBeVisible(); - const text = document.body.textContent ?? ""; - for (const secret of [ - authenticated.csrfToken, - authenticated.session.sessionId, - authenticated.session.userId, - authenticated.session.deviceId, - ]) - expect(text).not.toContain(secret); -}); - -test("account renders an anonymous visitor with no sign-out control", async () => { - fetcher.mockResolvedValue(jsonResponse(bootstrapBody(anonymous))); - render(); - await screen.findByText("You are not signed in."); - expect(screen.queryByRole("button")).toBeNull(); -}); - -test("account renders display text from the server as text, never markup", async () => { - const hostile = { - ...authenticated, - profile: { ...authenticated.profile, displayName: "" }, - }; - fetcher.mockResolvedValue(jsonResponse(bootstrapBody(hostile))); - render(); - await screen.findByText(""); - expect(document.querySelector("img")).toBeNull(); -}); - -test("sign out posts the CSRF token from this page's bootstrap and then shows the ended state", async () => { - fetcher - .mockResolvedValueOnce(jsonResponse(bootstrapBody(authenticated))) - .mockResolvedValueOnce(jsonResponse(receiptBody("happened"))); - render(); - fireEvent.click(await screen.findByRole("button", { name: "Sign out" })); - await screen.findByText("You are signed out. Reload to check again."); - expect(fetcher).toHaveBeenCalledTimes(2); - const logout = requestOf(1); - expect(logout.method).toBe("POST"); - expect(logout.url).toBe(`${window.location.origin}/session/v1/logout`); - expect(logout.headers.get("x-af-csrf")).toBe(authenticated.csrfToken); - expect(logout.headers.get(csrfHeader)).toBe(authenticated.csrfToken); - expect(screen.queryByText("Ada Lovelace")).toBeNull(); -}); - -test("a session that ended on the server is shown as ended, not as a transient fault", async () => { - fetcher - .mockResolvedValueOnce(jsonResponse(bootstrapBody(authenticated))) - .mockResolvedValueOnce(new Response("{}", { status: 401 })); - render(); - fireEvent.click(await screen.findByRole("button", { name: "Sign out" })); - await screen.findByText("You are signed out. Reload to check again."); - expect(screen.queryByRole("alert")).toBeNull(); -}); - -test("a refused or unconfirmed sign out is reported as a failure and offers a fresh read", async () => { - for (const [answer, text] of [ - [() => new Response("{}", { status: 403 }), failureText.forbidden], - [() => jsonResponse(receiptBody("didNotHappen")), failureText.rejected], - [() => jsonResponse(receiptBody("unknown")), failureText.unexpected], - ] as const) { - cleanup(); - fetcher.mockReset(); - fetcher.mockResolvedValueOnce(jsonResponse(bootstrapBody(authenticated))); - fetcher.mockResolvedValueOnce(answer()); - render(); - fireEvent.click(await screen.findByRole("button", { name: "Sign out" })); - expect(await screen.findByRole("alert")).toHaveTextContent(text); - expect(screen.queryByText("You are signed out. Reload to check again.")).toBeNull(); - // The page does not claim a sign-out; it offers a fresh read (a new session and token). - expect(screen.getByRole("button", { name: "Try again" })).toBeVisible(); - expect(screen.queryByRole("button", { name: "Sign out" })).toBeNull(); - } -}); - -test("a failed read is announced and can be retried", async () => { - fetcher - .mockResolvedValueOnce(new Response("down", { status: 503 })) - .mockResolvedValueOnce(jsonResponse(bootstrapBody(anonymous))); - render(); - expect(await screen.findByRole("alert")).toHaveTextContent(failureText.unavailable); - fireEvent.click(screen.getByRole("button", { name: "Try again" })); - await screen.findByText("You are not signed in."); - expect(fetcher).toHaveBeenCalledTimes(2); -}); - -test("an unreadable answer is shown as malformed and leaves no session", async () => { - fetcher.mockResolvedValue(jsonResponse('{"csrfToken":"x"}')); - render(); - expect(await screen.findByRole("alert")).toHaveTextContent(failureText.malformed); -}); - -test("leaving the page cancels the read that is still in flight", async () => { - let signal: AbortSignal | undefined; - fetcher.mockImplementation( - (_input, init) => - new Promise((_resolve, reject) => { - signal = init?.signal ?? undefined; - init?.signal?.addEventListener("abort", () => reject(new DOMException("a", "AbortError"))); - }), - ); - const view = render(); - await waitFor(() => expect(signal).toBeDefined()); - view.unmount(); - expect(signal?.aborted).toBe(true); -}); - -test("leaving the chat page cancels the message that is still pending", async () => { - let signal: AbortSignal | undefined; - fetcher.mockImplementation( - (_input, init) => - new Promise((_resolve, reject) => { - signal = init?.signal ?? undefined; - init?.signal?.addEventListener("abort", () => reject(new DOMException("a", "AbortError"))); - }), - ); - const view = render(); - fireEvent.click(screen.getByRole("button", { name: "Send" })); - await waitFor(() => expect(signal).toBeDefined()); - expect(signal?.aborted).toBe(false); - view.unmount(); - expect(signal?.aborted).toBe(true); -}); - -test("chat sends nothing until asked, then shows both sides of one anonymous exchange", async () => { - fetcher.mockImplementation( - async () => new Response(helloResponse("Hello, ArcForges!") as BodyInit, { headers: grpcWeb }), - ); - render(); - expect(fetcher).not.toHaveBeenCalled(); - fireEvent.click(screen.getByRole("button", { name: "Send" })); - await screen.findByText("Hello, ArcForges!"); - expect(fetcher).toHaveBeenCalledTimes(1); - expect(requestOf(0).url).toBe( - `${window.location.origin}/api/arcforges.hello.v1.HelloService/SayHello`, - ); - expect(requestOf(0).credentials).toBe("omit"); - const items = screen.getAllByRole("listitem").map((item) => item.textContent); - expect(items).toEqual(["youArcForges", "serverHello, ArcForges!"]); -}); - -test("chat shows the server's typed refusal as a fixed notice and keeps working", async () => { - fetcher - .mockResolvedValueOnce( - new Response( - frame( - new TextEncoder().encode( - "grpc-status: 3\r\ngrpc-message: Name%20must%20not%20be%20empty.\r\n", - ), - 0x80, - ) as BodyInit, - { headers: grpcWeb }, - ), - ) - .mockResolvedValueOnce( - new Response(helloResponse("Hello, again!") as BodyInit, { headers: grpcWeb }), - ); - render(); - const input = screen.getByLabelText("Name"); - fireEvent.change(input, { target: { value: "" } }); - fireEvent.click(screen.getByRole("button", { name: "Send" })); - await screen.findByText(failureText.rejected); - expect(document.body.textContent).not.toContain("must not be empty"); - fireEvent.change(input, { target: { value: "again" } }); - fireEvent.click(screen.getByRole("button", { name: "Send" })); - await screen.findByText("Hello, again!"); -}); - -test("chat can cancel a pending message, reports it as cancelled and does not double-send", async () => { - let signal: AbortSignal | undefined; - fetcher.mockImplementation( - (_input, init) => - new Promise((_resolve, reject) => { - signal = init?.signal ?? undefined; - init?.signal?.addEventListener("abort", () => reject(new DOMException("a", "AbortError"))); - }), - ); - render(); - fireEvent.click(screen.getByRole("button", { name: "Send" })); - const send = await screen.findByRole("button", { name: "Sending…" }); - expect(send).toBeDisabled(); - fireEvent.submit(send.closest("form") as HTMLFormElement); - expect(fetcher).toHaveBeenCalledTimes(1); - fireEvent.click(screen.getByRole("button", { name: "Cancel" })); - await screen.findByText(failureText.cancelled); - expect(signal?.aborted).toBe(true); - expect(screen.queryByRole("button", { name: "Cancel" })).toBeNull(); - expect(screen.getByRole("button", { name: "Send" })).toBeEnabled(); -}); - -test("chat renders untrusted names as text and keeps only the last twenty entries", async () => { - fetcher.mockImplementation(async (input, init) => { - const body = new Uint8Array(await new Request(input, init).arrayBuffer()); - const name = new TextDecoder().decode(body.slice(7)); - return new Response(helloResponse(`Hello, ${name}!`) as BodyInit, { headers: grpcWeb }); - }); - render(); - const input = screen.getByLabelText("Name"); - fireEvent.change(input, { target: { value: "n" } }); - for (let index = 0; index < 11; index++) { - fireEvent.click(screen.getByRole("button", { name: "Send" })); - await waitFor(() => expect(screen.getByRole("button", { name: "Send" })).toBeEnabled()); - } - expect(screen.getAllByRole("listitem")).toHaveLength(20); - expect(fetcher).toHaveBeenCalledTimes(11); - fireEvent.change(input, { target: { value: "" } }); - fireEvent.click(screen.getByRole("button", { name: "Send" })); - await waitFor(() => - expect(screen.getAllByText("").length).toBeGreaterThan(0), - ); - expect(document.querySelector("img")).toBeNull(); -}); diff --git a/tests/unit/app-session.test.ts b/tests/unit/app-session.test.ts deleted file mode 100644 index e3c2f00..0000000 --- a/tests/unit/app-session.test.ts +++ /dev/null @@ -1,413 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -import { expect, test } from "vitest"; -import { exactUnsigned, tryExactUnsigned } from "../../apps/app/app/probe/exact.ts"; -import { type FailureKind, ProbeFailure } from "../../apps/app/app/probe/failure.ts"; -import { csrfHeader, endSession, readSession } from "../../apps/app/app/probe/session.ts"; -import { - anonymous, - authenticated, - bootstrapBody, - jsonResponse, - origin, - receiptBody, - streamed, -} from "../fixtures/app-probe.ts"; - -const signal = () => new AbortController().signal; - -async function failureOf(task: Promise): Promise { - const error = await task.then( - () => undefined, - (reason: unknown) => reason, - ); - expect(error).toBeInstanceOf(ProbeFailure); - return (error as ProbeFailure).kind; -} - -test("bootstrap is a same-origin cookie GET driven by the generated route catalogue", async () => { - let seen: Request | undefined; - const session = await readSession({ - origin, - signal: signal(), - fetcher: async (input, init) => { - seen = new Request(input, init); - return jsonResponse(bootstrapBody(authenticated)); - }, - }); - expect(seen?.url).toBe(`${origin}/session/v1/bootstrap`); - expect(seen?.method).toBe("GET"); - expect(seen?.credentials).toBe("same-origin"); - expect(seen?.redirect).toBe("error"); - expect(seen?.cache).toBe("no-store"); - expect(seen?.headers.has(csrfHeader)).toBe(false); - expect(seen?.headers.has("authorization")).toBe(false); - expect(session).toEqual({ - state: "authenticated", - csrfToken: authenticated.csrfToken, - displayName: "Ada Lovelace", - locale: "en-GB", - timezone: "Europe/London", - expiresAt: authenticated.session.expiresAt, - idleExpiresAt: authenticated.session.idleExpiresAt, - workspaces: 2, - recoveryGeneration: "18446744073709551615", - }); -}); - -test("an anonymous answer carries only the CSRF token", async () => { - const session = await readSession({ - origin, - signal: signal(), - fetcher: async () => jsonResponse(bootstrapBody(anonymous)), - }); - expect(session).toEqual({ state: "anonymous", csrfToken: anonymous.csrfToken }); -}); - -test("an authenticated session without a profile still presents", async () => { - const { profile: _profile, ...withoutProfile } = authenticated; - const session = await readSession({ - origin, - signal: signal(), - fetcher: async () => jsonResponse(bootstrapBody(withoutProfile)), - }); - expect(session).toMatchObject({ - state: "authenticated", - displayName: undefined, - locale: undefined, - }); -}); - -test("contradictory or inexact bootstrap answers are malformed, never a session", async () => { - const { session: _session, ...noSession } = authenticated; - const cases: [string, unknown][] = [ - ["authenticated without a session", noSession], - ["anonymous with a session", { ...anonymous, session: authenticated.session }], - ["anonymous with a profile", { ...anonymous, profile: authenticated.profile }], - [ - "leading zero generation", - { ...authenticated, session: { ...authenticated.session, recoveryGeneration: "01" } }, - ], - [ - "generation above uint64", - { - ...authenticated, - session: { ...authenticated.session, recoveryGeneration: "18446744073709551616" }, - }, - ], - ]; - for (const [name, value] of cases) { - let bytes: Uint8Array; - try { - bytes = bootstrapBody(value); - } catch { - // The generated codec already refuses this shape on the server side; send raw text instead. - bytes = new TextEncoder().encode(JSON.stringify(value)); - } - const kind = await failureOf( - readSession({ origin, signal: signal(), fetcher: async () => jsonResponse(bytes) }), - ); - expect(kind, name).toBe("malformed"); - } -}); - -test("the generated codec refuses malformed, unknown-field and wrong-type documents", async () => { - const bodies = [ - "", - "{", - "[]", - "null", - '{"csrfToken":"x"}', - '{"csrfToken":"x","authenticated":"yes"}', - '{"csrfToken":"x","authenticated":false,"extra":1}', - '{"csrfToken":"x","authenticated":false,"authenticated":true}', - `{"csrfToken":"${"x".repeat(20)}","authenticated":false`, - ]; - for (const body of bodies) - expect( - await failureOf( - readSession({ origin, signal: signal(), fetcher: async () => jsonResponse(body) }), - ), - body, - ).toBe("malformed"); -}); - -test("only a JSON 200 is read; every other status is a typed failure with a fixed message", async () => { - const statuses: [number, FailureKind][] = [ - [301, "malformed"], - [400, "rejected"], - [401, "unauthenticated"], - [403, "forbidden"], - [404, "rejected"], - [408, "timeout"], - [413, "limit"], - [429, "limit"], - [500, "unexpected"], - [502, "unavailable"], - [503, "unavailable"], - [504, "timeout"], - ]; - for (const [status, kind] of statuses) { - const error = await readSession({ - origin, - signal: signal(), - // A body that would parse as a session proves the status is checked first. - fetcher: async () => jsonResponse(bootstrapBody(authenticated), { status }), - }).then( - () => undefined, - (reason: unknown) => reason as ProbeFailure, - ); - expect(error?.kind, String(status)).toBe(kind); - expect(error?.message).not.toContain(authenticated.csrfToken); - } - for (const type of ["text/html", "application/x-ndjson", "", "application/jsonp"]) - expect( - await failureOf( - readSession({ - origin, - signal: signal(), - fetcher: async () => - new Response(bootstrapBody(authenticated) as BodyInit, { - headers: type ? { "content-type": type } : {}, - }), - }), - ), - type, - ).toBe("malformed"); - const parameters = await readSession({ - origin, - signal: signal(), - fetcher: async () => - new Response(bootstrapBody(anonymous) as BodyInit, { - headers: { "content-type": "application/json; charset=utf-8" }, - }), - }); - expect(parameters.state).toBe("anonymous"); -}); - -test("the body is bounded before it is parsed", async () => { - const exact = bootstrapBody(anonymous); - const padded = new Uint8Array(16384 + 1).fill(0x20); - padded.set(exact); - // At the schema bound a document is still read (padding is JSON whitespace). - const atBound = new Uint8Array(16384).fill(0x20); - atBound.set(exact); - expect( - ( - await readSession({ - origin, - signal: signal(), - fetcher: async () => jsonResponse(atBound), - }) - ).state, - ).toBe("anonymous"); - expect( - await failureOf( - readSession({ origin, signal: signal(), fetcher: async () => jsonResponse(padded) }), - ), - ).toBe("malformed"); - // A declared length above the bound is refused without reading, and a stream above it is cut off - // after at most one chunk beyond the bound, for the session and the receipt alike. - for (const [bound, call] of [ - [16384, (fetcher: typeof fetch) => readSession({ origin, signal: signal(), fetcher })], - [ - 4096, - (fetcher: typeof fetch) => endSession({ origin, signal: signal(), csrfToken: "t", fetcher }), - ], - ] as const) { - let cancelled = false; - let pulled = 0; - const endless = new Response( - new ReadableStream({ - pull(controller) { - pulled += 1024; - controller.enqueue(new Uint8Array(1024).fill(0x20)); - }, - cancel() { - cancelled = true; - }, - }), - { headers: { "content-type": "application/json" } }, - ); - expect(await failureOf(call(async () => endless)), String(bound)).toBe("malformed"); - expect(cancelled).toBe(true); - expect(pulled).toBeGreaterThan(bound); - expect(pulled).toBeLessThanOrEqual(bound + 4096); - } - expect( - await failureOf( - readSession({ - origin, - signal: signal(), - fetcher: async () => jsonResponse(exact, { headers: { "content-length": "16385" } }), - }), - ), - ).toBe("malformed"); - expect( - await failureOf( - readSession({ - origin, - signal: signal(), - fetcher: async () => jsonResponse(exact, { headers: { "content-length": "12e3" } }), - }), - ), - ).toBe("malformed"); -}); - -test("the stream bound is exact: one byte over it is the first byte refused", async () => { - for (const [bound, call] of [ - [16384, (fetcher: typeof fetch) => readSession({ origin, signal: signal(), fetcher })], - [ - 4096, - (fetcher: typeof fetch) => endSession({ origin, signal: signal(), csrfToken: "t", fetcher }), - ], - ] as const) { - let pulled = 0; - const body = new ReadableStream( - { - pull(controller) { - pulled += 1; - controller.enqueue(Uint8Array.of(0x20)); - }, - }, - { highWaterMark: 0 }, - ); - const answer = new Response(body, { headers: { "content-type": "application/json" } }); - expect(await failureOf(call(async () => answer)), String(bound)).toBe("malformed"); - expect(pulled, String(bound)).toBe(bound + 1); - } -}); - -test("a body split across chunks reads as one document; a failing stream is unavailable", async () => { - const bytes = bootstrapBody(authenticated); - const chunks = [bytes.slice(0, 7), bytes.slice(7, 90), bytes.slice(90)]; - expect( - ( - await readSession({ - origin, - signal: signal(), - fetcher: async () => streamed(chunks), - }) - ).state, - ).toBe("authenticated"); - expect( - await failureOf( - readSession({ - origin, - signal: signal(), - fetcher: async () => streamed([bytes.slice(0, 20)], new TypeError("connection reset")), - }), - ), - ).toBe("unavailable"); -}); - -test("network failure is unavailable and cancellation is distinct from it", async () => { - expect( - await failureOf( - readSession({ - origin, - signal: signal(), - fetcher: async () => { - throw new TypeError("Failed to fetch"); - }, - }), - ), - ).toBe("unavailable"); - const controller = new AbortController(); - const pending = readSession({ - origin, - signal: controller.signal, - fetcher: (_input, init) => - new Promise((_resolve, reject) => { - init?.signal?.addEventListener("abort", () => - reject(new DOMException("aborted", "AbortError")), - ); - }), - }); - controller.abort(); - expect(await failureOf(pending)).toBe("cancelled"); - const already = new AbortController(); - already.abort(); - expect( - await failureOf( - readSession({ - origin, - signal: already.signal, - fetcher: async (_input, init) => { - if (init?.signal?.aborted) throw new DOMException("aborted", "AbortError"); - return jsonResponse(bootstrapBody(anonymous)); - }, - }), - ), - ).toBe("cancelled"); -}); - -test("logout sends the CSRF token as the only credential header, with no body", async () => { - let seen: Request | undefined; - const effect = await endSession({ - origin, - signal: signal(), - csrfToken: authenticated.csrfToken, - fetcher: async (input, init) => { - seen = new Request(input, init); - expect(init?.body).toBeUndefined(); - return jsonResponse(receiptBody("happened")); - }, - }); - expect(effect).toBe("happened"); - expect(seen?.url).toBe(`${origin}/session/v1/logout`); - expect(seen?.method).toBe("POST"); - expect(seen?.credentials).toBe("same-origin"); - expect(seen?.redirect).toBe("error"); - // The literal wire name is pinned here, independent of the module constant. - expect(seen?.headers.get("x-af-csrf")).toBe(authenticated.csrfToken); - expect(csrfHeader.toLowerCase()).toBe("x-af-csrf"); - expect(seen?.headers.get(csrfHeader)).toBe(authenticated.csrfToken); - expect(seen?.headers.has("authorization")).toBe(false); - expect(await seen?.text()).toBe(""); -}); - -test("logout reports an ended session, a refused token and a malformed receipt distinctly", async () => { - const run = (response: () => Response) => - endSession({ origin, signal: signal(), csrfToken: "t", fetcher: async () => response() }); - expect(await failureOf(run(() => new Response("{}", { status: 401 })))).toBe("unauthenticated"); - expect(await failureOf(run(() => new Response("{}", { status: 403 })))).toBe("forbidden"); - expect(await failureOf(run(() => new Response("{}", { status: 400 })))).toBe("rejected"); - expect(await failureOf(run(() => new Response("{}", { status: 503 })))).toBe("unavailable"); - expect(await failureOf(run(() => jsonResponse(receiptBody("didNotHappen"))))).toBe("rejected"); - expect(await failureOf(run(() => jsonResponse(receiptBody("unknown"))))).toBe("unexpected"); - expect(await failureOf(run(() => jsonResponse("{}")))).toBe("malformed"); - expect(await failureOf(run(() => jsonResponse('{"commandId":"x"}')))).toBe("malformed"); - expect(await failureOf(run(() => jsonResponse(new Uint8Array(4097).fill(0x20))))).toBe( - "malformed", - ); -}); - -test("64-bit values stay exact: canonical text in, the same text out, nothing through a number", () => { - for (const value of [ - "0", - "9007199254740991", - "9007199254740993", - "9223372036854775807", - "18446744073709551615", - ]) { - const exact = exactUnsigned(value); - expect(exact.text).toBe(value); - expect(exact.value).toBe(BigInt(value)); - } - // A JavaScript number cannot hold these two values apart; the exact path can. - expect(Number("9007199254740993")).toBe(Number("9007199254740992")); - expect(exactUnsigned("9007199254740993").value).not.toBe(exactUnsigned("9007199254740992").value); - for (const value of [ - "", - "01", - "-1", - "+1", - "1.0", - "1e3", - " 1", - "1 ", - "0x10", - "18446744073709551616", - ]) - expect(tryExactUnsigned(value), value).toBeUndefined(); -}); diff --git a/tests/unit/architecture-policy.test.ts b/tests/unit/architecture-policy.test.ts deleted file mode 100644 index 976c0e2..0000000 --- a/tests/unit/architecture-policy.test.ts +++ /dev/null @@ -1,267 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -import { describe, expect, it } from "vitest"; -import { auditArchitecture, type Sources } from "../../eng/policy/architecture.ts"; - -function baseline(): Sources { - const manifest = { - name: "web", - workspaces: ["apps/site"], - packageManager: "npm@11.19.0", - engines: { node: ">=24.21.0 <25", npm: ">=11.19.0 <12" }, - devDependencies: { typescript: "7.0.2", "@react-router/dev": "8.4.0" }, - }; - return { - "package.json": JSON.stringify(manifest), - "apps/site/package.json": JSON.stringify({ name: "site", license: "AGPL-3.0-only" }), - "package-lock.json": JSON.stringify({ - lockfileVersion: 3, - packages: { "": { devDependencies: manifest.devDependencies }, "apps/site": {} }, - }), - ".node-version": "24.21.0", - "apps/site/app/root.ts": - 'import { Message } from "@arcforges/proto"; document.title = "Web DOM is allowed";', - }; -} -function alterManifest(sources: Sources, update: (manifest: Record) => void) { - const manifest = JSON.parse(sources["package.json"] ?? "{}"); - update(manifest); - sources["package.json"] = JSON.stringify(manifest); -} -describe("GOV.11 architecture refusal fixtures", () => { - const cases: [string, (sources: Sources) => void][] = [ - [ - "wire-source", - (s) => { - s["apps/site/app/root.ts"] = - 'interface LoginPayload { password: string }; JSON.stringify({password:"x"});'; - }, - ], - [ - "wire-source", - (s) => { - s["apps/site/app/root.ts"] = 'fetch("/api/custom", {body:"handwritten"});'; - }, - ], - [ - "wire-source", - (s) => { - s["apps/site/app/root.ts"] = 'import { messageDesc } from "@bufbuild/protobuf/codegenv2";'; - }, - ], - [ - "private-import", - (s) => { - s["apps/site/app/root.ts"] = 'import "../bridge";'; - s["apps/site/bridge.ts"] = 'import "@arcforges/operator-client";'; - }, - ], - [ - "release-fixture", - (s) => { - s["apps/site/app/root.ts"] = 'import "../bridge";'; - s["apps/site/bridge.ts"] = 'import "../../tests/helper";'; - s["tests/helper.ts"] = "export const fake = true;"; - }, - ], - [ - "production-command", - (s) => - alterManifest(s, (m) => { - m.scripts = { build: "npm run dev", dev: "vite dev" }; - }), - ], - [ - "production-command", - (s) => - alterManifest(s, (m) => { - m.scripts = { build: "npm run other", other: "npm run build" }; - }), - ], - [ - "production-command", - (s) => { - s["Web.esproj"] = "true"; - }, - ], - [ - "production-command", - (s) => { - s["Web.esproj"] = "npm run dev"; - }, - ], - [ - "workspace", - (s) => { - s["apps/site/package-lock.json"] = "{}"; - }, - ], - [ - "workspace", - (s) => { - s["yarn.lock"] = "alternate"; - }, - ], - [ - "workspace", - (s) => { - s["hidden/package.json"] = '{"name":"hidden"}'; - }, - ], - [ - "pins", - (s) => - alterManifest(s, (m) => { - m.packageManager = "npm@latest"; - }), - ], - [ - "pins", - (s) => - alterManifest(s, (m) => { - m.devDependencies = { typescript: "^7.0.2" }; - }), - ], - [ - "sdk-ui", - (s) => { - s["packages/sdk/src/index.ts"] = 'export * from "@arcforges/web-ui";'; - }, - ], - [ - "private-import", - (s) => { - s["apps/site/app/root.ts"] = 'export * from "@arcforges/operator-client";'; - }, - ], - [ - "private-import", - (s) => { - s["apps/site/app/root.ts"] = 'import type { X } from "@arcforges/proto/internal/x";'; - }, - ], - [ - "private-import", - (s) => { - s["apps/site/app/root.ts"] = 'const x = import("../server/private.ts");'; - }, - ], - [ - "private-import", - (s) => { - s["apps/site/app/root.ts"] = 'type T = import("@arcforges/local-rpc").T;'; - }, - ], - [ - "private-import", - (s) => { - s["apps/site/app/root.ts"] = 'const x = require("@arcforges/ai-internal");'; - }, - ], - [ - "computed-import", - (s) => { - s["apps/site/app/root.ts"] = "const x = import(variable);"; - }, - ], - [ - "desktop-dom", - (s) => { - s["packages/desktop/src/index.ts"] = "document.createElement('div');"; - }, - ], - [ - "obsolete-target", - (s) => { - s["obsolete.csproj"] = ''; - }, - ], - [ - "portable-reference", - (s) => { - s["Portable.csproj"] = ''; - }, - ], - [ - "production-command", - (s) => - alterManifest(s, (m) => { - m.scripts = { build: "npm install && vite build" }; - }), - ], - [ - "production-command", - (s) => - alterManifest(s, (m) => { - m.scripts = { start: "vite dev --host" }; - }), - ], - [ - "wire-source", - (s) => { - s["apps/site/app/root.ts"] = "export interface LoginRequest { token: string }"; - }, - ], - [ - "release-fixture", - (s) => { - s["apps/site/app/root.ts"] = 'import "./bridge";'; - s["apps/site/app/bridge.ts"] = 'export * from "./fixtures/secret";'; - s["apps/site/app/fixtures/secret.ts"] = "export const fake = true"; - }, - ], - [ - "unresolved-import", - (s) => { - s["apps/site/app/root.ts"] = 'import "./missing";'; - }, - ], - ]; - for (const [rule, mutation] of cases) - it(`${rule}: accepts valid graph and rejects mutation`, () => { - const sources = baseline(); - expect(auditArchitecture(sources)).toEqual([]); - mutation(sources); - expect(auditArchitecture(sources).some((finding) => finding.rule === rule)).toBe(true); - }); - it("ignores quoted/commented fake imports and permits local development", () => { - const sources = baseline(); - sources["apps/site/app/root.ts"] = - '// import x from "@arcforges/private";\nconst text = "document @arcforges/private";'; - alterManifest(sources, (m) => { - m.scripts = { dev: "vite dev", build: "vite build" }; - }); - expect(auditArchitecture(sources)).toEqual([]); - }); - it("permits UI-local models and aliases of imported generated wire types", () => { - const sources = baseline(); - sources["apps/site/app/root.ts"] = - 'import type { SayHelloRequest } from "@arcforges/proto"; type HelloRequest = SayHelloRequest; type OtherRequest = import("@arcforges/proto").SayHelloRequest; interface ViewState { selected: boolean }'; - expect(auditArchitecture(sources)).toEqual([]); - }); - it("rejects transitive Apache-to-AGPL manifest dependencies without source imports", () => { - const sources = baseline(); - const root = JSON.parse(sources["package.json"] ?? "{}"); - root.workspaces.push("packages/sdk", "packages/bridge", "packages/other"); - sources["package.json"] = JSON.stringify(root); - const lock = JSON.parse(sources["package-lock.json"] ?? "{}"); - for (const [directory, name, license, dependencies] of [ - ["sdk", "@arcforges/web-sdk", "Apache-2.0", { "@arcforges/web-bridge": "1.0.0" }], - ["bridge", "@arcforges/web-bridge", "Apache-2.0", { "@arcforges/web-other": "1.0.0" }], - ["other", "@arcforges/web-other", "Apache-2.0", {}], - ] as const) { - sources[`packages/${directory}/package.json`] = JSON.stringify({ - name, - license, - dependencies, - }); - lock.packages[`packages/${directory}`] = { dependencies }; - } - sources["package-lock.json"] = JSON.stringify(lock); - expect(auditArchitecture(sources)).toEqual([]); - sources["packages/other/package.json"] = (sources["packages/other/package.json"] ?? "").replace( - "Apache-2.0", - "AGPL-3.0-only", - ); - expect(auditArchitecture(sources).some((f) => f.rule === "sdk-ui")).toBe(true); - }); -}); diff --git a/tests/unit/canonical-host.test.ts b/tests/unit/canonical-host.test.ts deleted file mode 100644 index bc1a9b0..0000000 --- a/tests/unit/canonical-host.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { basename, dirname, join } from "node:path"; -import { expect, test, vi } from "vitest"; -import worker from "../../worker/index.js"; -import { root, verifyWorkerScript } from "../../tooling/project.ts"; - -test.each([ - ["https://www.arcforges.com/", "https://arcforges.com/"], - [ - "https://www.arcforges.com/cloud-hello/?name=a%2Fb&next=%2Fhello%2F", - "https://arcforges.com/cloud-hello/?name=a%2Fb&next=%2Fhello%2F", - ], - [ - "http://www.arcforges.com:8080//other.example/path?x=1&x=2", - "https://arcforges.com//other.example/path?x=1&x=2", - ], -])("www redirects before static assets: %s", async (url, destination) => { - const fetch = vi.fn<(_request: Request) => Promise>(); - const response = await worker.fetch(new Request(url), { ASSETS: { fetch } }); - expect(response.status).toBe(308); - expect(response.headers.get("location")).toBe(destination); - expect(fetch).not.toHaveBeenCalled(); -}); - -test.each(["HEAD", "POST"])("canonical redirect preserves %s semantics", async (method) => { - const fetch = vi.fn<(_request: Request) => Promise>(); - const response = await worker.fetch( - new Request("https://www.arcforges.com/api/example?mode=binary", { method }), - { ASSETS: { fetch } }, - ); - expect(response.status).toBe(308); - expect(response.headers.get("location")).toBe("https://arcforges.com/api/example?mode=binary"); - expect(fetch).not.toHaveBeenCalled(); -}); - -test.each([ - "https://arcforges.com/cloud-hello/", - "http://127.0.0.1:4173/hello/", - "https://www.arcforges.com.other.example/", - "https://other.example/?next=https://www.arcforges.com/", -])("other hosts preserve the original asset request and response: %s", async (url) => { - const request = new Request(url, { headers: { accept: "text/html" } }); - const asset = new Response("asset", { - headers: { "content-security-policy": "default-src 'self'" }, - }); - const fetch = vi.fn(async () => asset); - const response = await worker.fetch(request, { ASSETS: { fetch } }); - expect(fetch).toHaveBeenCalledExactlyOnceWith(request); - expect(response).toBe(asset); -}); - -test("delivery runs the reviewed Worker before assets without a deployment rebuild", async () => { - const config = JSON.parse(await readFile(join(root, "wrangler.json"), "utf8")); - expect(config.main).toBe("./worker/index.js"); - expect(config.no_bundle).toBe(true); - expect(config.assets.binding).toBe("ASSETS"); - expect(config.assets.run_worker_first).toBe(true); -}); - -test("candidate verification rejects a replaced private Worker independently of its manifest", async () => { - const directory = await mkdtemp(join(tmpdir(), "arcforges-canonical-worker-")); - try { - await mkdir(join(directory, "worker")); - const source = (await readFile(join(root, "worker/index.js"), "utf8")).replaceAll("\r\n", "\n"); - await writeFile(join(directory, "worker/index.js"), source); - await expect(verifyWorkerScript(directory)).resolves.toBeUndefined(); - await writeFile( - join(directory, "worker/index.js"), - source.replace('arcforges.com";', 'other.example";'), - ); - await expect(verifyWorkerScript(directory)).rejects.toThrow("Worker script changed"); - } finally { - expect(dirname(directory)).toBe(tmpdir()); - expect(basename(directory)).toMatch(/^arcforges-canonical-worker-/u); - await rm(directory, { recursive: true, force: true }); - } -}); diff --git a/tests/unit/cloud-hello.test.ts b/tests/unit/cloud-hello.test.ts deleted file mode 100644 index c011d55..0000000 --- a/tests/unit/cloud-hello.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -import { expect, test } from "vitest"; -import { checkServerConnection, helloApiPath } from "../../apps/site/app/cloud-hello"; -import { decodeHelloRequest, helloResponse } from "../fixtures/grpc-web"; - -test("connection uses the published binary client, same-origin API and no credentials", async () => { - const fetcher: typeof fetch = async (input, init) => { - const request = new Request(input, init); - expect(request.url).toBe(`https://arcforges.com${helloApiPath}`); - expect(request.method).toBe("POST"); - expect(request.headers.get("content-type")).toContain("application/grpc-web+proto"); - expect(request.credentials).toBe("omit"); - expect(request.redirect).toBe("error"); - expect(request.headers.has("authorization")).toBe(false); - expect(decodeHelloRequest(new Uint8Array(await request.arrayBuffer())).name).toBe("ArcForges"); - return new Response(helloResponse(), { - headers: { "content-type": "application/grpc-web+proto" }, - }); - }; - await expect( - checkServerConnection("https://arcforges.com", new AbortController().signal, fetcher), - ).resolves.toBe("Hello, ArcForges!"); -}); - -test("missing API and unexpected server output never become a local successful greeting", async () => { - for (const response of [ - new Response("Not found", { status: 404 }), - new Response("Method not allowed", { status: 405 }), - new Response("Not an API", { headers: { "content-type": "text/html" } }), - new Response(helloResponse("different service"), { - headers: { "content-type": "application/grpc-web+proto" }, - }), - ]) - await expect( - checkServerConnection( - "https://arcforges.com", - new AbortController().signal, - async () => response, - ), - ).rejects.toThrow(); -}); diff --git a/tests/unit/contracts.test.ts b/tests/unit/contracts.test.ts deleted file mode 100644 index 5631689..0000000 --- a/tests/unit/contracts.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -import { createHelloClient } from "@arcforges/api-client"; -import { SayHelloRequestSchema, SayHelloResponseSchema } from "@arcforges/proto"; -import { create, fromBinary, toBinary } from "@bufbuild/protobuf"; -import { expect, test } from "vitest"; -function frame(payload: Uint8Array, flag: number) { - const bytes = new Uint8Array(payload.length + 5); - bytes[0] = flag; - new DataView(bytes.buffer).setUint32(1, payload.length); - bytes.set(payload, 5); - return bytes; -} -// Explicit wire fixture; this does not assert that the C# Cloud service exists. -test("published SDK sends protobuf gRPC-Web and decodes the framed response", async () => { - const client = createHelloClient({ - baseUrl: "https://cloud.example.test", - fetch: async (input, init) => { - const request = new Request(input, init); - expect(new URL(request.url).pathname).toBe("/arcforges.hello.v1.HelloService/SayHello"); - expect(request.headers.get("content-type")).toContain("application/grpc-web+proto"); - const bytes = new Uint8Array(await request.arrayBuffer()); - expect(bytes[0]).toBe(0); - const decoded = fromBinary(SayHelloRequestSchema, bytes.slice(5)); - expect(decoded.name).toBe("ArcForges 世界"); - const message = frame( - toBinary( - SayHelloResponseSchema, - create(SayHelloResponseSchema, { message: `Hello, ${decoded.name}!` }), - ), - 0, - ); - const trailer = frame(new TextEncoder().encode("grpc-status: 0\r\n"), 0x80); - return new Response(new Blob([message, trailer]), { - headers: { "content-type": "application/grpc-web+proto" }, - }); - }, - }); - await expect(client.sayHello({ name: "ArcForges 世界" })).resolves.toMatchObject({ - message: "Hello, ArcForges 世界!", - }); -}); -test("SDK surfaces server failures instead of inventing a successful greeting", async () => { - const client = createHelloClient({ - baseUrl: "https://cloud.example.test", - fetch: async () => - new Response(null, { - headers: { - "content-type": "application/grpc-web+proto", - "grpc-status": "16", - "grpc-message": "Unauthenticated", - }, - }), - }); - await expect(client.sayHello({ name: "World" })).rejects.toThrow(/Unauthenticated/i); -}); diff --git a/tests/unit/delivery.test.ts b/tests/unit/delivery.test.ts deleted file mode 100644 index 70448e6..0000000 --- a/tests/unit/delivery.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -import { createHash } from "node:crypto"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { expect, test } from "vitest"; -import { contentSecurityPolicy, releaseVersion, seal, verify } from "../../tooling/project.ts"; -import { - deploymentUrl, - requireCustomDomain, - waitForDelivery, - waitForIdentity, -} from "../../tooling/cloudflare.ts"; -test("CSP authorizes exact prerendered scripts without unsafe inline/eval", () => { - const script = "console.log('hello')"; - const csp = contentSecurityPolicy([``]); - expect(csp).toContain(createHash("sha256").update(script).digest("base64")); - expect(csp).not.toMatch(/unsafe-inline|unsafe-eval/); - expect(csp).toContain("form-action 'none'"); - const variant = contentSecurityPolicy([ - ``, - ]); - expect(variant).toContain(createHash("sha256").update(`\n${script}`).digest("base64")); - expect(variant).not.toContain(createHash("sha256").update("not executable").digest("base64")); -}); -test("CI versions are unique across runs and reruns", () => { - expect(releaseVersion("10", "2")).toBe("0.1.0-ci.10.2"); - expect(releaseVersion(undefined, undefined)).toBe("0.1.0-local"); - expect(() => releaseVersion("1", "bad")).toThrow(); -}); -test("candidate verification rejects tampering and added files before deployment", async () => { - const dir = await mkdtemp(join(tmpdir(), "arcforges-web-test-")); - try { - await writeFile(join(dir, "example.txt"), "original"); - await seal(dir, { source: "a".repeat(40), version: "0.1.0-local", dirty: true }); - await writeFile(join(dir, "example.txt"), "changed"); - await expect(verify(dir, "a".repeat(40))).rejects.toThrow("Candidate changed"); - await writeFile(join(dir, "unexpected.txt"), "extra"); - await expect(verify(dir, "a".repeat(40))).rejects.toThrow("Candidate file set changed"); - await expect(verify(dir, "b".repeat(40))).rejects.toThrow("Candidate source"); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); -test("live verification tolerates propagation with read-only requests", async () => { - const identity = { source: "a".repeat(40), version: "0.1.0-ci.1.1" }; - let calls = 0; - await waitForIdentity(deploymentUrl, identity, { - attempts: 3, - pause: async () => {}, - fetcher: async (_input, init) => { - expect(init?.method).toBeUndefined(); - calls++; - return Response.json(calls < 2 ? { ...identity, version: "old" } : identity); - }, - }); - expect(calls).toBe(2); - await expect( - waitForIdentity(deploymentUrl, identity, { - attempts: 2, - pause: async () => {}, - fetcher: async () => Response.json({ version: "old" }), - }), - ).rejects.toThrow("No redeployment"); - await expect(waitForIdentity("https://other.example.com", identity)).rejects.toThrow( - "Unexpected deployment host", - ); -}); - -test("deployment requires the custom domain to belong to this production Worker", () => { - const domain = { - hostname: "arcforges.com", - service: "arcforges-web", - environment: "production", - }; - expect(() => requireCustomDomain([domain])).not.toThrow(); - for (const domains of [ - [], - [{ ...domain, service: "another-worker" }], - [{ ...domain, hostname: "other.example.com" }], - [{ ...domain, environment: "staging" }], - ]) - expect(() => requireCustomDomain(domains)).toThrow("Attach arcforges.com"); -}); - -test("asset verification waits for headers as well as identity and fails boundedly", async () => { - let reads = 0; - await waitForDelivery( - async (signal) => { - expect(signal.aborted).toBe(false); - reads++; - if (reads === 1) throw new Error("Missing no-transform: /404.css"); - }, - { timeoutMs: 1000, intervalMs: 0 }, - ); - expect(reads).toBe(2); - await expect( - waitForDelivery( - async () => { - throw new Error("Deployed bytes differ: /hello/"); - }, - { timeoutMs: 10, intervalMs: 0 }, - ), - ).rejects.toThrow("no redeployment was attempted. Error: Deployed bytes differ: /hello/"); -}); diff --git a/tests/unit/hello.test.tsx b/tests/unit/hello.test.tsx deleted file mode 100644 index 3d4ac7a..0000000 --- a/tests/unit/hello.test.tsx +++ /dev/null @@ -1,25 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -// @vitest-environment jsdom -import "@testing-library/jest-dom/vitest"; -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import { afterEach, expect, test } from "vitest"; -import { greet } from "../../apps/site/app/hello"; -import Hello from "../../apps/site/app/routes/hello"; -afterEach(cleanup); -test("published protobuf round-trip preserves Unicode names", () => { - expect(greet(" 世界 🌍 ")).toBe("Hello, 世界 🌍!"); - expect(greet("🌍".repeat(80))).toBe(`Hello, ${"🌍".repeat(80)}!`); - for (const invalid of [" ", "x".repeat(81), "hi\u0000"]) expect(() => greet(invalid)).toThrow(); -}); -test("form validates, recovers and renders untrusted names as text", () => { - render(); - const input = screen.getByLabelText("Your name"); - fireEvent.change(input, { target: { value: "" } }); - fireEvent.click(screen.getByRole("button", { name: "Say hello" })); - expect(screen.getByRole("alert")).toBeVisible(); - fireEvent.change(input, { target: { value: "" } }); - fireEvent.click(screen.getByRole("button", { name: "Say hello" })); - expect(screen.queryByRole("alert")).not.toBeInTheDocument(); - expect(screen.getByRole("status")).toHaveTextContent("Hello, !"); - expect(document.querySelector("img")).toBeNull(); -}); diff --git a/tests/unit/licence-boundary.test.ts b/tests/unit/licence-boundary.test.ts deleted file mode 100644 index 2cdce7b..0000000 --- a/tests/unit/licence-boundary.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -import assert from "node:assert/strict"; -import { execFileSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { test } from "vitest"; -import { auditLicences } from "../../tooling/licence-boundary.ts"; - -function fixture() { - const root = mkdtempSync(path.join(os.tmpdir(), "licence-inventory-")); - execFileSync("git", ["init", "-q"], { cwd: root, windowsHide: true }); - execFileSync( - "git", - [ - "-c", - "user.name=Licence Test", - "-c", - "user.email=licence@example.invalid", - "-c", - "commit.gpgsign=false", - "commit", - "--allow-empty", - "-qm", - "fixture", - ], - { cwd: root, windowsHide: true }, - ); - const write = (file: string, value: unknown) => { - mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); - writeFileSync(path.join(root, file), typeof value === "string" ? value : JSON.stringify(value)); - }; - const policy = { - schemaVersion: 1, - repository: "Web", - spdxLicense: "AGPL-3.0-only", - licenceBoundary: "AGPL", - projects: [{ path: "package.json", kind: "npm" }], - }; - const manifest = { - name: "@arcforges/test", - license: "AGPL-3.0-only", - arcforges: { licenceBoundary: "AGPL" }, - }; - write("eng/policy/licence-boundary.json", policy); - write("package.json", manifest); - const cleanup = () => { - assert.equal(path.dirname(root), os.tmpdir()); - assert(path.basename(root).startsWith("licence-inventory-")); - rmSync(root, { recursive: true }); - }; - return { root, write, policy, manifest, cleanup }; -} -test("Git inventory checks existing and newly added projects", () => { - const f = fixture(); - try { - assert.equal(auditLicences(f.root).projects.length, 1); - f.write("unregistered/package.json", f.manifest); - assert.throws(() => auditLicences(f.root), /inventory drift/u); - } finally { - f.cleanup(); - } -}); -test("a missing boundary and edited owner assignment both fail", () => { - const f = fixture(); - try { - f.write("package.json", { ...f.manifest, arcforges: {} }); - assert.throws(() => auditLicences(f.root), /Incorrect boundary/u); - f.write("package.json", f.manifest); - f.policy.licenceBoundary = "Apache"; - f.write("eng/policy/licence-boundary.json", f.policy); - assert.throws(() => auditLicences(f.root)); - } finally { - f.cleanup(); - } -}); -test("inconsistent managed SPDX, imported overrides and escaped references fail", () => { - const f = fixture(); - try { - f.policy.projects.push({ path: "app.csproj", kind: "msbuild" }); - f.write("eng/policy/licence-boundary.json", f.policy); - const project = - "AGPL-3.0-onlyAGPL"; - f.write("app.csproj", project); - assert.equal(auditLicences(f.root).projects.length, 2); - f.write("app.csproj", project.replace("AGPL-3.0-only", "Apache-2.0")); - assert.throws(() => auditLicences(f.root), /Incorrect SPDX/u); - f.write( - "app.csproj", - project.replace( - "", - '', - ), - ); - assert.throws(() => auditLicences(f.root), /Escaped\/unregistered/u); - f.write("app.csproj", project); - f.write( - "Directory.Build.props", - "Apache", - ); - assert.throws(() => auditLicences(f.root), /Imported licence override/u); - } finally { - f.cleanup(); - } -}); -test("unknown locked transitive packages and unversioned npm aliases fail", () => { - const f = fixture(); - try { - f.write("package-lock.json", { packages: { "node_modules/@arcforges/unknown": {} } }); - assert.throws(() => auditLicences(f.root), /Unknown first-party/u); - f.write("package-lock.json", { packages: {} }); - f.write("package.json", { ...f.manifest, dependencies: { alias: "npm:@arcforges/unknown" } }); - assert.throws(() => auditLicences(f.root), /Unknown first-party/u); - } finally { - f.cleanup(); - } -}); -test("Gradle declarations and unpublished source inputs are checked", () => { - const f = fixture(); - try { - f.policy.projects.push({ path: "build.gradle.kts", kind: "gradle" }); - f.write("eng/policy/licence-boundary.json", f.policy); - f.write( - "build.gradle.kts", - 'extra["spdxLicense"] = "AGPL-3.0-only"\nextra["licenceBoundary"] = "Apache"\n', - ); - assert.throws(() => auditLicences(f.root), /Incorrect Gradle/u); - f.write( - "build.gradle.kts", - 'extra["spdxLicense"] = "AGPL-3.0-only"\nextra["licenceBoundary"] = "AGPL"\nmavenLocal()\n', - ); - assert.throws(() => auditLicences(f.root), /Unpublished Gradle/u); - } finally { - f.cleanup(); - } -}); - -test("comments cannot join fragments into a licence declaration", () => { - const f = fixture(); - try { - f.policy.projects.push({ path: "app.csproj", kind: "msbuild" }); - f.write("eng/policy/licence-boundary.json", f.policy); - f.write( - "app.csproj", - "AGPL-3.0-onlyoundary>AGPL", - ); - assert.throws(() => auditLicences(f.root), /Incorrect boundary/u); - } finally { - f.cleanup(); - } -}); diff --git a/tests/unit/naming-policy.test.ts b/tests/unit/naming-policy.test.ts deleted file mode 100644 index bfc28d0..0000000 --- a/tests/unit/naming-policy.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -import assert from "node:assert/strict"; -import { execFileSync, spawnSync } from "node:child_process"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { expect, it } from "vitest"; - -it("published naming scanner accepts current terms and detects every forbidden term", () => { - const packageRoot = path.resolve("node_modules/@arcforges/proto/tools/naming/eng"); - const policy = JSON.parse( - readFileSync(path.join(packageRoot, "policy/product-names.json"), "utf8"), - ); - const root = mkdtempSync(path.join(os.tmpdir(), "web-naming-")); - const git = (...args: string[]) => - execFileSync("git", args, { cwd: root, windowsHide: true, stdio: "pipe" }); - assert.equal(path.dirname(root), path.resolve(os.tmpdir())); - assert(path.basename(root).startsWith("web-naming-")); - try { - git("init", "-q"); - git("remote", "add", "origin", "https://github.com/ArcForges/Web.git"); - git( - "-c", - "user.name=Fixture", - "-c", - "user.email=fixture@example.invalid", - "-c", - "commit.gpgsign=false", - "commit", - "--allow-empty", - "-qm", - "fixture", - ); - const probe = path.join(root, "probe.txt"); - const scan = () => - spawnSync( - "python", - [path.join(packageRoot, "check_naming.py"), "--repository", `Web=${root}`], - { encoding: "utf8", windowsHide: true }, - ); - writeFileSync(probe, "ArcForges ArcScope"); - expect(scan().status).toBe(0); - for (const item of policy.forbiddenNames as { name: string }[]) { - writeFileSync(probe, item.name); - const result = scan(); - expect(result.status, item.name).toBe(1); - expect(result.stdout).toContain('"kind": "forbidden content"'); - } - } finally { - rmSync(root, { recursive: true, force: true }); - } -}); diff --git a/tests/worker/canonical-host.test.ts b/tests/worker/canonical-host.test.ts new file mode 100644 index 0000000..2f8a45f --- /dev/null +++ b/tests/worker/canonical-host.test.ts @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// The canonical-host Worker (worker/index.ts) and its emitted candidate form. Ported one-for-one from the vitest file +// tests/unit/canonical-host.test.ts to node:test; the cases and the expected values are unchanged. +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, dirname, join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { test } from "node:test"; +import worker from "../../worker/index.ts"; +import { root, verifyWorkerScript, workerScript } from "../../tooling/project.ts"; + +type Env = Parameters[1]; + +function recordingAsset(body: string, headers: Record = {}) { + const calls: Request[] = []; + const fetch = async (request: Request) => { + calls.push(request); + return new Response(body, { headers }); + }; + return { calls, env: { ASSETS: { fetch } } as unknown as Env }; +} + +const redirects: [string, string][] = [ + ["https://www.arcforges.com/", "https://arcforges.com/"], + [ + "https://www.arcforges.com/cloud-hello/?name=a%2Fb&next=%2Fhello%2F", + "https://arcforges.com/cloud-hello/?name=a%2Fb&next=%2Fhello%2F", + ], + [ + "http://www.arcforges.com:8080//other.example/path?x=1&x=2", + "https://arcforges.com//other.example/path?x=1&x=2", + ], +]; + +for (const [url, destination] of redirects) + test(`www redirects before static assets: ${url}`, async () => { + const asset = recordingAsset("asset"); + const response = await worker.fetch(new Request(url), asset.env); + assert.equal(response.status, 308); + assert.equal(response.headers.get("location"), destination); + assert.equal(asset.calls.length, 0); + }); + +for (const method of ["HEAD", "POST"]) + test(`canonical redirect preserves ${method} semantics`, async () => { + const asset = recordingAsset("asset"); + const response = await worker.fetch( + new Request("https://www.arcforges.com/api/example?mode=binary", { method }), + asset.env, + ); + assert.equal(response.status, 308); + assert.equal( + response.headers.get("location"), + "https://arcforges.com/api/example?mode=binary", + ); + assert.equal(asset.calls.length, 0); + }); + +for (const url of [ + "https://arcforges.com/cloud-hello/", + "http://127.0.0.1:4173/hello/", + "https://www.arcforges.com.other.example/", + "https://other.example/?next=https://www.arcforges.com/", +]) + test(`other hosts preserve the original asset request and response: ${url}`, async () => { + const request = new Request(url, { headers: { accept: "text/html" } }); + const asset = recordingAsset("asset", { "content-security-policy": "default-src 'self'" }); + const response = await worker.fetch(request, asset.env); + assert.equal(asset.calls.length, 1); + assert.equal(asset.calls[0], request); + assert.equal(response.headers.get("content-security-policy"), "default-src 'self'"); + }); + +test("delivery runs the reviewed Worker before assets without a deployment rebuild", async () => { + const config = JSON.parse(await readFile(join(root, "wrangler.json"), "utf8")); + assert.equal(config.main, "./worker/index.js"); + assert.equal(config.no_bundle, true); + assert.equal(config.assets.binding, "ASSETS"); + assert.equal(config.assets.run_worker_first, true); +}); + +test("the emitted candidate Worker is importless and carries no type syntax", async () => { + const emitted = await workerScript(); + assert.doesNotMatch(emitted, /^\s*import\s/mu); + assert.doesNotMatch(emitted, /\brequire\(/u); + assert.doesNotMatch(emitted, /\binterface\s|:\s*Env\b|:\s*Request\b/u); + assert.match(emitted, /export default \{/u); +}); + +for (const url of [ + "https://www.arcforges.com/", + "https://www.arcforges.com/cloud-hello/?name=a%2Fb&next=%2Fhello%2F", + "http://www.arcforges.com:8080//other.example/path?x=1&x=2", + "https://www.arcforges.com/api/example?mode=binary", + "https://arcforges.com/cloud-hello/", + "http://127.0.0.1:4173/hello/", +]) + test(`the emitted candidate Worker answers like the TypeScript source: ${url}`, async () => { + const directory = await mkdtemp(join(tmpdir(), "arcforges-emitted-worker-")); + try { + const modulePath = join(directory, "index.mjs"); + await writeFile(modulePath, await workerScript()); + const emitted = (await import(pathToFileURL(modulePath).href)).default; + const sourceAsset = recordingAsset("asset"); + const emittedAsset = recordingAsset("asset"); + const sourceResponse = await worker.fetch(new Request(url), sourceAsset.env); + const emittedResponse = await emitted.fetch(new Request(url), emittedAsset.env); + assert.equal(emittedResponse.status, sourceResponse.status); + assert.equal(emittedResponse.headers.get("location"), sourceResponse.headers.get("location")); + assert.equal(emittedAsset.calls.length, sourceAsset.calls.length); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + +test("candidate verification rejects a replaced private Worker independently of its manifest", async () => { + const directory = await mkdtemp(join(tmpdir(), "arcforges-canonical-worker-")); + try { + await mkdir(join(directory, "worker")); + const source = await workerScript(); + await writeFile(join(directory, "worker/index.js"), source); + await verifyWorkerScript(directory); + await writeFile( + join(directory, "worker/index.js"), + source.replace('arcforges.com";', 'other.example";'), + ); + await assert.rejects(verifyWorkerScript(directory), /Worker script changed/u); + } finally { + assert.equal(dirname(directory), tmpdir()); + assert.match(basename(directory), /^arcforges-canonical-worker-/u); + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/tooling/browser-provenance.ts b/tooling/browser-provenance.ts deleted file mode 100644 index 25114b4..0000000 --- a/tooling/browser-provenance.ts +++ /dev/null @@ -1,392 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -import assert from "node:assert/strict"; -import { createHash } from "node:crypto"; -import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; -import path from "node:path"; -import type { Plugin } from "vite"; -import { auditProvenance, hash, parseDocument, readOwned, relative, store } from "./provenance.ts"; - -const owner = path.resolve(import.meta.dirname, ".."); -export const profilePath = "eng/provenance/profiles/browser-resources-r10.json"; -const sha = (value: string | Uint8Array) => createHash("sha256").update(value).digest("hex"); -const lf = (value: Buffer) => - new TextDecoder("utf-8", { fatal: true }).decode(value).replaceAll("\r\n", "\n"); -const json = (root: string, file: string) => parseDocument(readOwned(root, file)) as T; -const write = (root: string, file: string, value: unknown) => { - relative(file); - mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); - writeFileSync(path.join(root, file), `${JSON.stringify(value, null, 2)}\n`); -}; -export function members(root: string, prefix = ""): string[] { - return readdirSync(path.join(root, prefix), { withFileTypes: true }) - .flatMap((entry) => { - assert(!entry.isSymbolicLink(), "Linked artifact member"); - const file = `${prefix}${entry.name}`; - if (entry.isDirectory()) return members(root, `${file}/`); - assert(entry.isFile(), "Nonregular artifact member"); - return [relative(file)]; - }) - .sort(); -} -const logical = (name: string) => name.replace(/-[A-Za-z0-9_-]{8}(?=\.(?:js|css)$)/u, ""); -interface Module { - id: string; - imports: string[]; - dynamicImports: string[]; - sourceSha256: string | null; - helperSha256: string | null; -} -interface Chunk { - name: string; - modules: string[]; - imports: string[]; - dynamicImports: string[]; - exports: string[]; -} -interface Graph { - modules: Module[]; - chunks: Chunk[]; - assets: string[]; -} -interface Template { - sha256: string; - revisions: number; - routerVersions: number; - assetReferences: number; -} -interface Component { - name: string; - version: string; - spdx: string; - repository: string; - commit: string; - packagePath: string | null; - emitted: boolean; - role: string; -} -interface Profile { - schemaVersion: 1; - repository: "Web"; - graph: Graph; - templates: Record; - inputs: Record; - contracts: Record; - packageIdentities: Record; - noticeInputs: Record; - additionalNotices: string[]; - components: Component[]; - buildSbomSha256: string; - runtimeSbom: unknown; - evidence: unknown; -} -export function profile(root = owner): Profile { - const value = json(root, profilePath); - assert.equal(value.schemaVersion, 1); - assert.equal(value.repository, "Web"); - return value; -} - -// Observe the actual client compilation, without changing resolution or output bytes. -export function observeBrowser(): Plugin { - const clean = (id: string) => - id.replaceAll("\\", "/").replaceAll(`${owner.replaceAll("\\", "/")}/`, ""); - return { - name: "arcforges-browser-provenance", - enforce: "post", - generateBundle(_options, bundle) { - if (this.environment.name !== "client") return; - const modules = [...this.getModuleIds()] - .map((id): Module => { - const info = this.getModuleInfo(id); - assert(info, "Missing browser module information"); - const file = id.split("?")[0]; - assert(file); - const physical = existsSync(file); - const source = physical ? lf(readFileSync(file)) : null; - return { - id: clean(id), - imports: info.importedIds.map(clean).sort(), - dynamicImports: info.dynamicallyImportedIds.map(clean).sort(), - sourceSha256: source === null ? null : sha(source), - helperSha256: physical ? null : sha(clean(info.code ?? "")), - }; - }) - .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); - const chunks: Chunk[] = []; - const assets: string[] = []; - for (const item of Object.values(bundle)) { - if (item.type === "asset") assets.push(logical(item.fileName)); - else - chunks.push({ - name: logical(item.fileName), - modules: Object.keys(item.modules).map(clean).sort(), - imports: item.imports.map(logical).sort(), - dynamicImports: item.dynamicImports.map(logical).sort(), - exports: [...item.exports].sort(), - }); - } - chunks.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); - const graph = { modules, chunks, assets: assets.sort() }; - assert.deepEqual( - graph, - profile().graph, - "Actual browser module/resource graph changed; review a new profile", - ); - write(owner, "artifacts/evidence/browser-graph.json", graph); - }, - }; -} - -export function browserTemplates(root: string, revision: string): Record { - assert(/^[a-f0-9]{40}$/u.test(revision), "Invalid browser source identity"); - const all = members(root); - const aliases = new Map(); - for (const file of all.filter((file) => file.startsWith("assets/"))) { - assert( - /^assets\/[^/]+-[A-Za-z0-9_-]{8}\.(?:js|css)$/u.test(file), - "Unclassified compiled browser resource", - ); - const name = logical(path.posix.basename(file)); - assert(!aliases.has(name), "Duplicate logical browser resource"); - aliases.set(name, path.posix.basename(file)); - } - const manifestName = aliases.get("manifest.js"); - assert(manifestName, "Missing Router manifest"); - const manifestText = lf(readOwned(root, `assets/${manifestName}`)); - assert( - manifestText.startsWith("window.__reactRouterManifest=") && manifestText.endsWith(";"), - "Changed Router manifest wrapper", - ); - const manifest = parseDocument( - manifestText.slice("window.__reactRouterManifest=".length, -1), - ) as { entry: unknown; routes: unknown; version: string }; - const version = sha(JSON.stringify({ entry: manifest.entry, routes: manifest.routes })).slice( - 0, - 8, - ); - assert.equal(manifest.version, version, "Router manifest fingerprint mismatch"); - assert.equal(manifestName, `manifest-${version}.js`); - const expected = profile().templates; - for (const name of aliases.keys()) - assert(Object.hasOwn(expected, `assets/${name}`), "Unclassified compiled browser resource"); - const result: Record = {}; - for (const file of all) { - const key = file.startsWith("assets/") ? `assets/${logical(path.posix.basename(file))}` : file; - if (!Object.hasOwn(expected, key)) continue; // The complete candidate member gate checks all remaining packaging outputs. - let value = lf(readOwned(root, file)); - assert( - !/|/u.test(value), - "Reserved template marker in input", - ); - for (const [name, actual] of [...aliases].sort((a, b) => b[1].length - a[1].length)) - value = value.replaceAll(actual, ``); - value = value.replaceAll(revision, "").replaceAll(version, ""); - result[key] = { - sha256: sha(value), - revisions: value.split("").length - 1, - routerVersions: value.split("").length - 1, - assetReferences: value.split("; - }>(root, "package-lock.json"); - for (const [file, expected] of Object.entries(approved.packageIdentities)) { - const actual = lock.packages[file]; - assert(actual, `Missing reviewed package: ${file}`); - assert.deepEqual( - { version: actual.version, integrity: actual.integrity, resolved: actual.resolved }, - expected, - `Package identity changed: ${file}`, - ); - } - return approved; -} -export function extraNotices(root = owner): string { - const approved = verifyBrowserInputs(root); - let result = "\n===== Additional generated browser resources and embedded origins =====\n"; - for (const file of approved.additionalNotices) - result += `\n${file}\n${lf(readOwned(root, file))}\n`; - return result; -} - -interface Bom { - serialNumber?: string; - metadata: { timestamp?: string; component: { name: string } }; -} -export function canonicalBuildSbom(value: unknown): unknown { - const copy = structuredClone(value) as Bom; - delete copy.serialNumber; - delete copy.metadata.timestamp; - copy.metadata.component.name = "@arcforges/web-workspace"; - return copy; -} -export function canonicalJson(value: unknown): string { - if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; - if (value !== null && typeof value === "object") - return `{${Object.entries(value) - .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) - .map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`) - .join(",")}}`; - return JSON.stringify(value); -} -export function runtimeSbom() { - return profile().runtimeSbom; -} - -interface Identity { - source: string; - version: string; -} -function publicNames(root: string) { - return members(path.join(root, "assets")); -} -function receipt(root: string, identity: Identity, active: string[]) { - return { - schemaVersion: 1, - repository: "Web", - source: identity.source, - version: identity.version, - profile: profilePath, - profileSha256: hash(readOwned(owner, profilePath), "lf"), - records: active, - parsedModules: profile().graph.modules.length, - emittedModules: new Set(profile().graph.chunks.flatMap((chunk) => chunk.modules)).size, - members: Object.fromEntries( - members(root) - .filter((file) => !["manifest.json", "provenance/receipt.json"].includes(file)) - .map((file) => [file, hash(readOwned(root, file))]), - ), - }; -} -export function prepareProvenance(root: string, identity: Identity) { - const audit = auditProvenance(owner); - const graph = json(owner, "artifacts/evidence/browser-graph.json"); - assert.deepEqual(graph, profile().graph); - write(root, "provenance/browser-graph.json", graph); - writeFileSync(path.join(root, "provenance/profile.json"), lf(readOwned(owner, profilePath))); - mkdirSync(path.join(root, "provenance/records"), { recursive: true }); - for (const id of audit.activeRecords) - writeFileSync( - path.join(root, `provenance/records/${id}.json`), - lf(readOwned(owner, `${store}${id}.json`)), - ); - writeFileSync( - path.join(root, "assets/source-provenance.txt"), - readOwned(owner, "eng/provenance/NOTICE.txt"), - ); - write(root, "provenance/receipt.json", receipt(root, identity, audit.activeRecords)); -} -export function verifyBrowserCandidate( - root: string, - identity: Identity, - notices: string, - headers: string, -) { - const audit = auditProvenance(owner); - const approved = verifyBrowserInputs(); - assert.deepEqual( - json(root, "provenance/profile.json"), - approved, - "Candidate substituted the reviewed profile", - ); - assert.equal( - hash(readOwned(root, "provenance/profile.json"), "lf"), - hash(readOwned(owner, profilePath), "lf"), - "Candidate profile bytes changed", - ); - assert.deepEqual( - json(root, "provenance/browser-graph.json"), - approved.graph, - "Candidate graph differs from actual reviewed inputs", - ); - for (const id of audit.activeRecords) { - assert.deepEqual( - json(root, `provenance/records/${id}.json`), - json(owner, `${store}${id}.json`), - "Candidate record changed", - ); - assert.equal( - hash(readOwned(root, `provenance/records/${id}.json`), "lf"), - hash(readOwned(owner, `${store}${id}.json`), "lf"), - "Candidate record bytes changed", - ); - } - browserTemplates(path.join(root, "assets"), identity.source); - const expectedPublic = [ - ...Object.keys(approved.templates).filter((name) => !name.startsWith("assets/")), - ...publicNames(root) - .filter((name) => name.startsWith("assets/")) - .map(relative), - "_headers", - "__build.json", - "__build-info.json", - "license.txt", - "third-party-notices.txt", - "source-provenance.txt", - ].sort(); - assert.deepEqual(publicNames(root), expectedPublic, "Unclassified public member"); - for (const [file, expected] of Object.entries(approved.contracts)) - assert.equal( - hash(readOwned(root, file)), - expected, - `Published Contracts member changed: ${file}`, - ); - assert.equal( - lf(readOwned(root, "assets/license.txt")), - lf(readOwned(owner, "LICENSE")), - "Full AGPL legal text changed", - ); - assert.equal( - lf(readOwned(root, "assets/third-party-notices.txt")), - notices.replaceAll("\r\n", "\n"), - "Required full third-party notices changed", - ); - assert.equal( - lf(readOwned(root, "assets/source-provenance.txt")), - lf(readOwned(owner, "eng/provenance/NOTICE.txt")), - "Source notice changed", - ); - assert.equal(lf(readOwned(root, "assets/_headers")), headers, "Security/cache headers changed"); - assert.equal( - sha(canonicalJson(json(root, "sbom.cdx.json"))), - approved.buildSbomSha256, - "Build SBOM identities/edges changed", - ); - assert.deepEqual( - json(root, "runtime-sbom.cdx.json"), - approved.runtimeSbom, - "Emitted browser SBOM identities/edges changed", - ); - const expectedFiles = [ - ...expectedPublic.map((name) => `assets/${name}`), - ...Object.keys(approved.contracts), - ...audit.activeRecords.map((id) => `provenance/records/${id}.json`), - "provenance/profile.json", - "provenance/browser-graph.json", - "provenance/receipt.json", - "sbom.cdx.json", - "runtime-sbom.cdx.json", - "wrangler.json", - "worker/index.js", - "manifest.json", - ].sort(); - assert.deepEqual(members(root), expectedFiles, "Unclassified candidate member"); - assert.deepEqual( - json(root, "provenance/receipt.json"), - receipt(root, identity, audit.activeRecords), - "Source-bound provenance receipt changed", - ); -} diff --git a/tooling/build-identity.ts b/tooling/build-identity.ts index 1376681..77a6b7d 100644 --- a/tooling/build-identity.ts +++ b/tooling/build-identity.ts @@ -234,6 +234,19 @@ export function verifyContractSources( assert.deepEqual([...seen].sort(), Object.keys(schemaSources).sort()); } +export const contractsPackage = "ArcForges.Contracts.PublicApi"; +export const contractsRecord = "eng/contracts/ArcForges.Contracts.PublicApi/1.0.0-ci.287.1"; +/** The one central NuGet pin of the Contracts publication (Directory.Packages.props). */ +export function contractsPackageVersion(props: string) { + const pins = [ + ...props.matchAll( + //gu, + ), + ]; + assert.equal(pins.length, 1, "Exactly one central pin of the Contracts publication"); + return pins[0]?.[1] ?? ""; +} + export function expectedIdentity(version: string) { const git = (...args: string[]) => execFileSync("git", args, { @@ -254,25 +267,15 @@ export function expectedIdentity(version: string) { const read = (name: string) => { if (name === "release/app.json") return JSON.stringify({ versions: [{ subject: "Web", version }] }); - if (name === "packages/contracts/source.json") - return readFileSync( - path.join(root, "node_modules/@arcforges/api-client/source.json"), - "utf8", - ); - if (name === "packages/contracts/build-identity.json") - return readFileSync( - path.join(root, "node_modules/@arcforges/api-client/build-identity.json"), - "utf8", - ); return readFileSync(path.join(root, name), "utf8"); }; - // The consumed producer receipt must match the independently pinned site package. - const contracts = object(JSON.parse(read("packages/contracts/source.json"))); - const pinned = JSON.parse(readFileSync(path.join(root, "apps/site/package.json"), "utf8")) - .dependencies["@arcforges/api-client"]; + // The consumed Contracts publication is the NuGet package the Web profiles reference. Its committed record is the + // CON.07 publication copy, and it must match the exact package version pinned in Directory.Packages.props. + const contracts = object(JSON.parse(read(`${contractsRecord}/source.json`))); + const pinned = contractsPackageVersion(read("Directory.Packages.props")); assert.equal(contracts.version, pinned); - const identity = object(JSON.parse(read("packages/contracts/build-identity.json"))); - assert.equal(object(identity.artifact).id, "@arcforges/api-client"); + const identity = object(JSON.parse(read(`${contractsRecord}/build-identity.json`))); + assert.equal(object(identity.artifact).id, contractsPackage); assert.equal(object(identity.artifact).version, pinned); assert.equal(object(identity.build).sourceCommit, contracts.commit); verifyContractSources(object(identity.axes).ContractSet, contracts); diff --git a/tooling/candidate.ts b/tooling/candidate.ts new file mode 100644 index 0000000..4ff26d5 --- /dev/null +++ b/tooling/candidate.ts @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Node glue of the C# candidate. The C# tool (tools/ArcForges.Web.Tooling) builds, seals and verifies the public candidate +// and the Blazor profile bundle. Node produces the two inputs only Node can produce: the Worker JavaScript emitted from +// worker/index.ts by type stripping, and the build identity derived from git and the reviewed version sources. Before any +// deployment Node also re-derives the identity from the checkout, so a candidate with a foreign identity cannot ship. +// node tooling/candidate.ts worker +// node tooling/candidate.ts identity +// node tooling/candidate.ts verify [candidate-directory] +import assert from "node:assert/strict"; +import { readFile, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { expectedIdentity, verifyIdentity } from "./build-identity.ts"; +import { candidate, root, run, save, workerScript } from "./project.ts"; + +export const toolLibrary = join( + root, + "tools/ArcForges.Web.Tooling/bin/Release/net10.0/ArcForges.Web.Tooling.dll", +); + +export interface SealedManifest { + version: string; + source: string; + dirty: boolean; + files: Record; +} + +/** Runs one command of the C# tool and returns its standard output. The tool is built by `dotnet build` beforehand. */ +export function tool(args: string[]): string { + return run("dotnet", [toolLibrary, ...args]); +} + +/** + * Verifies a sealed candidate before anything is uploaded: the C# verifier checks the seal, the members, the wrangler + * configuration, the pinned Worker, the regenerated Site and the reviewed inputs; then the identity is re-derived here. + */ +export async function verifyCandidate( + directory: string = candidate, + expectedSource: string | undefined = process.env.GITHUB_SHA, +): Promise { + const args = ["candidate", "verify", "--dir", directory, "--repo", root]; + if (expectedSource) args.push("--expected-source", expectedSource); + process.stdout.write(tool(args)); + const manifest = JSON.parse( + await readFile(join(directory, "manifest.json"), "utf8"), + ) as SealedManifest; + const identity = JSON.parse(await readFile(join(directory, "assets/__build-info.json"), "utf8")); + verifyIdentity(identity, manifest.version); + assert.equal( + identity.build.dirty, + manifest.dirty, + "The identity dirty flag differs from the seal", + ); + return manifest; +} + +async function main() { + const [command, first, second] = process.argv.slice(2); + switch (command) { + case "worker": { + assert(first, "Use: worker "); + await writeFile(first, await workerScript()); + break; + } + case "identity": { + assert(first && second, "Use: identity "); + await save(second, expectedIdentity(first)); + break; + } + case "verify": { + const manifest = await verifyCandidate(first ? resolve(first) : candidate); + console.log(`Candidate verified: ${manifest.version} ${manifest.source}`); + break; + } + default: + throw new Error("Use worker, identity or verify."); + } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main(); diff --git a/tooling/cloudflare.ts b/tooling/cloudflare.ts index fc1da1d..467e1ef 100644 --- a/tooling/cloudflare.ts +++ b/tooling/cloudflare.ts @@ -1,10 +1,12 @@ // SPDX-License-Identifier: AGPL-3.0-only import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; +import { spawnSync } from "node:child_process"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { setTimeout as delay } from "node:timers/promises"; -import { candidate, digest, json, npm, root, run, save, verify } from "./project.ts"; +import { verifyCandidate } from "./candidate.ts"; +import { candidate, digest, json, root, run, save } from "./project.ts"; const statePath = join(root, "artifacts/deployment.json"); type Identity = { source: string; version: string }; @@ -83,8 +85,23 @@ export async function waitForDelivery( } throw new Error(`Public assets did not converge; no redeployment was attempted. ${lastError}`); } +// Local dry run: the same sealed-candidate verification as the deployment, then wrangler's own dry run, which bundles +// the Worker and lists the assets without any upload. It needs no credentials and changes nothing remote. +async function dryRun() { + await verifyCandidate(); + const output = run(process.execPath, [ + join(root, "node_modules/wrangler/bin/wrangler.js"), + "deploy", + "--dry-run", + "--config", + join(candidate, "wrangler.json"), + ]); + process.stdout.write(output); + console.log("Dry run only: nothing was uploaded."); +} + async function deploy() { - const manifest = await verify(); + const manifest = await verifyCandidate(); assert(!manifest.dirty, "Commit the source before deployment"); assert.equal( process.env.GITHUB_REF, @@ -158,7 +175,7 @@ async function deploy() { } async function smoke() { assert.notEqual(process.env.CI, "true", "Live browser tests are local opt-in only."); - const manifest = await verify(); + const manifest = await verifyCandidate(); const state = (await json(statePath)) as Deployment; assert.equal(state.source, manifest.source); assert.equal(state.version, manifest.version); @@ -212,10 +229,24 @@ async function smoke() { ); } }); - console.log("Verifying the real domain in Chromium, Firefox and WebKit."); - process.stdout.write( - npm(["exec", "--no", "--", "playwright", "test", "--config", "playwright.live.config.ts"]), + // The local opt-in C# browser suite (tests/browser) reads the deployed domain. It never runs on CI (P2-017). + console.log("Verifying the real domain in local Chromium through the C# browser suite."); + const browserSuite = spawnSync( + "dotnet", + ["test", join(root, "tests/browser/ArcForges.Web.Browser.Tests/ArcForges.Web.Browser.Tests.csproj"), "-c", "Release"], + { + cwd: root, + encoding: "utf8", + windowsHide: true, + env: { + ...process.env, + ARCFORGES_LOCAL_BROWSER: "1", + ARCFORGES_BROWSER_BASE_URL: state.url, + }, + }, ); + process.stdout.write(browserSuite.stdout ?? ""); + assert.equal(browserSuite.status, 0, "The local browser suite failed."); await save(statePath, { ...state, verified: true, @@ -228,5 +259,6 @@ async function smoke() { if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { if (process.argv[2] === "deploy") await deploy(); else if (process.argv[2] === "smoke") await smoke(); - else throw new Error("Use deploy or smoke."); + else if (process.argv[2] === "dry-run") await dryRun(); + else throw new Error("Use deploy, smoke or dry-run."); } diff --git a/tooling/dependency-policy.test.ts b/tooling/dependency-policy.test.ts index fd62480..cf39069 100644 --- a/tooling/dependency-policy.test.ts +++ b/tooling/dependency-policy.test.ts @@ -37,10 +37,10 @@ test("same version with altered bytes", () => { packages[firstDependency].integrity = `sha512-${Buffer.alloc(64).toString("base64")}`; assert.throws(() => validateClosure(baseline, packages), /mutable-version/u); }); -test("wrong publisher", () => { +test("retired first-party npm package cannot re-enter the admission", () => { const policy = structuredClone(baseline); - policy.firstParty["@arcforges/proto"] = { publisher: "other/Contracts", visibility: "public" }; - assert.throws(() => validatePolicy(policy), /Wrong publisher/u); + policy.firstParty["@arcforges/proto"] = { publisher: "ArcForges/Contracts", visibility: "public" }; + assert.throws(() => validatePolicy(policy), /Unadmitted internal\/first-party package/u); }); test("untrusted registry", () => { const packages = structuredClone(lock.packages); @@ -77,7 +77,10 @@ test("source cannot escape into a sibling repository", () => { () => validateImports(root, "example.ts", 'import "../Contracts/private.ts";', baseline), /Sibling source/u, ); - validateImports(root, "src/example.ts", 'import type { X } from "@arcforges/proto";', baseline); + assert.throws( + () => validateImports(root, "src/example.ts", 'import type { X } from "@arcforges/proto";', baseline), + /Forbidden internal import/u, + ); }); test("stable closure cannot inherit foundation candidates", () => { const policy = structuredClone(baseline); diff --git a/tooling/dependency-policy.ts b/tooling/dependency-policy.ts index 9a42ec9..430c24e 100644 --- a/tooling/dependency-policy.ts +++ b/tooling/dependency-policy.ts @@ -90,14 +90,14 @@ export function validatePolicy(policy: Policy) { "Native adoption requires AD-01 through AD-08", ); assert(policy.native.evidence.length > 0, "Missing native closure evidence"); - for (const [name, entry] of Object.entries(policy.firstParty)) { - assert( - ["@arcforges/proto", "@arcforges/api-client"].includes(name), - `Unadmitted internal/first-party package: ${name}`, - ); - assert.equal(entry.publisher, "ArcForges/Contracts", `Wrong publisher: ${name}`); - assert.equal(entry.visibility, "public", `Internal package: ${name}`); - } + // The Contracts npm packages (@arcforges/proto and @arcforges/api-client) are retired by WEB.40 U5: the naming + // authority is vendored from its NuGet publication and the identity is the NuGet record. No first-party npm package + // is admitted, so a first-party entry is refused before its publisher is inspected. + assert.deepEqual( + Object.keys(policy.firstParty), + [], + "Unadmitted internal/first-party package: the Contracts npm packages are retired", + ); } export function immutableCoordinates(closure: Record) { const coordinates: Record = {}; diff --git a/tooling/ide.ts b/tooling/ide.ts deleted file mode 100644 index 14add2b..0000000 --- a/tooling/ide.ts +++ /dev/null @@ -1,106 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; -import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { digest, root, save, verify } from "./project.ts"; -import { gitEnvironment } from "./provenance.ts"; - -assert.equal(process.platform, "win32", "The IDE adapter is a Windows entry point."); -const evidence = join(root, "artifacts/evidence"); -await mkdir(evidence, { recursive: true }); -async function msbuild(name: string, args: string[]) { - const result = spawnSync("dotnet", ["msbuild", "-nologo", ...args], { - cwd: root, - env: gitEnvironment(), - encoding: "utf8", - windowsHide: true, - maxBuffer: 16 * 1024 * 1024, - timeout: 10 * 60 * 1000, - }); - const output = `${result.stdout ?? ""}${result.stderr ?? ""}`; - await writeFile(join(evidence, `ide-${name}.log`), output); - assert.equal(result.status, 0, `IDE ${name} failed: ${result.error ?? output}`); - return output; -} - -const properties = JSON.parse( - await msbuild("properties", [ - "ArcForges.Web.esproj", - "-getProperty:StartupCommand,BuildCommand,ShouldRunNpmInstall,ShouldRunBuildScript,PublishAot,IsAotCompatible", - "-getItem:ProjectReference", - ]), -); -assert.deepEqual(properties.Items.ProjectReference, []); -assert.equal(properties.Properties.StartupCommand, "npm run dev"); -assert.equal(properties.Properties.BuildCommand, "npm run build && npm run build:profiles"); -assert.equal(properties.Properties.ShouldRunNpmInstall, "false"); -assert.equal(properties.Properties.ShouldRunBuildScript, "true"); -assert.equal(properties.Properties.PublishAot, ""); -assert.equal(properties.Properties.IsAotCompatible, ""); - -const lock = join(root, "package-lock.json"); -const lockHash = digest(await readFile(lock)); -const restore = await msbuild("restore", ["win.slnx", "-t:Restore"]); -assert( - restore.includes("ArcForges explicit locked npm restore"), - "Solution restore skipped npm ci.", -); -assert.equal(digest(await readFile(lock)), lockHash, "IDE restore changed the committed lock."); -const installedLock = join(root, "node_modules/.package-lock.json"); -const installed = { - hash: digest(await readFile(installedLock)), - mtimeMs: (await stat(installedLock)).mtimeMs, -}; -const designTime = await msbuild("design-time", [ - "win.slnx", - "-t:Restore", - "-p:DesignTimeBuild=true", -]); -assert( - !designTime.includes("ArcForges explicit locked npm restore"), - "Design-time evaluation unexpectedly restored npm.", -); -assert.equal((await stat(installedLock)).mtimeMs, installed.mtimeMs); -const build = await msbuild("build", ["win.slnx", "-t:Build", "-p:Configuration=Release"]); -assert(build.includes("Verified candidate "), "Solution build skipped the npm candidate build."); -assert( - build.includes("account: ") && build.includes("chat: "), - "Solution build skipped the production profile builds.", -); -assert( - !build.includes("ArcForges explicit locked npm restore"), - "Build unexpectedly restored npm.", -); -assert.equal(digest(await readFile(lock)), lockHash, "Build changed the committed lock."); -assert.equal(digest(await readFile(installedLock)), installed.hash); -assert.equal( - (await stat(installedLock)).mtimeMs, - installed.mtimeMs, - "Build reinstalled npm dependencies.", -); -const manifest = await verify(); -const startup = JSON.parse( - await msbuild("startup", [ - "ArcForges.Web.esproj", - "-t:ComputeRunArguments", - "-getProperty:RunCommand,RunArguments,RunWorkingDirectory", - ]), -); -assert(startup.Properties.RunArguments.includes("npm run dev")); -assert.equal((await stat(installedLock)).mtimeMs, installed.mtimeMs); -const launch = JSON.parse(await readFile(join(root, ".vscode/launch.json"), "utf8")); -assert.equal(launch.configurations[0].url, "http://127.0.0.1:5173"); -await save(join(evidence, "ide-entrypoints.json"), { - source: manifest.source, - candidateVersion: manifest.version, - properties, - startup, - lockSha256: lockHash, - solutionRestore: "passed", - solutionBuild: "passed", - designTimeRestoredDependencies: false, - buildReinstalledDependencies: false, - actualVisualStudioF5: "separate local IDE verification; CLI dispatch is not F5 evidence", -}); -console.log("IDE solution restore/build and startup dispatch passed."); diff --git a/tooling/licence-boundary.ts b/tooling/licence-boundary.ts index 91cb03f..c65cde7 100644 --- a/tooling/licence-boundary.ts +++ b/tooling/licence-boundary.ts @@ -89,7 +89,11 @@ function checkPackage(name: string) { assert(npmOwners.has(name), `Unknown first-party package owner: ${name}`); if (name.toLowerCase().startsWith("arcforges.")) assert( - ["arcforges.contracts.publicapi", "arcforges.build.policy"].includes(name.toLowerCase()), + [ + "arcforges.contracts.publicapi", + "arcforges.contracts.foundation", + "arcforges.build.policy", + ].includes(name.toLowerCase()), `Unknown first-party package owner: ${name}`, ); if (name.toLowerCase().startsWith("io.github.arcforges:")) diff --git a/tooling/project.ts b/tooling/project.ts index a43943e..db0c8f9 100644 --- a/tooling/project.ts +++ b/tooling/project.ts @@ -1,25 +1,26 @@ // SPDX-License-Identifier: AGPL-3.0-only +// The Node tooling that remains after the React removal (WEB.40 unit U5). The public Site, the Blazor profiles, the sealed +// candidate and the profile bundle are built and verified by the C# tool (tools/ArcForges.Web.Tooling). What stays here is +// the Worker emission, the reviewed policy checks and the npm lock provenance rule. import assert from "node:assert/strict"; -import { createHash } from "node:crypto"; import { spawnSync } from "node:child_process"; -import { cp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; -import { dirname, join, relative, resolve, sep } from "node:path"; +import { createHash } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { stripTypeScriptTypes } from "node:module"; +import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { type DefaultTreeAdapterTypes, parse } from "parse5"; import { auditLicences, evaluatedManagedLicences } from "./licence-boundary.ts"; -import { expectedIdentity, verifyIdentity } from "./build-identity.ts"; import { auditProvenance, gitEnvironment } from "./provenance.ts"; -import { - canonicalBuildSbom, - extraNotices, - prepareProvenance, - runtimeSbom, - verifyBrowserCandidate, - verifyBrowserInputs, -} from "./browser-provenance.ts"; export const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); export const candidate = join(root, "artifacts/candidate"); +// The canonical-host Worker is authored in TypeScript (worker/index.ts). The candidate ships its JavaScript form, produced +// by Node type stripping: no bundling, no import, no added code. +export const workerSource = join(root, "worker/index.ts"); +export async function workerScript(): Promise { + const source = (await readFile(workerSource, "utf8")).replaceAll("\r\n", "\n"); + return stripTypeScriptTypes(source); +} export function run(command: string, args: string[], cwd = root): string { const result = spawnSync(command, args, { cwd, @@ -39,8 +40,7 @@ export function npm(args: string[]): string { assert(process.env.npm_execpath, "Run this command through npm run."); return run(process.execPath, [process.env.npm_execpath, ...args]); } -export const digest = (value: string | Uint8Array) => - createHash("sha256").update(value).digest("hex"); +export const digest = (value: string | Uint8Array) => createHash("sha256").update(value).digest("hex"); export async function json(path: string) { return JSON.parse(await readFile(path, "utf8")); } @@ -48,17 +48,6 @@ export async function save(path: string, value: unknown) { await mkdir(dirname(path), { recursive: true }); await writeFile(path, `${JSON.stringify(value, null, 2)}\n`); } -export async function files(directory: string): Promise { - const found: string[] = []; - for (const entry of await readdir(directory, { withFileTypes: true })) { - assert(!entry.isSymbolicLink(), `Symlink not allowed in candidate: ${entry.name}`); - if (entry.isDirectory()) - for (const child of await files(join(directory, entry.name))) - found.push(`${entry.name}/${child}`); - else found.push(entry.name); - } - return found.sort(); -} export function releaseVersion(runNumber: string | undefined, attempt: string | undefined) { if (!runNumber) return "0.1.0-local"; assert( @@ -67,232 +56,15 @@ export function releaseVersion(runNumber: string | undefined, attempt: string | ); return `0.1.0-ci.${runNumber}.${attempt}`; } -export function contentSecurityPolicy(html: string[]) { - const scripts = new Set(); - function visit(node: DefaultTreeAdapterTypes.Node) { - if ( - "tagName" in node && - node.tagName === "script" && - !node.attrs.some((attr) => attr.name === "src") - ) { - const script = node.childNodes.map((child) => ("value" in child ? child.value : "")).join(""); - if (script) scripts.add(`'sha256-${createHash("sha256").update(script).digest("base64")}'`); - } - if ("childNodes" in node) for (const child of node.childNodes) visit(child); - } - for (const page of html) visit(parse(page)); - return `default-src 'self'; script-src 'self' ${[...scripts].sort().join(" ")}; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'`; -} -interface Manifest { - schema: 1; - version: string; - source: string; - dirty: boolean; - files: Record; -} -export async function seal(directory: string, identity: Omit) { - const hashes: Record = {}; - for (const path of await files(directory)) - if (path !== "manifest.json") hashes[path] = digest(await readFile(join(directory, path))); - await save(join(directory, "manifest.json"), { schema: 1, ...identity, files: hashes }); -} -export async function verify( - directory = candidate, - expectedSource = process.env.GITHUB_SHA, -): Promise { - const manifest = (await json(join(directory, "manifest.json"))) as Manifest; - assert.deepEqual(Object.keys(manifest).sort(), ["dirty", "files", "schema", "source", "version"]); - assert.equal(manifest.schema, 1); - assert.equal(typeof manifest.dirty, "boolean"); - assert( - /^0\.1\.0-(?:local|ci\.[1-9]\d*\.[1-9]\d*)$/u.test(manifest.version), - "Invalid candidate version", - ); - assert(/^[a-f0-9]{40}$/.test(manifest.source), "Invalid source revision"); - if (expectedSource) - assert.equal(manifest.source, expectedSource, "Candidate source does not match this run"); - assert.deepEqual( - (await files(directory)).filter((path) => path !== "manifest.json"), - Object.keys(manifest.files).sort(), - "Candidate file set changed", - ); - for (const [path, hash] of Object.entries(manifest.files)) { - const absolute = resolve(directory, path); - assert(absolute.startsWith(resolve(directory) + sep), "Invalid candidate path"); - assert.equal(digest(await readFile(absolute)), hash, `Candidate changed: ${path}`); - if (path.startsWith("assets/")) - assert( - !/(?:\.map$|(?:^|\/)(?:\.env|\.dev\.vars|node_modules|server|\.vite|__spa-fallback))/.test( - path, - ), - `Private/build file in public assets: ${path}`, - ); - } - const config = await json(join(directory, "wrangler.json")); - assert.deepEqual(Object.keys(config).sort(), [ - "assets", - "compatibility_date", - "main", - "name", - "no_bundle", - "preview_urls", - "workers_dev", - ]); - assert.equal(config.name, "arcforges-web"); - assert.equal(config.main, "./worker/index.js"); - assert.equal(config.no_bundle, true); - assert.equal(config.preview_urls, false); - assert.equal(config.workers_dev, false); - assert.deepEqual(config.assets, { - directory: "./assets", - binding: "ASSETS", - run_worker_first: true, - html_handling: "auto-trailing-slash", - not_found_handling: "404-page", - }); - await verifyWorkerScript(directory); - const identity = await json(join(directory, "assets/__build.json")); - assert.deepEqual(identity, { source: manifest.source, version: manifest.version }); - verifyIdentity(await json(join(directory, "assets/__build-info.json")), manifest.version); - for (const path of [ - "assets/index.html", - "assets/hello/index.html", - "assets/cloud-hello/index.html", - "assets/404.html", - "assets/_headers", - "sbom.cdx.json", - "runtime-sbom.cdx.json", - "assets/license.txt", - "assets/third-party-notices.txt", - ]) - assert(manifest.files[path], `Missing required file: ${path}`); - const html = await Promise.all( - ["index.html", "hello/index.html", "cloud-hello/index.html"].map((file) => - readFile(join(directory, "assets", file), "utf8"), - ), - ); - verifyBrowserCandidate(directory, manifest, await notices(), securityHeaders(html)); - const { $schema: _schema, ...expectedConfig } = await json(join(root, "wrangler.json")); - expectedConfig.assets.directory = "./assets"; - assert.deepEqual(config, expectedConfig, "Deployment configuration changed"); - return manifest; -} +/** The C# candidate tool emits the Worker's expected bytes; the candidate must carry exactly them. */ export async function verifyWorkerScript(directory: string) { assert.equal( await readFile(join(directory, "worker/index.js"), "utf8"), - (await readFile(join(root, "worker/index.js"), "utf8")).replaceAll("\r\n", "\n"), + await workerScript(), "Worker script changed from the reviewed source", ); } -async function notices() { - const lock = await json(join(root, "package-lock.json")); - let text = - "ArcForges Web third-party notices\nGenerated from the production dependency closure. Some packages are build-time only or tree-shaken.\n"; - for (const [path, entry] of Object.entries(lock.packages) as [ - string, - { dev?: boolean; link?: boolean; version?: string; license?: string }, - ][]) { - if (!path.includes("node_modules/") || entry.dev || entry.link) continue; - const name = path.split("node_modules/").at(-1); - text += `\n===== ${name} ${entry.version} (${entry.license ?? "see included license"}) =====\n`; - const directory = join(root, path); - const names = (await readdir(directory)).filter((name) => - /^(?:license|licence|copying|notice)(?:[.-].*)?$/i.test(name), - ); - if ( - name === "@bufbuild/protobuf" || - name === "@connectrpc/connect" || - name === "@connectrpc/connect-web" - ) { - const group = name === "@bufbuild/protobuf" ? "protobuf-es" : "connect-es"; - text += await readFile(join(root, "third-party", `${group}-LICENSE.txt`), "utf8"); - const sources = [ - "dist/esm/index.js", - ...(name === "@bufbuild/protobuf" ? ["dist/esm/wire/varint.js"] : []), - ]; - for (const source of sources) { - const header = (await readFile(join(directory, source), "utf8")).match( - /^(?:\/\/[^\n]*\n)+/, - )?.[0]; - assert(header?.includes("Copyright"), `Review changed license header in ${name}/${source}`); - text += `\n${header}\n`; - } - } else assert(names.length > 0, `Review missing upstream license: ${name}`); - for (const name of names) - text += `\n${name}\n${await readFile(join(directory, name), "utf8")}\n`; - } - return text + extraNotices(); -} -function securityHeaders(html: string[]) { - const csp = contentSecurityPolicy(html); - assert(csp.length < 1800, "CSP exceeds the Workers header line budget"); - return `/*\n Content-Security-Policy: ${csp}\n X-Content-Type-Options: nosniff\n Referrer-Policy: no-referrer\n Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()\n X-Frame-Options: DENY\n X-Robots-Tag: noindex, nofollow\n Cache-Control: public, no-cache, no-transform\n/assets/*\n ! Cache-Control\n Cache-Control: public, max-age=31536000, immutable, no-transform\n/__build.json\n ! Cache-Control\n Cache-Control: no-store, no-transform\n/__build-info.json\n ! Cache-Control\n Cache-Control: no-store, no-transform\n`; -} -async function build() { - await save(join(root, "artifacts/evidence/licence-boundary.json"), auditLicences(root)); - await save(join(root, "artifacts/evidence/source-provenance.json"), auditProvenance(root)); - verifyBrowserInputs(); - const source = run("git", ["rev-parse", "HEAD"]).trim(); - const dirty = run("git", ["status", "--porcelain", "--untracked-files=normal"]).trim().length > 0; - const version = releaseVersion(process.env.GITHUB_RUN_NUMBER, process.env.GITHUB_RUN_ATTEMPT); - process.env.VITE_SOURCE_REF = source; - process.stdout.write(npm(["run", "build", "--workspace", "@arcforges/web-site"])); - // Only reset this task's generated candidate, after checking its absolute boundary. - assert.equal(relative(join(root, "artifacts"), candidate), "candidate"); - await rm(candidate, { recursive: true, force: true }); - await mkdir(candidate, { recursive: true }); - const publicRoot = join(candidate, "assets"); - const built = join(root, "apps/site/build/client"); - for (const path of await files(built)) { - if (path.startsWith(".vite/") || path === "__spa-fallback.html") continue; - await mkdir(dirname(join(publicRoot, path)), { recursive: true }); - await cp(join(built, path), join(publicRoot, path)); - } - const html: string[] = []; - for (const path of await files(publicRoot)) - if (path.endsWith(".html")) { - // HTML parsing normalizes CRLF before CSP checks: hash the bytes browsers execute. - const page = (await readFile(join(publicRoot, path), "utf8")).replaceAll("\r\n", "\n"); - await writeFile(join(publicRoot, path), page); - html.push(page); - } - await writeFile(join(publicRoot, "_headers"), securityHeaders(html)); - await cp(join(root, "LICENSE"), join(publicRoot, "license.txt")); - await writeFile(join(publicRoot, "third-party-notices.txt"), await notices()); - await save(join(publicRoot, "__build.json"), { version, source }); - const buildIdentity = expectedIdentity(version); - assert.equal(buildIdentity.build.sourceCommit, source); - await save(join(publicRoot, "__build-info.json"), buildIdentity); - const { $schema: _schema, ...config } = await json(join(root, "wrangler.json")); - config.assets.directory = "./assets"; - await save(join(candidate, "wrangler.json"), config); - await mkdir(join(candidate, "worker"), { recursive: true }); - await writeFile( - join(candidate, "worker/index.js"), - (await readFile(join(root, "worker/index.js"), "utf8")).replaceAll("\r\n", "\n"), - ); - for (const packageName of ["proto", "api-client"]) - for (const path of ["source.json", "LICENSE", "NOTICE", "sbom.cdx.json"]) { - await mkdir(join(candidate, "contracts", packageName), { recursive: true }); - await cp( - join(root, "node_modules/@arcforges", packageName, path), - join(candidate, "contracts", packageName, path), - ); - } - await save( - join(candidate, "sbom.cdx.json"), - canonicalBuildSbom( - JSON.parse(npm(["sbom", "--sbom-format", "cyclonedx", "--package-lock-only"])), - ), - ); - await save(join(candidate, "runtime-sbom.cdx.json"), runtimeSbom()); - prepareProvenance(candidate, { source, version }); - await seal(candidate, { version, source, dirty }); - await verify(); - console.log( - `Verified candidate ${version} at ${source}${dirty ? " (local changes; not deployable)" : ""}`, - ); -} + type LockEntry = { link?: boolean; inBundle?: boolean; integrity?: string; resolved?: string }; function requireRegistryArtifact(path: string, entry: LockEntry) { @@ -303,16 +75,12 @@ function requireRegistryArtifact(path: string, entry: LockEntry) { ); } +/** The lock may hold registry artifacts only. The repository has no npm workspaces, so no link is admitted. */ export function verifyLockProvenance(packages: Record) { for (const [path, entry] of Object.entries(packages)) { if (!path.includes("node_modules/")) continue; if (entry.link) { - assert(!entry.inBundle, `Bundled dependency cannot be a workspace link: ${path}`); - assert( - ["apps/app", "apps/site", "packages/ui"].includes(entry.resolved ?? ""), - "Unexpected workspace link", - ); - continue; + assert.fail(`Unexpected workspace link: ${path}`); } let artifactPath = path; let artifact = entry; @@ -334,13 +102,12 @@ export function verifyLockProvenance(packages: Record) { } } -async function policy() { - const { auditRepository } = await import("../eng/policy/architecture.ts"); - await save(join(root, "artifacts/evidence/architecture.json"), auditRepository(root)); +export async function policy() { + await save(join(root, "artifacts/evidence/licence-boundary.json"), auditLicences(root)); + await save(join(root, "artifacts/evidence/source-provenance.json"), auditProvenance(root)); const naming = await json(join(root, "eng/policy/naming-candidate.json")); - const namingRoot = join(root, "node_modules", naming.package); - assert.equal((await json(join(namingRoot, "package.json"))).version, naming.version); - assert.equal((await json(join(namingRoot, "source.json"))).commit, naming.sourceCommit); + assert.equal(naming.packageSource, "NuGet", "The naming authority is the NuGet Contracts publication"); + const namingRoot = join(root, naming.root); for (const [asset, expected] of Object.entries(naming.assets)) assert.equal( digest(await readFile(join(namingRoot, asset))), @@ -348,58 +115,37 @@ async function policy() { `Naming asset changed: ${asset}`, ); run("python", [ - join(root, "node_modules/@arcforges/proto/tools/naming/eng/check_naming.py"), + "-I", + join(namingRoot, "tools/naming/eng/check_naming.py"), "--repository", `Web=${root}`, "--report", join(root, "artifacts/evidence/naming.json"), ]); - await save(join(root, "artifacts/evidence/licence-boundary.json"), auditLicences(root)); - await save(join(root, "artifacts/evidence/source-provenance.json"), auditProvenance(root)); - verifyBrowserInputs(); assert.equal( process.version, `v${(await readFile(join(root, ".node-version"), "utf8")).trim()}`, "Select the pinned Node version", ); - const packagePaths = [ - "package.json", - "apps/app/package.json", - "apps/site/package.json", - "packages/ui/package.json", - ]; - for (const path of packagePaths) { - const manifest = await json(join(root, path)); - assert.equal(manifest.private, true, `${path} must not be published to npm`); - assert.equal(manifest.license, "AGPL-3.0-only"); - for (const section of ["dependencies", "devDependencies", "peerDependencies"]) - for (const [name, version] of Object.entries(manifest[section] ?? {})) { - assert( - /^\d+\.\d+\.\d+(?:-[\w.-]+)?$/.test(String(version)), - `${path}: pin ${name} exactly`, - ); - } - } + const manifest = await json(join(root, "package.json")); + assert.equal(manifest.private, true, "package.json must not be published to npm"); + assert.equal(manifest.license, "AGPL-3.0-only"); + for (const section of ["dependencies", "devDependencies", "peerDependencies"]) + for (const [name, version] of Object.entries(manifest[section] ?? {})) + assert( + /^\d+\.\d+\.\d+(?:-[\w.-]+)?$/.test(String(version)), + `package.json: pin ${name} exactly`, + ); const lock = await json(join(root, "package-lock.json")); assert.equal(lock.lockfileVersion, 3); verifyLockProvenance(lock.packages); assert.equal((await json(join(root, "wrangler.json"))).assets.not_found_handling, "404-page"); - assert.equal( - (await json(join(root, "package.json"))).packageManager, - `npm@${npm(["--version"]).trim()}`, - ); + assert.equal(manifest.packageManager, `npm@${npm(["--version"]).trim()}`); console.log("Toolchain, exact dependencies, lock provenance and static routing policy passed."); } + async function main() { switch (process.argv[2]) { - case "build": - await build(); - break; - case "verify": { - const manifest = await verify(); - console.log(`Candidate verified: ${manifest.version} ${manifest.source}`); - break; - } case "policy": await policy(); break; @@ -415,7 +161,7 @@ async function main() { console.log("Hooks enabled for this worktree."); break; default: - throw new Error("Use build, verify, policy or hooks."); + throw new Error("Use policy, licence-evaluated or hooks."); } } if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main(); diff --git a/tools/ArcForges.Web.Tooling/ArcForges.Web.Tooling.csproj b/tools/ArcForges.Web.Tooling/ArcForges.Web.Tooling.csproj new file mode 100644 index 0000000..00dd82d --- /dev/null +++ b/tools/ArcForges.Web.Tooling/ArcForges.Web.Tooling.csproj @@ -0,0 +1,13 @@ + + + + Exe + + + + + + AGPL + AGPL-3.0-only + + diff --git a/tools/ArcForges.Web.Tooling/Candidate/CandidateBuilder.cs b/tools/ArcForges.Web.Tooling/Candidate/CandidateBuilder.cs new file mode 100644 index 0000000..e8e09d3 --- /dev/null +++ b/tools/ArcForges.Web.Tooling/Candidate/CandidateBuilder.cs @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: AGPL-3.0-only +using System.Text.RegularExpressions; +using System.Text.Json.Nodes; +using ArcForges.Web.Site; + +namespace ArcForges.Web.Tooling.Candidate; + +/// The inputs of one candidate build. Every value is explicit; nothing is read from the environment or the clock. +public sealed record CandidateOptions +{ + /// The repository root that holds the reviewed inputs (LICENSE, NOTICE, lock files, the policy, wrangler.json). + public required string Repository { get; init; } + + /// The 40-hex lower-case source commit of the build. + public required string SourceRef { get; init; } + + /// The release version: 0.1.0-local, or 0.1.0-ci.RUN.ATTEMPT on CI. + public required string Version { get; init; } + + /// True when the source tree had uncommitted changes. A dirty candidate is never deployed. + public bool Dirty { get; init; } + + /// The build identity document (assets/__build-info.json), produced by the Node identity emitter. + public required byte[] Identity { get; init; } + + /// The emitted Worker script (worker/index.js), produced by the Node emitter from worker/index.ts. + public required byte[] Worker { get; init; } +} + +/// Builds the sealed public candidate in memory, then writes it. Two builds of the same inputs are the same bytes. +public static partial class CandidateBuilder +{ + /// Builds every member of the candidate, including the sealed manifest, keyed by candidate path. + public static async Task> BuildAsync(CandidateOptions options, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(options); + if (!VersionPattern().IsMatch(options.Version)) + throw new ArgumentException("Invalid candidate version.", nameof(options)); + if (!SourcePattern().IsMatch(options.SourceRef)) + throw new ArgumentException("The source ref must be a 40-character lower-case commit id.", nameof(options)); + CandidateCore.RequireWorker(options.Worker); + RequireIdentity(options.Identity, options.SourceRef, options.Version, options.Dirty); + + var site = await SiteBuilder.BuildAsync(new SiteOptions { SourceRef = options.SourceRef }, cancellationToken) + .ConfigureAwait(false); + var repository = options.Repository; + var members = new SortedDictionary(StringComparer.Ordinal); + void Add(string path, byte[] bytes) + { + if (members.ContainsKey(path)) + throw new InvalidOperationException("Duplicate candidate member: " + path); + members[path] = bytes; + } + + foreach (var file in site.Files) + Add(CandidateCore.AssetsPrefix + file.Path, file.Content); + // The Site archive is a root member, outside the deployed assets directory: the seal records its digest. + var siteArchive = SiteArchive.Build(site.Files); + Add(SiteArchive.Name(CandidateCore.Sha256(siteArchive)), siteArchive); + Add(CandidateCore.AssetsPrefix + "license.txt", CandidateCore.Licence(repository)); + Add(CandidateCore.AssetsPrefix + "third-party-notices.txt", CandidateCore.Notices(repository)); + Add(CandidateCore.AssetsPrefix + "source-provenance.txt", CandidateCore.SourceNotice(repository)); + Add(CandidateCore.AssetsPrefix + "__build.json", CandidateCore.BuildJson(options.Version, options.SourceRef)); + Add(CandidateCore.AssetsPrefix + "__build-info.json", options.Identity); + Add(CandidateCore.WranglerPath, CandidateCore.Json(CandidateCore.CandidateWrangler(repository))); + Add(CandidateCore.WorkerPath, options.Worker); + Add(CandidateCore.SbomPath, CandidateCore.BuildSbom(repository)); + Add(CandidateCore.RuntimeSbomPath, CandidateCore.RuntimeSbom()); + Add( + CandidateCore.ReceiptPath, + CandidateCore.Receipt(options.SourceRef, options.Version, CandidateCore.PolicySha256(repository), members)); + + var listed = new JsonObject(); + foreach (var (path, bytes) in members.OrderBy(pair => pair.Key, StringComparer.Ordinal)) + listed[path] = CandidateCore.Sha256(bytes); + var manifest = new JsonObject + { + ["schema"] = CandidateCore.ManifestSchema, + ["version"] = options.Version, + ["source"] = options.SourceRef, + ["dirty"] = options.Dirty, + ["files"] = listed, + }; + Add(CandidateCore.ManifestPath, CandidateCore.Json(manifest)); + return members; + } + + /// Writes the built members to a directory that must not exist yet (fail closed on a stale candidate). + public static void Write(string directory, IReadOnlyDictionary members) + { + var root = Path.GetFullPath(directory); + if (Directory.Exists(root) || File.Exists(root)) + throw new InvalidOperationException("Refusing to write into an existing path: " + root); + foreach (var (path, bytes) in members) + { + var target = Path.GetFullPath(Path.Combine(root, path.Replace('/', Path.DirectorySeparatorChar))); + if (!target.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.Ordinal)) + throw new InvalidOperationException("Candidate path escapes the output directory: " + path); + Directory.CreateDirectory(Path.GetDirectoryName(target)!); + File.WriteAllBytes(target, bytes); + } + } + + /// The identity must name this source, this version and this dirty state; a mismatch is refused before sealing. + internal static void RequireIdentity(byte[] identity, string sourceRef, string version, bool dirty) + { + var document = JsonNode.Parse(identity)?.AsObject() + ?? throw new InvalidOperationException("The build identity must be a JSON object."); + var build = document["build"]?.AsObject() + ?? throw new InvalidOperationException("The build identity has no build section."); + if (build["sourceCommit"]?.GetValue() != sourceRef) + throw new InvalidOperationException("The build identity names another source commit."); + if (build["dirty"]?.GetValue() != dirty) + throw new InvalidOperationException("The build identity names another dirty state."); + if (document["artifact"]?["version"]?.GetValue() != version) + throw new InvalidOperationException("The build identity names another version."); + } + + [GeneratedRegex(@"^0\.1\.0-(?:local|ci\.[1-9]\d*\.[1-9]\d*)$", RegexOptions.CultureInvariant)] + internal static partial Regex VersionPattern(); + + [GeneratedRegex(@"^[a-f0-9]{40}$", RegexOptions.CultureInvariant)] + internal static partial Regex SourcePattern(); +} diff --git a/tools/ArcForges.Web.Tooling/Candidate/CandidateCore.cs b/tools/ArcForges.Web.Tooling/Candidate/CandidateCore.cs new file mode 100644 index 0000000..74502ee --- /dev/null +++ b/tools/ArcForges.Web.Tooling/Candidate/CandidateCore.cs @@ -0,0 +1,291 @@ +// SPDX-License-Identifier: AGPL-3.0-only +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ArcForges.Web.Tooling.Candidate; + +/// +/// The rules shared by the candidate build and its verifier: the reviewed inputs, the pinned Worker, the legal and notice +/// files, the build SBOM and the sealed manifest. Every value here is derived from the repository or the Site source; no +/// timestamp or machine path is an input, so two builds of the same inputs are the same bytes. +/// +internal static class CandidateCore +{ + /// The manifest schema, the same as the React candidate's. + public const int ManifestSchema = 1; + + /// The sealed manifest path (not listed in its own file map). + public const string ManifestPath = "manifest.json"; + + /// The provenance receipt path (listed in the manifest, but its own members exclude it). + public const string ReceiptPath = "provenance/receipt.json"; + + /// The emitted canonical-host Worker (the JavaScript form of worker/index.ts). + public const string WorkerPath = "worker/index.js"; + + /// The candidate wrangler configuration. + public const string WranglerPath = "wrangler.json"; + + /// The build SBOM and the runtime SBOM of the public Site. + public const string SbomPath = "sbom.cdx.json"; + + /// The runtime SBOM path. + public const string RuntimeSbomPath = "runtime-sbom.cdx.json"; + + /// The static public assets are published under this prefix (the wrangler assets directory). + public const string AssetsPrefix = "assets/"; + + /// The full AGPL text, published as the licence file. + public const string LicencePath = "LICENSE"; + + /// The source provenance notice, published beside the licence. + public const string SourceNoticePath = "eng/provenance/NOTICE.txt"; + + /// The reviewed dependency policy, bound into the receipt. + public const string PolicyPath = "eng/policy/dependency-policy.json"; + + /// The Tailwind preflight licence text: the public stylesheet derives from Tailwind preflight. + public const string TailwindLicencePath = "third-party/tailwind.LICENSE.txt"; + + /// The per-project lock files of the Site closure, read for the build SBOM. + public static readonly string[] SiteLockPaths = + [ + "src/ArcForges.Web.Site/packages.lock.json", + "src/ArcForges.Web.Ui/packages.lock.json", + ]; + + /// + /// The SHA-256 of the Worker JavaScript emitted from worker/index.ts by Node type stripping (LF-normalised). The Node + /// emitter (tooling/candidate.ts) and this pin must agree; a changed Worker source needs a reviewed change to this value. + /// + public const string WorkerSha256 = "5020b1504e965aa428bb3bcca2bd270bfd5e4adb53dbe8fb0d3338bca401d088"; + + /// The public pages every candidate must publish (the React candidate's required pages). + public static readonly string[] RequiredPages = ["index.html", "hello/index.html", "cloud-hello/index.html", "404.html"]; + + /// The files that are not generated by the Site and that every candidate publishes beside it. + public static readonly string[] IdentityAndLegalFiles = + [ + "license.txt", + "third-party-notices.txt", + "source-provenance.txt", + "__build.json", + "__build-info.json", + ]; + + /// The members every candidate must hold, as candidate paths. + public static IReadOnlyList RequiredMembers { get; } = + [ + .. RequiredPages.Select(page => AssetsPrefix + page), + AssetsPrefix + "_headers", + .. IdentityAndLegalFiles.Select(file => AssetsPrefix + file), + SbomPath, + RuntimeSbomPath, + WranglerPath, + WorkerPath, + ReceiptPath, + ]; + + private static readonly UTF8Encoding StrictUtf8 = new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); + private static readonly JsonSerializerOptions Indented = new() { WriteIndented = true }; + + /// The lower-case hexadecimal SHA-256 of the bytes. + public static string Sha256(ReadOnlySpan bytes) => Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); + + /// Decodes strict UTF-8 and normalises CRLF to LF, the bytes a browser or Node text reader sees. + public static byte[] Lf(ReadOnlySpan bytes) => + Encoding.UTF8.GetBytes(StrictUtf8.GetString(bytes).Replace("\r\n", "\n", StringComparison.Ordinal)); + + /// Serialises a JSON document with two-space indentation and one trailing line feed. + public static byte[] Json(JsonNode node) => + Encoding.UTF8.GetBytes(JsonSerializer.Serialize(node, Indented) + "\n"); + + /// Reads one repository file. The path is a fixed constant of this tool, never user input. + public static byte[] ReadRepo(string repository, string relativePath) + { + var path = Path.Combine(repository, relativePath.Replace('/', Path.DirectorySeparatorChar)); + if (!File.Exists(path)) + throw new InvalidOperationException("Missing reviewed repository input: " + relativePath); + return File.ReadAllBytes(path); + } + + /// Reads every regular file under a directory as relative '/' paths. Links and reparse points are refused. + public static SortedDictionary ReadTree(string directory) + { + var files = new SortedDictionary(StringComparer.Ordinal); + var root = Path.GetFullPath(directory); + void Visit(string current) + { + foreach (var entry in Directory.EnumerateFileSystemEntries(current)) + { + var attributes = File.GetAttributes(entry); + if ((attributes & FileAttributes.ReparsePoint) != 0) + throw new InvalidOperationException("Links are not allowed in a candidate: " + entry); + if ((attributes & FileAttributes.Directory) != 0) + { + Visit(entry); + continue; + } + var relative = Path.GetRelativePath(root, entry).Replace(Path.DirectorySeparatorChar, '/'); + files[relative] = File.ReadAllBytes(entry); + } + } + Visit(root); + return files; + } + + /// + /// The published third-party notices. The static Site ships only Tailwind preflight-derived CSS from third-party + /// material, so the notice text covers exactly that licence; a change to the shipped third-party content changes it. + /// + public static byte[] Notices(string repository) + { + var tailwind = Lf(ReadRepo(repository, TailwindLicencePath)); + var text = new StringBuilder(); + text.Append("ArcForges Web third-party notices\n"); + text.Append("Generated for the C# static Site candidate. The public Site publishes no JavaScript and no runtime\n"); + text.Append("dependency. Its stylesheet derives from Tailwind preflight (MIT); every other published file is first-party.\n"); + text.Append("\n===== Tailwind CSS preflight (MIT) =====\n"); + text.Append(Encoding.UTF8.GetString(tailwind)); + return Encoding.UTF8.GetBytes(text.ToString()); + } + + /// The published licence file: the full AGPL text with LF line endings. + public static byte[] Licence(string repository) => Lf(ReadRepo(repository, LicencePath)); + + /// The published source provenance notice with LF line endings. + public static byte[] SourceNotice(string repository) => Lf(ReadRepo(repository, SourceNoticePath)); + + /// The build identity: the candidate's __build.json, which the deploy identity poll reads. + public static byte[] BuildJson(string version, string source) => + Json(new JsonObject { ["version"] = version, ["source"] = source }); + + /// + /// The build SBOM of the public Site: every non-project NuGet package of the Site and Ui locks, with its exact + /// version, its content hash and the licence the reviewed closure records for it. A package with no reviewed licence + /// fails the build. + /// + public static byte[] BuildSbom(string repository) + { + var closure = ReviewedLicences(repository); + var components = new SortedDictionary(StringComparer.Ordinal); + foreach (var lockPath in SiteLockPaths) + { + var document = JsonNode.Parse(ReadRepo(repository, lockPath))?.AsObject() + ?? throw new InvalidOperationException("Invalid lock file: " + lockPath); + var frameworks = document["dependencies"]?.AsObject() + ?? throw new InvalidOperationException("A lock file without dependencies: " + lockPath); + foreach (var (_, dependencies) in frameworks) + { + foreach (var (name, value) in dependencies!.AsObject()) + { + var entry = value!.AsObject(); + if (entry["type"]?.GetValue() == "Project") + continue; + var version = entry["resolved"]?.GetValue() + ?? throw new InvalidOperationException("A package without a resolved version: " + name); + var contentHash = entry["contentHash"]?.GetValue() + ?? throw new InvalidOperationException("A package without a content hash: " + name); + var purl = $"pkg:nuget/{name}@{version}"; + if (components.ContainsKey(purl)) + continue; + var licenceKey = (name + "/" + version).ToLowerInvariant(); + if (!closure.TryGetValue(licenceKey, out var licence)) + throw new InvalidOperationException("No reviewed licence for " + purl); + components[purl] = new JsonObject + { + ["type"] = "library", + ["bom-ref"] = purl, + ["name"] = name, + ["version"] = version, + ["purl"] = purl, + ["licenses"] = new JsonArray(new JsonObject { ["license"] = new JsonObject { ["id"] = licence } }), + ["hashes"] = new JsonArray(new JsonObject + { + ["alg"] = "SHA-512", + ["content"] = Convert.ToHexString(Convert.FromBase64String(contentHash)).ToLowerInvariant(), + }), + }; + } + } + } + var bom = new JsonObject + { + ["bomFormat"] = "CycloneDX", + ["specVersion"] = "1.5", + ["version"] = 1, + ["metadata"] = new JsonObject + { + ["component"] = new JsonObject { ["type"] = "application", ["name"] = "arcforges-web-site", ["bom-ref"] = "arcforges-web-site" }, + }, + ["components"] = new JsonArray(components.Values.Select(component => (JsonNode)component).ToArray()), + }; + return Json(bom); + } + + /// The runtime SBOM: the static Site ships no runtime dependency, so its component list is empty. + public static byte[] RuntimeSbom() => + Json(new JsonObject + { + ["bomFormat"] = "CycloneDX", + ["specVersion"] = "1.5", + ["version"] = 1, + ["components"] = new JsonArray(), + }); + + /// The candidate wrangler configuration: the reviewed root configuration with the candidate's assets directory. + public static JsonObject CandidateWrangler(string repository) + { + var root = JsonNode.Parse(ReadRepo(repository, WranglerPath))?.AsObject() + ?? throw new InvalidOperationException("Invalid wrangler.json"); + root.Remove("$schema"); + var assets = root["assets"]?.AsObject() + ?? throw new InvalidOperationException("wrangler.json has no assets section"); + assets["directory"] = "./assets"; + return root; + } + + /// The provenance receipt: the source, the version, the reviewed policy digest and every member's digest. + public static byte[] Receipt(string source, string version, string policySha256, IReadOnlyDictionary members) + { + var listed = new JsonObject(); + foreach (var (path, bytes) in members.OrderBy(pair => pair.Key, StringComparer.Ordinal)) + if (path != ManifestPath && path != ReceiptPath) + listed[path] = Sha256(bytes); + return Json(new JsonObject + { + ["schemaVersion"] = 1, + ["repository"] = "Web", + ["source"] = source, + ["version"] = version, + ["policySha256"] = policySha256, + ["members"] = listed, + }); + } + + /// The reviewed dependency policy digest, LF-normalised, that the receipt binds. + public static string PolicySha256(string repository) => Sha256(Lf(ReadRepo(repository, PolicyPath))); + + /// The Worker digest check: the emitted script must be exactly the pinned bytes. + public static void RequireWorker(ReadOnlySpan bytes) + { + if (Sha256(bytes) != WorkerSha256) + throw new InvalidOperationException("Worker script changed from the reviewed source (worker/index.ts)."); + } + + /// The licence key to SPDX licence map of the reviewed NuGet closure. + private static Dictionary ReviewedLicences(string repository) + { + var policy = JsonNode.Parse(ReadRepo(repository, PolicyPath))?.AsObject() + ?? throw new InvalidOperationException("Invalid dependency policy"); + var closure = policy["nugetClosure"]?.AsObject() + ?? throw new InvalidOperationException("The dependency policy has no nugetClosure"); + var result = new Dictionary(StringComparer.Ordinal); + foreach (var (key, value) in closure) + result[key.ToLowerInvariant()] = value!["licence"]?.GetValue() + ?? throw new InvalidOperationException("No licence for " + key); + return result; + } +} diff --git a/tools/ArcForges.Web.Tooling/Candidate/CandidateVerifier.cs b/tools/ArcForges.Web.Tooling/Candidate/CandidateVerifier.cs new file mode 100644 index 0000000..3622c2d --- /dev/null +++ b/tools/ArcForges.Web.Tooling/Candidate/CandidateVerifier.cs @@ -0,0 +1,231 @@ +// SPDX-License-Identifier: AGPL-3.0-only +using System.Text; +using System.Text.RegularExpressions; +using System.Text.Json.Nodes; +using ArcForges.Web.Site; + +namespace ArcForges.Web.Tooling.Candidate; + +/// +/// Verifies a sealed public candidate before anything is uploaded. It checks the seal, the exact member set, the wrangler +/// configuration, the pinned Worker, the identity, the public Site (by regenerating it from the sealed source and comparing +/// every byte), the static-page graph (no script without a hash, no event handlers, no frames, every stylesheet present), +/// and, when the repository is given, the licence, notice, SBOM and provenance receipt against the reviewed inputs. +/// +public static partial class CandidateVerifier +{ + private static readonly string[] ManifestKeys = ["dirty", "files", "schema", "source", "version"]; + private static readonly string[] WranglerKeys = ["assets", "compatibility_date", "main", "name", "no_bundle", "preview_urls", "workers_dev"]; + private static readonly string[] ForbiddenAssetPatterns = ["/.env", "/.dev.vars", "/node_modules", "/server", "/.vite", "/__spa-fallback"]; + + /// Verifies the candidate at . Throws at the first failed rule. + /// The verified manifest's version, source and member count. + public static async Task VerifyAsync( + string directory, + string? repository, + string? expectedSource, + CancellationToken cancellationToken = default) + { + var tree = CandidateCore.ReadTree(directory); + if (!tree.TryGetValue(CandidateCore.ManifestPath, out var manifestBytes)) + throw Fail("The candidate has no manifest."); + var manifest = JsonNode.Parse(manifestBytes)?.AsObject() ?? throw Fail("The manifest is not a JSON object."); + Require(manifest.Select(pair => pair.Key).OrderBy(key => key, StringComparer.Ordinal).SequenceEqual(ManifestKeys), + "The manifest keys changed."); + Require(manifest["schema"]?.GetValue() == CandidateCore.ManifestSchema, "Unsupported manifest schema."); + var version = manifest["version"]?.GetValue() ?? throw Fail("The manifest has no version."); + var source = manifest["source"]?.GetValue() ?? throw Fail("The manifest has no source."); + var dirty = manifest["dirty"]?.GetValue() ?? throw Fail("The manifest has no dirty flag."); + Require(CandidateBuilder.VersionPattern().IsMatch(version), "Invalid candidate version."); + Require(CandidateBuilder.SourcePattern().IsMatch(source), "Invalid source revision."); + if (expectedSource is not null) + Require(source == expectedSource, "Candidate source does not match this run."); + + var listed = manifest["files"]?.AsObject() ?? throw Fail("The manifest has no file map."); + var listedPaths = listed.Select(pair => pair.Key).OrderBy(path => path, StringComparer.Ordinal).ToArray(); + var actualPaths = tree.Keys.Where(path => path != CandidateCore.ManifestPath).ToArray(); + Require(listedPaths.SequenceEqual(actualPaths), "Candidate file set changed."); + foreach (var (path, hash) in listed) + Require(CandidateCore.Sha256(tree[path]) == hash?.GetValue(), "Candidate changed: " + path); + + foreach (var path in actualPaths.Where(path => path.StartsWith(CandidateCore.AssetsPrefix, StringComparison.Ordinal))) + { + Require(!path.EndsWith(".map", StringComparison.Ordinal), "Source map in public assets: " + path); + foreach (var pattern in ForbiddenAssetPatterns) + Require(!("/" + path).Contains(pattern, StringComparison.Ordinal), "Private or build file in public assets: " + path); + } + foreach (var required in CandidateCore.RequiredMembers) + Require(tree.ContainsKey(required), "Missing required file: " + required); + + VerifyWrangler(tree, repository); + CandidateCore.RequireWorker(tree[CandidateCore.WorkerPath]); + VerifyIdentity(tree, source, version, dirty); + VerifyReceipt(tree, source, version, repository); + await VerifySiteAsync(tree, source, cancellationToken).ConfigureAwait(false); + VerifyStaticGraph(tree); + if (repository is not null) + VerifyReviewedInputs(tree, repository); + return new CandidateSummary(version, source, dirty, tree.Count - 1); + } + + private static void VerifyWrangler(SortedDictionary tree, string? repository) + { + var config = JsonNode.Parse(tree[CandidateCore.WranglerPath])?.AsObject() ?? throw Fail("Invalid wrangler.json."); + Require(config.Select(pair => pair.Key).OrderBy(key => key, StringComparer.Ordinal).SequenceEqual(WranglerKeys), + "Deployment configuration keys changed."); + Require(config["name"]?.GetValue() == "arcforges-web", "Deployment name changed."); + Require(config["main"]?.GetValue() == "./worker/index.js", "Deployment entry changed."); + Require(config["no_bundle"]?.GetValue() == true, "Deployment must not bundle."); + Require(config["preview_urls"]?.GetValue() == false, "Preview URLs must stay disabled."); + Require(config["workers_dev"]?.GetValue() == false, "workers.dev must stay disabled."); + var expectedAssets = JsonNode.Parse(""" + {"directory":"./assets","binding":"ASSETS","run_worker_first":true,"html_handling":"auto-trailing-slash","not_found_handling":"404-page"} + """); + Require(JsonNode.DeepEquals(config["assets"], expectedAssets), "Deployment asset routing changed."); + if (repository is not null) + Require(JsonNode.DeepEquals(config, CandidateCore.CandidateWrangler(repository)), "Deployment configuration changed."); + } + + private static void VerifyIdentity(SortedDictionary tree, string source, string version, bool dirty) + { + var identity = JsonNode.Parse(tree[CandidateCore.AssetsPrefix + "__build.json"])?.AsObject() + ?? throw Fail("Invalid __build.json."); + Require(identity["source"]?.GetValue() == source && identity["version"]?.GetValue() == version, + "The build identity does not match the manifest."); + var info = JsonNode.Parse(tree[CandidateCore.AssetsPrefix + "__build-info.json"])?.AsObject() + ?? throw Fail("Invalid __build-info.json."); + CandidateBuilder.RequireIdentity(tree[CandidateCore.AssetsPrefix + "__build-info.json"], source, version, dirty); + Require(info["build"]?["sourceCommit"]?.GetValue() == source, "The build info names another source commit."); + } + + private static void VerifyReceipt(SortedDictionary tree, string source, string version, string? repository) + { + var receipt = JsonNode.Parse(tree[CandidateCore.ReceiptPath])?.AsObject() ?? throw Fail("Invalid receipt."); + Require(receipt["schemaVersion"]?.GetValue() == 1 && receipt["repository"]?.GetValue() == "Web", + "Unsupported receipt."); + Require(receipt["source"]?.GetValue() == source && receipt["version"]?.GetValue() == version, + "The receipt does not match the manifest."); + var members = new JsonObject(); + foreach (var (path, bytes) in tree.Where(pair => pair.Key != CandidateCore.ManifestPath && pair.Key != CandidateCore.ReceiptPath)) + members[path] = CandidateCore.Sha256(bytes); + Require(JsonNode.DeepEquals(receipt["members"], members), "The receipt members differ from the sealed files."); + if (repository is not null) + Require(receipt["policySha256"]?.GetValue() == CandidateCore.PolicySha256(repository), + "The receipt binds another dependency policy."); + } + + private static async Task VerifySiteAsync(SortedDictionary tree, string source, CancellationToken cancellationToken) + { + // The public Site is regenerated from the sealed source and compared byte for byte, so a changed page, header, + // policy or stylesheet cannot ship under a valid seal. + var site = await SiteBuilder.BuildAsync(new SiteOptions { SourceRef = source }, cancellationToken).ConfigureAwait(false); + var expected = site.Files.Select(file => CandidateCore.AssetsPrefix + file.Path).ToHashSet(StringComparer.Ordinal); + foreach (var identity in CandidateCore.IdentityAndLegalFiles) + expected.Add(CandidateCore.AssetsPrefix + identity); + var actual = tree.Keys.Where(path => path.StartsWith(CandidateCore.AssetsPrefix, StringComparison.Ordinal)).ToHashSet(StringComparer.Ordinal); + Require(expected.SetEquals(actual), "The public assets differ from the reviewed Site inventory."); + foreach (var file in site.Files) + Require(tree[CandidateCore.AssetsPrefix + file.Path].SequenceEqual(file.Content), + "Public Site bytes differ from the regenerated Site: " + file.Path); + + // The Site archive is sealed beside the assets: exactly one, named by the digest of its own bytes, and equal to the + // archive of the regenerated Site. Any other bytes, name or count is refused. + var archives = tree.Keys.Where(SiteArchive.IsName).ToArray(); + Require(archives.Length == 1, "The candidate must hold exactly one Site archive."); + var archive = archives[0]; + Require(archive == SiteArchive.Name(CandidateCore.Sha256(tree[archive])), "The Site archive name does not match its digest."); + Require(tree[archive].SequenceEqual(SiteArchive.Build(site.Files)), "The Site archive differs from the regenerated Site."); + } + + private static void VerifyStaticGraph(SortedDictionary tree) + { + // The public pages are static: no external script, no event-handler attribute, no frame or embedded object, and + // every inline script is covered by the page's policy hash. Each stylesheet must be a published asset. + var headers = Encoding.UTF8.GetString(tree[CandidateCore.AssetsPrefix + "_headers"]); + var policyLine = headers.Split('\n').FirstOrDefault(line => line.TrimStart().StartsWith("Content-Security-Policy:", StringComparison.Ordinal)) + ?? throw Fail("The headers carry no Content-Security-Policy."); + var policy = policyLine.Trim()["Content-Security-Policy:".Length..].Trim(); + Require(policy.Contains("script-src 'self'", StringComparison.Ordinal), "The policy has no script-src 'self'."); + foreach (var forbidden in new[] { "unsafe-inline", "unsafe-eval", "wasm-unsafe-eval" }) + Require(!policy.Contains(forbidden, StringComparison.Ordinal), "The public policy allows " + forbidden + "."); + var hashes = policy.Split(' ', StringSplitOptions.RemoveEmptyEntries) + .Where(token => token.StartsWith("'sha256-", StringComparison.Ordinal)) + .Select(token => token.Trim('\'')["sha256-".Length..]) + .ToHashSet(StringComparer.Ordinal); + + foreach (var page in CandidateCore.RequiredPages) + { + var path = CandidateCore.AssetsPrefix + page; + var html = Encoding.UTF8.GetString(tree[path]); + Require(!ExternalScript().IsMatch(html), "External script on a public page: " + page); + Require(!EventAttribute().IsMatch(html), "Event-handler attribute on a public page: " + page); + Require(!FrameElement().IsMatch(html), "Frame or embedded object on a public page: " + page); + foreach (Match inline in InlineScript().Matches(html)) + { + var body = inline.Groups["body"].Value; + if (body.Length == 0) + continue; + var digest = Convert.ToBase64String(System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(body))); + Require(hashes.Contains(digest), "Inline script without a policy hash on " + page); + } + var stylesheets = 0; + foreach (Match tag in LinkTag().Matches(html)) + { + if (!StylesheetRel().IsMatch(tag.Value)) + continue; + stylesheets++; + var href = HrefAttribute().Match(tag.Value); + Require(href.Success, "Stylesheet without an href on " + page); + Require(tree.ContainsKey(CandidateCore.AssetsPrefix + href.Groups["href"].Value.TrimStart('/')), + "Stylesheet not published: " + href.Groups["href"].Value); + } + Require(stylesheets == 1, "Each public page links exactly one published stylesheet: " + page); + } + } + + private static void VerifyReviewedInputs(SortedDictionary tree, string repository) + { + Require(tree[CandidateCore.AssetsPrefix + "license.txt"].SequenceEqual(CandidateCore.Licence(repository)), + "Full AGPL legal text changed."); + Require(tree[CandidateCore.AssetsPrefix + "third-party-notices.txt"].SequenceEqual(CandidateCore.Notices(repository)), + "Required third-party notices changed."); + Require(tree[CandidateCore.AssetsPrefix + "source-provenance.txt"].SequenceEqual(CandidateCore.SourceNotice(repository)), + "Source notice changed."); + Require(tree[CandidateCore.SbomPath].SequenceEqual(CandidateCore.BuildSbom(repository)), + "Build SBOM identities or edges changed."); + Require(tree[CandidateCore.RuntimeSbomPath].SequenceEqual(CandidateCore.RuntimeSbom()), + "Runtime SBOM changed."); + } + + private static InvalidOperationException Fail(string message) => new(message); + + private static void Require(bool condition, string message) + { + if (!condition) + throw Fail(message); + } + + [GeneratedRegex(@"]*\bsrc\s*=", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex ExternalScript(); + + [GeneratedRegex(@"\son[a-z]+\s*=", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex EventAttribute(); + + [GeneratedRegex(@"<(?:iframe|object|embed|frame|applet)\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex FrameElement(); + + [GeneratedRegex(@"]*>(?[\s\S]*?)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex InlineScript(); + + [GeneratedRegex(@"]*>", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex LinkTag(); + + [GeneratedRegex(@"\brel\s*=\s*""[^""]*\bstylesheet\b[^""]*""", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex StylesheetRel(); + + [GeneratedRegex(@"\bhref\s*=\s*""(?[^""]+)""", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex HrefAttribute(); +} + +/// The verified facts of a candidate. +public sealed record CandidateSummary(string Version, string Source, bool Dirty, int Members); diff --git a/tools/ArcForges.Web.Tooling/Candidate/SiteArchive.cs b/tools/ArcForges.Web.Tooling/Candidate/SiteArchive.cs new file mode 100644 index 0000000..d496f26 --- /dev/null +++ b/tools/ArcForges.Web.Tooling/Candidate/SiteArchive.cs @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: AGPL-3.0-only +using System.Text.RegularExpressions; +using ArcForges.Web.Site; +using ArcForges.Web.Tooling.Profiles; + +namespace ArcForges.Web.Tooling.Candidate; + +/// +/// The Site release asset: the deterministic ustar archive of the C# static Site output, named by the digest of its own +/// bytes (web-site-<sha256>.tar). It is written by the same ustar writer as the profile bundle, so its entries are +/// plain regular files in ordinal path order with fixed metadata, and equal Site output is the same bytes. +/// The archive sits at the root of the sealed candidate, beside the assets directory that Cloudflare deploys, so the seal +/// records its digest and the verifier refuses any other bytes. It is published only by the main-push release, beside +/// web-profiles. It is not part of the deployed assets. +/// +internal static partial class SiteArchive +{ + /// The archive file name for a digest. + public static string Name(string digest) => "web-site-" + digest + ".tar"; + + /// Writes the Site files, in ordinal path order, as one archive. + public static byte[] Build(IReadOnlyList files) + { + ArgumentNullException.ThrowIfNull(files); + var entries = files + .OrderBy(file => file.Path, StringComparer.Ordinal) + .Select(file => new TarArchive.Entry(file.Path, file.Content)) + .ToArray(); + return TarArchive.Write(entries); + } + + /// The root file name of a Site archive: its name pattern, with the digest of its own bytes. + public static bool IsName(string path) => NamePattern().IsMatch(path); + + [GeneratedRegex(@"^web-site-[0-9a-f]{64}\.tar$", RegexOptions.CultureInvariant)] + private static partial Regex NamePattern(); +} diff --git a/tools/ArcForges.Web.Tooling/Profiles/ProfileBudget.cs b/tools/ArcForges.Web.Tooling/Profiles/ProfileBudget.cs new file mode 100644 index 0000000..91088e9 --- /dev/null +++ b/tools/ArcForges.Web.Tooling/Profiles/ProfileBudget.cs @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: AGPL-3.0-only +using System.IO.Compression; +using System.Text.Json.Nodes; +using ArcForges.Web.Tooling.Candidate; + +namespace ArcForges.Web.Tooling.Profiles; + +/// The measured size facts of one published Blazor WebAssembly application. +/// +/// Every gzip figure is the length of the file compressed in-process with , so +/// the measure does not depend on the precompressed siblings the SDK writes. The initial set is every published file +/// except those siblings: the shell page is the only page, and the framework and application resources are loaded at +/// startup (the publish has no lazy assemblies). +/// +public sealed record ProfileMeasure( + int InitialRequests, + long HtmlBytes, + long InitialCssGzip, + long InitialJsGzip, + long InitialWasmGzip, + long InitialDataGzip, + long InitialOtherGzip, + long TotalGzip) +{ + /// The metric names, in the budget file's order. + public static readonly string[] Metrics = + [ + "initialRequests", + "htmlBytes", + "initialCssGzip", + "initialJsGzip", + "initialWasmGzip", + "initialDataGzip", + "initialOtherGzip", + "totalGzip", + ]; + + /// The value of a named metric. + public long Value(string metric) => metric switch + { + "initialRequests" => InitialRequests, + "htmlBytes" => HtmlBytes, + "initialCssGzip" => InitialCssGzip, + "initialJsGzip" => InitialJsGzip, + "initialWasmGzip" => InitialWasmGzip, + "initialDataGzip" => InitialDataGzip, + "initialOtherGzip" => InitialOtherGzip, + "totalGzip" => TotalGzip, + _ => throw new InvalidOperationException("Unknown budget metric: " + metric), + }; +} + +/// The reviewed budget file of the Blazor profiles (eng/policy/profile-budgets.json). +public sealed record ProfileBudgets(int RegressionPercent, IReadOnlyDictionary> Profiles) +{ + /// The profile routes the budget file must name exactly (the bundle's profile paths). + public static readonly string[] RequiredProfiles = ProfileBundle.Profiles; + + /// Parses and validates the budget document. Any missing, extra or non-integer field fails closed. + public static ProfileBudgets Parse(byte[] json) + { + var root = JsonNode.Parse(json)?.AsObject() ?? throw new InvalidOperationException("The budget file is not a JSON object."); + RequireKeys(root, "schema regressionPercent profiles interactionBudgets", "The budget file keys changed."); + Require(root["schema"]?.GetValue() == 1, "Unsupported budget schema."); + var regression = root["regressionPercent"]?.GetValue() ?? throw new InvalidOperationException("No regressionPercent."); + Require(regression is >= 0 and <= 100, "regressionPercent must be between 0 and 100."); + var profiles = root["profiles"]?.AsObject() ?? throw new InvalidOperationException("No profiles."); + Require(profiles.Select(pair => pair.Key).OrderBy(key => key, StringComparer.Ordinal).SequenceEqual(RequiredProfiles.OrderBy(key => key, StringComparer.Ordinal)), + "The budget file must name exactly the bundle profiles."); + var result = new Dictionary>(StringComparer.Ordinal); + foreach (var (profile, node) in profiles) + { + var metrics = node?.AsObject() ?? throw new InvalidOperationException("No budget object for " + profile); + Require(metrics.Select(pair => pair.Key).OrderBy(key => key, StringComparer.Ordinal).SequenceEqual(ProfileMeasure.Metrics.OrderBy(key => key, StringComparer.Ordinal)), + "The budget metrics of " + profile + " changed."); + var values = new Dictionary(StringComparer.Ordinal); + foreach (var metric in ProfileMeasure.Metrics) + { + var value = metrics[metric]?.GetValue() ?? throw new InvalidOperationException("No baseline for " + profile + "." + metric); + Require(value > 0, "A baseline must be a positive measured value: " + profile + "." + metric); + values[metric] = value; + } + result[profile] = values; + } + return new ProfileBudgets(regression, result); + } + + /// The largest value a metric may reach: its baseline plus the allowed regression, rounded down. + public static long Limit(long baseline, int regressionPercent) => baseline + baseline * regressionPercent / 100; + + private static void RequireKeys(JsonObject node, string expected, string message) + { + var names = expected.Split(' ', StringSplitOptions.RemoveEmptyEntries).OrderBy(key => key, StringComparer.Ordinal); + Require(node.Select(pair => pair.Key).OrderBy(key => key, StringComparer.Ordinal).SequenceEqual(names), message); + } + + private static void Require(bool condition, string message) + { + if (!condition) + throw new InvalidOperationException(message); + } +} + +/// Measures the published application and enforces the reviewed budgets against it. +public static class ProfileBudget +{ + /// Measures a publish directory (the output of dotnet publish of ArcForges.Web.App). + public static ProfileMeasure Measure(string publishDirectory) + { + var wwwroot = Path.Combine(Path.GetFullPath(publishDirectory), "wwwroot"); + if (!Directory.Exists(wwwroot)) + throw new InvalidOperationException("The publish output has no wwwroot directory."); + var tree = CandidateCore.ReadTree(wwwroot); + if (!tree.TryGetValue(ProfileBundle.ShellPath, out var shell)) + throw new InvalidOperationException("The publish output has no application shell."); + + long css = 0, js = 0, wasm = 0, data = 0, other = 0; + var requests = 0; + foreach (var (path, bytes) in tree) + { + if (path.EndsWith(".br", StringComparison.Ordinal) || path.EndsWith(".gz", StringComparison.Ordinal)) + continue; + requests++; + if (path == ProfileBundle.ShellPath) + continue; + var gzip = GzipLength(bytes); + if (path.EndsWith(".css", StringComparison.Ordinal)) + css += gzip; + else if (path.StartsWith("_framework/", StringComparison.Ordinal) && path.EndsWith(".js", StringComparison.Ordinal)) + js += gzip; + else if (path.StartsWith("_framework/", StringComparison.Ordinal) && path.EndsWith(".wasm", StringComparison.Ordinal)) + wasm += gzip; + else if (path.StartsWith("_framework/", StringComparison.Ordinal) && path.EndsWith(".dat", StringComparison.Ordinal)) + data += gzip; + else + other += gzip; + } + return new ProfileMeasure( + requests, + shell.Length, + css, + js, + wasm, + data, + other, + css + js + wasm + data + other); + } + + /// + /// Enforces the budgets: every profile metric must stay at or below its baseline plus the allowed regression. The + /// report lists every metric; the first failing one raises the verification error. + /// + public static string Check(ProfileMeasure measure, ProfileBudgets budgets) + { + var report = new System.Text.StringBuilder(); + foreach (var profile in budgets.Profiles.Keys.OrderBy(key => key, StringComparer.Ordinal)) + { + var baselines = budgets.Profiles[profile]; + foreach (var metric in ProfileMeasure.Metrics) + { + var baseline = baselines[metric]; + var limit = ProfileBudgets.Limit(baseline, budgets.RegressionPercent); + var value = measure.Value(metric); + report.Append($"{profile}.{metric}: {value} (baseline {baseline}, limit {limit})\n"); + if (value > limit) + throw new InvalidOperationException( + $"Profile budget exceeded: {profile}.{metric} is {value}, limit {limit} (baseline {baseline}, {budgets.RegressionPercent}% regression allowed)."); + } + } + return report.ToString(); + } + + /// The gzip length of the bytes at the fixed compression level. + public static long GzipLength(byte[] bytes) + { + using var output = new MemoryStream(); + using (var gzip = new GZipStream(output, CompressionLevel.SmallestSize, leaveOpen: true)) + gzip.Write(bytes); + return output.Length; + } +} diff --git a/tools/ArcForges.Web.Tooling/Profiles/ProfileBundle.cs b/tools/ArcForges.Web.Tooling/Profiles/ProfileBundle.cs new file mode 100644 index 0000000..06214ab --- /dev/null +++ b/tools/ArcForges.Web.Tooling/Profiles/ProfileBundle.cs @@ -0,0 +1,320 @@ +// SPDX-License-Identifier: AGPL-3.0-only +using System.IO.Compression; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using ArcForges.Web.Tooling.Candidate; +using ArcForges.Web.Ui; + +namespace ArcForges.Web.Tooling.Profiles; + +/// The result of one bundle build: the archive name is the digest of the archive bytes. +public sealed record ProfileBundleResult(string Name, string Digest, byte[] Archive, int Members); + +/// The facts of a verified bundle. +public sealed record ProfileBundleSummary(string Name, string Digest, int Members); + +/// +/// The Web profile bundle that the Cloud proof deployment consumes: the Blazor WebAssembly standalone application +/// (ArcForges.Web.App, published once and serving the Account and Chat routes) as one deterministic, digest-named archive. +/// The archive keeps the React bundle's name pattern (web-profiles-<sha256>.tar), its first entry +/// (manifest.json), its root _headers with one Content-Security-Policy per profile path, and its +/// <profile>/index.html pages. Blazor's framework files are root-relative under the page's base href="/", +/// so the published tree is served at its own root paths and assets/ is empty in this bundle. The shell and its +/// precompressed siblings (index.html.br, index.html.gz) are not served at the root; every other file is. +/// +public static partial class ProfileBundle +{ + /// The manifest schema of the bundle. + public const int Schema = 1; + + /// The first entry of the archive. + public const string ManifestPath = "manifest.json"; + + /// The Cloudflare headers file at the archive root. + public const string HeadersPath = "_headers"; + + /// The application shell. It is the source of every profile page and is not served at the root. + public const string ShellPath = "index.html"; + + /// + /// The shell's precompressed siblings that the SDK writes. They are encodings of the shell, so they are not served at the + /// root either. Every other file of the publish is kept. + /// + public static readonly string[] ShellEncodingPaths = ["index.html.br", "index.html.gz"]; + + /// The profile paths. The one published application serves both routes. + public static readonly string[] Profiles = ["account", "chat"]; + + /// The Cloudflare header line budget each profile policy must fit within. + public const int HeaderLineBudget = 1800; + + /// + /// The two framework loader scripts whose names carry no content fingerprint. They are revalidated on every load + /// (no-cache); every other framework file must carry a fingerprint and is cached as immutable. + /// + public static readonly string[] UnfingerprintedFrameworkLoaders = + [ + "_framework/blazor.webassembly.js", + "_framework/dotnet.js", + ]; + + /// The framework path prefix served from the bundle root (Blazor's base href is "/"). + public const string FrameworkPrefix = "_framework/"; + + /// The bundle file name for a digest. + public static string BundleName(string digest) => "web-profiles-" + digest + ".tar"; + + /// Builds the bundle from a publish directory (the output of dotnet publish of ArcForges.Web.App). + public static ProfileBundleResult Build(string publishDirectory) + { + var publish = Path.GetFullPath(publishDirectory); + var wwwroot = Path.Combine(publish, "wwwroot"); + if (!Directory.Exists(wwwroot)) + throw new InvalidOperationException("The publish output has no wwwroot directory."); + var tree = CandidateCore.ReadTree(wwwroot); + if (!tree.TryGetValue(ShellPath, out var shell)) + throw new InvalidOperationException("The publish output has no application shell."); + RequireFingerprintedFramework(tree.Keys); + VerifyStaticGraph(publish, tree); + + var page = CandidateCore.Lf(shell); + var pageText = Encoding.UTF8.GetString(page); + var served = new SortedDictionary(StringComparer.Ordinal); + foreach (var (path, bytes) in tree) + { + if (path == ShellPath || ShellEncodingPaths.Contains(path, StringComparer.Ordinal)) + continue; + if (path == HeadersPath || path == ManifestPath || Profiles.Any(profile => path.StartsWith(profile + "/", StringComparison.Ordinal))) + throw new InvalidOperationException("The publish output uses a reserved bundle path: " + path); + served[path] = bytes; + } + var policies = new SortedDictionary(StringComparer.Ordinal); + foreach (var profile in Profiles) + { + served[profile + "/index.html"] = page; + policies[profile] = WasmContentSecurityPolicy.FromHostPages([pageText]); + } + served[HeadersPath] = Encoding.UTF8.GetBytes(HeadersText(policies)); + + var buildDigest = BuildDigest(tree); + var profileRows = new JsonObject(); + foreach (var profile in Profiles) + profileRows[profile] = new JsonObject + { + ["page"] = profile + "/index.html", + ["buildDigest"] = buildDigest, + ["csp"] = policies[profile], + }; + var fileRows = new JsonObject(); + foreach (var (path, bytes) in served) + fileRows[path] = new JsonObject { ["sha256"] = CandidateCore.Sha256(bytes), ["bytes"] = bytes.Length }; + var manifest = new JsonObject + { + ["schema"] = Schema, + ["profiles"] = profileRows, + ["files"] = fileRows, + }; + + var entries = new List { new(ManifestPath, CandidateCore.Json(manifest)) }; + entries.AddRange(served.Select(pair => new TarArchive.Entry(pair.Key, pair.Value))); + var archive = TarArchive.Write(entries); + var digest = CandidateCore.Sha256(archive); + return new ProfileBundleResult(BundleName(digest), digest, archive, entries.Count); + } + + /// + /// Every framework file is either one of the named loaders or carries a content fingerprint (ten lower-case + /// characters before its extension, as the SDK writes them). Its precompressed siblings (.br, .gz) follow the same rule. + /// Anything else fails the build, so the immutable cache rule can only ever cover fingerprinted content. + /// + public static void RequireFingerprintedFramework(IEnumerable paths) + { + foreach (var path in paths) + { + if (!path.StartsWith(FrameworkPrefix, StringComparison.Ordinal)) + continue; + var core = PrecompressedSuffix().Replace(path, string.Empty); + if (UnfingerprintedFrameworkLoaders.Contains(core, StringComparer.Ordinal)) + continue; + if (!FingerprintedFile().IsMatch(core)) + throw new InvalidOperationException("Unfingerprinted framework file: " + path); + } + } + + [GeneratedRegex(@"\.(?:br|gz)$", RegexOptions.CultureInvariant)] + private static partial Regex PrecompressedSuffix(); + + [GeneratedRegex(@"^_framework/[^/]+\.[a-z0-9]{10}\.(?:js|wasm|dat)$", RegexOptions.CultureInvariant)] + private static partial Regex FingerprintedFile(); + + /// + /// Writes the bundle into a directory. Earlier bundles of this name pattern are removed first, so the directory holds + /// exactly one bundle afterwards. + /// + public static void Write(string outputDirectory, ProfileBundleResult bundle) + { + var directory = Path.GetFullPath(outputDirectory); + Directory.CreateDirectory(directory); + foreach (var name in Directory.EnumerateFiles(directory)) + if (BundleFilePattern().IsMatch(Path.GetFileName(name))) + File.Delete(name); + File.WriteAllBytes(Path.Combine(directory, bundle.Name), bundle.Archive); + } + + /// + /// Verifies a bundle archive: its name, its strict tar layout, its manifest and every member's digest, the profile + /// pages, the policy of each profile path against its page, and the exact headers file. + /// + public static ProfileBundleSummary Verify(byte[] archive, string? expectedDigest = null) + { + ArgumentNullException.ThrowIfNull(archive); + var digest = CandidateCore.Sha256(archive); + if (expectedDigest is not null) + Require(digest == expectedDigest, "Bundle digest mismatch."); + var entries = TarArchive.Read(archive); + Require(entries.Count > 0 && entries[0].Path == ManifestPath, "The manifest must be the first entry."); + var manifest = JsonNode.Parse(entries[0].Bytes)?.AsObject() ?? throw new InvalidOperationException("Invalid bundle manifest."); + Require(manifest["schema"]?.GetValue() == Schema, "Unsupported bundle schema."); + var profileRows = manifest["profiles"]?.AsObject() ?? throw new InvalidOperationException("The manifest has no profiles."); + Require(profileRows.Select(pair => pair.Key).OrderBy(name => name, StringComparer.Ordinal).SequenceEqual(Profiles.OrderBy(name => name, StringComparer.Ordinal)), + "The bundle does not hold exactly the reviewed profiles."); + var fileRows = manifest["files"]?.AsObject() ?? throw new InvalidOperationException("The manifest has no files."); + var rest = entries.Skip(1).ToArray(); + Require(rest.Select(entry => entry.Path).SequenceEqual(fileRows.Select(pair => pair.Key)), + "The archive does not hold exactly the manifest's files in order."); + foreach (var entry in rest) + { + var row = fileRows[entry.Path]?.AsObject() ?? throw new InvalidOperationException("Unlisted file " + entry.Path); + Require(row["bytes"]?.GetValue() == entry.Bytes.Length, "Size of " + entry.Path); + Require(row["sha256"]?.GetValue() == CandidateCore.Sha256(entry.Bytes), "Content of " + entry.Path); + } + Require(rest.All(entry => entry.Path != ShellPath), "The application shell must not be served at the root."); + Require(rest.All(entry => !ShellEncodingPaths.Contains(entry.Path, StringComparer.Ordinal)), + "The application shell's encodings must not be served at the root."); + + var members = rest.ToDictionary(entry => entry.Path, entry => entry.Bytes, StringComparer.Ordinal); + var policies = new SortedDictionary(StringComparer.Ordinal); + foreach (var profile in Profiles) + { + var pagePath = profile + "/index.html"; + Require(members.TryGetValue(pagePath, out var page), "Missing profile page " + pagePath); + var pageText = Encoding.UTF8.GetString(page!); + Require(pageText.Contains("
() == pagePath, "The profile row names another page."); + Require(row["csp"]?.GetValue() == policy, "The profile policy differs from its page."); + policies[profile] = policy; + } + Require(members.TryGetValue(HeadersPath, out var headers), "Missing _headers."); + Require(Encoding.UTF8.GetString(headers!) == HeadersText(policies), "The headers file differs from the reviewed rules."); + return new ProfileBundleSummary(BundleName(digest), digest, entries.Count); + } + + /// The exact headers file: the security headers, one policy per profile path, and the assets cache rule. + public static string HeadersText(IReadOnlyDictionary policies) + { + var lines = new List + { + "/*", + " X-Content-Type-Options: nosniff", + " Referrer-Policy: no-referrer", + " Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()", + " X-Robots-Tag: noindex, nofollow", + }; + foreach (var profile in Profiles) + { + Require(policies.TryGetValue(profile, out var policy), "No policy for " + profile); + Require(policy!.Length < HeaderLineBudget, "The " + profile + " policy exceeds the header line budget."); + lines.Add("/" + profile + "/*"); + lines.Add(" Content-Security-Policy: " + policy); + lines.Add(" X-Frame-Options: DENY"); + lines.Add(" Cache-Control: public, no-cache, no-transform"); + } + lines.Add("/assets/*"); + lines.Add(" ! Cache-Control"); + lines.Add(" Cache-Control: public, max-age=31536000, immutable, no-transform"); + // Fingerprinted framework files are immutable; the two unfingerprinted loaders are revalidated on every load. + lines.Add("/" + FrameworkPrefix + "*"); + lines.Add(" ! Cache-Control"); + lines.Add(" Cache-Control: public, max-age=31536000, immutable, no-transform"); + foreach (var loader in UnfingerprintedFrameworkLoaders) + { + lines.Add("/" + loader); + lines.Add(" ! Cache-Control"); + lines.Add(" Cache-Control: public, no-cache, no-transform"); + } + return string.Join('\n', lines) + "\n"; + } + + /// + /// The browser-graph check of the Blazor output: the publish's static web assets manifest names every served file + /// with the integrity of its original resource. Each file must decode (Brotli or gzip variants included) to that + /// integrity, and every published file must be named by the manifest. A changed, missing or unlisted file fails. + /// + private static void VerifyStaticGraph(string publish, SortedDictionary tree) + { + var manifests = Directory.GetFiles(publish, "*.staticwebassets.endpoints.json", SearchOption.TopDirectoryOnly); + Require(manifests.Length == 1, "The publish output needs exactly one static web assets manifest."); + var document = JsonNode.Parse(File.ReadAllBytes(manifests[0]))?.AsObject() + ?? throw new InvalidOperationException("Invalid static web assets manifest."); + Require(document["Version"]?.GetValue() == 1, "Unsupported static web assets manifest version."); + var endpoints = document["Endpoints"]?.AsArray() ?? throw new InvalidOperationException("No endpoints in the manifest."); + var covered = new HashSet(StringComparer.Ordinal); + foreach (var node in endpoints) + { + var endpoint = node?.AsObject() ?? throw new InvalidOperationException("Invalid endpoint."); + var assetFile = endpoint["AssetFile"]?.GetValue() ?? throw new InvalidOperationException("An endpoint has no asset file."); + Require(tree.TryGetValue(assetFile, out var stored), "The manifest names a missing file: " + assetFile); + covered.Add(assetFile); + var integrity = endpoint["EndpointProperties"]?.AsArray() + .Select(property => property?.AsObject()) + .FirstOrDefault(property => property?["Name"]?.GetValue() == "integrity")?["Value"]?.GetValue() + ?? throw new InvalidOperationException("An endpoint has no integrity: " + assetFile); + // An uncompressed file must carry the integrity of its own original bytes. A compressed variant carries either + // the original resource's integrity or the integrity of its own compressed bytes (the ETag form the publish + // records); both are bound to the served bytes, and the original is always recovered by decoding. + var plain = assetFile.EndsWith(".br", StringComparison.Ordinal) ? Decompress(stored!, brotli: true) + : assetFile.EndsWith(".gz", StringComparison.Ordinal) ? Decompress(stored!, brotli: false) + : stored!; + var original = "sha256-" + Convert.ToBase64String(SHA256.HashData(plain)); + var own = "sha256-" + Convert.ToBase64String(SHA256.HashData(stored!)); + if (plain == stored) + Require(integrity == original, "Served bytes differ from the build's integrity: " + assetFile); + else + Require(integrity == original || integrity == own, "Served bytes differ from the build's integrity: " + assetFile); + } + Require(covered.SetEquals(tree.Keys), "A published file is not named by the build's static web assets manifest."); + } + + private static byte[] Decompress(byte[] stored, bool brotli) + { + using var source = new MemoryStream(stored); + using var output = new MemoryStream(); + Stream reader = brotli ? new BrotliStream(source, CompressionMode.Decompress) : new GZipStream(source, CompressionMode.Decompress); + using (reader) + reader.CopyTo(output); + return output.ToArray(); + } + + private static string BuildDigest(SortedDictionary tree) + { + var text = new StringBuilder(); + foreach (var (path, bytes) in tree) + text.Append(path).Append('\n').Append(CandidateCore.Sha256(bytes)).Append('\n'); + return CandidateCore.Sha256(Encoding.UTF8.GetBytes(text.ToString())); + } + + private static void Require(bool condition, string message) + { + if (!condition) + throw new InvalidOperationException(message); + } + + [GeneratedRegex(@"^web-profiles-[0-9a-f]{64}\.tar$", RegexOptions.CultureInvariant)] + private static partial Regex BundleFilePattern(); +} diff --git a/tools/ArcForges.Web.Tooling/Profiles/TarArchive.cs b/tools/ArcForges.Web.Tooling/Profiles/TarArchive.cs new file mode 100644 index 0000000..ea8e13c --- /dev/null +++ b/tools/ArcForges.Web.Tooling/Profiles/TarArchive.cs @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: AGPL-3.0-only +using System.Runtime.CompilerServices; +using System.Text; + +// The archive tests reach the internal writer and reader. The attribute is in source, not in the project file, so the +// reviewed project inputs of the dependency policy are unchanged. +[assembly: InternalsVisibleTo("ArcForges.Web.Tooling.Tests")] + +namespace ArcForges.Web.Tooling.Profiles; + +/// +/// The deterministic archive of the profile bundle: plain POSIX ustar regular files in a fixed order, zero times and +/// owners, no compression. Equal content is the same bytes, so the bundle's name is the digest of its own archive. The +/// reader accepts exactly what the writer writes and refuses anything else. +/// +internal static class TarArchive +{ + private const int Block = 512; + private const int NameLength = 100; + + /// One regular file of the archive. + public sealed record Entry(string Path, byte[] Bytes); + + /// Writes the entries in the given order and returns the archive bytes. + public static byte[] Write(IReadOnlyList entries) + { + var seen = new HashSet(StringComparer.Ordinal); + using var output = new MemoryStream(); + foreach (var entry in entries) + { + if (!seen.Add(entry.Path)) + throw new InvalidOperationException("Duplicate entry " + entry.Path); + output.Write(Header(entry.Path, entry.Bytes.LongLength)); + output.Write(entry.Bytes); + var padding = (Block - entry.Bytes.Length % Block) % Block; + output.Write(new byte[padding]); + } + output.Write(new byte[Block * 2]); + return output.ToArray(); + } + + /// Reads exactly what writes. Any other header, size or padding is refused. + public static IReadOnlyList Read(byte[] archive) + { + ArgumentNullException.ThrowIfNull(archive); + if (archive.Length % Block != 0 || archive.Length < Block * 2) + throw new InvalidOperationException("Not a bundle archive."); + var entries = new List(); + var offset = 0; + while (true) + { + if (offset + Block > archive.Length) + throw new InvalidOperationException("The archive ends without its terminator."); + var head = archive.AsSpan(offset, Block); + if (head.IndexOfAnyExcept((byte)0) < 0) + { + if (offset + Block * 2 != archive.Length) + throw new InvalidOperationException("Data after the archive terminator."); + if (archive.AsSpan(offset + Block, Block).IndexOfAnyExcept((byte)0) >= 0) + throw new InvalidOperationException("The archive terminator is not zero."); + return entries; + } + var end = head[..NameLength].IndexOf((byte)0); + var nameBytes = end < 0 ? head[..NameLength] : head[..end]; + var path = Encoding.ASCII.GetString(nameBytes); + var sizeField = Encoding.ASCII.GetString(head.Slice(124, 11)).TrimEnd('\0'); + if (!IsOctal(sizeField)) + throw new InvalidOperationException("Bad entry size."); + var size = Convert.ToInt64(sizeField, 8); + if (size < 0 || size > int.MaxValue) + throw new InvalidOperationException("Bad entry size."); + var expected = Header(path, size); + if (!head.SequenceEqual(expected.AsSpan())) + throw new InvalidOperationException("Entry " + path + " is not a plain regular file header."); + var start = offset + Block; + if (start + size > archive.Length) + throw new InvalidOperationException("Entry " + path + " is truncated."); + var bytes = archive.AsSpan(start, (int)size).ToArray(); + var padded = (int)((size + Block - 1) / Block * Block); + if (start + padded > archive.Length) + throw new InvalidOperationException("Entry " + path + " has no padding."); + for (var index = start + (int)size; index < start + padded; index++) + if (archive[index] != 0) + throw new InvalidOperationException("Entry " + path + " has non-zero padding."); + entries.Add(new Entry(path, bytes)); + offset = start + padded; + } + } + + private static bool IsOctal(string text) => text.Length > 0 && text.All(character => character is >= '0' and <= '7'); + + private static byte[] Header(string path, long size) + { + if (!System.Text.RegularExpressions.Regex.IsMatch(path, @"^[A-Za-z0-9_][A-Za-z0-9._/-]*$", System.Text.RegularExpressions.RegexOptions.CultureInvariant) + || path.Contains("..", StringComparison.Ordinal) || path.Contains("//", StringComparison.Ordinal) || path.EndsWith('/')) + throw new InvalidOperationException("Bad archive path: " + path); + var name = Encoding.ASCII.GetBytes(path); + if (name.Length > NameLength) + throw new InvalidOperationException("Path too long for a plain tar header: " + path); + var head = new byte[Block]; + name.CopyTo(head, 0); + WriteOctal(head, 100, 8, 0x1A4); // 0644 + WriteOctal(head, 108, 8, 0); + WriteOctal(head, 116, 8, 0); + WriteOctal(head, 124, 12, size); + WriteOctal(head, 136, 12, 0); + for (var index = 148; index < 156; index++) + head[index] = 0x20; + head[156] = (byte)'0'; + Encoding.ASCII.GetBytes("ustar\0").CopyTo(head, 257); + Encoding.ASCII.GetBytes("00").CopyTo(head, 263); + long sum = 0; + foreach (var value in head) + sum += value; + Encoding.ASCII.GetBytes(Convert.ToString(sum, 8).PadLeft(6, '0') + "\0 ").CopyTo(head, 148); + return head; + } + + private static void WriteOctal(byte[] head, int offset, int length, long value) + { + var text = Convert.ToString(value, 8); + if (text.Length >= length) + throw new InvalidOperationException("Value does not fit its tar field."); + Encoding.ASCII.GetBytes(text.PadLeft(length - 1, '0') + "\0").CopyTo(head, offset); + } +} diff --git a/tools/ArcForges.Web.Tooling/Program.cs b/tools/ArcForges.Web.Tooling/Program.cs new file mode 100644 index 0000000..ab8633f --- /dev/null +++ b/tools/ArcForges.Web.Tooling/Program.cs @@ -0,0 +1,208 @@ +// SPDX-License-Identifier: AGPL-3.0-only +using ArcForges.Web.Site; +using ArcForges.Web.Tooling.Candidate; +using ArcForges.Web.Tooling.Profiles; + +namespace ArcForges.Web.Tooling; + +/// +/// Build tooling entry point of the C# Web. The TypeScript tooling ports here in steps; every command not listed below is +/// refused with exit code 2. Exit code 1 is a failed verification, 2 a usage error. +/// +public static class Program +{ + private const string SiteBuildUsage = "usage: site build --out [--source-ref <40-hex lower-case commit>]"; + private const string CandidateBuildUsage = + "usage: candidate build --out --repo --source-ref <40-hex> --version --identity --worker [--dirty true|false]"; + private const string CandidateVerifyUsage = "usage: candidate verify --dir [--repo ] [--expected-source <40-hex>]"; + private const string ProfilesBundleUsage = "usage: profiles bundle --publish --out "; + private const string ProfilesVerifyUsage = "usage: profiles verify --bundle [--expected-digest ]"; + private const string ProfilesBudgetUsage = "usage: profiles budget --publish --budgets "; + + /// Runs a ported command: site build, candidate build|verify and profiles bundle|verify|budget. + public static async Task Main(string[] args) + { + try + { + if (args is ["site", "build", .. var site]) + return await SiteBuildAsync(site).ConfigureAwait(false); + if (args is ["candidate", "build", .. var build]) + return await CandidateBuildAsync(build).ConfigureAwait(false); + if (args is ["candidate", "verify", .. var verify]) + return await CandidateVerifyAsync(verify).ConfigureAwait(false); + if (args is ["profiles", "bundle", .. var bundle]) + return ProfilesBundle(bundle); + if (args is ["profiles", "verify", .. var check]) + return ProfilesVerify(check); + if (args is ["profiles", "budget", .. var budget]) + return ProfilesBudget(budget); + } + catch (InvalidOperationException error) + { + Console.Error.WriteLine("Verification failed: " + error.Message); + return 1; + } + catch (ArgumentException error) + { + Console.Error.WriteLine(error.Message); + return 2; + } + + Console.Error.WriteLine( + "ArcForges.Web.Tooling: unknown or unported command. Ported commands: site build, candidate build, candidate verify, profiles bundle, profiles verify."); + return 2; + } + + /// Builds the static Site and writes every file to a directory that must not exist yet. + private static async Task SiteBuildAsync(string[] options) + { + if (!TryParse(options, ["--out", "--source-ref"], out var values)) + { + Console.Error.WriteLine(SiteBuildUsage); + return 2; + } + if (!values.TryGetValue("--out", out var outDirectory) || string.IsNullOrWhiteSpace(outDirectory)) + { + Console.Error.WriteLine(SiteBuildUsage); + return 2; + } + values.TryGetValue("--source-ref", out var sourceRef); + + // Fail closed: a stale file from an earlier build must never be published by a later one. + var root = Path.GetFullPath(outDirectory); + if (Directory.Exists(root) || File.Exists(root)) + { + Console.Error.WriteLine("Refusing to write into an existing path: " + root); + return 2; + } + + var output = await SiteBuilder.BuildAsync(new SiteOptions { SourceRef = sourceRef }).ConfigureAwait(false); + Directory.CreateDirectory(root); + foreach (var file in output.Files) + { + var target = Path.GetFullPath(Path.Combine(root, file.Path)); + if (!target.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.Ordinal)) + throw new InvalidOperationException("Site path escapes the output directory: " + file.Path); + Directory.CreateDirectory(Path.GetDirectoryName(target)!); + await File.WriteAllBytesAsync(target, file.Content).ConfigureAwait(false); + } + + Console.WriteLine($"Wrote {output.Files.Count} Site files to {root}"); + return 0; + } + + /// Builds the sealed public candidate (the C# Site, its identity, legal files, Worker, SBOMs and receipt). + private static async Task CandidateBuildAsync(string[] options) + { + if (!TryParse(options, ["--out", "--repo", "--source-ref", "--version", "--identity", "--worker", "--dirty"], out var values) + || !Has(values, "--out", "--repo", "--source-ref", "--version", "--identity", "--worker")) + { + Console.Error.WriteLine(CandidateBuildUsage); + return 2; + } + var dirty = false; + if (values.TryGetValue("--dirty", out var dirtyText)) + { + if (dirtyText is not ("true" or "false")) + { + Console.Error.WriteLine(CandidateBuildUsage); + return 2; + } + dirty = dirtyText == "true"; + } + var root = Path.GetFullPath(values["--out"]); + if (Directory.Exists(root) || File.Exists(root)) + throw new ArgumentException("Refusing to write into an existing path: " + root); + + var members = await CandidateBuilder.BuildAsync(new CandidateOptions + { + Repository = Path.GetFullPath(values["--repo"]), + SourceRef = values["--source-ref"], + Version = values["--version"], + Dirty = dirty, + Identity = await File.ReadAllBytesAsync(values["--identity"]).ConfigureAwait(false), + Worker = await File.ReadAllBytesAsync(values["--worker"]).ConfigureAwait(false), + }).ConfigureAwait(false); + CandidateBuilder.Write(root, members); + Console.WriteLine($"Wrote candidate {values["--version"]} {values["--source-ref"]} ({members.Count} members) to {root}"); + return 0; + } + + /// Verifies a sealed candidate. The repository, when given, binds the licence, notices, SBOM and policy receipt. + private static async Task CandidateVerifyAsync(string[] options) + { + if (!TryParse(options, ["--dir", "--repo", "--expected-source"], out var values) || !Has(values, "--dir")) + { + Console.Error.WriteLine(CandidateVerifyUsage); + return 2; + } + values.TryGetValue("--repo", out var repository); + values.TryGetValue("--expected-source", out var expectedSource); + if (expectedSource is not null && !System.Text.RegularExpressions.Regex.IsMatch(expectedSource, "^[a-f0-9]{40}$")) + throw new ArgumentException("The expected source must be a 40-character lower-case commit id."); + var summary = await CandidateVerifier.VerifyAsync( + Path.GetFullPath(values["--dir"]), + repository is null ? null : Path.GetFullPath(repository), + expectedSource).ConfigureAwait(false); + Console.WriteLine($"Candidate verified: {summary.Version} {summary.Source} ({summary.Members} members{(summary.Dirty ? ", local changes; not deployable" : "")})"); + return 0; + } + + /// Builds the Web profile bundle from a publish of ArcForges.Web.App and writes it to the output directory. + private static int ProfilesBundle(string[] options) + { + if (!TryParse(options, ["--publish", "--out"], out var values) || !Has(values, "--publish", "--out")) + { + Console.Error.WriteLine(ProfilesBundleUsage); + return 2; + } + var bundle = ProfileBundle.Build(values["--publish"]); + ProfileBundle.Write(values["--out"], bundle); + Console.WriteLine($"Profile bundle {bundle.Name}: {bundle.Members} members, {bundle.Archive.Length} bytes"); + return 0; + } + + /// Verifies a profile bundle archive against its own name, layout, manifest and reviewed rules. + private static int ProfilesVerify(string[] options) + { + if (!TryParse(options, ["--bundle", "--expected-digest"], out var values) || !Has(values, "--bundle")) + { + Console.Error.WriteLine(ProfilesVerifyUsage); + return 2; + } + values.TryGetValue("--expected-digest", out var expectedDigest); + var summary = ProfileBundle.Verify(File.ReadAllBytes(values["--bundle"]), expectedDigest); + Console.WriteLine($"Profile bundle verified: {summary.Name} ({summary.Members} members)"); + return 0; + } + + /// Measures the published application and enforces the reviewed profile budgets. A breach exits with code 1. + private static int ProfilesBudget(string[] options) + { + if (!TryParse(options, ["--publish", "--budgets"], out var values) || !Has(values, "--publish", "--budgets")) + { + Console.Error.WriteLine(ProfilesBudgetUsage); + return 2; + } + var budgets = ProfileBudgets.Parse(File.ReadAllBytes(values["--budgets"])); + var measure = ProfileBudget.Measure(values["--publish"]); + Console.Write(ProfileBudget.Check(measure, budgets)); + Console.WriteLine("Profile budgets passed."); + return 0; + } + + /// Parses --name value pairs. Each name must be allowed once; a bare value or a repeated name fails. + private static bool TryParse(string[] args, string[] allowed, out Dictionary values) + { + values = new Dictionary(StringComparer.Ordinal); + for (var index = 0; index < args.Length; index += 2) + { + if (index + 1 >= args.Length || !allowed.Contains(args[index], StringComparer.Ordinal) || values.ContainsKey(args[index])) + return false; + values[args[index]] = args[index + 1]; + } + return true; + } + + private static bool Has(Dictionary values, params string[] names) => names.All(values.ContainsKey); +} diff --git a/tools/ArcForges.Web.Tooling/packages.lock.json b/tools/ArcForges.Web.Tooling/packages.lock.json new file mode 100644 index 0000000..a7e5ef9 --- /dev/null +++ b/tools/ArcForges.Web.Tooling/packages.lock.json @@ -0,0 +1,54 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "ArcForges.Build.Policy": { + "type": "Direct", + "requested": "[1.0.0-ci.94.1, )", + "resolved": "1.0.0-ci.94.1", + "contentHash": "PCpeEXikk2m87zhMF6L8tbHe2QrI87mO4Nc5lJBenEDhOePwwV4TkArc6IaM1G11Pm8ojVjurLAIoPW22ozb/A==" + }, + "ArcForges.Contracts.Foundation": { + "type": "Transitive", + "resolved": "1.0.0-ci.287.1", + "contentHash": "Jwe5Z6K57vDsKmcxQFbbwyQZQUQsOkzYH26ogQIRb/biRtaGxLCtOsNg4felCuNTJt9bwRmC6AGx3eJyCoCk/g==", + "dependencies": { + "Google.Protobuf": "3.36.1" + } + }, + "Google.Protobuf": { + "type": "Transitive", + "resolved": "3.36.1", + "contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ==" + }, + "Grpc.Core.Api": { + "type": "Transitive", + "resolved": "2.84.0", + "contentHash": "p2SOMl6q/GZ4/5MLkgboC/z55g4zKUEruRn/g46QgppjQBVnsLZVBU/9VO7n60ll38G3Eo8Zu9b7x+W01GKFVg==" + }, + "arcforges.web.site": { + "type": "Project", + "dependencies": { + "ArcForges.Web.Ui": "[1.0.0, )" + } + }, + "arcforges.web.ui": { + "type": "Project", + "dependencies": { + "ArcForges.Contracts.PublicApi": "[1.0.0-ci.287.1, )" + } + }, + "ArcForges.Contracts.PublicApi": { + "type": "CentralTransitive", + "requested": "[1.0.0-ci.287.1, )", + "resolved": "1.0.0-ci.287.1", + "contentHash": "/chAZbT/NTs4yyQBV5dgh37/dWBTKt3PHCikIidI9T90LheydsJoCWxKHGC6Tjzw0rqA62rYxnQfXGzKhTea5Q==", + "dependencies": { + "ArcForges.Contracts.Foundation": "1.0.0-ci.287.1", + "Google.Protobuf": "3.36.1", + "Grpc.Core.Api": "2.84.0" + } + } + } + } +} diff --git a/tsconfig.json b/tsconfig.json index de733f1..0d6da7e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,11 +1,10 @@ { "compilerOptions": { "target": "ES2023", - "lib": ["ES2023", "DOM", "DOM.Iterable"], + "lib": ["ES2023", "DOM"], "module": "ESNext", "moduleResolution": "Bundler", - "jsx": "react-jsx", - "types": ["node", "vite/client"], + "types": ["node"], "strict": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, @@ -13,8 +12,6 @@ "noUnusedParameters": true, "verbatimModuleSyntax": true, "allowImportingTsExtensions": true, - "allowJs": true, - "checkJs": true, "erasableSyntaxOnly": true, "resolveJsonModule": true, "noEmit": true, @@ -23,10 +20,7 @@ "include": [ "tooling/**/*.ts", "tests/**/*.ts", - "tests/**/*.tsx", - "packages/ui/**/*.ts", - "packages/ui/**/*.tsx", - "worker/**/*.js", + "worker/**/*.ts", "*.ts" ] } diff --git a/vitest.config.ts b/vitest.config.ts deleted file mode 100644 index 2f6a224..0000000 --- a/vitest.config.ts +++ /dev/null @@ -1,5 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -import { defineConfig } from "vitest/config"; -export default defineConfig({ - test: { include: ["tests/unit/**/*.test.{ts,tsx}"], environment: "node", restoreMocks: true }, -}); diff --git a/win.slnx b/win.slnx index 87eda03..5aca624 100644 --- a/win.slnx +++ b/win.slnx @@ -1,6 +1,19 @@ - - - - + + + + + + + + + + + + + + + + + diff --git a/worker/index.js b/worker/index.js deleted file mode 100644 index bd29f05..0000000 --- a/worker/index.js +++ /dev/null @@ -1,18 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -export default { - /** - * @param {Request} request - * @param {{ ASSETS: { fetch(request: Request): Promise } }} env - * @returns {Promise} - */ - async fetch(request, env) { - const url = new URL(request.url); - if (url.hostname === "www.arcforges.com") { - url.protocol = "https:"; - url.hostname = "arcforges.com"; - url.port = ""; - return Response.redirect(url.href, 308); - } - return env.ASSETS.fetch(request); - }, -}; diff --git a/worker/index.ts b/worker/index.ts new file mode 100644 index 0000000..584561e --- /dev/null +++ b/worker/index.ts @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Canonical-host adapter. Its JavaScript form is emitted into the candidate as worker/index.js +// by tooling/project.ts (Node type stripping, no bundling); the behaviour below is the reviewed source. +interface Env { + ASSETS: { fetch(request: Request): Promise }; +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + if (url.hostname === "www.arcforges.com") { + url.protocol = "https:"; + url.hostname = "arcforges.com"; + url.port = ""; + return Response.redirect(url.href, 308); + } + return env.ASSETS.fetch(request); + }, +}; diff --git a/wrangler.json b/wrangler.json index 3d03917..821dee8 100644 --- a/wrangler.json +++ b/wrangler.json @@ -7,7 +7,7 @@ "workers_dev": false, "preview_urls": false, "assets": { - "directory": "./apps/site/build/client", + "directory": "./artifacts/site", "binding": "ASSETS", "run_worker_first": true, "html_handling": "auto-trailing-slash",