From b6f00a3a89be8992b1272f6bb6bf4e8cd79d8b9d Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:48:50 -0400 Subject: [PATCH 1/2] chore: cut code-scanning alerts on main (#231) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the code-scanning alert backlog on `main` from 248 to a floor of config-only Scorecard leftovers, matching the noise-floor approach the fleet-wide pilot proved in Extensions-Logging-Data. InspectCode (222 → expected ~0) - AuditTrail.slnx.DotSettings suppresses the eight rules that account for all 222 findings and are either analyzer noise (RedundantUsingDirective, RedundantNameQualifier, RedundantSuppressNullableWarningExpression), the library-defensive-null-check false positives (Condition/NullCoalescing… APIContract), the flat-root-namespace false positive (CheckNamespace), or the public-API "unused" false positives (UnusedAutoPropertyAccessor and NotAccessedPositionalProperty, .Global variants). - Real S8969 findings addressed by removing redundant `!` after Assert.NotNull / flow-narrowed nullable checks in DbContextItemBag, AuditSchemaInstaller, and three test files. - Real Unused/Redundant findings addressed: `context` renamed to `_` in two IHostBuilder lambdas; single-arg `IModelCacheKeyFactory.Create` overload removed (not part of the current interface); explicit type arguments dropped from two invocations. Test entity POCOs get scoped `// ReSharper disable UnusedAutoPropertyAccessor.Local` blocks since EF hydrates them via reflection. zizmor (10 → 0) - template-injection (5): pr.yaml codeql step and release.yaml NUGET_USER check bind context values through env vars. - dependabot-cooldown (2): 7-day cooldown added to both ecosystems. - artipacked (1): integration.yaml checkout gets persist-credentials: false. - superfluous-actions (1): release.yaml attach step swaps softprops/action-gh-release for `gh release upload` (release already exists — workflow only runs on release:published). - dangerous-triggers (1): `pull_request_target` on pr.yaml waived via new zizmor.yml with a documented rationale — migration to `pull_request` is a workflow-wide refactor tracked separately. Scorecard (16, already below the <25 bar) - DangerousWorkflowID x7 all trace to the same `pull_request_target` + refs/pull/… checkout pattern — resolved by the same follow-up. - PinnedDependenciesID x5 are `dotnet restore` calls; requires NuGet lockfiles (has known ETL-family gh-pages interaction — defer). - Config-only findings (SASTID, CodeReviewID, CIIBestPracticesID, BranchProtectionID) left as-is. Closes #231 Co-Authored-By: Claude Opus 4.7 --- .github/dependabot.yml | 7 +++ .github/workflows/codeql.yaml | 18 +++++-- .github/workflows/integration.yaml | 2 + .github/workflows/release.yaml | 29 ++++++---- .github/workflows/workflow-security.yaml | 3 +- AuditTrail.slnx.DotSettings | 54 +++++++++++++++++++ .../Framework/IHostBuilderExtensions.cs | 2 +- src/Wolfgang.AuditTrail.Cli/Program.cs | 2 +- .../Internal/DbContextItemBag.cs | 4 +- .../Schema/AuditSchemaInstaller.cs | 2 +- .../AuditValueSerializerContractTests.cs | 2 +- .../MigrateTests.cs | 2 +- .../AuditCaptureColumnNameTests.cs | 5 ++ .../AuditCapturePostSaveSnapshotTests.cs | 3 ++ .../ModelBuilderConfigurationTests.cs | 8 ++- .../PipeDelimitedEntityKeySerializerTests.cs | 2 +- .../SmallCoverageGapsTests.cs | 2 +- ...ingAuditValueSerializerExactFormatTests.cs | 3 ++ .../UseAuditingTests.cs | 2 +- zizmor.yml | 16 ++++++ 20 files changed, 138 insertions(+), 30 deletions(-) create mode 100644 AuditTrail.slnx.DotSettings create mode 100644 zizmor.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml index af9e08c6..7767d5da 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,6 +8,11 @@ updates: labels: - "dependencies" - "dotnet" + # Cooldown: wait 7 days after a release before proposing an update, so freshly + # published packages have time to have any release-day issues surfaced by the + # ecosystem before they land in a PR here. + cooldown: + default-days: 7 ignore: # The FsCheck.Xunit 3.x migration is done (#175). Keep holding *future major* # bumps (4.x+) so a breaking API change to the published contract-test bases @@ -27,6 +32,8 @@ updates: labels: - "dependencies" - "github-actions" + cooldown: + default-days: 7 groups: github-actions: patterns: diff --git a/.github/workflows/codeql.yaml b/.github/workflows/codeql.yaml index 5f33cfa4..8f45b16f 100644 --- a/.github/workflows/codeql.yaml +++ b/.github/workflows/codeql.yaml @@ -160,14 +160,22 @@ jobs: - name: Complete Security Scan if: always() shell: pwsh + env: + # Bind the workflow-context values to env vars so the PowerShell body + # reads them via $env:… — avoids template-injection into the run script + # (zizmor: template-injection). + CHECK_CSHARP_OUTCOME: ${{ steps.check-csharp.outcome }} + HAS_CSHARP: ${{ steps.check-csharp.outputs.has-csharp }} + BUILD_OUTCOME: ${{ steps.build.outcome }} + CODEQL_OUTCOME: ${{ steps.perform-codeql-analysis.outcome }} run: | Write-Host "=== CodeQL Security Scan Complete ===" -ForegroundColor Cyan - + # Check the outcome of previous steps - $checkCsharpOutcome = "${{ steps.check-csharp.outcome }}" - $hasCsharp = "${{ steps.check-csharp.outputs.has-csharp }}" - $buildOutcome = "${{ steps.build.outcome }}" - $codeqlOutcome = "${{ steps.perform-codeql-analysis.outcome }}" + $checkCsharpOutcome = $env:CHECK_CSHARP_OUTCOME + $hasCsharp = $env:HAS_CSHARP + $buildOutcome = $env:BUILD_OUTCOME + $codeqlOutcome = $env:CODEQL_OUTCOME # Determine overall status $hasFailure = $false diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml index b7276df4..c1ed2040 100644 --- a/.github/workflows/integration.yaml +++ b/.github/workflows/integration.yaml @@ -43,6 +43,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 0961c502..cba5a5eb 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -618,8 +618,13 @@ jobs: - name: Verify trusted-publishing username is configured shell: bash + env: + # Bind the workflow variable into an env var so the shell body reads + # it as "$NUGET_USER" instead of expanding ${{ vars.NUGET_USER }} + # directly into the run script (zizmor: template-injection). + NUGET_USER: ${{ vars.NUGET_USER }} run: | - if [ -z "${{ vars.NUGET_USER }}" ]; then + if [ -z "$NUGET_USER" ]; then echo "::error::vars.NUGET_USER is not set. Add the nuget.org account username under Settings → Secrets and variables → Actions → Variables, and create a Trusted Publishing policy on nuget.org for Chris-Wolfgang/AuditTrail / release.yaml." exit 1 fi @@ -744,12 +749,18 @@ jobs: subject-path: './nuget-packages/*.nupkg' - name: Attach artifacts to release - uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 - with: - tag_name: ${{ github.event.release.tag_name }} - files: | - ./nuget-packages/*.nupkg - ./nuget-packages/*.bom.json - release-coverage.zip - reproducible-build-manifest.json + # Uses the runner-bundled `gh` CLI instead of an external action — the + # release already exists (user-tagged; this workflow only runs on + # release:published), so `gh release upload` attaches to it directly + # (zizmor: superfluous-actions). + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + gh release upload "$RELEASE_TAG" \ + ./nuget-packages/*.nupkg \ + ./nuget-packages/*.bom.json \ + release-coverage.zip \ + reproducible-build-manifest.json \ + --clobber diff --git a/.github/workflows/workflow-security.yaml b/.github/workflows/workflow-security.yaml index 9792e840..51d84992 100644 --- a/.github/workflows/workflow-security.yaml +++ b/.github/workflows/workflow-security.yaml @@ -64,7 +64,8 @@ jobs: continue-on-error: true env: GH_TOKEN: ${{ github.token }} - run: zizmor --format sarif . > zizmor.sarif + # --config points at repo-root zizmor.yml (rule waivers with rationale). + run: zizmor --config zizmor.yml --format sarif . > zizmor.sarif - name: Upload SARIF uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 diff --git a/AuditTrail.slnx.DotSettings b/AuditTrail.slnx.DotSettings new file mode 100644 index 00000000..fdc89011 --- /dev/null +++ b/AuditTrail.slnx.DotSettings @@ -0,0 +1,54 @@ + + + + + DO_NOT_SHOW + + + DO_NOT_SHOW + DO_NOT_SHOW + + + DO_NOT_SHOW + DO_NOT_SHOW + DO_NOT_SHOW + + + DO_NOT_SHOW + DO_NOT_SHOW + diff --git a/src/Wolfgang.AuditTrail.Cli/Framework/IHostBuilderExtensions.cs b/src/Wolfgang.AuditTrail.Cli/Framework/IHostBuilderExtensions.cs index 0046bce6..fbe32b76 100644 --- a/src/Wolfgang.AuditTrail.Cli/Framework/IHostBuilderExtensions.cs +++ b/src/Wolfgang.AuditTrail.Cli/Framework/IHostBuilderExtensions.cs @@ -57,7 +57,7 @@ private static IHostBuilder AddSingleConfigFile bool reloadOnChange ) { - builder.ConfigureAppConfiguration((context, configurationBuilder) => + builder.ConfigureAppConfiguration((_, configurationBuilder) => { configurationBuilder .SetBasePath(AppContext.BaseDirectory) diff --git a/src/Wolfgang.AuditTrail.Cli/Program.cs b/src/Wolfgang.AuditTrail.Cli/Program.cs index 6a95ed9d..b89527ac 100644 --- a/src/Wolfgang.AuditTrail.Cli/Program.cs +++ b/src/Wolfgang.AuditTrail.Cli/Program.cs @@ -37,7 +37,7 @@ private static async Task Main(string[] args) .ReadFrom.Configuration(context.Configuration) .Enrich.WithProperty("Version", Assembly.GetEntryAssembly()?.GetName().Version); }) - .ConfigureServices((context, services) => + .ConfigureServices((_, services) => { services .AddSingleton() diff --git a/src/Wolfgang.AuditTrail.EntityFrameworkCore/Internal/DbContextItemBag.cs b/src/Wolfgang.AuditTrail.EntityFrameworkCore/Internal/DbContextItemBag.cs index 25dc0393..02360b4c 100644 --- a/src/Wolfgang.AuditTrail.EntityFrameworkCore/Internal/DbContextItemBag.cs +++ b/src/Wolfgang.AuditTrail.EntityFrameworkCore/Internal/DbContextItemBag.cs @@ -34,7 +34,7 @@ public static void SetItem(this DbContext context, string key, object? value) public static T? GetItem(this DbContext context, string key) { - if (_state.TryGetValue(context, out var state) && state!.TryGetValue(key, out var value)) + if (_state.TryGetValue(context, out var state) && state.TryGetValue(key, out var value)) { return (T?)value; } @@ -47,7 +47,7 @@ public static void RemoveItem(this DbContext context, string key) { if (_state.TryGetValue(context, out var state)) { - state!.Remove(key); + state.Remove(key); } } diff --git a/src/Wolfgang.AuditTrail.EntityFrameworkCore/Schema/AuditSchemaInstaller.cs b/src/Wolfgang.AuditTrail.EntityFrameworkCore/Schema/AuditSchemaInstaller.cs index 2a6ce06b..613d4bbc 100644 --- a/src/Wolfgang.AuditTrail.EntityFrameworkCore/Schema/AuditSchemaInstaller.cs +++ b/src/Wolfgang.AuditTrail.EntityFrameworkCore/Schema/AuditSchemaInstaller.cs @@ -67,7 +67,7 @@ public async Task DropTablesAsync(DbContext context, CancellationToken cancellat var headerTable = EnsureSafeIdentifier(_options.HeaderTableName, nameof(_options.HeaderTableName)); var schema = string.IsNullOrWhiteSpace(_options.Schema) ? null - : EnsureSafeIdentifier(_options.Schema!, nameof(_options.Schema)); + : EnsureSafeIdentifier(_options.Schema, nameof(_options.Schema)); var detailFqn = QuoteIdentifier(context.Database.ProviderName, schema, detailTable); var headerFqn = QuoteIdentifier(context.Database.ProviderName, schema, headerTable); diff --git a/src/Wolfgang.AuditTrail.TestKit.Xunit/AuditValueSerializerContractTests.cs b/src/Wolfgang.AuditTrail.TestKit.Xunit/AuditValueSerializerContractTests.cs index 44acd7e3..4f2573ca 100644 --- a/src/Wolfgang.AuditTrail.TestKit.Xunit/AuditValueSerializerContractTests.cs +++ b/src/Wolfgang.AuditTrail.TestKit.Xunit/AuditValueSerializerContractTests.cs @@ -180,7 +180,7 @@ public void Null_string_round_trips() [Fact] public void Empty_byte_array_round_trips() { - var decoded = RoundTrip(Array.Empty()); + var decoded = RoundTrip(Array.Empty()); Assert.Equal(Array.Empty(), decoded); } diff --git a/tests/Wolfgang.AuditTrail.Cli.Tests.Unit/MigrateTests.cs b/tests/Wolfgang.AuditTrail.Cli.Tests.Unit/MigrateTests.cs index 4136ff0b..c0a6332d 100644 --- a/tests/Wolfgang.AuditTrail.Cli.Tests.Unit/MigrateTests.cs +++ b/tests/Wolfgang.AuditTrail.Cli.Tests.Unit/MigrateTests.cs @@ -91,7 +91,7 @@ public async Task OnExecuteAsync_when_env_var_set_uses_resolved_value() Assert.Equal(ExitCode.Success, exit); Assert.NotNull(runner.CapturedOptions); - Assert.Equal("Server=.;Database=fromenv", runner.CapturedOptions!.ConnectionString); + Assert.Equal("Server=.;Database=fromenv", runner.CapturedOptions.ConnectionString); } finally { diff --git a/tests/Wolfgang.AuditTrail.EntityFrameworkCore.Tests.Unit/AuditCaptureColumnNameTests.cs b/tests/Wolfgang.AuditTrail.EntityFrameworkCore.Tests.Unit/AuditCaptureColumnNameTests.cs index 15c3bd1d..c584874d 100644 --- a/tests/Wolfgang.AuditTrail.EntityFrameworkCore.Tests.Unit/AuditCaptureColumnNameTests.cs +++ b/tests/Wolfgang.AuditTrail.EntityFrameworkCore.Tests.Unit/AuditCaptureColumnNameTests.cs @@ -84,6 +84,9 @@ public MappedColumnContext(DbContextOptions options, IAudit + // EF hydrates these via reflection; R# can't see the runtime usage. + // ReSharper disable UnusedMember.Local + // ReSharper disable UnusedAutoPropertyAccessor.Local private sealed class MappedItem { public int Id { get; set; } @@ -91,4 +94,6 @@ private sealed class MappedItem [Column("item_display")] public string DisplayName { get; set; } = string.Empty; } + // ReSharper restore UnusedAutoPropertyAccessor.Local + // ReSharper restore UnusedMember.Local } diff --git a/tests/Wolfgang.AuditTrail.EntityFrameworkCore.Tests.Unit/AuditCapturePostSaveSnapshotTests.cs b/tests/Wolfgang.AuditTrail.EntityFrameworkCore.Tests.Unit/AuditCapturePostSaveSnapshotTests.cs index 3b113637..941705db 100644 --- a/tests/Wolfgang.AuditTrail.EntityFrameworkCore.Tests.Unit/AuditCapturePostSaveSnapshotTests.cs +++ b/tests/Wolfgang.AuditTrail.EntityFrameworkCore.Tests.Unit/AuditCapturePostSaveSnapshotTests.cs @@ -120,6 +120,8 @@ public async Task Delete_captures_OriginalValue_from_pre_save_snapshot() // ── Fixture and entity isolated from the shared TestDbContext ─────────── + // EF hydrates these via reflection; R# can't see the runtime usage. + // ReSharper disable UnusedAutoPropertyAccessor.Local [ExcludeFromCodeCoverage] private sealed class Widget { @@ -129,6 +131,7 @@ private sealed class Widget public int RowVersion { get; set; } } + // ReSharper restore UnusedAutoPropertyAccessor.Local diff --git a/tests/Wolfgang.AuditTrail.EntityFrameworkCore.Tests.Unit/ModelBuilderConfigurationTests.cs b/tests/Wolfgang.AuditTrail.EntityFrameworkCore.Tests.Unit/ModelBuilderConfigurationTests.cs index 8f029f90..bc5916e3 100644 --- a/tests/Wolfgang.AuditTrail.EntityFrameworkCore.Tests.Unit/ModelBuilderConfigurationTests.cs +++ b/tests/Wolfgang.AuditTrail.EntityFrameworkCore.Tests.Unit/ModelBuilderConfigurationTests.cs @@ -48,17 +48,15 @@ private TestDbContext BuildContext(string? schema) // testing can't attribute those mutants to these assertions. Force a // fresh model per build so each test actually exercises the config. .ReplaceService(); - return new TestDbContext(builder.Options, new StaticAuditUserProvider("u", null), options); + return new TestDbContext(builder.Options, new StaticAuditUserProvider("u"), options); } - // Interface-mandated signatures; parameters are intentionally unused because a - // unique key per call is exactly what disables the model cache. + // Interface-mandated signature; the parameters are intentionally unused because + // a unique key per call is exactly what disables the model cache. #pragma warning disable RCS1163, S1172 // Unused parameter — intentional (unique key per call) private sealed class UncachedModelCacheKeyFactory : IModelCacheKeyFactory { public object Create(DbContext context, bool designTime) => new object(); - - public object Create(DbContext context) => new object(); } #pragma warning restore RCS1163, S1172 diff --git a/tests/Wolfgang.AuditTrail.EntityFrameworkCore.Tests.Unit/PipeDelimitedEntityKeySerializerTests.cs b/tests/Wolfgang.AuditTrail.EntityFrameworkCore.Tests.Unit/PipeDelimitedEntityKeySerializerTests.cs index 666c74a0..dd94bdb6 100644 --- a/tests/Wolfgang.AuditTrail.EntityFrameworkCore.Tests.Unit/PipeDelimitedEntityKeySerializerTests.cs +++ b/tests/Wolfgang.AuditTrail.EntityFrameworkCore.Tests.Unit/PipeDelimitedEntityKeySerializerTests.cs @@ -115,7 +115,7 @@ public void Serialize_primitive_types_use_invariant_formatting(object value, str { var sut = new PipeDelimitedEntityKeySerializer(); - Assert.Equal(expected, sut.Serialize(new object?[] { value })); + Assert.Equal(expected, sut.Serialize(new[] { value })); } diff --git a/tests/Wolfgang.AuditTrail.EntityFrameworkCore.Tests.Unit/SmallCoverageGapsTests.cs b/tests/Wolfgang.AuditTrail.EntityFrameworkCore.Tests.Unit/SmallCoverageGapsTests.cs index 0061aaa6..e9614c4a 100644 --- a/tests/Wolfgang.AuditTrail.EntityFrameworkCore.Tests.Unit/SmallCoverageGapsTests.cs +++ b/tests/Wolfgang.AuditTrail.EntityFrameworkCore.Tests.Unit/SmallCoverageGapsTests.cs @@ -128,7 +128,7 @@ public void AuditMigrationsDbContext_when_schema_is_set_routes_version_table_und using var ctx = new AuditMigrationsDbContext(dbOptions, auditOptions); var entity = ctx.Model.FindEntityType(typeof(AuditSchemaVersion)); Assert.NotNull(entity); - Assert.Equal("myaudit", entity!.GetSchema()); + Assert.Equal("myaudit", entity.GetSchema()); } diff --git a/tests/Wolfgang.AuditTrail.EntityFrameworkCore.Tests.Unit/StringAuditValueSerializerExactFormatTests.cs b/tests/Wolfgang.AuditTrail.EntityFrameworkCore.Tests.Unit/StringAuditValueSerializerExactFormatTests.cs index 3d5fc4ae..2c580674 100644 --- a/tests/Wolfgang.AuditTrail.EntityFrameworkCore.Tests.Unit/StringAuditValueSerializerExactFormatTests.cs +++ b/tests/Wolfgang.AuditTrail.EntityFrameworkCore.Tests.Unit/StringAuditValueSerializerExactFormatTests.cs @@ -17,6 +17,9 @@ public sealed class StringAuditValueSerializerExactFormatTests { private enum Color { + // Kept so the enum has more than one member — the encoding test only + // exercises Green, but a single-member enum would be an odd shape. + // ReSharper disable once UnusedMember.Local Red, Green, } diff --git a/tests/Wolfgang.AuditTrail.EntityFrameworkCore.Tests.Unit/UseAuditingTests.cs b/tests/Wolfgang.AuditTrail.EntityFrameworkCore.Tests.Unit/UseAuditingTests.cs index f8eb6cca..9a8c2873 100644 --- a/tests/Wolfgang.AuditTrail.EntityFrameworkCore.Tests.Unit/UseAuditingTests.cs +++ b/tests/Wolfgang.AuditTrail.EntityFrameworkCore.Tests.Unit/UseAuditingTests.cs @@ -41,7 +41,7 @@ public void UseAuditing_adds_AuditSaveChangesInterceptor_to_DbContextOptions() ?.Interceptors; Assert.NotNull(interceptors); - Assert.Contains(interceptors!, i => i is AuditSaveChangesInterceptor); + Assert.Contains(interceptors, i => i is AuditSaveChangesInterceptor); } diff --git a/zizmor.yml b/zizmor.yml new file mode 100644 index 00000000..2e7be95d --- /dev/null +++ b/zizmor.yml @@ -0,0 +1,16 @@ +# zizmor configuration — see https://docs.zizmor.sh/configuration/ +# +# rules..ignore is a list of "path[:line[:column]]" globs. Findings whose +# location matches ANY entry are suppressed. Keep every waiver justified inline; +# a bare entry is a technical-debt marker with no accountability. +rules: + dangerous-triggers: + ignore: + # pr.yaml deliberately uses `pull_request_target` so gating checks run from + # the base-branch definition of pr.yaml (not from PR-authored copies), which + # is required for the coverage / analyzer / license gates to be tamper-proof + # on this workflow's design. AuditTrail has no external forked-PR + # contributors today, so the untrusted-code-checkout risk that motivates the + # zizmor warning does not apply here. Migration to `pull_request` (per the + # fleet-wide preference in CLAUDE MEMORY.md) is tracked separately. + - pr.yaml From 27e0c17016d5b46aaec4425a35da43d77ab7b21a Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:37:03 -0400 Subject: [PATCH 2/2] test: pass StringComparer.Ordinal to Assert.Contains/NotEqual/DoesNotContain (MA0002) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 2 on #235 caught seven new MA0002 errors after #234 bumped Meziantou.Analyzer 3.0.142 → 3.0.164. The newer analyzer flags xunit Assert.Contains / DoesNotContain over IEnumerable and Assert.NotEqual over two strings as needing an explicit IEqualityComparer, per the rule's `use-comparer-that-controls-equality` guidance. Pass StringComparer.Ordinal to each flagged call — matches how xunit already resolves the parameterless overload for strings (ordinal via generic Equals), so no behavior change: - AuditCaptureBranchTests.cs:93,94 - AuditingDbContextSaveChangesTests.cs:32 - PipeDelimitedEntityKeySerializerTests.cs:34,64,82,83 All 22 affected tests still pass on net10.0 locally. Co-Authored-By: Claude Opus 4.7 --- .../AuditCaptureBranchTests.cs | 4 ++-- .../AuditingDbContextSaveChangesTests.cs | 2 +- .../PipeDelimitedEntityKeySerializerTests.cs | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/Wolfgang.AuditTrail.EntityFrameworkCore.Tests.Unit/AuditCaptureBranchTests.cs b/tests/Wolfgang.AuditTrail.EntityFrameworkCore.Tests.Unit/AuditCaptureBranchTests.cs index fd870a89..39f89c8e 100644 --- a/tests/Wolfgang.AuditTrail.EntityFrameworkCore.Tests.Unit/AuditCaptureBranchTests.cs +++ b/tests/Wolfgang.AuditTrail.EntityFrameworkCore.Tests.Unit/AuditCaptureBranchTests.cs @@ -90,8 +90,8 @@ public async Task Update_excludes_NotAudited_columns_from_detail_rows() .Select(d => d.ColumnName) .ToListAsync(); - Assert.Contains(nameof(Customer.Name), columns); - Assert.DoesNotContain(nameof(Customer.Notes), columns); + Assert.Contains(nameof(Customer.Name), columns, StringComparer.Ordinal); + Assert.DoesNotContain(nameof(Customer.Notes), columns, StringComparer.Ordinal); } diff --git a/tests/Wolfgang.AuditTrail.EntityFrameworkCore.Tests.Unit/AuditingDbContextSaveChangesTests.cs b/tests/Wolfgang.AuditTrail.EntityFrameworkCore.Tests.Unit/AuditingDbContextSaveChangesTests.cs index dd0e76ed..94eaa3f4 100644 --- a/tests/Wolfgang.AuditTrail.EntityFrameworkCore.Tests.Unit/AuditingDbContextSaveChangesTests.cs +++ b/tests/Wolfgang.AuditTrail.EntityFrameworkCore.Tests.Unit/AuditingDbContextSaveChangesTests.cs @@ -29,7 +29,7 @@ public async Task SaveChangesAsync_when_inserting_a_customer_writes_header_and_d var detailsByColumn = header.Details.ToDictionary(d => d.ColumnName, StringComparer.Ordinal); Assert.Equal("Alice", detailsByColumn["Name"].ValueText); Assert.Equal("alice@example.com", detailsByColumn["Email"].ValueText); - Assert.DoesNotContain("Notes", detailsByColumn.Keys); + Assert.DoesNotContain("Notes", detailsByColumn.Keys, StringComparer.Ordinal); } [Fact] diff --git a/tests/Wolfgang.AuditTrail.EntityFrameworkCore.Tests.Unit/PipeDelimitedEntityKeySerializerTests.cs b/tests/Wolfgang.AuditTrail.EntityFrameworkCore.Tests.Unit/PipeDelimitedEntityKeySerializerTests.cs index dd94bdb6..378493c8 100644 --- a/tests/Wolfgang.AuditTrail.EntityFrameworkCore.Tests.Unit/PipeDelimitedEntityKeySerializerTests.cs +++ b/tests/Wolfgang.AuditTrail.EntityFrameworkCore.Tests.Unit/PipeDelimitedEntityKeySerializerTests.cs @@ -31,7 +31,7 @@ public void Serialize_distinct_byte_array_keys_produce_distinct_strings() var a = sut.Serialize(new object?[] { new byte[] { 0x01, 0x02, 0x03 } }); var b = sut.Serialize(new object?[] { new byte[] { 0x04, 0x05, 0x06 } }); - Assert.NotEqual(a, b); + Assert.NotEqual(a, b, StringComparer.Ordinal); Assert.Equal("010203", a); Assert.Equal("040506", b); } @@ -61,7 +61,7 @@ public void Serialize_DateTime_with_fractional_seconds_does_not_truncate() var sa = sut.Serialize(new object?[] { a }); var sb = sut.Serialize(new object?[] { b }); - Assert.NotEqual(sa, sb); + Assert.NotEqual(sa, sb, StringComparer.Ordinal); // "o" (round-trip) format preserves full tick precision + Z kind suffix. Assert.EndsWith("Z", sa, StringComparison.Ordinal); } @@ -79,8 +79,8 @@ public void Serialize_DateTimeOffset_preserves_offset_and_fractional_seconds() var c = new DateTimeOffset(2026, 1, 15, 14, 30, 45, TimeSpan.FromHours(2)); var d = new DateTimeOffset(2026, 1, 15, 12, 30, 45, TimeSpan.Zero); - Assert.NotEqual(sut.Serialize(new object?[] { a }), sut.Serialize(new object?[] { b })); - Assert.NotEqual(sut.Serialize(new object?[] { c }), sut.Serialize(new object?[] { d })); + Assert.NotEqual(sut.Serialize(new object?[] { a }), sut.Serialize(new object?[] { b }), StringComparer.Ordinal); + Assert.NotEqual(sut.Serialize(new object?[] { c }), sut.Serialize(new object?[] { d }), StringComparer.Ordinal); }