From 232da7662d1fde3622343263f00711a58439e474 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 10:48:24 +0000 Subject: [PATCH 1/4] feat: Blazor WebAssembly and BFF support, pack-time version stamp, release 1.2.0 - Detect Blazor WebAssembly clients, Razor class libraries, Blazor hosts and BFF hosts, with client defaults, PurviewBrowserSafe libraries, the PRBLZ0001 reference check and the PDS0008/PDS0009 BFF cookie analyzers. - Fix the bootstrapped global.json version: Defaults.props set a literal before Sdk.props could detect the restored package version, so detection never ran. The fallback is now stamped into Props/PackedVersion.props at pack time. - Add `just version-sync` for the doc pins; PackagedVersionFallbackTests names each stale pin as file:line. - Release 1.2.0: changelog, shipped analyzer rules, pack validation of the new packed files. --- .config/purview-build.json | 2 + AGENTS.md | 7 + CHANGELOG.md | 54 +- Justfile | 8 + README.md | 101 +++- docs/wiki/Analyzers.md | 23 + docs/wiki/Configuration-Reference.md | 17 +- docs/wiki/Getting-Started.md | 2 +- docs/wiki/Project-Type-Detection.md | 33 + docs/wiki/Repository-Bootstrap.md | 2 +- package.json | 2 +- scripts/sync-version.ts | 40 ++ .../purview-buildsdk-configuration/SKILL.md | 9 +- .../references/properties.md | 24 +- .../SKILL.md | 27 +- .../references/blazor.md | 105 ++++ .../references/detection.md | 4 + .../purview-buildsdk-project-layout/SKILL.md | 29 +- src/src/Analyzers/AnalyzerReleases.Shipped.md | 9 + .../Bff/BffAuthenticationCookieAnalyzer.cs | 161 +++++ src/src/Analyzers/BuildPropertyKeys.cs | 2 + src/src/BuildSdk/BuildSdk.csproj | 46 ++ src/src/BuildSdk/Sdk/Props/Defaults.props | 18 +- src/src/BuildSdk/Sdk/Sdk.props | 112 +++- src/src/BuildSdk/Sdk/Sdk.targets | 2 + src/src/BuildSdk/Sdk/Targets/Blazor.targets | 262 ++++++++ .../AnalyzerTestInfrastructure.cs | 12 +- .../BffAuthenticationCookieAnalyzerTests.cs | 262 ++++++++ .../BlazorFixtures.cs | 135 +++++ .../BlazorProjectDetectionTests.cs | 570 ++++++++++++++++++ .../BlazorProjectIdentityTests.cs | 68 +++ .../BlazorWebAppTests.cs | 188 ++++++ .../BrowserSafeLibraryTests.cs | 251 ++++++++ .../BrowserSafeReferenceCheckTests.cs | 209 +++++++ .../CompilerVisiblePropertyTests.cs | 11 + .../PackagedVersionFallbackTests.cs | 193 +++++- templates/global.json | 2 +- 37 files changed, 2960 insertions(+), 42 deletions(-) create mode 100644 scripts/sync-version.ts create mode 100644 skills/purview-buildsdk-project-detection/references/blazor.md create mode 100644 src/src/Analyzers/Bff/BffAuthenticationCookieAnalyzer.cs create mode 100644 src/src/BuildSdk/Sdk/Targets/Blazor.targets create mode 100644 src/tests/Analyzers.IntegrationTests/Bff/BffAuthenticationCookieAnalyzerTests.cs create mode 100644 src/tests/BuildSdk.IntegrationTests/BlazorFixtures.cs create mode 100644 src/tests/BuildSdk.IntegrationTests/BlazorProjectDetectionTests.cs create mode 100644 src/tests/BuildSdk.IntegrationTests/BlazorProjectIdentityTests.cs create mode 100644 src/tests/BuildSdk.IntegrationTests/BlazorWebAppTests.cs create mode 100644 src/tests/BuildSdk.IntegrationTests/BrowserSafeLibraryTests.cs create mode 100644 src/tests/BuildSdk.IntegrationTests/BrowserSafeReferenceCheckTests.cs diff --git a/.config/purview-build.json b/.config/purview-build.json index 8f2fc36..732f2bd 100644 --- a/.config/purview-build.json +++ b/.config/purview-build.json @@ -18,9 +18,11 @@ "README.md", "Sdk/.editorconfig", "Sdk/Props/Defaults.props", + "Sdk/Props/PackedVersion.props", "Sdk/Props/VersionDetection.props", "Sdk/Sdk.props", "Sdk/Sdk.targets", + "Sdk/Targets/Blazor.targets", "Sdk/Targets/VersionDetection.targets" ], "purview.skills.testing": [ diff --git a/AGENTS.md b/AGENTS.md index 0c9a710..bacd71a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -279,6 +279,13 @@ Prefer `just` tasks: - `just pack` - `just skills-validate` — the pack-time check over the authored skills - `just skills-add ` — install a skill from another repository into `.agents/skills/` +- `just version-sync` — rewrite the `Purview.BuildSdk` pins in the docs and `templates/global.json` + after bumping `package.json` + +Nothing packed carries a hand-maintained version. The `.editorconfig` marker and +`Props/PackedVersion.props` (the `global.json` fallback when the SDK is not in a versioned package +folder) are stamped at pack time from `$(PackageVersion)`. Only the copy-paste pins in the docs live in +git, and `PackagedVersionFallbackTests` names each stale one as `file:line`. `dotnet` fallback uses `src/BuildSdk.slnx` and `Release`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c7ef75..5f9256e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,51 @@ release summarise what the commits recorded rather than what was written at the ## [Unreleased] +## [1.2.0] - 2026-10-28 + ### Added +- **Blazor WebAssembly clients, Blazor hosts and BFF hosts are detected.** `IsBlazorWebAssemblyProject` + (`Microsoft.NET.Sdk.BlazorWebAssembly`), `IsRazorClassLibraryProject` (`Microsoft.NET.Sdk.Razor`), + `IsBffProject` (a web SDK project named `*Bff`, or one setting `IsBffProject=true`) and + `IsBlazorHostProject` (a web SDK project referencing a WebAssembly client) are all compiler-visible. + A host never implies a BFF, because a host that keeps no tokens is not one. A host `` gets an + automatic reference to its `.Client` unless it declares one (`PurviewAutoBlazorClientReference=false` + opts out); any other pairing is resolved during the build by `ResolvePurviewBlazorHostProject`. + +- **WebAssembly client defaults.** A client is `IsPackable=false` unless it says otherwise, gets no + container defaults even beside a `Dockerfile` (`PublishAot` on a client is a publish that fails), and + no automatic `../Shared*` reference, so a server-side library cannot join the browser download because + of a folder name. Globalisation, trimming and the Blazor runtime settings are left to the Blazor SDK. + `DisablePurviewBlazorWebAssemblyDefaults=true` restores the generic defaults. + +- **`PurviewBrowserSafe` makes a library the browser downloads fail the build on what would fail at + runtime.** On a class library it applies `IsAotCompatible=true`, `` + and `WarningsAsErrors` for the trim, single-file, AOT and `CA1416` ids (`PurviewBrowserSafeWarningCodes`) + — each only where the project set nothing, and without `TreatWarningsAsErrors`, so the rest of its + warning policy is untouched. `PurviewBrowserSafeWarningsAsErrors=false` keeps them as warnings. + +- **`PRBLZ0001`: every library a client downloads must have been declared browser-safe.** A client, and + each browser-safe library, asks every `ProjectReference` for its `PurviewBrowserSafe` value and reports + the ones that do not set it, so the guarantee covers the whole download rather than its first level. + `PurviewBrowserSafe="true"` on the `ProjectReference` records the decision for a project that does not + use this SDK; `PurviewBrowserSafeReferenceCheckAsError=true` makes it an error and + `DisablePurviewBrowserSafeReferenceCheck=true` turns it off. + +- **BFF hosts.** No packages are added — YARP, OpenID Connect and any BFF library are the application's + choice — and the OpenAPI interceptor namespace is dropped, because a BFF that only forwards has no + document to generate. Referencing `Microsoft.AspNetCore.OpenApi`, or `PurviewBffExposesApi=true`, + keeps it. + +- **`PDS0008` and `PDS0009` report a BFF authentication cookie that undoes the BFF.** `HttpOnly = false` + lets script in the page read and replay the session; `SameSite = None` makes every BFF endpoint a + cross-site request forgery target. Only a constant assigned to the `CookieAuthenticationOptions` cookie + in a BFF project is reported — never the antiforgery cookie or the OpenID Connect correlation and nonce + cookies, which legitimately differ. + +- **`just version-sync`** rewrites the `Purview.BuildSdk` pins in the docs and `templates/global.json` + from `package.json`. + - **`CA1034` is suppressed on an `extension` block (`PDS0007`).** A C# 14 extension block is compiled into a nested type inside its owning static class, so `CA1034` ("nested types should not be visible") fires on every one, reported at the `extension` keyword and naming nothing because the generated type @@ -36,6 +79,14 @@ release summarise what the commits recorded rather than what was written at the ### Fixed +- **A bootstrapped `global.json` now pins the SDK version that was actually restored.** + `Defaults.props` is imported before `Sdk.props` detects the version from the restored package folder, + and it set a hand-maintained literal, so detection never ran: every bootstrapped `global.json` pinned + whatever was last typed into `Defaults.props`, which went stale on each bump. The literal is gone. The + folder name wins, and an SDK imported from anywhere else falls back to `Props/PackedVersion.props`, + stamped from `$(PackageVersion)` at pack time. The remaining copy-paste pins in the docs are rewritten + by `just version-sync`, and `PackagedVersionFallbackTests` names each stale one as `file:line`. + - **Pack-time skill validation now validates the repository it is packing.** `ValidatePurviewSkills` read the evaluation-time `PurviewSkillsSourceFolder`, which is resolved from `RepoRoot` and an `AGENTS.md` walk-up. On a CI runner `RepoRoot` is taken from `GITHUB_WORKSPACE`, and it can also arrive stale from @@ -210,7 +261,8 @@ release summarise what the commits recorded rather than what was written at the - `.editorconfig` values missing test projects. - Source-generator resolution on pack. -[Unreleased]: https://github.com/purview-dev/build-sdk/compare/v1.1.0...HEAD +[Unreleased]: https://github.com/purview-dev/build-sdk/compare/v1.2.0...HEAD +[1.2.0]: https://github.com/purview-dev/build-sdk/compare/v1.1.0...v1.2.0 [1.1.0]: https://github.com/purview-dev/build-sdk/compare/v1.0.7...v1.1.0 [1.0.7]: https://github.com/purview-dev/build-sdk/compare/v1.0.6...v1.0.7 [1.0.6]: https://github.com/purview-dev/build-sdk/compare/v1.0.5...v1.0.6 diff --git a/Justfile b/Justfile index ba77fb0..35088f0 100644 --- a/Justfile +++ b/Justfile @@ -88,6 +88,14 @@ restore *args: current_version: echo "==> Current version: {{ GREEN }}{{ current_version }}{{ NORMAL }} (defined in package.json and automatically included in the build output through the Purview.BuildSdk package)" +# Rewrite the Purview.BuildSdk pins in the docs and starter template to the version in package.json +# +# Run after bumping package.json. Packed files are stamped at pack time and need nothing. +[group('Build and Test')] +version-sync: + echo "==> Syncing version pins to {{ GREEN }}{{ current_version }}{{ NORMAL }}" + bun scripts/sync-version.ts + # Run tests for a specific project with a filter (e.g., "/*/*/*/*/", or "/*/*/*/*[Category=Unit]" to run just unit tests) and configuration (e.g., "Release") [group('Build and Test')] test filter="/*/*/*/*/" *args: diff --git a/README.md b/README.md index 9549048..b09af0b 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,8 @@ A reusable MSBuild SDK NuGet package that delivers standardised .NET project def | Feature | Detail | | -- | -- | -| **Project type detection** | `IsCSharpProject`, `IsTestProject`, `IsSharedTestingProject`, `IsContainerProject`, `IsWebSdkProject`, `IsAspireHostProject`, … | +| **Project type detection** | `IsCSharpProject`, `IsTestProject`, `IsSharedTestingProject`, `IsContainerProject`, `IsWebSdkProject`, `IsAspireHostProject`, `IsBlazorWebAssemblyProject`, `IsBffProject`, … | +| **Blazor and BFF** | Blazor WebAssembly clients, Blazor Web App hosts and BFF hosts are detected; `PurviewBrowserSafe` makes a library the client downloads fail the build on trim, AOT and browser-unsupported APIs, and the client checks every reference sets it (see [Blazor WebAssembly and BFF](#blazor-webassembly-and-bff)) | | **C# defaults** | `net10.0` TFM (overridable), `LangVersion=preview`, `Nullable=enable`, `ImplicitUsings=enable`, deterministic builds | | **Code style** | `.editorconfig` baked into the package, applied via `EditorConfigFilePath`, and auto-bootstrapped to repo root if missing; `EnforceCodeStyleInBuild=true`, `EnableNETAnalyzers=true`, `AnalysisLevel=latest`, `AnalysisMode=All`. Style policy: `dotnet_style_require_accessibility_modifiers=omit_if_default`, private instance fields named `_camelCase`, and `IDE0040`/`CA1515`/`CA1852`/`CA1034` at warning or above, enforced by `ValidatePurviewStylePolicy` (opt out with `DisablePurviewStylePolicyValidation=true`) | | **NuGet packaging** | `AssemblyName`/`PackageId` default to the fully evaluated `RootNamespace`; packable projects get `GenerateDocumentationFile=true`, `PublishRepositoryUrl=true`, `IncludeSymbols=true`, `SymbolPackageFormat=snupkg`, `EmbedUntrackedSources=true`, and portable PDBs delivered via `.snupkg` (not the `.nupkg`) | @@ -41,7 +42,7 @@ A reusable MSBuild SDK NuGet package that delivers standardised .NET project def "runner": "Microsoft.Testing.Platform" }, "msbuild-sdks": { - "Purview.BuildSdk": "1.1.0" + "Purview.BuildSdk": "1.2.0" } } ``` @@ -200,7 +201,7 @@ to a low-importance message) and only a copy that still fails after every retry The bootstrapped `.editorconfig` opens with a version marker: ```editorconfig -# Purview.BuildSdk 1.1.0 - managed file, do not edit +# Purview.BuildSdk 1.2.0 - managed file, do not edit ``` That line is how the SDK recognises a file **it** wrote. When the SDK version changes, a file carrying @@ -392,7 +393,7 @@ Non-packable projects (including web applications) default `WarnOnPackingNonPack | `RepositoryEditorConfigFilePath` | *(auto-detected)* | Override the destination path for the bootstrapped `.editorconfig`. | | `BootstrapGlobalJsonToRepoRoot` | `true` | Creates a `global.json` at the repository root when missing. | | `RepositoryGlobalJsonFilePath` | *(auto-detected)* | Override the destination path for the bootstrapped `global.json`. | -| `PurviewBuildSdkVersionForGlobalJson` | *(auto-detected or `1.0.0` fallback)* | Version written to the `msbuild-sdks.Purview.BuildSdk` entry in a bootstrapped `global.json`. | +| `PurviewBuildSdkVersionForGlobalJson` | *(restored package version, else the version it was packed as)* | Version written to the `msbuild-sdks.Purview.BuildSdk` entry in a bootstrapped `global.json`. | | `PurviewRepoBootstrapMode` | `IfMissing` | `IfMissing` never touches an existing file, `Always` overwrites it, `WarnOnDrift` reports a file that differs from the SDK-provided one, and `Never` skips bootstrapping. | | `PurviewRepoBootstrapCopyRetries` | `3` | Write attempts before a bootstrap write failure is reported. | | `PurviewRepoBootstrapCopyRetryDelayMilliseconds` | `500` | Base delay between bootstrap write attempts. | @@ -496,12 +497,23 @@ The SDK now exports its properties via `CompilerVisibleProperty`, so analyzers a | `IsWebSdkProject` | True when `SdkProjectName` is `Microsoft.NET.Sdk.Web`. | | `IsWorkerSdkProject` | True when `SdkProjectName` is `Microsoft.NET.Sdk.Worker`. | | `IsAspireHostProject` | True when SDK starts with `Aspire.Sdk.Host`. | +| `IsBlazorWebAssemblyProject` | True when `SdkProjectName` is `Microsoft.NET.Sdk.BlazorWebAssembly`. | +| `IsRazorClassLibraryProject` | True when `SdkProjectName` is `Microsoft.NET.Sdk.Razor`. | +| `IsBlazorHostProject` | True for a web SDK project that references a Blazor WebAssembly client. | +| `IsBffProject` | True for a web SDK project named `*Bff`, or that sets `IsBffProject=true`. | +| `PurviewBrowserSafe` | When `true` on a class library, applies the browser-safe defaults. | +| `PurviewBrowserSafeWarningsAsErrors` | When `false`, browser-safe trim and AOT diagnostics stay warnings. | +| `DisablePurviewBrowserSafeReferenceCheck` | Turns off `PRBLZ0001`. | +| `PurviewBrowserSafeReferenceCheckAsError` | Reports `PRBLZ0001` as an error. | +| `DisablePurviewBlazorWebAssemblyDefaults` | Keeps the generic defaults on a WebAssembly client. | +| `PurviewAutoBlazorClientReference` | When `false`, a host does not reference its `.Client` automatically. | +| `PurviewBffExposesApi` | Whether a BFF keeps the OpenAPI interceptor namespace. | | `EditorConfigFilePath` | Path to the SDK-provided `.editorconfig` that is injected into `@(EditorConfigFiles)`. | | `RepositoryEditorConfigFilePath` | Destination path for bootstrapping a physical repo-level `.editorconfig` (defaults to git repo root; falls back to `Directory.Build.props` directory). | | `BootstrapEditorConfigToRepoRoot` | When `true` (default), copies the SDK `.editorconfig` to `RepositoryEditorConfigFilePath` if missing. | | `RepositoryGlobalJsonFilePath` | Destination path for bootstrapping a physical repo-level `global.json` (defaults to git repo root; falls back to `Directory.Build.props` directory). | | `BootstrapGlobalJsonToRepoRoot` | When `true` (default), creates `global.json` at `RepositoryGlobalJsonFilePath` if missing. | -| `PurviewBuildSdkVersionForGlobalJson` | Version used for `msbuild-sdks.Purview.BuildSdk` when bootstrapping `global.json` (auto-detected from SDK package path, fallback `1.0.0`). | +| `PurviewBuildSdkVersionForGlobalJson` | Version used for `msbuild-sdks.Purview.BuildSdk` when bootstrapping `global.json` (auto-detected from SDK package path, falling back to the version stamped in at pack time). | | `DisableAutoCopySdkFiles` | When `true`, disables SDK auto-copy/bootstrap for repo files (`.editorconfig`, `global.json`). | | `PurviewRepoBootstrapMode` | Controls repo file bootstrapping: `IfMissing` (default), `Always`, `WarnOnDrift` or `Never`. | | `PurviewRepoBootstrapCopyRetries` | Number of write attempts before a bootstrap failure is reported (default `3`). | @@ -543,6 +555,83 @@ The SDK now exports its properties via `CompilerVisibleProperty`, so analyzers a --- +## Blazor WebAssembly and BFF + +Every behaviour here applies only to the project type it names. A project that is not a WebAssembly +client, a Blazor host, a BFF or a `PurviewBrowserSafe` library is unchanged. + +### Detection + +| Signal | Result | +| -- | -- | +| `Sdk="Microsoft.NET.Sdk.BlazorWebAssembly"` | `IsBlazorWebAssemblyProject=true` | +| `Sdk="Microsoft.NET.Sdk.Razor"` | `IsRazorClassLibraryProject=true` | +| a web SDK project whose name ends with `Bff` (case-sensitive), or that sets `IsBffProject=true` | `IsBffProject=true`; `IsBffProject=false` opts out | +| a web SDK project that references an `IsBlazorWebAssemblyProject` project | `IsBlazorHostProject=true` | + +`IsBlazorHostProject` never implies `IsBffProject`: a host that keeps no tokens is not a BFF. For the +`` (host) / `.Client` (WebAssembly) pairing it is known at evaluation, and the host gets an +automatic reference to the client unless it declares one (`PurviewAutoBlazorClientReference=false` +opts out). A host referencing a client under another name is resolved during the build: +`dotnet msbuild -t:ResolvePurviewBlazorHostProject -getProperty:IsBlazorHostProject`. + +### WebAssembly client defaults + +| Applied | Failure it prevents | +| -- | -- | +| `IsPackable=false` (the project can still set `true`) | packing an app's `wwwroot` as a package nobody can consume | +| no container defaults, even with a `Dockerfile` | `PublishAot` on a client is a publish that fails | +| no automatic `../Shared*` reference | a server-side library joining the browser download because of a folder name | +| `PRBLZ0001` for each `ProjectReference` that does not set `PurviewBrowserSafe` | a library joining the download without anyone deciding it was safe there | + +Globalisation, trimming mode and the Blazor runtime settings are left to the Blazor SDK. +`DisablePurviewBlazorWebAssemblyDefaults=true` (before the SDK import) restores the generic +defaults. The automatic telemetry packages were checked rather than assumed: the generator is +analyzer-only, and its generated code and `Microsoft.Extensions.Telemetry.Abstractions` publish +trim-clean and run on the WebAssembly runtime (an integration test boots it), so nothing is excluded. +The SDK adds no exporter. + +### Browser-safe libraries + +A library a WebAssembly client downloads runs in the browser, where reflection the trimmer cannot +follow and APIs the browser does not support fail at runtime. `true` +on a class library turns those into build failures, applying only what the project has not set: + +- `IsAotCompatible=true`, which turns on `IsTrimmable` and the trim, single-file and AOT analyzers; +- ``, so `CA1416` reports browser-unsupported APIs such as + `System.Diagnostics.Process`; +- `WarningsAsErrors` for each trim, single-file, AOT and `CA1416` id (`PurviewBrowserSafeWarningCodes`), + rather than `TreatWarningsAsErrors`, so the rest of the project's warning policy is untouched. + `PurviewBrowserSafeWarningsAsErrors=false` opts out; `CA1416` then falls back to the repository's + severity (`suggestion` in the shipped `.editorconfig`). + +File and network I/O are not reported - `File` works against the browser's in-memory file system and +`HttpClient` through `fetch`, so neither is annotated as unsupported - and stay a review obligation. + +The client asks every `ProjectReference`, through an MSBuild call on the referenced project, for its +`PurviewBrowserSafe` value and reports `PRBLZ0001` for each one that does not set it. A browser-safe +library runs the same check over its own references, so the guarantee covers the whole download. +`PurviewBrowserSafe="true"` on the `ProjectReference` records the decision for a project that does not +use this SDK; `PurviewBrowserSafeReferenceCheckAsError=true` makes it an error and +`DisablePurviewBrowserSafeReferenceCheck=true` turns it off. + +### BFF hosts + +- No packages are added: YARP, OpenID Connect and any BFF library (Duende.BFF is commercially + licensed) are the application's choice. +- The OpenAPI interceptor namespace is not added, because a BFF that only forwards has no document to + generate. A BFF referencing `Microsoft.AspNetCore.OpenApi`, or setting `PurviewBffExposesApi=true`, + keeps it. +- `PDS0008` reports `HttpOnly = false` and `PDS0009` reports `SameSite = None` on the + `CookieAuthenticationOptions` cookie - not on the antiforgery cookie or the OpenID Connect + correlation and nonce cookies, which legitimately differ. + +A persisted Data Protection key ring is not checked: it is routinely configured in a shared +ServiceDefaults project the BFF's compilation cannot see, so the rule could not be precise. +Anti-forgery on forwarded routes stays a review obligation. + +--- + ## Test project naming conventions Test projects are automatically detected by their suffix. Supported patterns: @@ -779,6 +868,8 @@ copy of the shipped file, a stale copy is reported rather than obeyed: refresh i | `PDS0005` | (suppressor) | — | Suppresses `IDE0130` for files under a project-root `Extensions/` folder, whose namespace is reset by `PDS0002` | | `PDS0006` | (suppressor) | — | Suppresses `CA1815` on a union declaration | | `PDS0007` | (suppressor) | — | Suppresses `CA1034` on an `extension` block | +| `PDS0008` | Security | Warning | A BFF's authentication cookie must not be set `HttpOnly = false` (see [Blazor WebAssembly and BFF](#blazor-webassembly-and-bff)) | +| `PDS0009` | Security | Warning | A BFF's authentication cookie must not be set `SameSite = None` | `PDS0001`, `PDS0005`, `PDS0006` and `PDS0007` are `DiagnosticSuppressor`s rather than analyzers, so they declare `SuppressionDescriptor`s and do not appear in `AnalyzerReleases.Shipped.md` — that file tracks diff --git a/docs/wiki/Analyzers.md b/docs/wiki/Analyzers.md index fe9e7ae..d98646b 100644 --- a/docs/wiki/Analyzers.md +++ b/docs/wiki/Analyzers.md @@ -13,6 +13,29 @@ command-line builds and Visual Studio. | `PDS0005` | (suppressor) | — | Suppresses `IDE0130` for files rooted under `Extensions/` | | `PDS0006` | (suppressor) | — | Suppresses `CA1815` on a union declaration | | `PDS0007` | (suppressor) | — | Suppresses `CA1034` on an `extension` block | +| `PDS0008` | Security | Warning | A BFF's authentication cookie must not be set `HttpOnly = false` | +| `PDS0009` | Security | Warning | A BFF's authentication cookie must not be set `SameSite = None` | + +## PDS0008 / PDS0009 — BFF authentication cookie + +Active only in a project the SDK classifies as a BFF (`build_property.IsBffProject=true`). A BFF keeps +the tokens on the server and hands the browser a session cookie; these rules report the two settings +that undo that: + +- `PDS0008` — `HttpOnly = false`: script running in the page, injected script included, can read the + session and replay it from anywhere. +- `PDS0009` — `SameSite = None`: the browser attaches the session to cross-site requests, so every BFF + endpoint becomes a cross-site request forgery target. + +Only a **constant** assigned to the cookie of `CookieAuthenticationOptions` is reported +(`options.Cookie.HttpOnly = false`, or `Cookie = { SameSite = SameSiteMode.None }`). The antiforgery +cookie, which an SPA legitimately reads, and the OpenID Connect correlation and nonce cookies, which +legitimately need `SameSite=None`, are never reported. A cookie configured in another assembly, or +through a local variable holding the builder, is missed rather than guessed at. + +A persisted Data Protection key ring is deliberately not checked: the `PersistKeysTo*` call usually +lives in a shared ServiceDefaults project the BFF's compilation cannot see, so a rule would report +correctly configured BFFs. Anti-forgery on forwarded routes stays a review obligation. ## Style policy diff --git a/docs/wiki/Configuration-Reference.md b/docs/wiki/Configuration-Reference.md index a17018b..d8a8f05 100644 --- a/docs/wiki/Configuration-Reference.md +++ b/docs/wiki/Configuration-Reference.md @@ -152,7 +152,7 @@ operations skip them silently. | `RepositoryEditorConfigFilePath` | *(auto-detected)* | Override the destination path for the bootstrapped `.editorconfig`. | | `BootstrapGlobalJsonToRepoRoot` | `true` | Creates a `global.json` at the repository root when missing. | | `RepositoryGlobalJsonFilePath` | *(auto-detected)* | Override the destination path for the bootstrapped `global.json`. | -| `PurviewBuildSdkVersionForGlobalJson` | *(auto-detected or `1.0.0` fallback)* | Version written to the `msbuild-sdks.Purview.BuildSdk` entry in a bootstrapped `global.json`. | +| `PurviewBuildSdkVersionForGlobalJson` | *(restored package version, else the version it was packed as)* | Version written to the `msbuild-sdks.Purview.BuildSdk` entry in a bootstrapped `global.json`. | | `PurviewRepoBootstrapMode` | `IfMissing` | `IfMissing` never touches an existing file, `Always` overwrites it, `WarnOnDrift` reports that an existing file differs from the SDK-provided one, and `Never` skips bootstrapping entirely. | | `PurviewRepoBootstrapCopyRetries` | `3` | Copy attempts before a bootstrap write failure is reported. | | `PurviewRepoBootstrapCopyRetryDelayMilliseconds` | `500` | Base delay between bootstrap write attempts. | @@ -181,6 +181,21 @@ consuming repository; that is gone, because the skills CLI covers it. See | `ExcludePurviewTelemetry` | `false` | Set to `true` to exclude `Purview.Telemetry.SourceGenerator` from all projects. | | `ExcludeMSTelemetryExtension` | `false` | Set to `true` to exclude `Microsoft.Extensions.Telemetry.Abstractions`. Only relevant when `ExcludePurviewTelemetry` is also `false` — when `ExcludePurviewTelemetry=true` the whole telemetry group is skipped anyway. | +## Blazor WebAssembly, browser-safe libraries and BFF hosts + +| Property | Default | Description | +| -- | -- | -- | +| `IsBffProject` | `true` for a web SDK project named `*Bff` | Classifies a BFF host. `false` opts out, `true` opts in. | +| `IsBlazorHostProject` | *(computed)* | `true` for a web SDK project referencing a WebAssembly client. `false` opts out. | +| `PurviewAutoBlazorClientReference` | `true` | A host `` references its `.Client` automatically. | +| `DisablePurviewBlazorWebAssemblyDefaults` | `false` | Keeps the generic defaults on a WebAssembly client. Set before the SDK import. | +| `PurviewBrowserSafe` | *(unset)* | `true` on a class library applies `IsAotCompatible`, `SupportedPlatform browser` and the browser-safe `WarningsAsErrors`. | +| `PurviewBrowserSafeWarningsAsErrors` | `true` | `false` keeps trim/AOT diagnostics as warnings. | +| `PurviewBrowserSafeWarningCodes` | `CA1416`, `IL2001`-`IL2128`, `IL3000`-`IL3005`, `IL3050`-`IL3059` | The ids added to `WarningsAsErrors`. | +| `DisablePurviewBrowserSafeReferenceCheck` | `false` | Turns off `PRBLZ0001`. | +| `PurviewBrowserSafeReferenceCheckAsError` | `false` | Reports `PRBLZ0001` as an error. | +| `PurviewBffExposesApi` | `true` if the BFF references `Microsoft.AspNetCore.OpenApi` | Whether a BFF keeps the OpenAPI interceptor namespace. | + ## Testing | Property | Default | Description | diff --git a/docs/wiki/Getting-Started.md b/docs/wiki/Getting-Started.md index fe97d3d..1e20ee4 100644 --- a/docs/wiki/Getting-Started.md +++ b/docs/wiki/Getting-Started.md @@ -14,7 +14,7 @@ Add `Purview.BuildSdk` to the `msbuild-sdks` section so MSBuild can resolve the "runner": "Microsoft.Testing.Platform" }, "msbuild-sdks": { - "Purview.BuildSdk": "1.1.0" + "Purview.BuildSdk": "1.2.0" } } ``` diff --git a/docs/wiki/Project-Type-Detection.md b/docs/wiki/Project-Type-Detection.md index defd0c0..c0fadf7 100644 --- a/docs/wiki/Project-Type-Detection.md +++ b/docs/wiki/Project-Type-Detection.md @@ -21,6 +21,10 @@ wiki. | `IsWorkerSdkProject` | `SdkProjectName` is `Microsoft.NET.Sdk.Worker`. | | `IsAspireHostProject` | `SdkProjectName` starts with `Aspire.Sdk.Host` or `Aspire.AppHost.Sdk`. | | `IsCLIProject` | Project name ends with `CLI`, `Console`, `CommandLine`, `QuickStart`, or `QuickStarts`. | +| `IsBlazorWebAssemblyProject` | `SdkProjectName` is `Microsoft.NET.Sdk.BlazorWebAssembly`. | +| `IsRazorClassLibraryProject` | `SdkProjectName` is `Microsoft.NET.Sdk.Razor`. | +| `IsBffProject` | A web SDK project whose name ends with `Bff` (case-sensitive), or that sets `IsBffProject=true`. `IsBffProject=false` opts out. | +| `IsBlazorHostProject` | A web SDK project that references an `IsBlazorWebAssemblyProject` project - at evaluation for the ``/`.Client` pairing, during the build (`ResolvePurviewBlazorHostProject`) otherwise. Never implies `IsBffProject`. | | `IsRoslynComponent` | `true` is declared in the project file. | | `IsPackable` | `true` is declared in the project file. | | `IsWebProject` | Marker used in SDK web-project behaviour. | @@ -103,6 +107,35 @@ The SDK ships the following full `TestType` set, in addition to the standard `Un - `OutputType=Exe` (when not a test/shared-testing project), `CA1515` suppressed. +### Blazor WebAssembly clients (`IsBlazorWebAssemblyProject=true`) + +- `IsPackable=false` unless the project sets it, no container defaults even with a `Dockerfile`, and no + automatic `../Shared*` reference. `DisablePurviewBlazorWebAssemblyDefaults=true` (before the SDK + import) restores the generic defaults. +- `PRBLZ0001` for each `ProjectReference` that does not set `PurviewBrowserSafe`. +- Globalisation, trimming and the Blazor runtime settings are left to the Blazor SDK. + +### Blazor hosts (`IsBlazorHostProject=true`) + +- A host named `` gets an automatic reference to its `.Client` client unless it declares one. + `PurviewAutoBlazorClientReference=false` opts out. + +### BFF hosts (`IsBffProject=true`) + +- No packages added. No OpenAPI interceptor namespace unless the BFF references + `Microsoft.AspNetCore.OpenApi` or sets `PurviewBffExposesApi=true`. +- `PDS0008`/`PDS0009` on the authentication cookie (see [Analyzers](Analyzers.md)). + +### Browser-safe libraries (`PurviewBrowserSafe=true`) + +- `IsAotCompatible=true`, ``, and the trim, single-file, AOT and + `CA1416` ids in `WarningsAsErrors` - each only where the project set nothing. + `PurviewBrowserSafeWarningsAsErrors=false` keeps them as warnings. +- The same `PRBLZ0001` reference check a client runs. + +The README's [Blazor WebAssembly and BFF](../../README.md#blazor-webassembly-and-bff) section has the +failure each behaviour prevents. + ### Roslyn components (`IsRoslynComponent=true`) - Default `netstandard2.0` target, `LangVersion=latest`, `Nullable=enable`, diff --git a/docs/wiki/Repository-Bootstrap.md b/docs/wiki/Repository-Bootstrap.md index e6c6599..8348e36 100644 --- a/docs/wiki/Repository-Bootstrap.md +++ b/docs/wiki/Repository-Bootstrap.md @@ -62,7 +62,7 @@ file, and honours `PurviewRepoBootstrapMode`: | -- | -- | -- | | `BootstrapGlobalJsonToRepoRoot` | `true` | Creates a `global.json` at the repository root when missing. | | `RepositoryGlobalJsonFilePath` | *(auto-detected)* | Override the destination path for the bootstrapped `global.json`. | -| `PurviewBuildSdkVersionForGlobalJson` | *(auto-detected or `1.0.0` fallback)* | Version written to the `msbuild-sdks.Purview.BuildSdk` entry. | +| `PurviewBuildSdkVersionForGlobalJson` | *(restored package version, else the version it was packed as)* | Version written to the `msbuild-sdks.Purview.BuildSdk` entry. | | `DisableAutoCopySdkFiles` | `false` | Master switch that disables repo-level SDK file bootstrapping. | | `PurviewRepoBootstrapMode` | `IfMissing` | `IfMissing`, `Always`, `WarnOnDrift` or `Never`. | diff --git a/package.json b/package.json index 7d495f9..7dc5c6d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "build-sdk", - "version": "1.1.0", + "version": "1.2.0", "homepage": "https://purview.dev/projects/build-sdk/", "bugs": { "url": "https://github.com/purview-dev/build-sdk/issues" diff --git a/scripts/sync-version.ts b/scripts/sync-version.ts new file mode 100644 index 0000000..c7548d5 --- /dev/null +++ b/scripts/sync-version.ts @@ -0,0 +1,40 @@ +// Rewrites the Purview.BuildSdk version pins that an adopter copies by hand - the quick-start snippets +// and the starter template - to the version in package.json, the single source of truth. +// +// Only text that is copied verbatim lives here. Everything that is packed is stamped at pack time +// instead (see StampPurviewPackedVersionTarget and StampPurviewEditorConfigVersionTarget in +// BuildSdk.csproj), so it never needs syncing. PackagedVersionFallbackTests fails the build if a file +// listed here drifts, and names this script as the fix. +// +// just version-sync + +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +const root = join(import.meta.dir, ".."); +const { version } = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); + +const files = ["README.md", "docs/wiki/Getting-Started.md", "templates/global.json"]; + +const replacements: [RegExp, string][] = [ + // The global.json pin. A '$(...)' property reference documents auto-detection and is not a pin. + [/("Purview\.BuildSdk"\s*:\s*")(?!\$)[^"]+(")/g, `$1${version}$2`], + // The example marker line of a bootstrapped .editorconfig. + [/(# Purview\.BuildSdk )\S+( - managed file)/g, `$1${version}$2`], +]; + +let changed = 0; + +for (const file of files) { + const path = join(root, file); + const before = readFileSync(path, "utf8"); + const after = replacements.reduce((text, [pattern, replacement]) => text.replace(pattern, replacement), before); + + if (after !== before) { + writeFileSync(path, after); + changed++; + console.log(` updated ${file}`); + } +} + +console.log(changed === 0 ? `All pins already at ${version}.` : `Pinned ${changed} file(s) to ${version}.`); diff --git a/skills/purview-buildsdk-configuration/SKILL.md b/skills/purview-buildsdk-configuration/SKILL.md index f0ee715..d82768b 100644 --- a/skills/purview-buildsdk-configuration/SKILL.md +++ b/skills/purview-buildsdk-configuration/SKILL.md @@ -1,6 +1,6 @@ --- name: purview-buildsdk-configuration -description: Configure Purview.BuildSdk from Directory.Build.props or a .csproj. Use when setting NamespacePrefix, choosing a testing or mocking framework, turning telemetry or SourceLink off, controlling version detection from package.json, tuning the repository bootstrap, or working out whether a property must be set before the SDK import. Also use when the SDK "did something automatically" and you need the property that controls it. +description: Configure Purview.BuildSdk from Directory.Build.props or a .csproj. Use when setting NamespacePrefix, choosing a testing or mocking framework, turning telemetry or SourceLink off, controlling version detection from package.json, tuning the repository bootstrap, making a library browser-safe for a Blazor WebAssembly client, configuring a BFF host, or working out whether a property must be set before the SDK import. Also use when the SDK "did something automatically" and you need the property that controls it. license: MIT metadata: package: Purview.BuildSdk @@ -27,6 +27,7 @@ Pre-import properties include: - `EnableAssemblyNameGeneration` - `PurviewSharedTestingOutputType`, `PurviewTestContextNoWarn` - `AutoIncludeUsings` +- `DisablePurviewBlazorWebAssemblyDefaults` - repo-wide testing-framework selection you want every project to inherit Everything else is read in `Sdk.targets` and can be set in a project file. Prefer repo-wide defaults @@ -46,6 +47,9 @@ in `Directory.Build.props` unless the setting is deliberately project-specific. | `ExcludePurviewTelemetry` | `false` | Drops the telemetry source generator | | `DisableSourceLink` | `false` | Drops SourceLink from packable projects | | `EnableAssemblyNameGeneration` | `true` | `AssemblyName`/`PackageId` follow the resolved `RootNamespace` | +| `PurviewBrowserSafe` | *(unset)* | On a class library a WebAssembly client downloads: trim, AOT and browser-unsupported APIs fail the build | +| `IsBffProject` | name ends `Bff` | Classifies a BFF host; `false` opts out | +| `PurviewBrowserSafeReferenceCheckAsError` | `false` | Makes `PRBLZ0001` (a client reference that is not browser-safe) an error | The full set, grouped by area and with the behaviour each one changes, is in [references/properties.md](references/properties.md). Read it rather than guessing a property name — @@ -58,7 +62,8 @@ name: `PurviewLogicalProjectName`, `PurviewNamespacePrefix`, `PurviewProjectShortName`, `PurviewTestType`, `RootNamespace`, `AssemblyName`, `PackageVersion`, `TestingType`, `TargetProjectName`, `RepoRoot`, -`RootPackageJson`. +`RootPackageJson`, `IsBlazorHostProject`, `IsBffProject`, `PurviewBffExposesApi`, `IsAotCompatible`, +`WarningsAsErrors`. Ask MSBuild for one directly: diff --git a/skills/purview-buildsdk-configuration/references/properties.md b/skills/purview-buildsdk-configuration/references/properties.md index 93a0d9b..1fb1f81 100644 --- a/skills/purview-buildsdk-configuration/references/properties.md +++ b/skills/purview-buildsdk-configuration/references/properties.md @@ -81,6 +81,24 @@ forces SourceLink's CI-mode dirty-repository checks. | `SourceLinkPackageName` | `Microsoft.SourceLink.GitHub` | | | `DisableSourceLink` | `false` | | +## Blazor WebAssembly, browser-safe libraries and BFF hosts + +| Property | Default | Behaviour | +| -- | -- | -- | +| `IsBffProject` | `true` for a web SDK project named `*Bff`, else `false` | Classifies a BFF host. `false` opts a `*Bff` project out; `true` opts another web project in | +| `IsBlazorHostProject` | *(computed)* | `true` for a web SDK project that references a WebAssembly client. Set `false` to opt out. Never implies `IsBffProject` | +| `PurviewAutoBlazorClientReference` | `true` | A host named `` references its `.Client` WebAssembly client automatically. Skipped when the reference is already declared | +| `DisablePurviewBlazorWebAssemblyDefaults` | `false` | Keeps the generic defaults on a WebAssembly client: packable when asked, Dockerfile container detection, the automatic `Shared` reference. Set before the SDK import | +| `PurviewBrowserSafe` | *(unset)* | `true` on a class library applies `IsAotCompatible`, `SupportedPlatform browser` and the browser-safe `WarningsAsErrors`, each only where the project set nothing | +| `PurviewBrowserSafeWarningsAsErrors` | `true` | `false` keeps the trim and AOT diagnostics as warnings; `CA1416` then falls back to the repository's severity (`suggestion` in the shipped `.editorconfig`) | +| `PurviewBrowserSafeWarningCodes` | `CA1416`, `IL2001`-`IL2128`, `IL3000`-`IL3005`, `IL3050`-`IL3059` | The ids a browser-safe library adds to `WarningsAsErrors` | +| `DisablePurviewBrowserSafeReferenceCheck` | `false` | Turns off `PRBLZ0001`, the check that every project a client (or browser-safe library) references sets `PurviewBrowserSafe` | +| `PurviewBrowserSafeReferenceCheckAsError` | `false` | Reports `PRBLZ0001` as an error | +| `PurviewBffExposesApi` | `true` when the BFF references `Microsoft.AspNetCore.OpenApi`, else `false` | Whether a BFF keeps the `Microsoft.AspNetCore.OpenApi.Generated` interceptor namespace | + +`PurviewBrowserSafe="true"` is also accepted as `ProjectReference` metadata, recording that a +reference was judged browser-safe when it cannot say so itself. + ## Version detection | Property | Default | Behaviour | @@ -101,7 +119,7 @@ forces SourceLink's CI-mode dirty-repository checks. | `RepositoryEditorConfigFilePath` | *(derived)* | Overrides the destination `.editorconfig` | | `BootstrapGlobalJsonToRepoRoot` | `true` | | | `RepositoryGlobalJsonFilePath` | *(derived)* | Overrides the destination `global.json` | -| `PurviewBuildSdkVersionForGlobalJson` | detected SDK version, else `1.0.0` | | +| `PurviewBuildSdkVersionForGlobalJson` | restored package version, else the version it was packed as | | | `PurviewRepoBootstrapCopyRetries` | `3` | | | `PurviewRepoBootstrapCopyRetryDelayMilliseconds` | `500` | | | `PurviewRepoBootstrapCopyFailureAsError` | `true` | `false` downgrades a persistent write failure to a warning | @@ -134,6 +152,10 @@ Exported to analyzers and generators as `build_property.`: - `TestingType`, `TargetProjectName` - `IsContainerProject`, `IsSdkProject`, `SdkProjectName`, `IsWebProject`, `IsWebSdkProject`, `IsWorkerSdkProject`, `IsAspireHostProject`, `IsCLIProject` +- `IsBlazorWebAssemblyProject`, `IsRazorClassLibraryProject`, `IsBlazorHostProject`, `IsBffProject` +- `PurviewBrowserSafe`, `PurviewBrowserSafeWarningsAsErrors`, `DisablePurviewBrowserSafeReferenceCheck`, + `PurviewBrowserSafeReferenceCheckAsError`, `DisablePurviewBlazorWebAssemblyDefaults`, + `PurviewAutoBlazorClientReference`, `PurviewBffExposesApi` - `EditorConfigFilePath`, `RepositoryEditorConfigFilePath`, `BootstrapEditorConfigToRepoRoot` - `RepositoryGlobalJsonFilePath`, `BootstrapGlobalJsonToRepoRoot`, `DisableAutoCopySdkFiles` - `PurviewRepoBootstrapMode`, `PurviewRepoBootstrapCopyRetries`, diff --git a/skills/purview-buildsdk-project-detection/SKILL.md b/skills/purview-buildsdk-project-detection/SKILL.md index 2d0d986..8573ca4 100644 --- a/skills/purview-buildsdk-project-detection/SKILL.md +++ b/skills/purview-buildsdk-project-detection/SKILL.md @@ -1,6 +1,6 @@ --- name: purview-buildsdk-project-detection -description: Explain why Purview.BuildSdk classified a project as test, shared, shared-testing, CLI, web, worker, Aspire host, container or Roslyn component, and why it added a package, a project reference, a namespace or an analyzer automatically. Use when SDK behaviour looks surprising, when a test project gets no runner or no InternalsVisibleTo, or when reasoning about RootNamespace, AssemblyName and TargetProjectName. +description: Explain why Purview.BuildSdk classified a project as test, shared, shared-testing, CLI, web, worker, Aspire host, container, Roslyn component, Blazor WebAssembly client, Razor class library, Blazor host or BFF, and why it added a package, a project reference, a namespace or an analyzer automatically. Use when SDK behaviour looks surprising, when a test project gets no runner or no InternalsVisibleTo, or when reasoning about RootNamespace, AssemblyName and TargetProjectName. license: MIT metadata: package: Purview.BuildSdk @@ -26,7 +26,8 @@ dotnet msbuild -getProperty:PurviewLogicalProjectName -getProperty:Roo The values worth inspecting first: `MSBuildProjectName`, `NamespacePrefix`, `PurviewLogicalProjectName`, `RootNamespace`, `TestingType`, `TargetProjectName`, `SdkProjectName`, `IsTestProject`, `IsSharedProject`, `IsSharedTestingProject`, `IsContainerProject`, `IsCLIProject`, -`IsWebSdkProject`, `IsAspireHostProject`. +`IsWebSdkProject`, `IsAspireHostProject`, `IsBlazorWebAssemblyProject`, `IsRazorClassLibraryProject`, +`IsBlazorHostProject`, `IsBffProject`. ## Detection rules @@ -67,6 +68,24 @@ Recognised by **exact name**, never by substring. The two lists are in | SDK starts with `Aspire.Sdk.Host` or `Aspire.AppHost.Sdk` | `IsAspireHostProject=true` | | `Dockerfile`, `dockerfile` or `Dockerfile.dev` in the project directory | `IsContainerProject=true` | | name ends with `CLI`, `Console`, `CommandLine`, `QuickStart`, `QuickStarts` | `IsCLIProject=true` | +| `Microsoft.NET.Sdk.BlazorWebAssembly` | `IsBlazorWebAssemblyProject=true` | +| `Microsoft.NET.Sdk.Razor` | `IsRazorClassLibraryProject=true` | +| a web SDK project whose name ends with `Bff` (case-sensitive, like `CLI`), or that sets `IsBffProject=true` | `IsBffProject=true` | +| a web SDK project that references an `IsBlazorWebAssemblyProject` project | `IsBlazorHostProject=true` | + +`IsBffProject=false` opts a `*Bff` project out. `IsBlazorHostProject` is **never** inferred into +`IsBffProject`: a host that keeps no tokens is not a BFF, and BFF rules do not describe it. + +`IsBlazorHostProject` is known at evaluation for the ``/`.Client` pairing. A host +referencing a client under any other name is resolved during the build, so ask for it with the +target that resolves it: + +```bash +dotnet msbuild -t:ResolvePurviewBlazorHostProject -getProperty:IsBlazorHostProject +``` + +What each Blazor and BFF classification applies - and the browser-safe library check - is in +[references/blazor.md](references/blazor.md). ## Namespace and identity @@ -94,6 +113,8 @@ Recognised by **exact name**, never by substring. The two lists are in Shared-testing projects skip the runnable test package. - **Packable projects**, **Roslyn components** and **special project types** (CLI, container, web, Aspire host): see [references/detection.md](references/detection.md). +- **Blazor WebAssembly clients, Blazor hosts, BFFs and browser-safe libraries**: see + [references/blazor.md](references/blazor.md). Project-reference probing is layout-driven and covered by `purview-buildsdk-project-layout`. @@ -107,6 +128,8 @@ surface in command-line builds and in Visual Studio. The code-fix assembly ships | `PDS0002` | Extensions classes live under `Extensions//` | | `PDS0003` | Explicit types with target-typed `new()` | | `PDS0004` | Correct acronym capitalisation | +| `PDS0008` | A BFF's authentication cookie is not set `HttpOnly = false` | +| `PDS0009` | A BFF's authentication cookie is not set `SameSite = None` | `PDS0004` follows .NET naming guidance: well-known framework spellings (`Sql`, `Guid`, `Uuid`, `Url`, `Dns`, `Tcp`, `Http`, `Xml`, `Db`, …) are exempt — `Db` among them, so `DbContext`, diff --git a/skills/purview-buildsdk-project-detection/references/blazor.md b/skills/purview-buildsdk-project-detection/references/blazor.md new file mode 100644 index 0000000..c65d153 --- /dev/null +++ b/skills/purview-buildsdk-project-detection/references/blazor.md @@ -0,0 +1,105 @@ +# Blazor WebAssembly, browser-safe libraries and BFF hosts + +Every behaviour below applies only to the project type it names, and each one names the failure it +prevents. Nothing here changes a project that is not a WebAssembly client, a Blazor host, a BFF or a +`PurviewBrowserSafe` library. + +## Blazor WebAssembly client (`IsBlazorWebAssemblyProject`) + +| Applied | Failure it prevents | Opt out | +| -- | -- | -- | +| `IsPackable=false` | packing an app's `wwwroot` as a package nobody can consume | `IsPackable=true` in the project | +| `IsContainerProject=false`, so no `PublishAot`, `InvariantGlobalization` or Docker defaults | `PublishAot` on a client is a publish that fails, and the Blazor SDK owns globalisation | `DisablePurviewBlazorWebAssemblyDefaults=true` before the SDK import | +| no automatic `../Shared*/Shared*.csproj` reference | a server-side library joining the browser download because of a folder name | `DisablePurviewBlazorWebAssemblyDefaults=true` before the SDK import | +| the browser-safe reference check (`PRBLZ0001`) | a library joining the download without anyone deciding it was safe there | `DisablePurviewBrowserSafeReferenceCheck=true` | + +Globalisation, trimming mode and the Blazor runtime settings are deliberately left alone: the Blazor +SDK owns them, and a second default would compete with it. + +### The automatic packages + +The SDK adds `Purview.Telemetry.SourceGenerator` and `Microsoft.Extensions.Telemetry.Abstractions` +to every non-test project, a client included. Both were checked rather than assumed: + +- the generator is analyzer-only (`PrivateAssets=all`), so nothing of it is downloaded; +- its generated `ActivitySource`, `ILogger` and `Meter` code, and `Telemetry.Abstractions` itself, + publish with no trim warnings and run on the WebAssembly runtime + (`BlazorWebAppTests.AutomaticTelemetryPackages_RunInsideTheWebAssemblyRuntime` boots it); +- the SDK adds no exporter. An OTLP exporter cannot work from a browser, which is exactly why the SDK + never adds one - that is the application's choice. + +Nothing is therefore excluded from a client by default. `ExcludePurviewTelemetry` and +`ExcludeMSTelemetryExtension` still remove them. + +## Blazor host (`IsBlazorHostProject`) + +A web SDK project named `` beside a WebAssembly client named `.Client` is the Blazor Web +App pairing. The host gets an automatic `ProjectReference` to the client - probed the same way a test +project's target is, and skipped when the project already references it - so the client's framework +files are served. Opt out with `PurviewAutoBlazorClientReference=false`. + +`IsBlazorHostProject` says nothing about `IsBffProject`. In a Blazor Web App the host is the BFF only +if it keeps the tokens. + +## BFF host (`IsBffProject`) + +- **No packages are added.** YARP, OpenID Connect and any BFF library are the application's choice, + and Duende.BFF carries a commercial licence. +- The `Microsoft.AspNetCore.OpenApi.Generated` interceptor namespace every other web project gets is + removed, because a BFF that only forwards has no API document to generate. A BFF referencing + `Microsoft.AspNetCore.OpenApi` keeps it; `PurviewBffExposesApi` decides explicitly. +- `PDS0008` reports `HttpOnly = false` on the `CookieAuthenticationOptions` cookie (script could read + the session). `PDS0009` reports `SameSite = None` on it (cross-site requests would carry it). Only a + constant assigned to that cookie is reported - never the antiforgery cookie, which script may + legitimately read, nor the OpenID Connect correlation and nonce cookies, which need + `SameSite=None`. + +Deliberately **not** checked, because a build cannot check them precisely: + +- **a persisted Data Protection key ring.** The call that persists it (`PersistKeysTo*`) is routinely + made in a shared ServiceDefaults project, which the BFF's compilation cannot see, so the rule would + fire on correctly configured BFFs - and a noisy rule gets suppressed wholesale. +- **anti-forgery on forwarded routes.** It stays a review obligation. + +## Browser-safe library (`PurviewBrowserSafe=true`) + +Applies to class libraries only, and only what the project has not set: + +- `IsAotCompatible=true` (on a TFM that supports it), which turns on `IsTrimmable` and the trim, + single-file and AOT analyzers; +- ``, so `CA1416` reports an API the browser does not support; +- `WarningsAsErrors` for every trim (`IL2001`-`IL2128`), single-file (`IL3000`-`IL3005`) and AOT + (`IL3050`-`IL3059`) diagnostic and `CA1416`, listed in `PurviewBrowserSafeWarningCodes`. + `TreatWarningsAsErrors` is not touched. + +`CA1416` is `suggestion` in the shipped `.editorconfig`. The compiler's `/warnaserror+:CA1416` +outranks an `.editorconfig` severity, so the error still fires without the repository's setting +changing. The consequence of `PurviewBrowserSafeWarningsAsErrors=false` is that the trim and AOT +diagnostics stay warnings while `CA1416` falls back to the repository's configured severity. + +What it cannot catch: file and network I/O. `File` works against the browser's in-memory file system +and `HttpClient` works through `fetch`, so neither is annotated as unsupported, and no analyzer reports +them. Reviewing for them stays a human obligation. + +### The reference check (`PRBLZ0001`) + +A WebAssembly client asks each `ProjectReference` - through an MSBuild call on the referenced project, +never by guessing from its name - for its `PurviewBrowserSafe` value, and warns for each one that does +not set it. A browser-safe library runs the same check over its own references, so the guarantee +covers the whole download, not only the first level. + +- `PurviewBrowserSafe="true"` metadata on the `ProjectReference` records the decision for a project + that does not use this SDK. +- References with `ReferenceOutputAssembly="false"` or an `OutputItemType` are not downloaded and are + not checked. Package references are not checked either. +- `PurviewBrowserSafeReferenceCheckAsError=true` makes it an error. + +### Purview packages a Contracts library builds on + +Checked against a `PurviewBrowserSafe` library, then run on the WebAssembly runtime: + +| Package | Result | +| -- | -- | +| `Purview.Results` 1.0.4 | clean | +| `Purview.ZodSharp` 2.4.2 | clean, including `[ZodSchema]` | +| `Purview.ValueObjects` 1.2.3 | the runtime assembly is AOT-compatible, but the JSON converter the generator emits for a scalar calls the reflection-based `JsonSerializer.Serialize`/`Deserialize(..., options)` overloads, which is `IL2026` and `IL3050` - errors in a browser-safe library. Set `GenerateJsonConverter = false` on the scalar until the generator writes the value directly. | diff --git a/skills/purview-buildsdk-project-detection/references/detection.md b/skills/purview-buildsdk-project-detection/references/detection.md index bf75823..85fde09 100644 --- a/skills/purview-buildsdk-project-detection/references/detection.md +++ b/skills/purview-buildsdk-project-detection/references/detection.md @@ -64,6 +64,10 @@ dual-role component that wants normal library symbol packaging. | Container | `InvariantGlobalization`, `PublishAot`, Linux Docker defaults, container tooling packages | | Web SDK | `Microsoft.AspNetCore.OpenApi.Generated` added to `InterceptorsNamespaces`, unless marked as a separate web-project mode | | Aspire host | `OutputType=Exe` | +| Blazor WebAssembly client | not packable, no container defaults, no automatic `Shared` reference, the browser-safe reference check - see [blazor.md](blazor.md) | +| Blazor host | an automatic reference to its `.Client` - see [blazor.md](blazor.md) | +| BFF | no packages, no OpenAPI interceptor unless it exposes an API, `PDS0008`/`PDS0009` - see [blazor.md](blazor.md) | +| `PurviewBrowserSafe` library | `IsAotCompatible`, `SupportedPlatform browser`, trim/AOT/`CA1416` as errors - see [blazor.md](blazor.md) | ## The Visual Studio refactorings diff --git a/skills/purview-buildsdk-project-layout/SKILL.md b/skills/purview-buildsdk-project-layout/SKILL.md index 55a019c..6c19c3d 100644 --- a/skills/purview-buildsdk-project-layout/SKILL.md +++ b/skills/purview-buildsdk-project-layout/SKILL.md @@ -1,6 +1,6 @@ --- name: purview-buildsdk-project-layout -description: Decide where a project goes and what to call it in a repository that uses Purview.BuildSdk. Use when creating, moving, splitting or renaming a project or test project, choosing a test suffix, aligning namespaces and assembly identity with NamespacePrefix, or relying on the SDK's automatic project references. Also use when rationalising a repository's shape so the SDK's conventions work without overrides. +description: Decide where a project goes and what to call it in a repository that uses Purview.BuildSdk. Use when creating, moving, splitting or renaming a project or test project, choosing a test suffix, aligning namespaces and assembly identity with NamespacePrefix, laying out a Blazor Web App host, WebAssembly client and browser-safe Contracts library, or relying on the SDK's automatic project references. Also use when rationalising a repository's shape so the SDK's conventions work without overrides. license: MIT metadata: package: Purview.BuildSdk @@ -103,7 +103,32 @@ Test projects probe for their target, in order: - `../../src/$(TargetProjectName)/$(TargetProjectName).csproj` Non-test projects pick up sibling shared projects via `../Shared*/Shared*.csproj`; test projects -pick up `../SharedTesting*/SharedTesting*.csproj`. +pick up `../SharedTesting*/SharedTesting*.csproj`. A Blazor WebAssembly client is the exception: it +does not pick up `Shared*`, because everything it references is downloaded to the browser. + +## Blazor Web App: host and client + +```text +src/src/ + Shop/Shop.csproj Microsoft.NET.Sdk.Web - the host + Shop.Client/Shop.Client.csproj Microsoft.NET.Sdk.BlazorWebAssembly - the client + Shop.ClientShared/Shop.ClientShared.csproj a PurviewBrowserSafe library both use + Shop.Bff/Shop.Bff.csproj a BFF, when tokens are kept server-side in a separate host +src/tests/ + Shop.Client.UnitTests/ TargetProjectName = Shop.Client +``` + +- A web host named `` beside a WebAssembly client named `.Client` is the pairing the SDK + recognises: the host gets `IsBlazorHostProject=true` and a reference to the client automatically + (skipped when declared; `PurviewAutoBlazorClientReference=false` opts out). +- Identities with `NamespacePrefix=Acme`: `Shop.Client` is `Acme.Shop.Client`; `Shop.Bff` is + `Acme.Shop.Bff`; `ClientShared` is a stripped segment, so `Shop.ClientShared` has namespace + `Acme.Shop` and assembly `Acme.Shop.ClientShared`. +- Every library the client references must set `true`, or the + build reports `PRBLZ0001`. Keep server-only code (file, process, database access) out of anything + the client references. +- Name a BFF host `*Bff` (case-sensitive) for `IsBffProject=true`. Being the client's host does not + make a project a BFF. ## Creating a new project diff --git a/src/src/Analyzers/AnalyzerReleases.Shipped.md b/src/src/Analyzers/AnalyzerReleases.Shipped.md index fd7e728..9770203 100644 --- a/src/src/Analyzers/AnalyzerReleases.Shipped.md +++ b/src/src/Analyzers/AnalyzerReleases.Shipped.md @@ -7,3 +7,12 @@ Rule ID | Category | Severity | Notes PDS0002 | Naming | Warning | Extensions root folder resets namespace PDS0003 | Style | Warning | Prefer explicit types with target-typed object creation PDS0004 | Naming | Warning | Use correct acronym capitalization (Api → API) + +## Release 1.2.0 + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------ +PDS0008 | Security | Warning | BFF authentication cookie must be HttpOnly +PDS0009 | Security | Warning | BFF authentication cookie must not use SameSite=None diff --git a/src/src/Analyzers/Bff/BffAuthenticationCookieAnalyzer.cs b/src/src/Analyzers/Bff/BffAuthenticationCookieAnalyzer.cs new file mode 100644 index 0000000..6c9c179 --- /dev/null +++ b/src/src/Analyzers/Bff/BffAuthenticationCookieAnalyzer.cs @@ -0,0 +1,161 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace Purview.BuildSdk.Analyzers.Bff; + +/// +/// Reports an authentication cookie that a BFF (IsBffProject=true) configures in a way that undoes +/// what a BFF exists to provide: a session the browser holds but script cannot read and other sites cannot +/// send. +/// +/// +/// Only an explicit assignment of a constant is reported, and only on the cookie of +/// CookieAuthenticationOptions - never on a CookieBuilder in general. The antiforgery cookie is +/// legitimately readable by script, and the OpenID Connect correlation and nonce cookies legitimately need +/// SameSite=None; reporting those would make the rule noise, and a noisy rule gets suppressed +/// wholesale. A cookie configured somewhere the compilation cannot see (another assembly, a local variable +/// holding the builder) is not reported: the rule trades those misses for never being wrong. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class BffAuthenticationCookieAnalyzer : DiagnosticAnalyzer +{ + public const string HttpOnlyDiagnosticId = "PDS0008"; + public const string SameSiteNoneDiagnosticId = "PDS0009"; + + const string CookieBuilderMetadataName = "Microsoft.AspNetCore.Http.CookieBuilder"; + const string CookieAuthenticationOptionsMetadataName = + "Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationOptions"; + const string SameSiteModeMetadataName = "Microsoft.AspNetCore.Http.SameSiteMode"; + + // SameSiteMode.None. Unspecified is -1, Lax 1 and Strict 2. + const int SameSiteModeNone = 0; + + static readonly DiagnosticDescriptor HttpOnlyRule = new( + HttpOnlyDiagnosticId, + "BFF authentication cookie must be HttpOnly", + "The authentication cookie is configured with HttpOnly = false, so script running in the page can read the session", + "Security", + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "A BFF keeps the tokens on the server and hands the browser only a session cookie. Without HttpOnly, any script that runs in the page - including injected script - can read that cookie and replay the session from anywhere." + ); + + static readonly DiagnosticDescriptor SameSiteNoneRule = new( + SameSiteNoneDiagnosticId, + "BFF authentication cookie must not use SameSite=None", + "The authentication cookie is configured with SameSite = None, so requests from other sites carry the session", + "Security", + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "SameSite=None makes the browser attach the BFF's session cookie to cross-site requests, which turns every BFF endpoint into a cross-site request forgery target. Use Strict, or Lax when top-level navigation from another site must arrive signed in." + ); + + public override ImmutableArray SupportedDiagnostics => [HttpOnlyRule, SameSiteNoneRule]; + + public override void Initialize(AnalysisContext context) + { + if (context is null) + throw new ArgumentNullException(nameof(context)); + + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterCompilationStartAction(startContext => + { + var globalOptions = startContext.Options.AnalyzerConfigOptionsProvider.GlobalOptions; + if ( + !globalOptions.TryGetBuildProperty(BuildPropertyKeys.IsBffProject, out var isBffProject) + || !string.Equals(isBffProject, "true", StringComparison.OrdinalIgnoreCase) + ) + { + return; + } + + var compilation = startContext.Compilation; + var cookieBuilder = compilation.GetTypeByMetadataName(CookieBuilderMetadataName); + var cookieAuthenticationOptions = compilation.GetTypeByMetadataName( + CookieAuthenticationOptionsMetadataName + ); + var sameSiteMode = compilation.GetTypeByMetadataName(SameSiteModeMetadataName); + + // A BFF that does not reference cookie authentication has no authentication cookie to check. + if (cookieBuilder is null || cookieAuthenticationOptions is null) + return; + + startContext.RegisterOperationAction( + operationContext => + AnalyzeAssignment(operationContext, cookieBuilder, cookieAuthenticationOptions, sameSiteMode), + OperationKind.SimpleAssignment + ); + }); + } + + static void AnalyzeAssignment( + OperationAnalysisContext context, + INamedTypeSymbol cookieBuilder, + INamedTypeSymbol cookieAuthenticationOptions, + INamedTypeSymbol? sameSiteMode + ) + { + var assignment = (ISimpleAssignmentOperation)context.Operation; + if (assignment.Target is not IPropertyReferenceOperation target) + return; + + if (!InheritsFrom(target.Property.ContainingType, cookieBuilder)) + return; + + if (!IsAuthenticationCookie(ResolveBuilder(assignment, target), cookieAuthenticationOptions)) + return; + + var value = assignment.Value.ConstantValue; + if (!value.HasValue) + return; + + if (target.Property.Name == "HttpOnly" && value.Value is false) + { + context.ReportDiagnostic(Diagnostic.Create(HttpOnlyRule, assignment.Syntax.GetLocation())); + } + else if ( + target.Property.Name == "SameSite" + && sameSiteMode is not null + && SymbolEqualityComparer.Default.Equals(target.Property.Type, sameSiteMode) + && value.Value is int mode + && mode == SameSiteModeNone + ) + { + context.ReportDiagnostic(Diagnostic.Create(SameSiteNoneRule, assignment.Syntax.GetLocation())); + } + } + + /// + /// The operation that yields the builder being assigned to. For options.Cookie.HttpOnly = false + /// that is the receiver itself; inside a nested initializer (Cookie = { HttpOnly = false }) the + /// receiver is implicit, and the builder is the member the enclosing initializer initializes. + /// + static IOperation? ResolveBuilder(ISimpleAssignmentOperation assignment, IPropertyReferenceOperation target) => + target.Instance is IInstanceReferenceOperation { ReferenceKind: InstanceReferenceKind.ImplicitReceiver } + && assignment.Parent is IObjectOrCollectionInitializerOperation { Parent: IMemberInitializerOperation member } + ? member.InitializedMember + : target.Instance; + + /// + /// True only for options.Cookie where options is CookieAuthenticationOptions (or a + /// type derived from it). + /// + static bool IsAuthenticationCookie(IOperation? instance, INamedTypeSymbol cookieAuthenticationOptions) => + instance is IPropertyReferenceOperation { Property.Name: "Cookie" } cookieProperty + && InheritsFrom(cookieProperty.Property.ContainingType, cookieAuthenticationOptions); + + static bool InheritsFrom(INamedTypeSymbol? type, INamedTypeSymbol baseType) + { + for (var current = type; current is not null; current = current.BaseType) + { + if (SymbolEqualityComparer.Default.Equals(current, baseType)) + return true; + } + + return false; + } +} diff --git a/src/src/Analyzers/BuildPropertyKeys.cs b/src/src/Analyzers/BuildPropertyKeys.cs index 56afd23..d03690b 100644 --- a/src/src/Analyzers/BuildPropertyKeys.cs +++ b/src/src/Analyzers/BuildPropertyKeys.cs @@ -8,6 +8,8 @@ namespace Purview.BuildSdk.Analyzers; static class BuildPropertyKeys { public const string ProjectDir = "build_property.ProjectDir"; + + public const string IsBffProject = "build_property.IsBffProject"; } /// diff --git a/src/src/BuildSdk/BuildSdk.csproj b/src/src/BuildSdk/BuildSdk.csproj index 5dd3e95..a85845a 100644 --- a/src/src/BuildSdk/BuildSdk.csproj +++ b/src/src/BuildSdk/BuildSdk.csproj @@ -186,6 +186,52 @@ + + + + <_PurviewPackedVersionStampVersion>$([System.String]::Copy('$(PackageVersion)').Trim()) + <_PurviewPackedVersionStampVersion Condition="'$(_PurviewPackedVersionStampVersion)' == ''" + >$(Version) + + <_PurviewStampedPackedVersionProps Condition="'$(_PurviewStampedPackedVersionProps)' == ''" + >$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/$(IntermediateOutputPath)purview-version/PackedVersion.props')) + + + <_PurviewPackedVersionPropsLines Include="<Project>" /> + <_PurviewPackedVersionPropsLines Include="<!-- Generated at pack time by StampPurviewPackedVersionTarget. Do not edit. -->" /> + <_PurviewPackedVersionPropsLines Include="<PropertyGroup>" /> + <_PurviewPackedVersionPropsLines Include="<_PurviewPackedSdkVersion>$(_PurviewPackedVersionStampVersion)</_PurviewPackedSdkVersion>" /> + <_PurviewPackedVersionPropsLines Include="</PropertyGroup>" /> + <_PurviewPackedVersionPropsLines Include="</Project>" /> + + + + + + + diff --git a/src/src/BuildSdk/Sdk/Props/Defaults.props b/src/src/BuildSdk/Sdk/Props/Defaults.props index 0328c53..ccf2d87 100644 --- a/src/src/BuildSdk/Sdk/Props/Defaults.props +++ b/src/src/BuildSdk/Sdk/Props/Defaults.props @@ -130,6 +130,13 @@ false false + false + false + + false false false false @@ -208,11 +215,12 @@ - - 1.1.0 + + diff --git a/src/src/BuildSdk/Sdk/Sdk.props b/src/src/BuildSdk/Sdk/Sdk.props index 55fbbbe..c005468 100644 --- a/src/src/BuildSdk/Sdk/Sdk.props +++ b/src/src/BuildSdk/Sdk/Sdk.props @@ -1,6 +1,11 @@ + + + true + false + + <_PurviewBlazorClientPairProjectPath>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/../$(MSBuildProjectName).Client/$(MSBuildProjectName).Client.csproj')) + <_PurviewBlazorClientPairProjectText + Condition="'$(IsWebSdkProject)' == 'true' AND Exists('$(_PurviewBlazorClientPairProjectPath)')" + >$([System.IO.File]::ReadAllText(`$(_PurviewBlazorClientPairProjectPath)`)) + <_PurviewHasBlazorClientPair Condition="'$(_PurviewBlazorClientPairProjectText)' != ''" + >$([System.Text.RegularExpressions.Regex]::IsMatch($(_PurviewBlazorClientPairProjectText), `Sdk\s*=\s*"Microsoft\.NET\.Sdk\.BlazorWebAssembly"`)) + true true + + false True when the project SDK starts with Aspire.Sdk.Host. + + True when SdkProjectName is Microsoft.NET.Sdk.BlazorWebAssembly. + + + True when SdkProjectName is Microsoft.NET.Sdk.Razor. + + + True for a web SDK project that references a Blazor WebAssembly client. Says nothing about whether the host is a BFF. + + + True for a web SDK project whose name ends with Bff, or that sets IsBffProject=true. IsBffProject=false opts out. + + + When true on a class library, applies the browser-safe defaults (IsAotCompatible, SupportedPlatform browser, trim/AOT/CA1416 as errors). Unset by default. + + + When false, a browser-safe library keeps its trim, AOT and CA1416 diagnostics as warnings. Defaults to true. + + + When true, a Blazor WebAssembly client (or browser-safe library) does not check that its project references set PurviewBrowserSafe (PRBLZ0001). + + + When true, PRBLZ0001 is reported as an error instead of a warning. Defaults to false. + + + When true, a Blazor WebAssembly client keeps the generic defaults (packable when asked, container detection, the automatic Shared reference). Set before the SDK import. + + + When false, a host named Name does not automatically reference its Name.Client WebAssembly client. Defaults to true. + + + When true, a BFF keeps the OpenAPI interceptor namespace. Defaults to true only when the BFF references Microsoft.AspNetCore.OpenApi. + Path to the SDK-provided .editorconfig used for build-time style enforcement. @@ -738,6 +822,11 @@ $(_PurviewSdkContainerDirectoryName) + + $(_PurviewPackedSdkVersion) 1.0.0 @@ -797,6 +886,23 @@ $(PurviewPolicyExemptNoWarn);CA1515; + + + false + + @@ -1282,9 +1388,13 @@ AUTOMATIC PROJECT REFERENCES FOR TEST PROJECTS Resolves the target project relative to the test project location. ===================================================================== --> + diff --git a/src/src/BuildSdk/Sdk/Sdk.targets b/src/src/BuildSdk/Sdk/Sdk.targets index afd6905..e6da7de 100644 --- a/src/src/BuildSdk/Sdk/Sdk.targets +++ b/src/src/BuildSdk/Sdk/Sdk.targets @@ -8,6 +8,8 @@ >$(TargetFramework) + + + + + + <_PurviewResolveBlazorHostProject Condition="'$(IsBlazorHostProject)' == '' AND '$(IsWebSdkProject)' == 'true'" + >true + false + true + + + + + <_PurviewBlazorClientPairReference Include="$(_PurviewBlazorClientPairProjectPath)" /> + <_PurviewBlazorClientPairReference Remove="@(ProjectReference->'%(FullPath)')" /> + + + + + + <_PurviewBrowserSafeApplies + Condition="'$(PurviewBrowserSafe)' == 'true' AND ('$(OutputType)' == '' OR '$(OutputType)' == 'Library') AND '$(IsWebSdkProject)' != 'true' AND '$(IsBlazorWebAssemblyProject)' != 'true' AND '$(IsTestProject)' != 'true' AND '$(IsSharedTestingProject)' != 'true' AND '$(IsRoslynComponent)' != 'true'" + >true + + + + + true + true + + + CA1416;IL2001;IL2002;IL2003;IL2004;IL2005;IL2006;IL2007;IL2008;IL2009;IL2010;IL2011;IL2012;IL2013;IL2014;IL2015; + IL2016;IL2017;IL2018;IL2019;IL2020;IL2021;IL2022;IL2023;IL2024;IL2025;IL2026;IL2027;IL2028;IL2029;IL2030;IL2031; + IL2032;IL2033;IL2034;IL2035;IL2036;IL2037;IL2038;IL2039;IL2040;IL2041;IL2042;IL2043;IL2044;IL2045;IL2046;IL2047; + IL2048;IL2049;IL2050;IL2051;IL2052;IL2053;IL2054;IL2055;IL2056;IL2057;IL2058;IL2059;IL2060;IL2061;IL2062;IL2063; + IL2064;IL2065;IL2066;IL2067;IL2068;IL2069;IL2070;IL2071;IL2072;IL2073;IL2074;IL2075;IL2076;IL2077;IL2078;IL2079; + IL2080;IL2081;IL2082;IL2083;IL2084;IL2085;IL2086;IL2087;IL2088;IL2089;IL2090;IL2091;IL2092;IL2093;IL2094;IL2095; + IL2096;IL2097;IL2098;IL2099;IL2100;IL2101;IL2102;IL2103;IL2104;IL2105;IL2106;IL2107;IL2108;IL2109;IL2110;IL2111; + IL2112;IL2113;IL2114;IL2115;IL2116;IL2117;IL2118;IL2119;IL2120;IL2121;IL2122;IL2123;IL2124;IL2125;IL2126;IL2127; + IL2128;IL3000;IL3001;IL3002;IL3003;IL3004;IL3005;IL3050;IL3051;IL3052;IL3053;IL3054;IL3055;IL3056;IL3057;IL3058; + IL3059; + + + $([System.Text.RegularExpressions.Regex]::Replace('$(PurviewBrowserSafeWarningCodes)', '\s', '')) + $(WarningsAsErrors);$(PurviewBrowserSafeWarningCodes) + + + + + + + + + + <_PurviewBrowserProjectInfo Include="$(MSBuildProjectFullPath)"> + $(MSBuildProjectName) + $(IsBlazorWebAssemblyProject) + $(PurviewBrowserSafe) + + + + + + + + <_PurviewBrowserProjectReference + Include="@(ProjectReference)" + Condition="'%(ProjectReference.ReferenceOutputAssembly)' != 'false' AND '%(ProjectReference.OutputItemType)' == ''" + /> + + + + + + + + + true + + + + + + + <_PurviewBrowserSafeCandidate + Include="@(_PurviewBrowserProjectReference->'%(FullPath)')" + Condition="'%(_PurviewBrowserProjectReference.PurviewBrowserSafe)' != 'true'" + /> + + + + + + <_PurviewBrowserSafeConfirmed Include="@(_PurviewBrowserSafeReferenceInfo->WithMetadataValue('PurviewBrowserSafe', 'true'))" /> + <_PurviewBrowserUnsafeReference + Include="@(_PurviewBrowserSafeCandidate)" + Exclude="@(_PurviewBrowserSafeConfirmed)" + /> + + + <_PurviewBrowserSafeReferenceRole Condition="'$(IsBlazorWebAssemblyProject)' == 'true'" + >a Blazor WebAssembly client + <_PurviewBrowserSafeReferenceRole Condition="'$(IsBlazorWebAssemblyProject)' != 'true'" + >a browser-safe library + <_PurviewBrowserSafeReferenceText>Set <PurviewBrowserSafe>true</PurviewBrowserSafe> in that project so its build checks it for browser-unsupported APIs, trimming and reflection, or set PurviewBrowserSafe="true" on the ProjectReference to record that it was judged safe. Suppress with DisablePurviewBrowserSafeReferenceCheck=true. + + + + + + + + <_PurviewBffReferencesOpenApi>$([System.Text.RegularExpressions.Regex]::IsMatch($(_PurviewProjectText), `(?is)<PackageReference\s[^>]*Include\s*=\s*["']Microsoft\.AspNetCore\.OpenApi["']`)) + true + false + $([System.String]::Copy(';$(InterceptorsNamespaces);').Replace(';Microsoft.AspNetCore.OpenApi.Generated;', ';').Trim(';')) + + diff --git a/src/tests/Analyzers.IntegrationTests/AnalyzerTestInfrastructure.cs b/src/tests/Analyzers.IntegrationTests/AnalyzerTestInfrastructure.cs index f5f06f3..31fee68 100644 --- a/src/tests/Analyzers.IntegrationTests/AnalyzerTestInfrastructure.cs +++ b/src/tests/Analyzers.IntegrationTests/AnalyzerTestInfrastructure.cs @@ -41,9 +41,16 @@ public static CSharpCompilation CreateCompilation( ); } + /// The fake project directory exposed as build_property.ProjectDir. + /// The value exposed as build_property.RootNamespace. + /// + /// Further build_property.* values, keyed by property name without the prefix - for example + /// IsBffProject. + /// public static AnalyzerOptions CreateAnalyzerOptions( string projectDir = @"C:\FakeProject\", - string rootNamespace = "An.Example.Project" + string rootNamespace = "An.Example.Project", + IReadOnlyDictionary? buildProperties = null ) { var values = new Dictionary(StringComparer.OrdinalIgnoreCase) @@ -52,6 +59,9 @@ public static AnalyzerOptions CreateAnalyzerOptions( ["build_property.RootNamespace"] = rootNamespace, }; + foreach (var (name, value) in buildProperties ?? new Dictionary()) + values["build_property." + name] = value; + InMemoryAnalyzerConfigOptions options = new(values); InMemoryAnalyzerConfigOptionsProvider provider = new(options); return new AnalyzerOptions([], provider); diff --git a/src/tests/Analyzers.IntegrationTests/Bff/BffAuthenticationCookieAnalyzerTests.cs b/src/tests/Analyzers.IntegrationTests/Bff/BffAuthenticationCookieAnalyzerTests.cs new file mode 100644 index 0000000..ef5625f --- /dev/null +++ b/src/tests/Analyzers.IntegrationTests/Bff/BffAuthenticationCookieAnalyzerTests.cs @@ -0,0 +1,262 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Purview.BuildSdk.Analyzers.Bff; + +/// +/// Verifies the BFF authentication-cookie rules (PDS0008, PDS0009) report only an explicit constant on the +/// CookieAuthenticationOptions cookie, and only in a project the SDK classifies as a BFF. +/// +/// +/// The ASP.NET Core types are declared as stand-ins in the test source: the analyzer matches them by +/// metadata name, so a stand-in exercises exactly the lookup a real BFF compilation goes through. +/// +sealed class BffAuthenticationCookieAnalyzerTests +{ + const string AspNetCoreStandIns = """ + namespace Microsoft.AspNetCore.Http + { + public enum SameSiteMode { Unspecified = -1, None = 0, Lax = 1, Strict = 2 } + + public class CookieBuilder + { + public virtual bool HttpOnly { get; set; } + public virtual SameSiteMode SameSite { get; set; } + } + } + + namespace Microsoft.AspNetCore.Antiforgery + { + public class AntiforgeryOptions + { + public Microsoft.AspNetCore.Http.CookieBuilder Cookie { get; set; } = new(); + } + } + + namespace Microsoft.AspNetCore.Authentication.OpenIdConnect + { + public class OpenIdConnectOptions + { + public Microsoft.AspNetCore.Http.CookieBuilder CorrelationCookie { get; set; } = new(); + public Microsoft.AspNetCore.Http.CookieBuilder NonceCookie { get; set; } = new(); + } + } + + namespace Microsoft.AspNetCore.Authentication.Cookies + { + public class CookieAuthenticationOptions + { + public Microsoft.AspNetCore.Http.CookieBuilder Cookie { get; set; } = new(); + } + } + """; + + [Test] + public async Task HttpOnlyFalse_OnAuthenticationCookie_InBff_ReportsDiagnostic(CancellationToken cancellationToken) + { + var diagnostics = await GetDiagnosticsAsync( + """ + static class Setup + { + public static void Configure(Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationOptions options) + { + options.Cookie.HttpOnly = false; + } + } + """, + isBffProject: true, + cancellationToken + ); + + await Assert + .That(diagnostics.Select(d => d.Id)) + .IsEquivalentTo([BffAuthenticationCookieAnalyzer.HttpOnlyDiagnosticId]); + } + + [Test] + public async Task SameSiteNone_OnAuthenticationCookie_InBff_ReportsDiagnostic(CancellationToken cancellationToken) + { + var diagnostics = await GetDiagnosticsAsync( + """ + using Microsoft.AspNetCore.Http; + + static class Setup + { + public static void Configure(Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationOptions options) + { + options.Cookie.SameSite = SameSiteMode.None; + } + } + """, + isBffProject: true, + cancellationToken + ); + + await Assert + .That(diagnostics.Select(d => d.Id)) + .IsEquivalentTo([BffAuthenticationCookieAnalyzer.SameSiteNoneDiagnosticId]); + } + + [Test] + public async Task NestedCookieInitializer_InBff_ReportsBothDiagnostics(CancellationToken cancellationToken) + { + var diagnostics = await GetDiagnosticsAsync( + """ + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Authentication.Cookies; + + static class Setup + { + public static CookieAuthenticationOptions Create() => + new() { Cookie = { HttpOnly = false, SameSite = SameSiteMode.None } }; + } + """, + isBffProject: true, + cancellationToken + ); + + await Assert + .That(diagnostics.Select(d => d.Id)) + .IsEquivalentTo([ + BffAuthenticationCookieAnalyzer.HttpOnlyDiagnosticId, + BffAuthenticationCookieAnalyzer.SameSiteNoneDiagnosticId, + ]); + } + + [Test] + public async Task SecureValues_OnAuthenticationCookie_InBff_DoNotReportDiagnostics( + CancellationToken cancellationToken + ) + { + var diagnostics = await GetDiagnosticsAsync( + """ + using Microsoft.AspNetCore.Http; + + static class Setup + { + public static void Configure(Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationOptions options) + { + options.Cookie.HttpOnly = true; + options.Cookie.SameSite = SameSiteMode.Strict; + options.Cookie.SameSite = SameSiteMode.Lax; + } + } + """, + isBffProject: true, + cancellationToken + ); + + await Assert.That(diagnostics).IsEmpty(); + } + + [Test] + public async Task OtherCookies_InBff_DoNotReportDiagnostics(CancellationToken cancellationToken) + { + // The antiforgery cookie is legitimately readable by script (an SPA echoes it in a header), and the + // OpenID Connect correlation and nonce cookies need SameSite=None to survive the identity provider's + // cross-site redirect. Reporting any of them would make the rule noise. + var diagnostics = await GetDiagnosticsAsync( + """ + using Microsoft.AspNetCore.Http; + + static class Setup + { + public static void Configure( + Microsoft.AspNetCore.Antiforgery.AntiforgeryOptions antiforgery, + Microsoft.AspNetCore.Authentication.OpenIdConnect.OpenIdConnectOptions oidc, + CookieBuilder standalone + ) + { + antiforgery.Cookie.HttpOnly = false; + antiforgery.Cookie.SameSite = SameSiteMode.None; + oidc.CorrelationCookie.SameSite = SameSiteMode.None; + oidc.NonceCookie.SameSite = SameSiteMode.None; + standalone.HttpOnly = false; + } + } + """, + isBffProject: true, + cancellationToken + ); + + await Assert.That(diagnostics).IsEmpty(); + } + + [Test] + public async Task NonConstantValue_InBff_DoesNotReportDiagnostic(CancellationToken cancellationToken) + { + var diagnostics = await GetDiagnosticsAsync( + """ + static class Setup + { + public static void Configure( + Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationOptions options, + bool httpOnly + ) + { + options.Cookie.HttpOnly = httpOnly; + } + } + """, + isBffProject: true, + cancellationToken + ); + + await Assert.That(diagnostics).IsEmpty(); + } + + [Test] + [Arguments(false)] + [Arguments(null)] + public async Task HttpOnlyFalse_OutsideBff_DoesNotReportDiagnostic( + bool? isBffProject, + CancellationToken cancellationToken + ) + { + var diagnostics = await GetDiagnosticsAsync( + """ + static class Setup + { + public static void Configure(Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationOptions options) + { + options.Cookie.HttpOnly = false; + } + } + """, + isBffProject, + cancellationToken + ); + + await Assert.That(diagnostics).IsEmpty(); + } + + static async Task> GetDiagnosticsAsync( + string source, + bool? isBffProject, + CancellationToken cancellationToken + ) + { + var compilation = AnalyzerTestInfrastructure + .CreateCompilation(source, @"C:\FakeProject\Setup.cs") + .AddSyntaxTrees( + Microsoft.CodeAnalysis.CSharp.CSharpSyntaxTree.ParseText( + AspNetCoreStandIns, + path: AnalyzerTestInfrastructure.NormalizeFakePath(@"C:\FakeProject\AspNetCore.cs"), + cancellationToken: cancellationToken + ) + ); + + var buildProperties = isBffProject is { } value + ? new Dictionary { ["IsBffProject"] = value ? "true" : "false" } + : null; + + var analyzers = ImmutableArray.Create(new BffAuthenticationCookieAnalyzer()); + return await compilation + .WithAnalyzers( + analyzers, + AnalyzerTestInfrastructure.CreateAnalyzerOptions(buildProperties: buildProperties) + ) + .GetAnalyzerDiagnosticsAsync(cancellationToken); + } +} diff --git a/src/tests/BuildSdk.IntegrationTests/BlazorFixtures.cs b/src/tests/BuildSdk.IntegrationTests/BlazorFixtures.cs new file mode 100644 index 0000000..83c2bbe --- /dev/null +++ b/src/tests/BuildSdk.IntegrationTests/BlazorFixtures.cs @@ -0,0 +1,135 @@ +using Purview.BuildSdk.Harness; + +namespace Purview.BuildSdk; + +/// +/// Project files and solution wiring shared by the Blazor WebAssembly, browser-safe and BFF tests. +/// +static class BlazorFixtures +{ + /// The automatic SDK packages, removed where a test is not about them. + public const string RemoveAutomaticPackages = """ + + + """; + + public static string Library(string properties = "", string items = "") => + $""" + + + net10.0 + true + {properties} + + + {items} + + + """; + + public static string Client(string properties = "", string items = "") => + $""" + + + net10.0 + true + {properties} + + + + {items} + + + """; + + public static string Web(string properties = "", string items = "") => + $""" + + + net10.0 + true + {properties} + + + {items} + + + """; + + public const string ClientProgram = """ + using Microsoft.AspNetCore.Components.WebAssembly.Hosting; + + var builder = WebAssemblyHostBuilder.CreateDefault(args); + await builder.Build().RunAsync().ConfigureAwait(false); + """; + + public const string HostProgram = """ + var builder = WebApplication.CreateBuilder(args); + var app = builder.Build(); + app.UseBlazorFrameworkFiles(); + app.UseStaticFiles(); + await app.RunAsync().ConfigureAwait(false); + """; + + /// + /// Creates a project in a new throwaway solution. The returned harness owns the solution directory, so + /// disposing it removes every sibling added with . + /// + public static Task CreateAsync( + string projectName, + string content, + string namespacePrefix = "Acme", + CancellationToken cancellationToken = default + ) => + ProjectHarness + .For(projectName) + .WithNamespacePrefix(namespacePrefix) + .WithProjectFileContent(content) + .BuildAsync(cancellationToken); + + /// Adds a project beside , in the same solution. + public static Task AddSiblingAsync( + ProjectHarness owner, + string projectName, + string content, + string namespacePrefix = "Acme", + CancellationToken cancellationToken = default + ) => + ProjectHarness + .For(projectName) + .WithNamespacePrefix(namespacePrefix) + .WithProjectFileContent(content) + .WithSolutionDirectory(owner.SolutionDirectory) + .BuildAsync(cancellationToken); + + /// + /// Replaces the harness's central package versions with the ones a Blazor Web App restores: the + /// WebAssembly packages for the TFM the fixtures target, and the automatic Purview telemetry packages. + /// + public static Task WritePackageVersionsAsync(ProjectHarness owner, CancellationToken cancellationToken) => + File.WriteAllTextAsync( + Path.Combine(owner.SolutionDirectory, "Directory.Packages.props"), + """ + + + true + + + + + + + + + + """, + cancellationToken + ); + + public static Task WriteSourceAsync( + ProjectHarness project, + string fileName, + string content, + CancellationToken cancellationToken + ) => File.WriteAllTextAsync(Path.Combine(project.ProjectDirectory, fileName), content, cancellationToken); +} diff --git a/src/tests/BuildSdk.IntegrationTests/BlazorProjectDetectionTests.cs b/src/tests/BuildSdk.IntegrationTests/BlazorProjectDetectionTests.cs new file mode 100644 index 0000000..6ddf134 --- /dev/null +++ b/src/tests/BuildSdk.IntegrationTests/BlazorProjectDetectionTests.cs @@ -0,0 +1,570 @@ +using Purview.BuildSdk.Harness; +using Purview.BuildSdk.Infra; + +namespace Purview.BuildSdk; + +/// +/// Pins the Blazor WebAssembly, Razor class library, Blazor host and BFF detection signals, each opt-out, +/// and that a project which is none of them is left exactly as it was. +/// +sealed class BlazorProjectDetectionTests +{ + [Test] + public async Task BlazorWebAssemblySdk_IsBlazorWebAssemblyProject(CancellationToken cancellationToken) + { + using var h = await BlazorFixtures.CreateAsync( + "Shop.Client", + BlazorFixtures.Client(), + cancellationToken: cancellationToken + ); + + var properties = await h.GetPropertiesAsync( + cancellationToken, + "IsBlazorWebAssemblyProject", + "IsRazorClassLibraryProject", + "IsWebSdkProject", + "IsBffProject", + "IsBlazorHostProject" + ); + + await Assert.That(properties["IsBlazorWebAssemblyProject"]).IsEqualTo("true"); + await Assert.That(properties["IsRazorClassLibraryProject"]).IsEqualTo("false"); + await Assert.That(properties["IsWebSdkProject"]).IsEqualTo("false"); + await Assert.That(properties["IsBffProject"]).IsEqualTo("false"); + await Assert.That(properties["IsBlazorHostProject"]).IsEqualTo("false"); + } + + [Test] + public async Task RazorSdk_IsRazorClassLibraryProject(CancellationToken cancellationToken) + { + using var h = await BlazorFixtures.CreateAsync( + "Shop.Components", + BlazorFixtures + .Library() + .Replace("Microsoft.NET.Sdk\"", "Microsoft.NET.Sdk.Razor\"", StringComparison.Ordinal), + cancellationToken: cancellationToken + ); + + var properties = await h.GetPropertiesAsync( + cancellationToken, + "IsRazorClassLibraryProject", + "IsBlazorWebAssemblyProject" + ); + + await Assert.That(properties["IsRazorClassLibraryProject"]).IsEqualTo("true"); + await Assert.That(properties["IsBlazorWebAssemblyProject"]).IsEqualTo("false"); + } + + [Test] + [Arguments("Microsoft.NET.Sdk", "Shop.Orders")] + [Arguments("Microsoft.NET.Sdk.Web", "Shop.Api")] + [Arguments("Microsoft.NET.Sdk.Worker", "Shop.Worker")] + public async Task OtherProjects_AreNeitherBlazorNorBff( + string sdk, + string projectName, + CancellationToken cancellationToken + ) + { + using var h = await ProjectHarness.For(projectName).WithSdk(sdk).BuildAsync(cancellationToken); + + var properties = await h.GetPropertiesAsync( + cancellationToken, + "IsBlazorWebAssemblyProject", + "IsRazorClassLibraryProject", + "IsBlazorHostProject", + "IsBffProject", + "IsAotCompatible", + "WarningsAsErrors" + ); + + await Assert.That(properties["IsBlazorWebAssemblyProject"]).IsEqualTo("false"); + await Assert.That(properties["IsRazorClassLibraryProject"]).IsEqualTo("false"); + await Assert.That(properties["IsBlazorHostProject"]).IsEqualTo("false"); + await Assert.That(properties["IsBffProject"]).IsEqualTo("false"); + await Assert.That(properties["IsAotCompatible"]).IsEmpty(); + await Assert.That(properties["WarningsAsErrors"]).DoesNotContain("IL2026"); + } + + [Test] + [Arguments("Shop.Bff")] + [Arguments("ShopBff")] + [Arguments("Bff")] + public async Task WebProject_NamedWithBffSuffix_IsBffProject( + string projectName, + CancellationToken cancellationToken + ) + { + using var h = await BlazorFixtures.CreateAsync( + projectName, + BlazorFixtures.Web(), + cancellationToken: cancellationToken + ); + + await Assert.That(await h.GetPropertyAsync("IsBffProject", cancellationToken)).IsEqualTo("true"); + } + + [Test] + [Arguments("Shop.BFF")] + [Arguments("Shop.bff")] + [Arguments("Shop.BffTools")] + public async Task WebProject_WithoutExactBffSuffix_IsNotBffProject( + string projectName, + CancellationToken cancellationToken + ) + { + // Matched the way IsCLIProject matches 'CLI': case-sensitive, and only as a suffix. + using var h = await BlazorFixtures.CreateAsync( + projectName, + BlazorFixtures.Web(), + cancellationToken: cancellationToken + ); + + await Assert.That(await h.GetPropertyAsync("IsBffProject", cancellationToken)).IsEqualTo("false"); + } + + [Test] + public async Task LibraryProject_NamedWithBffSuffix_IsNotBffProject(CancellationToken cancellationToken) + { + // A BFF is a web host; a library that happens to end in Bff is not one. + using var h = await BlazorFixtures.CreateAsync( + "Shop.Bff", + BlazorFixtures.Library(), + cancellationToken: cancellationToken + ); + + await Assert.That(await h.GetPropertyAsync("IsBffProject", cancellationToken)).IsEqualTo("false"); + } + + [Test] + public async Task IsBffProjectFalse_OptsANamedBffOut(CancellationToken cancellationToken) + { + using var h = await BlazorFixtures.CreateAsync( + "Shop.Bff", + BlazorFixtures.Web("false"), + cancellationToken: cancellationToken + ); + + var properties = await h.GetPropertiesAsync(cancellationToken, "IsBffProject", "InterceptorsNamespaces"); + + await Assert.That(properties["IsBffProject"]).IsEqualTo("false"); + // No longer a BFF, so it is an ordinary web project again and keeps the OpenAPI interceptors. + await Assert.That(properties["InterceptorsNamespaces"]).Contains("Microsoft.AspNetCore.OpenApi.Generated"); + } + + [Test] + public async Task IsBffProjectTrue_OptsAWebProjectIn(CancellationToken cancellationToken) + { + using var h = await BlazorFixtures.CreateAsync( + "Shop.Gateway", + BlazorFixtures.Web("true"), + cancellationToken: cancellationToken + ); + + var properties = await h.GetPropertiesAsync(cancellationToken, "IsBffProject", "InterceptorsNamespaces"); + + await Assert.That(properties["IsBffProject"]).IsEqualTo("true"); + await Assert + .That(properties["InterceptorsNamespaces"]) + .DoesNotContain("Microsoft.AspNetCore.OpenApi.Generated"); + } + + [Test] + public async Task IsBffProjectTrue_SetBeforeTheSdkImport_IsHonoured(CancellationToken cancellationToken) + { + using var h = await ProjectHarness + .For("Shop.Gateway") + .WithNamespacePrefix("Acme") + .WithPreImportProperty("IsBffProject", "true") + .WithProjectFileContent(BlazorFixtures.Web()) + .BuildAsync(cancellationToken); + + await Assert.That(await h.GetPropertyAsync("IsBffProject", cancellationToken)).IsEqualTo("true"); + } + + [Test] + public async Task Bff_WithoutAnApiOfItsOwn_HasNoOpenApiInterceptors(CancellationToken cancellationToken) + { + // A BFF that only forwards has no OpenAPI document to generate. + using var h = await BlazorFixtures.CreateAsync( + "Shop.Bff", + BlazorFixtures.Web(), + cancellationToken: cancellationToken + ); + + var properties = await h.GetPropertiesAsync( + cancellationToken, + "InterceptorsNamespaces", + "PurviewBffExposesApi" + ); + + await Assert + .That(properties["InterceptorsNamespaces"]) + .DoesNotContain("Microsoft.AspNetCore.OpenApi.Generated"); + await Assert.That(properties["PurviewBffExposesApi"]).IsEqualTo("false"); + } + + [Test] + public async Task Bff_ReferencingOpenApi_KeepsOpenApiInterceptors(CancellationToken cancellationToken) + { + using var h = await BlazorFixtures.CreateAsync( + "Shop.Bff", + BlazorFixtures.Web(items: """"""), + cancellationToken: cancellationToken + ); + + var properties = await h.GetPropertiesAsync( + cancellationToken, + "InterceptorsNamespaces", + "PurviewBffExposesApi" + ); + + await Assert.That(properties["InterceptorsNamespaces"]).Contains("Microsoft.AspNetCore.OpenApi.Generated"); + await Assert.That(properties["PurviewBffExposesApi"]).IsEqualTo("true"); + } + + [Test] + public async Task Bff_PurviewBffExposesApiTrue_KeepsOpenApiInterceptors(CancellationToken cancellationToken) + { + using var h = await BlazorFixtures.CreateAsync( + "Shop.Bff", + BlazorFixtures.Web("true"), + cancellationToken: cancellationToken + ); + + await Assert + .That(await h.GetPropertyAsync("InterceptorsNamespaces", cancellationToken)) + .Contains("Microsoft.AspNetCore.OpenApi.Generated"); + } + + [Test] + public async Task NonBffWebProject_KeepsOpenApiInterceptors(CancellationToken cancellationToken) + { + using var h = await BlazorFixtures.CreateAsync( + "Shop.Api", + BlazorFixtures.Web(), + cancellationToken: cancellationToken + ); + + await Assert + .That(await h.GetPropertyAsync("InterceptorsNamespaces", cancellationToken)) + .Contains("Microsoft.AspNetCore.OpenApi.Generated"); + } + + [Test] + public async Task Bff_AddsNoPackages(CancellationToken cancellationToken) + { + // YARP, OpenID Connect and any BFF library (Duende.BFF is commercially licensed) are the application's + // choice. A BFF gets exactly the packages any other web project gets. + using var bff = await BlazorFixtures.CreateAsync( + "Shop.Bff", + BlazorFixtures.Web(), + cancellationToken: cancellationToken + ); + using var api = await BlazorFixtures.AddSiblingAsync( + bff, + "Shop.Api", + BlazorFixtures.Web(), + cancellationToken: cancellationToken + ); + + var bffPackages = await bff.GetItemIdentitiesAsync("PackageReference", cancellationToken); + var apiPackages = await api.GetItemIdentitiesAsync("PackageReference", cancellationToken); + + await Assert.That(bffPackages).IsEquivalentTo(apiPackages); + } + + [Test] + public async Task Host_PairedWithClient_IsBlazorHostProject_AndReferencesIt(CancellationToken cancellationToken) + { + using var client = await BlazorFixtures.CreateAsync( + "Shop.Client", + BlazorFixtures.Client(), + cancellationToken: cancellationToken + ); + using var host = await BlazorFixtures.AddSiblingAsync( + client, + "Shop", + BlazorFixtures.Web(), + cancellationToken: cancellationToken + ); + + var evaluation = await host.EvaluateAsync( + ["IsBlazorHostProject", "IsBffProject"], + ["ProjectReference"], + cancellationToken + ); + + await Assert.That(evaluation.Properties["IsBlazorHostProject"]).IsEqualTo("true"); + // A host that keeps no tokens is not a BFF, so being a host must not make it one. + await Assert.That(evaluation.Properties["IsBffProject"]).IsEqualTo("false"); + await Assert + .That(evaluation.Items["ProjectReference"].Select(TestHelpers.NormalizePath)) + .IsEquivalentTo(["../Shop.Client/Shop.Client.csproj"]); + } + + [Test] + [Arguments("../Shop.Client/Shop.Client.csproj")] + [Arguments(@"..\Shop.Client\Shop.Client.csproj")] + public async Task Host_AlreadyReferencingItsClient_GetsNoSecondReference( + string declaredReference, + CancellationToken cancellationToken + ) + { + using var client = await BlazorFixtures.CreateAsync( + "Shop.Client", + BlazorFixtures.Client(), + cancellationToken: cancellationToken + ); + using var host = await BlazorFixtures.AddSiblingAsync( + client, + "Shop", + BlazorFixtures.Web(items: $""""""), + cancellationToken: cancellationToken + ); + + var references = await host.GetProjectReferencesAsync(cancellationToken); + + await Assert.That(references).IsEquivalentTo([declaredReference]); + } + + [Test] + public async Task Host_PurviewAutoBlazorClientReferenceFalse_AddsNoReference(CancellationToken cancellationToken) + { + using var client = await BlazorFixtures.CreateAsync( + "Shop.Client", + BlazorFixtures.Client(), + cancellationToken: cancellationToken + ); + using var host = await BlazorFixtures.AddSiblingAsync( + client, + "Shop", + BlazorFixtures.Web("false"), + cancellationToken: cancellationToken + ); + + await Assert.That(await host.GetProjectReferencesAsync(cancellationToken)).IsEmpty(); + } + + [Test] + public async Task Host_IsBlazorHostProjectFalse_IsHonoured(CancellationToken cancellationToken) + { + using var client = await BlazorFixtures.CreateAsync( + "Shop.Client", + BlazorFixtures.Client(), + cancellationToken: cancellationToken + ); + using var host = await BlazorFixtures.AddSiblingAsync( + client, + "Shop", + BlazorFixtures.Web("false"), + cancellationToken: cancellationToken + ); + + await Assert.That(await host.GetPropertyAsync("IsBlazorHostProject", cancellationToken)).IsEqualTo("false"); + } + + [Test] + public async Task Host_SiblingNamedClientThatIsNotWebAssembly_IsNotPaired(CancellationToken cancellationToken) + { + using var library = await BlazorFixtures.CreateAsync( + "Shop.Client", + BlazorFixtures.Library(), + cancellationToken: cancellationToken + ); + using var host = await BlazorFixtures.AddSiblingAsync( + library, + "Shop", + BlazorFixtures.Web(), + cancellationToken: cancellationToken + ); + + var evaluation = await host.EvaluateAsync(["IsBlazorHostProject"], ["ProjectReference"], cancellationToken); + + await Assert.That(evaluation.Properties["IsBlazorHostProject"]).IsEqualTo("false"); + await Assert.That(evaluation.Items["ProjectReference"]).IsEmpty(); + } + + [Test] + public async Task Host_ReferencingAClientUnderAnotherName_IsResolvedByTheBuild(CancellationToken cancellationToken) + { + // Not the /.Client pairing, so evaluation cannot know; the build asks the reference. + using var client = await BlazorFixtures.CreateAsync( + "Storefront", + BlazorFixtures.Client(), + cancellationToken: cancellationToken + ); + using var host = await BlazorFixtures.AddSiblingAsync( + client, + "Shop", + BlazorFixtures.Web(items: """"""), + cancellationToken: cancellationToken + ); + + await Assert.That(await host.GetPropertyAsync("IsBlazorHostProject", cancellationToken)).IsEqualTo("false"); + + var (code, stdOut, stdErr) = await host.RunMSBuildAsync( + "-noconlog -t:ResolvePurviewBlazorHostProject -getProperty:IsBlazorHostProject", + cancellationToken + ); + + await Assert.That(code).IsZero().Because(TestHelpers.GenerateError(stdOut, stdErr)); + await Assert.That(stdOut.Trim()).IsEqualTo("true"); + } + + [Test] + public async Task Client_IsNotPackable_AndHasNoContainerDefaults(CancellationToken cancellationToken) + { + using var h = await ProjectHarness + .For("Shop.Client") + .WithNamespacePrefix("Acme") + .WithProjectFileContent(BlazorFixtures.Client()) + .WithDockerfile() + .BuildAsync(cancellationToken); + + var evaluation = await h.EvaluateAsync( + ["IsPackable", "IsContainerProject", "PublishAot", "DockerDefaultTargetOS"], + ["PackageReference"], + cancellationToken + ); + + await Assert.That(evaluation.Properties["IsPackable"]).IsEqualTo("false"); + await Assert.That(evaluation.Properties["IsContainerProject"]).IsEqualTo("false"); + // PublishAot on a WebAssembly client is a publish that fails; the container group must not apply. + await Assert.That(evaluation.Properties["PublishAot"]).IsNotEqualTo("true"); + await Assert.That(evaluation.Properties["DockerDefaultTargetOS"]).IsNotEqualTo("Linux"); + await Assert + .That(evaluation.Items["PackageReference"]) + .DoesNotContain("Microsoft.VisualStudio.Azure.Containers.Tools.Targets"); + } + + [Test] + public async Task Client_IsPackableTrueInTheProject_StillWins(CancellationToken cancellationToken) + { + using var h = await BlazorFixtures.CreateAsync( + "Shop.Client", + BlazorFixtures.Client("true"), + cancellationToken: cancellationToken + ); + + await Assert.That(await h.GetPropertyAsync("IsPackable", cancellationToken)).IsEqualTo("true"); + } + + [Test] + public async Task Client_LeavesTheBlazorSdkSettingsAlone(CancellationToken cancellationToken) + { + // Globalisation, trimming mode and the runtime settings belong to the Blazor SDK. A second default here + // would compete with it, so every one of them must read exactly what the Blazor SDK alone produces. + string[] blazorOwned = + [ + "InvariantGlobalization", + "BlazorWebAssemblyLoadAllGlobalizationData", + "TrimMode", + "PublishTrimmed", + "RunAOTCompilation", + ]; + + using var withSdk = await BlazorFixtures.CreateAsync( + "Shop.Client", + BlazorFixtures.Client(), + cancellationToken: cancellationToken + ); + var actual = await withSdk.GetPropertiesAsync(cancellationToken, blazorOwned); + + var bareDirectory = Path.Combine(withSdk.SolutionDirectory, "bare"); + Directory.CreateDirectory(bareDirectory); + var bareProject = Path.Combine(bareDirectory, "Bare.csproj"); + await File.WriteAllTextAsync( + Path.Combine(bareDirectory, "Directory.Build.props"), + "", + cancellationToken + ); + await File.WriteAllTextAsync( + Path.Combine(bareDirectory, "Directory.Build.targets"), + "", + cancellationToken + ); + await File.WriteAllTextAsync( + bareProject, + """ + + + net10.0 + + + """, + cancellationToken + ); + + var (code, stdOut, stdErr) = await withSdk.RunProcessAsync( + "dotnet", + $"msbuild \"{bareProject}\" -nologo -noconlog -getProperty:{string.Join(",", blazorOwned)}", + bareDirectory, + cancellationToken + ); + + await Assert.That(code).IsZero().Because(TestHelpers.GenerateError(stdOut, stdErr)); + foreach (var property in blazorOwned) + { + await Assert.That(stdOut).Contains($"\"{property}\": \"{actual[property]}\"").Because(property); + } + } + + [Test] + public async Task Client_DoesNotAutoReferenceShared(CancellationToken cancellationToken) + { + // Every assembly a client references is downloaded to the browser; a server-side Shared library must + // not join that download because of a folder name. + using var shared = await BlazorFixtures.CreateAsync( + "Shared", + BlazorFixtures.Library(), + cancellationToken: cancellationToken + ); + using var client = await BlazorFixtures.AddSiblingAsync( + shared, + "Shop.Client", + BlazorFixtures.Client(), + cancellationToken: cancellationToken + ); + using var library = await BlazorFixtures.AddSiblingAsync( + shared, + "Shop.Orders", + BlazorFixtures.Library(), + cancellationToken: cancellationToken + ); + + await Assert.That(await client.GetProjectReferencesAsync(cancellationToken)).IsEmpty(); + await Assert + .That((await library.GetProjectReferencesAsync(cancellationToken)).Select(TestHelpers.NormalizePath)) + .Contains("../Shared/Shared.csproj"); + } + + [Test] + public async Task Client_DisablePurviewBlazorWebAssemblyDefaults_RestoresTheGenericDefaults( + CancellationToken cancellationToken + ) + { + using var shared = await BlazorFixtures.CreateAsync( + "Shared", + BlazorFixtures.Library(), + cancellationToken: cancellationToken + ); + using var client = await ProjectHarness + .For("Shop.Client") + .WithNamespacePrefix("Acme") + .WithProjectFileContent(BlazorFixtures.Client()) + .WithDockerfile() + .WithSolutionDirectory(shared.SolutionDirectory) + .BuildAsync(cancellationToken); + + // The opt-out is read where the defaults are, before the project body, so it is set as a global + // property here - the same position Directory.Build.props would give it. + var (code, stdOut, stdErr) = await client.RunMSBuildAsync( + "-noconlog -p:DisablePurviewBlazorWebAssemblyDefaults=true -getProperty:IsContainerProject -getItem:ProjectReference", + cancellationToken + ); + + await Assert.That(code).IsZero().Because(TestHelpers.GenerateError(stdOut, stdErr)); + await Assert.That(stdOut).Contains("\"IsContainerProject\": \"true\""); + await Assert.That(stdOut).Contains("Shared.csproj"); + } +} diff --git a/src/tests/BuildSdk.IntegrationTests/BlazorProjectIdentityTests.cs b/src/tests/BuildSdk.IntegrationTests/BlazorProjectIdentityTests.cs new file mode 100644 index 0000000..66c8da7 --- /dev/null +++ b/src/tests/BuildSdk.IntegrationTests/BlazorProjectIdentityTests.cs @@ -0,0 +1,68 @@ +using Purview.BuildSdk.Infra; + +namespace Purview.BuildSdk; + +/// +/// Pins the namespace and assembly identities the naming rules produce for a Blazor Web App's projects, and +/// that a client's test project resolves its target. +/// +sealed class BlazorProjectIdentityTests +{ + [Test] + [Arguments("Shop.Client", "Microsoft.NET.Sdk.BlazorWebAssembly", "Acme.Shop.Client", "Acme.Shop.Client")] + [Arguments("Shop.Bff", "Microsoft.NET.Sdk.Web", "Acme.Shop.Bff", "Acme.Shop.Bff")] + [Arguments("Shop", "Microsoft.NET.Sdk.Web", "Acme.Shop", "Acme.Shop")] + // ClientShared is a stripped segment, so the namespace collapses onto the product while the assembly + // keeps the full logical name and stays distinct from the host's. + [Arguments("Shop.ClientShared", "Microsoft.NET.Sdk", "Acme.Shop", "Acme.Shop.ClientShared")] + [Arguments("ClientShared", "Microsoft.NET.Sdk", "Acme", "Acme.ClientShared")] + public async Task BlazorWebAppProjects_HaveSensibleIdentities( + string projectName, + string sdk, + string expectedRootNamespace, + string expectedAssemblyName, + CancellationToken cancellationToken + ) + { + var content = BlazorFixtures + .Library() + .Replace("Sdk=\"Microsoft.NET.Sdk\"", $"Sdk=\"{sdk}\"", StringComparison.Ordinal); + using var h = await BlazorFixtures.CreateAsync(projectName, content, cancellationToken: cancellationToken); + + var properties = await h.GetPropertiesAsync(cancellationToken, "RootNamespace", "AssemblyName", "PackageId"); + + await Assert.That(properties["RootNamespace"]).IsEqualTo(expectedRootNamespace); + await Assert.That(properties["AssemblyName"]).IsEqualTo(expectedAssemblyName); + await Assert.That(properties["PackageId"]).IsEqualTo(expectedAssemblyName); + } + + [Test] + public async Task ClientTestProject_ResolvesTheClientAsItsTarget(CancellationToken cancellationToken) + { + using var client = await BlazorFixtures.CreateAsync( + "Shop.Client", + BlazorFixtures.Client(), + cancellationToken: cancellationToken + ); + using var tests = await BlazorFixtures.AddSiblingAsync( + client, + "Shop.Client.UnitTests", + BlazorFixtures.Library(), + cancellationToken: cancellationToken + ); + + var evaluation = await tests.EvaluateAsync( + ["TargetProjectName", "RootNamespace", "AssemblyName", "IsTestProject"], + ["ProjectReference"], + cancellationToken + ); + + await Assert.That(evaluation.Properties["IsTestProject"]).IsEqualTo("true"); + await Assert.That(evaluation.Properties["TargetProjectName"]).IsEqualTo("Shop.Client"); + await Assert.That(evaluation.Properties["RootNamespace"]).IsEqualTo("Acme.Shop.Client"); + await Assert.That(evaluation.Properties["AssemblyName"]).IsEqualTo("Acme.Shop.Client.UnitTests"); + await Assert + .That(evaluation.Items["ProjectReference"].Select(TestHelpers.NormalizePath)) + .Contains("../Shop.Client/Shop.Client.csproj"); + } +} diff --git a/src/tests/BuildSdk.IntegrationTests/BlazorWebAppTests.cs b/src/tests/BuildSdk.IntegrationTests/BlazorWebAppTests.cs new file mode 100644 index 0000000..a28f1e7 --- /dev/null +++ b/src/tests/BuildSdk.IntegrationTests/BlazorWebAppTests.cs @@ -0,0 +1,188 @@ +using System.ComponentModel; +using Purview.BuildSdk.Infra; + +namespace Purview.BuildSdk; + +/// +/// End-to-end Blazor Web App scenarios: a host, its WebAssembly client and a browser-safe Contracts library +/// build with no warnings, and the packages the SDK adds automatically actually run inside the WebAssembly +/// runtime rather than merely compiling. +/// +sealed class BlazorWebAppTests +{ + const string ContractsSource = """ + namespace Acme; + + /// An order placed through the storefront. + /// The order identifier. + /// The order total. + public sealed record Order(string Id, decimal Total); + """; + + [Test] + public async Task BlazorWebApp_WithBrowserSafeContracts_BuildsWithNoWarnings(CancellationToken cancellationToken) + { + using var contracts = await BlazorFixtures.CreateAsync( + "Contracts", + BlazorFixtures.Library("true"), + cancellationToken: cancellationToken + ); + await BlazorFixtures.WritePackageVersionsAsync(contracts, cancellationToken); + await BlazorFixtures.WriteSourceAsync(contracts, "Order.cs", ContractsSource, cancellationToken); + + using var client = await BlazorFixtures.AddSiblingAsync( + contracts, + "Shop.Client", + BlazorFixtures.Client(items: """"""), + cancellationToken: cancellationToken + ); + await BlazorFixtures.WriteSourceAsync(client, "Program.cs", BlazorFixtures.ClientProgram, cancellationToken); + + // The host declares no reference to its client: the /.Client pairing supplies it. + using var host = await BlazorFixtures.AddSiblingAsync( + contracts, + "Shop", + BlazorFixtures.Web( + items: """""" + ), + cancellationToken: cancellationToken + ); + await BlazorFixtures.WriteSourceAsync(host, "Program.cs", BlazorFixtures.HostProgram, cancellationToken); + + var (success, output, errors) = await host.BuildAsync(restore: true, cancellationToken: cancellationToken); + + await Assert.That(success).IsTrue().Because(TestHelpers.GenerateError(output, errors)); + var warnings = output + .Split('\n') + .Where(line => line.Contains(": warning ", StringComparison.Ordinal)) + .Distinct() + .ToArray(); + await Assert.That(warnings).IsEmpty().Because(string.Join('\n', warnings)); + + // The client's framework files reached the host, so the pairing reference really was used. + await Assert + .That( + Directory.Exists( + Path.Combine(client.ProjectDirectory, "bin", "Debug", "net10.0", "wwwroot", "_framework") + ) + ) + .IsTrue(); + } + + [Test] + public async Task AutomaticTelemetryPackages_RunInsideTheWebAssemblyRuntime(CancellationToken cancellationToken) + { + // The SDK adds Purview.Telemetry.SourceGenerator (analyzer-only) and + // Microsoft.Extensions.Telemetry.Abstractions to every non-test project, a WebAssembly client included. + // Compiling is not evidence that they work in the browser, so this publishes a client that exercises + // both - generated activity, log and metric code, and a Telemetry.Abstractions type - and runs it on + // the published WebAssembly runtime, booted under Node. + using var client = await BlazorFixtures.CreateAsync( + "Shop.Client", + BlazorFixtures.Client(), + cancellationToken: cancellationToken + ); + await BlazorFixtures.WritePackageVersionsAsync(client, cancellationToken); + await BlazorFixtures.WriteSourceAsync(client, "Program.cs", TelemetryProbeProgram, cancellationToken); + + var publishDirectory = Path.Combine(client.SolutionDirectory, "publish"); + var (publishCode, publishOutput, publishErrors) = await client.RunProcessAsync( + "dotnet", + $"publish \"{client.ProjectFilePath}\" -nologo -v:q -p:IsPublishable=true -o \"{publishDirectory}\"", + cancellationToken: cancellationToken + ); + + await Assert.That(publishCode).IsZero().Because(TestHelpers.GenerateError(publishOutput, publishErrors)); + // Trimming is where a package that is not browser-safe shows itself first. + await Assert.That(publishOutput).DoesNotContain("warning IL"); + + var framework = Path.Combine(publishDirectory, "wwwroot", "_framework"); + await Assert.That(File.Exists(Path.Combine(framework, "dotnet.js"))).IsTrue(); + + // The runner lives outside the published output, which is only ever passed in as an argument. + var runnerDirectory = Path.Combine(client.SolutionDirectory, "runner"); + Directory.CreateDirectory(runnerDirectory); + var runner = Path.Combine(runnerDirectory, "run-wasm.mjs"); + await File.WriteAllTextAsync(runner, NodeRunner, cancellationToken); + + (int Code, string StdOut, string StdErr) run; + try + { + run = await client.RunProcessAsync( + "node", + $"\"{runner}\" \"{framework}\"", + runnerDirectory, + cancellationToken + ); + } + catch (Win32Exception exception) + { + Skip.Test($"Node is not on PATH, so the WebAssembly runtime cannot be booted: {exception.Message}"); + return; + } + + await Assert.That(run.Code).IsZero().Because(TestHelpers.GenerateError(run.StdOut, run.StdErr)); + await Assert.That(run.StdOut).Contains("PROBE-OK activity=PlacingOrder request=GetOrder"); + } + + const string TelemetryProbeProgram = """ + using System.Diagnostics; + using Microsoft.AspNetCore.Components.WebAssembly.Hosting; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Http.Diagnostics; + using Purview.Telemetry; + + if (args.Contains("--probe")) + { + using var listener = new ActivityListener + { + ShouldListenTo = _ => true, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, + }; + ActivitySource.AddActivityListener(listener); + + ServiceCollection services = new(); + services.AddLogging(); + services.AddMetrics(); + services.AddShopTelemetry(); + using var provider = services.BuildServiceProvider(); + + var telemetry = provider.GetRequiredService(); + using var activity = telemetry.PlacingOrder("order-1"); + telemetry.OrderPlaced("order-1"); + telemetry.RecordOrderValue(42); + + RequestMetadata metadata = new("GET", "/orders/{id}") { RequestName = "GetOrder" }; + Console.WriteLine($"PROBE-OK activity={activity?.OperationName} request={metadata.RequestName}"); + return; + } + + var builder = WebAssemblyHostBuilder.CreateDefault(args); + await builder.Build().RunAsync().ConfigureAwait(false); + + [ActivitySource] + [Logger] + [Meter] + interface IShopTelemetry + { + [Activity] + Activity? PlacingOrder(string orderId); + + [Info] + void OrderPlaced(string orderId); + + [Histogram] + void RecordOrderValue(int value); + } + """; + + const string NodeRunner = """ + // Boots a published Blazor WebAssembly client's runtime under Node and runs its Main with --probe. + import { pathToFileURL } from 'node:url'; + import path from 'node:path'; + + const { dotnet } = await import(pathToFileURL(path.join(process.argv[2], 'dotnet.js')).href); + const runtime = await dotnet.withApplicationArguments('--probe').create(); + process.exit(await runtime.runMain()); + """; +} diff --git a/src/tests/BuildSdk.IntegrationTests/BrowserSafeLibraryTests.cs b/src/tests/BuildSdk.IntegrationTests/BrowserSafeLibraryTests.cs new file mode 100644 index 0000000..71ae119 --- /dev/null +++ b/src/tests/BuildSdk.IntegrationTests/BrowserSafeLibraryTests.cs @@ -0,0 +1,251 @@ +using Purview.BuildSdk.Harness; +using Purview.BuildSdk.Infra; + +namespace Purview.BuildSdk; + +/// +/// Pins what PurviewBrowserSafe=true applies to a class library, that it never overrides what the +/// project set, and that it turns a browser runtime failure - reflection the trimmer cannot follow, an API +/// the browser does not support - into a build failure. +/// +sealed class BrowserSafeLibraryTests +{ + const string ReflectionSource = """ + namespace Acme; + + /// Creates a type by name. + public static class Factory + { + /// Creates an instance of the named type. + /// The assembly-qualified type name. + /// The instance. + public static object? Create(string typeName) => Activator.CreateInstance(Type.GetType(typeName)!); + } + """; + + const string ProcessSource = """ + using System.Diagnostics; + + namespace Acme; + + /// Starts a process. + public static class Launcher + { + /// Starts the tool. + public static void Launch() => Process.Start("tool")?.Dispose(); + } + """; + + [Test] + public async Task BrowserSafe_AppliesTheBrowserSafeDefaults(CancellationToken cancellationToken) + { + using var h = await BlazorFixtures.CreateAsync( + "Contracts", + BlazorFixtures.Library("true"), + cancellationToken: cancellationToken + ); + + var evaluation = await h.EvaluateAsync( + [ + "IsAotCompatible", + "IsTrimmable", + "EnableTrimAnalyzer", + "EnableAotAnalyzer", + "WarningsAsErrors", + "TreatWarningsAsErrors", + ], + ["SupportedPlatform"], + cancellationToken + ); + + await Assert.That(evaluation.Properties["IsAotCompatible"]).IsEqualTo("true"); + await Assert.That(evaluation.Properties["IsTrimmable"]).IsEqualTo("true"); + await Assert.That(evaluation.Properties["EnableTrimAnalyzer"]).IsEqualTo("true"); + await Assert.That(evaluation.Properties["EnableAotAnalyzer"]).IsEqualTo("true"); + await Assert.That(evaluation.Items["SupportedPlatform"]).Contains("browser"); + + var warningsAsErrors = evaluation + .Properties["WarningsAsErrors"] + .Split(';', StringSplitOptions.RemoveEmptyEntries); + await Assert.That(warningsAsErrors).Contains("CA1416"); + await Assert.That(warningsAsErrors).Contains("IL2026"); + await Assert.That(warningsAsErrors).Contains("IL2057"); + await Assert.That(warningsAsErrors).Contains("IL3050"); + // The aim is to make *these* failures fatal, not to change the project's whole warning policy. + await Assert.That(evaluation.Properties["TreatWarningsAsErrors"]).IsNotEqualTo("true"); + } + + [Test] + public async Task BrowserSafe_KeepsWhatTheProjectSet(CancellationToken cancellationToken) + { + using var h = await BlazorFixtures.CreateAsync( + "Contracts", + BlazorFixtures.Library( + """ + true + false + CS0168 + """, + """""" + ), + cancellationToken: cancellationToken + ); + + var evaluation = await h.EvaluateAsync( + ["IsAotCompatible", "WarningsAsErrors"], + ["SupportedPlatform"], + cancellationToken + ); + + await Assert.That(evaluation.Properties["IsAotCompatible"]).IsEqualTo("false"); + await Assert.That(evaluation.Properties["WarningsAsErrors"]).StartsWith("CS0168;"); + await Assert.That(evaluation.Items["SupportedPlatform"].Count(p => p == "browser")).IsEqualTo(1); + } + + [Test] + public async Task BrowserSafeWarningsAsErrorsFalse_LeavesWarningsAsErrorsAlone(CancellationToken cancellationToken) + { + using var h = await BlazorFixtures.CreateAsync( + "Contracts", + BlazorFixtures.Library( + """ + true + false + """ + ), + cancellationToken: cancellationToken + ); + + var properties = await h.GetPropertiesAsync(cancellationToken, "IsAotCompatible", "WarningsAsErrors"); + + await Assert.That(properties["IsAotCompatible"]).IsEqualTo("true"); + await Assert.That(properties["WarningsAsErrors"]).DoesNotContain("IL2026"); + await Assert.That(properties["WarningsAsErrors"]).DoesNotContain("CA1416"); + } + + [Test] + public async Task BrowserSafe_OnAnExecutable_IsNotApplied(CancellationToken cancellationToken) + { + // Only a library is downloaded as a dependency of a client. + using var h = await BlazorFixtures.CreateAsync( + "Shop.Tool", + BlazorFixtures.Library("trueExe"), + cancellationToken: cancellationToken + ); + + var properties = await h.GetPropertiesAsync(cancellationToken, "IsAotCompatible", "WarningsAsErrors"); + + await Assert.That(properties["IsAotCompatible"]).IsEmpty(); + await Assert.That(properties["WarningsAsErrors"]).DoesNotContain("IL2026"); + } + + [Test] + public async Task BrowserSafe_MultiTargeted_SetsIsAotCompatibleOnlyWhereItIsSupported( + CancellationToken cancellationToken + ) + { + using var h = await BlazorFixtures.CreateAsync( + "Contracts", + BlazorFixtures + .Library("true") + .Replace( + "net10.0", + "net10.0;netstandard2.0", + StringComparison.Ordinal + ), + cancellationToken: cancellationToken + ); + + var modern = await h.GetPropertiesAsync( + new Dictionary { ["TargetFramework"] = "net10.0" }, + cancellationToken, + "IsAotCompatible" + ); + var legacy = await h.GetPropertiesAsync( + new Dictionary { ["TargetFramework"] = "netstandard2.0" }, + cancellationToken, + "IsAotCompatible" + ); + + await Assert.That(modern["IsAotCompatible"]).IsEqualTo("true"); + // The .NET SDK warns (NETSDK1210) for IsAotCompatible on a TFM that cannot honour it. + await Assert.That(legacy["IsAotCompatible"]).IsEmpty(); + } + + [Test] + public async Task BrowserSafe_ReflectionTheTrimmerCannotFollow_FailsTheBuild(CancellationToken cancellationToken) + { + using var h = await CreateLibraryAsync( + "true", + ReflectionSource, + cancellationToken + ); + + var (success, output, errors) = await h.BuildAsync(restore: true, cancellationToken: cancellationToken); + + await Assert.That(success).IsFalse().Because(TestHelpers.GenerateError(output, errors)); + await Assert.That(output).Contains("error IL2057"); + } + + [Test] + public async Task BrowserSafe_ProcessStart_FailsTheBuild(CancellationToken cancellationToken) + { + // The shipped .editorconfig sets CA1416 to suggestion; /warnaserror+:CA1416 still has to make a + // browser-unsupported call fatal. If the compiler ever stops letting WarningsAsErrors outrank an + // .editorconfig severity, this is the test that says so. + using var h = await CreateLibraryAsync( + "true", + ProcessSource, + cancellationToken + ); + + var (success, output, errors) = await h.BuildAsync(restore: true, cancellationToken: cancellationToken); + + await Assert.That(success).IsFalse().Because(TestHelpers.GenerateError(output, errors)); + await Assert.That(output).Contains("error CA1416"); + await Assert.That(output).Contains("'browser'"); + } + + [Test] + public async Task BrowserSafeWarningsAsErrorsFalse_ReflectionIsAWarning(CancellationToken cancellationToken) + { + using var h = await CreateLibraryAsync( + "truefalse", + ReflectionSource, + cancellationToken + ); + + var (success, output, errors) = await h.BuildAsync(restore: true, cancellationToken: cancellationToken); + + await Assert.That(success).IsTrue().Because(TestHelpers.GenerateError(output, errors)); + await Assert.That(output).Contains("warning IL2057"); + } + + [Test] + public async Task NotBrowserSafe_TheSameCalls_BuildAsBefore(CancellationToken cancellationToken) + { + using var h = await CreateLibraryAsync("", ReflectionSource, cancellationToken); + await BlazorFixtures.WriteSourceAsync(h, "Launcher.cs", ProcessSource, cancellationToken); + + var (success, output, errors) = await h.BuildAsync(restore: true, cancellationToken: cancellationToken); + + await Assert.That(success).IsTrue().Because(TestHelpers.GenerateError(output, errors)); + await Assert.That(output).DoesNotContain("IL2057"); + await Assert.That(output).DoesNotContain("CA1416"); + } + + static async Task CreateLibraryAsync( + string properties, + string source, + CancellationToken cancellationToken + ) + { + var h = await BlazorFixtures.CreateAsync( + "Contracts", + BlazorFixtures.Library(properties, BlazorFixtures.RemoveAutomaticPackages), + cancellationToken: cancellationToken + ); + await BlazorFixtures.WriteSourceAsync(h, "Source.cs", source, cancellationToken); + return h; + } +} diff --git a/src/tests/BuildSdk.IntegrationTests/BrowserSafeReferenceCheckTests.cs b/src/tests/BuildSdk.IntegrationTests/BrowserSafeReferenceCheckTests.cs new file mode 100644 index 0000000..1dcfdf2 --- /dev/null +++ b/src/tests/BuildSdk.IntegrationTests/BrowserSafeReferenceCheckTests.cs @@ -0,0 +1,209 @@ +using Purview.BuildSdk.Harness; +using Purview.BuildSdk.Infra; + +namespace Purview.BuildSdk; + +/// +/// Pins PRBLZ0001: a WebAssembly client (or a browser-safe library) asks each of its project references +/// whether it sets PurviewBrowserSafe, so no library joins the browser download without anyone +/// deciding it was safe there. +/// +/// +/// One test runs a full build to prove the check is wired into it; the rest invoke the target by name, which +/// asks the same question without restoring the WebAssembly packages. +/// +sealed class BrowserSafeReferenceCheckTests +{ + const string CheckTarget = "-t:PurviewValidateBrowserSafeReferences"; + + [Test] + public async Task Client_ReferencingAnUndeclaredLibrary_WarnsDuringTheBuild(CancellationToken cancellationToken) + { + using var contracts = await BlazorFixtures.CreateAsync( + "Contracts", + BlazorFixtures.Library(items: BlazorFixtures.RemoveAutomaticPackages), + cancellationToken: cancellationToken + ); + await BlazorFixtures.WritePackageVersionsAsync(contracts, cancellationToken); + using var client = await BlazorFixtures.AddSiblingAsync( + contracts, + "Shop.Client", + BlazorFixtures.Client( + items: """""" + + BlazorFixtures.RemoveAutomaticPackages + ), + cancellationToken: cancellationToken + ); + await BlazorFixtures.WriteSourceAsync(client, "Program.cs", BlazorFixtures.ClientProgram, cancellationToken); + + var (success, output, errors) = await client.BuildAsync(restore: true, cancellationToken: cancellationToken); + + await Assert.That(success).IsTrue().Because(TestHelpers.GenerateError(output, errors)); + await Assert.That(output).Contains("warning PRBLZ0001"); + await Assert.That(output).Contains("'Contracts'"); + await Assert.That(output).Contains("true"); + } + + [Test] + public async Task Client_ReferencingABrowserSafeLibrary_DoesNotWarn(CancellationToken cancellationToken) + { + using var library = await BlazorFixtures.CreateAsync( + "Contracts", + BlazorFixtures.Library("true"), + cancellationToken: cancellationToken + ); + using var client = await AddClientAsync(library, cancellationToken: cancellationToken); + + var (code, output, errors) = await client.RunMSBuildAsync(CheckTarget, cancellationToken); + + await Assert.That(code).IsZero().Because(TestHelpers.GenerateError(output, errors)); + await Assert.That(output).DoesNotContain("PRBLZ0001"); + } + + [Test] + public async Task Client_ReferenceMetadataDeclaresItSafe_DoesNotWarn(CancellationToken cancellationToken) + { + // The route for a project that does not use this SDK, and so cannot answer the question itself. + using var library = await BlazorFixtures.CreateAsync( + "Contracts", + BlazorFixtures.Library(), + cancellationToken: cancellationToken + ); + using var client = await AddClientAsync( + library, + referenceMetadata: """ PurviewBrowserSafe="true" """, + cancellationToken: cancellationToken + ); + + var (code, output, errors) = await client.RunMSBuildAsync(CheckTarget, cancellationToken); + + await Assert.That(code).IsZero().Because(TestHelpers.GenerateError(output, errors)); + await Assert.That(output).DoesNotContain("PRBLZ0001"); + } + + [Test] + public async Task Client_ReferenceThatIsNotAnAssembly_IsNotChecked(CancellationToken cancellationToken) + { + using var library = await BlazorFixtures.CreateAsync( + "Contracts", + BlazorFixtures.Library(), + cancellationToken: cancellationToken + ); + using var client = await AddClientAsync( + library, + referenceMetadata: """ ReferenceOutputAssembly="false" """, + cancellationToken: cancellationToken + ); + + var (code, output, errors) = await client.RunMSBuildAsync(CheckTarget, cancellationToken); + + await Assert.That(code).IsZero().Because(TestHelpers.GenerateError(output, errors)); + await Assert.That(output).DoesNotContain("PRBLZ0001"); + } + + [Test] + public async Task Client_CheckAsError_FailsOnAnUndeclaredLibrary(CancellationToken cancellationToken) + { + using var library = await BlazorFixtures.CreateAsync( + "Contracts", + BlazorFixtures.Library(), + cancellationToken: cancellationToken + ); + using var client = await AddClientAsync( + library, + clientProperties: "true", + cancellationToken: cancellationToken + ); + + var (code, output, errors) = await client.RunMSBuildAsync(CheckTarget, cancellationToken); + + await Assert.That(code).IsNotZero().Because(TestHelpers.GenerateError(output, errors)); + await Assert.That(output).Contains("error PRBLZ0001"); + } + + [Test] + public async Task Client_CheckDisabled_DoesNotWarn(CancellationToken cancellationToken) + { + using var library = await BlazorFixtures.CreateAsync( + "Contracts", + BlazorFixtures.Library(), + cancellationToken: cancellationToken + ); + using var client = await AddClientAsync( + library, + clientProperties: "true", + cancellationToken: cancellationToken + ); + + var (code, output, errors) = await client.RunMSBuildAsync(CheckTarget, cancellationToken); + + await Assert.That(code).IsZero().Because(TestHelpers.GenerateError(output, errors)); + await Assert.That(output).DoesNotContain("PRBLZ0001"); + } + + [Test] + public async Task BrowserSafeLibrary_ReferencingAnUndeclaredLibrary_Warns(CancellationToken cancellationToken) + { + // A browser-safe library is downloaded with the client, and so is everything it references: checking + // each browser-safe library's own references is what makes the guarantee hold for the whole download. + using var core = await BlazorFixtures.CreateAsync( + "Core", + BlazorFixtures.Library(), + cancellationToken: cancellationToken + ); + using var contracts = await BlazorFixtures.AddSiblingAsync( + core, + "Contracts", + BlazorFixtures.Library( + "true", + """""" + ), + cancellationToken: cancellationToken + ); + + var (code, output, errors) = await contracts.RunMSBuildAsync(CheckTarget, cancellationToken); + + await Assert.That(code).IsZero().Because(TestHelpers.GenerateError(output, errors)); + await Assert.That(output).Contains("warning PRBLZ0001"); + await Assert.That(output).Contains("'Core'"); + await Assert.That(output).Contains("browser-safe library"); + } + + [Test] + public async Task OrdinaryLibrary_ReferencingAnUndeclaredLibrary_IsNotChecked(CancellationToken cancellationToken) + { + using var core = await BlazorFixtures.CreateAsync( + "Core", + BlazorFixtures.Library(), + cancellationToken: cancellationToken + ); + using var orders = await BlazorFixtures.AddSiblingAsync( + core, + "Orders", + BlazorFixtures.Library(items: """"""), + cancellationToken: cancellationToken + ); + + var (code, output, errors) = await orders.RunMSBuildAsync(CheckTarget, cancellationToken); + + await Assert.That(code).IsZero().Because(TestHelpers.GenerateError(output, errors)); + await Assert.That(output).DoesNotContain("PRBLZ0001"); + } + + /// Adds a WebAssembly client that references . + static Task AddClientAsync( + ProjectHarness library, + string clientProperties = "", + string referenceMetadata = "", + CancellationToken cancellationToken = default + ) => + BlazorFixtures.AddSiblingAsync( + library, + "Shop.Client", + BlazorFixtures.Client( + clientProperties, + $"""""" + ), + cancellationToken: cancellationToken + ); +} diff --git a/src/tests/BuildSdk.IntegrationTests/CompilerVisiblePropertyTests.cs b/src/tests/BuildSdk.IntegrationTests/CompilerVisiblePropertyTests.cs index b19f293..7f306b6 100644 --- a/src/tests/BuildSdk.IntegrationTests/CompilerVisiblePropertyTests.cs +++ b/src/tests/BuildSdk.IntegrationTests/CompilerVisiblePropertyTests.cs @@ -51,6 +51,17 @@ public async Task CompilerVisibleProperties_Include_AllSdkProperties(Cancellatio "IsWebSdkProject", "IsWorkerSdkProject", "IsAspireHostProject", + "IsBlazorWebAssemblyProject", + "IsRazorClassLibraryProject", + "IsBlazorHostProject", + "IsBffProject", + "PurviewBrowserSafe", + "PurviewBrowserSafeWarningsAsErrors", + "DisablePurviewBrowserSafeReferenceCheck", + "PurviewBrowserSafeReferenceCheckAsError", + "DisablePurviewBlazorWebAssemblyDefaults", + "PurviewAutoBlazorClientReference", + "PurviewBffExposesApi", "EditorConfigFilePath", "CurrentYear", "AutoGeneratedAssemblyInfoFile", diff --git a/src/tests/BuildSdk.IntegrationTests/PackagedVersionFallbackTests.cs b/src/tests/BuildSdk.IntegrationTests/PackagedVersionFallbackTests.cs index ba12819..11f36e5 100644 --- a/src/tests/BuildSdk.IntegrationTests/PackagedVersionFallbackTests.cs +++ b/src/tests/BuildSdk.IntegrationTests/PackagedVersionFallbackTests.cs @@ -10,21 +10,26 @@ namespace Purview.BuildSdk; /// /// /// These drift silently and are only noticed by a consumer. PurviewBuildSdkVersionForGlobalJson is -/// the one that bites: it is the version written into a bootstrapped global.json when the SDK -/// cannot detect its own version from the restored package path, so a stale fallback pins a brand-new -/// repository to a release that predates every fix in the SDK doing the bootstrapping. +/// the one that bites: it is the version written into a bootstrapped global.json, so a stale value +/// pins a brand-new repository to a release that predates every fix in the SDK doing the bootstrapping. +/// It is therefore never maintained by hand — it is detected from the restored package folder, or read +/// from the version stamped in at pack time — and only the copy-paste pins in the docs remain, which +/// just version-sync rewrites. /// public sealed partial class PackagedVersionFallbackTests { [GeneratedRegex( - @"]*>\s*(?[^<\s]+)\s*", + @"]*>(?[^<]*)", RegexOptions.Singleline )] - private static partial Regex GlobalJsonVersionFallback(); + private static partial Regex GlobalJsonVersionDefault(); [GeneratedRegex(@"""Purview\.BuildSdk""\s*:\s*""(?[^""]+)""")] private static partial Regex BuildSdkPin(); + [GeneratedRegex(@"# Purview\.BuildSdk (?\S+) - managed file")] + private static partial Regex EditorConfigExampleMarker(); + [GeneratedRegex(@"^#\s*Purview\.BuildSdk\s+(?\S+)")] private static partial Regex EditorConfigVersionMarker(); @@ -61,18 +66,155 @@ static string PackageVersion() ?? throw new InvalidOperationException("package.json has no 'version'."); } + /// + /// Defaults.props is imported before Sdk.props detects the version, so any literal here + /// silently wins over detection — which is how a hand-maintained fallback once pinned every bootstrapped + /// global.json to whatever was last typed into it. + /// [Test] - public async Task GlobalJsonVersionFallback_MatchesPackageJson(CancellationToken cancellationToken) + public async Task GlobalJsonVersionDefault_IsEmpty(CancellationToken cancellationToken) { var defaults = await File.ReadAllTextAsync( Path.Combine(RepositoryRoot, "src", "src", "BuildSdk", "Sdk", "Props", "Defaults.props"), cancellationToken ); - var match = GlobalJsonVersionFallback().Match(defaults); + var match = GlobalJsonVersionDefault().Match(defaults); await Assert.That(match.Success).IsTrue(); - await Assert.That(match.Groups["version"].Value).IsEqualTo(PackageVersion()); + await Assert + .That(match.Groups["version"].Value.Trim()) + .IsEmpty() + .Because( + "Defaults.props must not set PurviewBuildSdkVersionForGlobalJson: it is imported first, so a literal there wins over detecting the version from the package folder and over the pack-time stamp." + ); + } + + /// + /// A restored SDK lives at <id>/<version>/Sdk/, so the folder name is the version a + /// consumer actually restored, and has to be what a bootstrapped global.json pins. + /// + [Test] + public async Task GlobalJsonVersion_IsDetectedFromTheRestoredPackageFolder(CancellationToken cancellationToken) + { + var scratchDirectory = CreateScratchDirectory(); + + try + { + var sdkDirectory = CopySdkTo(Path.Combine(scratchDirectory, "purview.buildsdk", "9.8.7")); + + await Assert + .That(await EvaluateGlobalJsonVersionAsync(sdkDirectory, scratchDirectory, cancellationToken)) + .IsEqualTo("9.8.7") + .Because("a restored SDK's folder name is its version, and must outrank every fallback"); + } + finally + { + Directory.Delete(scratchDirectory, recursive: true); + } + } + + /// + /// Drives the real pack-time stamp into an SDK that does not sit in a versioned package folder, and asserts + /// the version it was packed with is the one a bootstrapped global.json pins. + /// + /// + /// The version asked for is deliberately not the repository's: a stamp that agrees with + /// package.json by coincidence proves nothing. + /// + [Test] + public async Task GlobalJsonVersion_FallsBackToTheStampedPackedVersion(CancellationToken cancellationToken) + { + const string stampedVersion = "4.5.6-test"; + + var projectPath = Path.Combine(RepositoryRoot, "src", "src", "BuildSdk", "BuildSdk.csproj"); + var scratchDirectory = CreateScratchDirectory(); + + try + { + var sdkDirectory = CopySdkTo(Path.Combine(scratchDirectory, "unversioned")); + var stampPath = Path.Combine(sdkDirectory, "Props", "PackedVersion.props"); + + var (exitCode, stdOut, stdErr) = await ProcessRunner.RunDotNetAsync( + $"msbuild \"{projectPath}\" -t:StampPurviewPackedVersionTarget -p:PackageVersion={stampedVersion} \"-p:_PurviewStampedPackedVersionProps={stampPath}\" -v:m", + RepositoryRoot, + cancellationToken + ); + + await Assert.That(exitCode).IsEqualTo(0).Because(stdOut + stdErr); + await Assert.That(File.Exists(stampPath)).IsTrue().Because(stdOut + stdErr); + + await Assert + .That(await EvaluateGlobalJsonVersionAsync(sdkDirectory, scratchDirectory, cancellationToken)) + .IsEqualTo(stampedVersion) + .Because("an SDK outside a versioned package folder must fall back to Props/PackedVersion.props"); + } + finally + { + Directory.Delete(scratchDirectory, recursive: true); + } + } + + static string CreateScratchDirectory() + { + var path = Path.Combine(Path.GetTempPath(), "purview-version-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + + return path; + } + + /// + /// Copies the source SDK to <container>/Sdk, the shape it has inside a package. + /// + static string CopySdkTo(string containerDirectory) + { + var source = SdkPaths.SdkDirectory.Replace('/', Path.DirectorySeparatorChar); + var destination = Path.Combine(containerDirectory, "Sdk"); + + foreach (var file in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories)) + { + // A stamp left in the source tree by a local pack would mask the case under test. + if (Path.GetFileName(file) == "PackedVersion.props") + continue; + + var target = Path.Combine(destination, Path.GetRelativePath(source, file)); + Directory.CreateDirectory(Path.GetDirectoryName(target)!); + File.Copy(file, target); + } + + return destination; + } + + static async Task EvaluateGlobalJsonVersionAsync( + string sdkDirectory, + string workingDirectory, + CancellationToken cancellationToken + ) + { + // A .csproj, because Sdk.props only resolves the version for C# projects. + var projectPath = Path.Combine(workingDirectory, "Probe.csproj"); + await File.WriteAllTextAsync( + projectPath, + $""" + + + Test + + + + """, + cancellationToken + ); + + var (exitCode, stdOut, stdErr) = await ProcessRunner.RunDotNetAsync( + $"msbuild \"{projectPath}\" -nologo -getProperty:PurviewBuildSdkVersionForGlobalJson", + workingDirectory, + cancellationToken + ); + + await Assert.That(exitCode).IsEqualTo(0).Because(stdOut + stdErr); + + return stdOut.Trim(); } /// @@ -187,6 +329,11 @@ CancellationToken cancellationToken /// The quick-start snippets and the starter template are what somebody adopting the SDK copies, so a /// stale pin there is adopted verbatim. /// + /// + /// These are the only version literals left that are maintained in git rather than stamped at pack + /// time, and just version-sync (scripts/sync-version.ts) rewrites exactly this list. The + /// failure names every stale line, so the fix does not need a debugger. + /// [Test] [Arguments("README.md")] [Arguments("docs/wiki/Getting-Started.md")] @@ -194,18 +341,30 @@ CancellationToken cancellationToken public async Task AdoptionPin_MatchesPackageJson(string relativePath, CancellationToken cancellationToken) { var path = Path.Combine(RepositoryRoot, relativePath.Replace('/', Path.DirectorySeparatorChar)); - var content = await File.ReadAllTextAsync(path, cancellationToken); - - var pins = BuildSdkPin() - .Matches(content) - .Select(match => match.Groups["version"].Value) + var lines = await File.ReadAllLinesAsync(path, cancellationToken); + var expected = PackageVersion(); + + var pins = lines + .SelectMany( + (line, index) => + BuildSdkPin() + .Matches(line) + .Concat(EditorConfigExampleMarker().Matches(line)) + .Select(match => (Line: index + 1, Version: match.Groups["version"].Value)) + ) // The property reference in the auto-detection documentation is not a pin. - .Where(version => !version.StartsWith('$')) + .Where(pin => !pin.Version.StartsWith('$')) .ToArray(); - await Assert.That(pins).IsNotEmpty(); + await Assert.That(pins).IsNotEmpty().Because($"{relativePath} is expected to pin Purview.BuildSdk."); + + var stale = pins.Where(pin => pin.Version != expected) + .Select(pin => $"{relativePath}:{pin.Line} pins {pin.Version}") + .ToArray(); - foreach (var pin in pins) - await Assert.That(pin).IsEqualTo(PackageVersion()); + await Assert + .That(stale) + .IsEmpty() + .Because($"package.json is {expected}; run `just version-sync` to update: {string.Join(", ", stale)}"); } } diff --git a/templates/global.json b/templates/global.json index 838474b..1614832 100644 --- a/templates/global.json +++ b/templates/global.json @@ -3,6 +3,6 @@ "runner": "Microsoft.Testing.Platform" }, "msbuild-sdks": { - "Purview.BuildSdk": "1.1.0" + "Purview.BuildSdk": "1.2.0" } } From 63b9e181c49c3dc41a32a3f4ab16b0363b346a19 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 11:52:34 +0000 Subject: [PATCH 2/4] test: compare normalised paths in the declared client reference test MSBuild on Linux and macOS rewrites backslashes in an item spec, so the backslash case read back with forward slashes and failed on ubuntu-latest. --- .../BlazorProjectDetectionTests.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/tests/BuildSdk.IntegrationTests/BlazorProjectDetectionTests.cs b/src/tests/BuildSdk.IntegrationTests/BlazorProjectDetectionTests.cs index 6ddf134..24659fc 100644 --- a/src/tests/BuildSdk.IntegrationTests/BlazorProjectDetectionTests.cs +++ b/src/tests/BuildSdk.IntegrationTests/BlazorProjectDetectionTests.cs @@ -324,7 +324,11 @@ CancellationToken cancellationToken var references = await host.GetProjectReferencesAsync(cancellationToken); - await Assert.That(references).IsEquivalentTo([declaredReference]); + // Normalised because MSBuild on Linux and macOS rewrites the backslashes in an item spec, so the + // second case reads back with forward slashes there. What matters is that there is only one. + await Assert + .That(references.Select(TestHelpers.NormalizePath)) + .IsEquivalentTo([TestHelpers.NormalizePath(declaredReference)]); } [Test] From 3356567cf131237e9dc39fd62fb9b8286580bc29 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 12:00:38 +0000 Subject: [PATCH 3/4] test: run the full integration suite in just test-linux The script still filtered on AgentPackFolderTests, which no longer exists, so it ran nothing. It now runs the whole suite by default (-Filter narrows it) and initialises an empty git repository so the SDK can resolve the repository root. --- scripts/test-linux-docker.ps1 | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/scripts/test-linux-docker.ps1 b/scripts/test-linux-docker.ps1 index 30e7c75..b2c5035 100644 --- a/scripts/test-linux-docker.ps1 +++ b/scripts/test-linux-docker.ps1 @@ -1,6 +1,9 @@ [CmdletBinding()] param( - [string]$Image = "mcr.microsoft.com/dotnet/sdk:10.0.400" + [string]$Image = "mcr.microsoft.com/dotnet/sdk:10.0.400", + # The whole integration suite by default, matching CI. Narrow it with a treenode filter, e.g. + # -Filter '/*/*/BlazorProjectDetectionTests/*'. + [string]$Filter = "/*/*/*/*" ) $ErrorActionPreference = "Stop" @@ -12,11 +15,14 @@ if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { $repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path $containerRepositoryRoot = "/repo" +# The working copy is copied rather than mounted, so the build cannot write into it, and .git is left +# behind with the build output. An empty repository is initialised in its place, because the SDK finds +# the repository root by walking up for .git/. docker run --rm ` --mount "type=bind,source=$repositoryRoot,target=$containerRepositoryRoot,readonly" ` --workdir /work ` $Image ` - bash -c "tar --exclude='.git' --exclude='.vs' --exclude='bin' --exclude='obj' --exclude='artifacts' --exclude='TestResults' --exclude='node_modules' -C /repo -cf - . | tar -C /work -xf - && dotnet test src/tests/BuildSdk.IntegrationTests/BuildSdk.IntegrationTests.csproj -c Release -- --treenode-filter '/*/*/AgentPackFolderTests/*'" + bash -c "tar --exclude='.git' --exclude='.vs' --exclude='bin' --exclude='obj' --exclude='artifacts' --exclude='TestResults' --exclude='node_modules' -C /repo -cf - . | tar -C /work -xf - && git init -q && dotnet test --project src/tests/BuildSdk.IntegrationTests/BuildSdk.IntegrationTests.csproj -c Release --ignore-exit-code 8 --treenode-filter '$Filter'" if ($LASTEXITCODE -ne 0) { throw "Linux integration tests failed with exit code $LASTEXITCODE." From cbfbbdabf9d2c9d03792aea9266684ccb52d6830 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 12:00:56 +0000 Subject: [PATCH 4/4] docs: describe just test-linux as the full integration suite --- Justfile | 2 +- docs/wiki/Release-Flow.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Justfile b/Justfile index 35088f0..a737d6a 100644 --- a/Justfile +++ b/Justfile @@ -102,7 +102,7 @@ test filter="/*/*/*/*/" *args: echo "==> Testing {{ BLUE }}{{ solution_file }}{{ NORMAL }} ({{ GREEN }}{{ build_configuration }}{{ NORMAL }}) with filter {{ YELLOW }}{{ filter }}{{ NORMAL }}" dotnet test --project {{ solution_file }} --configuration {{ build_configuration }} --treenode-filter "{{ filter }}" --ignore-exit-code 8 {{ args }} -# Run agent-pack integration tests in the same Linux SDK environment used by CI +# Run the SDK integration tests in the same Linux SDK environment used by CI [group('Build and Test')] test-linux: pwsh -NoProfile -File ./scripts/test-linux-docker.ps1 diff --git a/docs/wiki/Release-Flow.md b/docs/wiki/Release-Flow.md index a324acf..2ff0233 100644 --- a/docs/wiki/Release-Flow.md +++ b/docs/wiki/Release-Flow.md @@ -69,4 +69,4 @@ The pipeline configuration lives in `purview-build.json`: Windows, Linux, and macOS — never hardcode platform-specific paths in tests or fixtures. - The integration harness (`ProjectHarness`) creates throwaway projects under `Path.GetTempPath()`; see `src/tests/BuildSdk.IntegrationTests/Harness/ProjectHarness.cs`. -- Linux agent-pack integration tests can be run locally via `just test-linux` (Docker-based). \ No newline at end of file +- The SDK integration suite can be run on Linux locally via `just test-linux` (Docker-based). \ No newline at end of file