From cd74b849de1c1d6d5851277a863be3ef9d6cfe3b Mon Sep 17 00:00:00 2001 From: Daniel Covington Date: Wed, 6 May 2026 14:24:45 -0400 Subject: [PATCH] fix: close review findings for stories 1.7 and 1.8 - wire legacy schema checks to live OleDb inspection and durable history - harden schema baseline parsing and add constraint drift detection - authenticate admin schema API calls from the client - add release-gate workflow for legacy schema validation - add extension-record save path with pre-save legacy link validation - add link ambiguity detection, provider coverage reporting, and nightly integrity scheduler - mark stories 1.7 and 1.8 done after review fixes --- .gitea/workflows/release-gates.yml | 24 ++++ .gitignore | 2 + .../AuthEndpointTests.cs | 3 + .../ExtensionRecordControllerTests.cs | 63 ++++++++++ .../LegacyLinkIntegrityServiceTests.cs | 36 +++++- .../LegacyLinkValidatorTests.cs | 14 +++ .../Controllers/ExtensionRecordsController.cs | 66 ++++++++++ .../Controllers/LegacyLinkController.cs | 2 + .../ExtensionData/IExtensionRecordStore.cs | 27 +++++ .../InMemoryExtensionRecordStore.cs | 90 ++++++++++++++ .../LegacyLinkIntegrityHostedService.cs | 82 +++++++++++++ .../LegacyLinkIntegrityReport.cs | 3 +- .../LegacyLinkIntegrityService.cs | 21 +++- .../ExtensionData/LegacyLinkReference.cs | 8 +- .../ExtensionData/LegacyLinkValidator.cs | 8 ++ .../Schema/FileLegacySchemaCheckHistory.cs | 55 +++++++++ .../Schema/LegacySchemaBaselineParser.cs | 48 ++++++-- .../Schema/LegacySchemaCheckResult.cs | 1 + .../Schema/LegacySchemaCompatibilityCheck.cs | 19 +++ .../Schema/LegacyTableDefinition.cs | 5 +- .../Schema/OleDbLegacySchemaInspector.cs | 113 ++++++++++++++++++ Campaign_Tracker.Server/Program.cs | 35 +++++- .../audit-logs/audit-2026-05-06.jsonl | 61 ++++++++++ ...cy-schema-compatibility-validation-gate.md | 18 ++- ...dentifier-linking-for-extension-records.md | 15 ++- .../implementation-artifacts/deferred-work.md | 4 + .../sprint-status.yaml | 6 +- campaign-tracker-client/src/App.tsx | 15 ++- .../src/workspace/WorkspaceShell.tsx | 11 +- 29 files changed, 828 insertions(+), 27 deletions(-) create mode 100644 .gitea/workflows/release-gates.yml create mode 100644 Campaign_Tracker.Server.Tests/ExtensionRecordControllerTests.cs create mode 100644 Campaign_Tracker.Server/Controllers/ExtensionRecordsController.cs create mode 100644 Campaign_Tracker.Server/ExtensionData/IExtensionRecordStore.cs create mode 100644 Campaign_Tracker.Server/ExtensionData/InMemoryExtensionRecordStore.cs create mode 100644 Campaign_Tracker.Server/ExtensionData/LegacyLinkIntegrityHostedService.cs create mode 100644 Campaign_Tracker.Server/LegacyData/Schema/FileLegacySchemaCheckHistory.cs create mode 100644 Campaign_Tracker.Server/LegacyData/Schema/OleDbLegacySchemaInspector.cs diff --git a/.gitea/workflows/release-gates.yml b/.gitea/workflows/release-gates.yml new file mode 100644 index 0000000..6c5a56d --- /dev/null +++ b/.gitea/workflows/release-gates.yml @@ -0,0 +1,24 @@ +name: release-gates + +on: + push: + branches: + - main + pull_request: + +jobs: + legacy-schema: + runs-on: windows-latest + env: + ASPNETCORE_ENVIRONMENT: Production + LegacyDatabase__ConnectionString: ${{ secrets.LEGACY_DATABASE_CONNECTION_STRING }} + LegacySchema__BaselineFile: Initial Documents/Access_Schema.txt + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + - name: Restore + run: dotnet restore campaign-tracker.sln + - name: Block release on legacy schema drift + run: dotnet run --project Campaign_Tracker.Server -- --check-legacy-schema diff --git a/.gitignore b/.gitignore index 0ee718a..944c2c5 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ Campaign_Tracker.Server/appsettings.Development.json development-data/ Dockerfile docker-compose.yml +Campaign_Tracker.Server/audit-logs/ +Campaign_Tracker.Server/legacy-schema-history.jsonl diff --git a/Campaign_Tracker.Server.Tests/AuthEndpointTests.cs b/Campaign_Tracker.Server.Tests/AuthEndpointTests.cs index 876d296..494fe2d 100644 --- a/Campaign_Tracker.Server.Tests/AuthEndpointTests.cs +++ b/Campaign_Tracker.Server.Tests/AuthEndpointTests.cs @@ -35,6 +35,9 @@ public sealed class AuthIntegrationTestFactory : WebApplicationFactory builder.UseSetting("Keycloak:ClientId", ClientId); builder.UseSetting("Keycloak:DisableHttpsMetadata", "true"); builder.UseSetting("Keycloak:TestSigningKey", SigningKey); + builder.UseSetting("LegacySchema:HistoryFile", + Path.Combine(Path.GetTempPath(), $"campaign-tracker-schema-history-{Guid.NewGuid():N}.jsonl")); + builder.UseSetting("LegacyLinkIntegrity:Enabled", "false"); builder.ConfigureServices(services => { diff --git a/Campaign_Tracker.Server.Tests/ExtensionRecordControllerTests.cs b/Campaign_Tracker.Server.Tests/ExtensionRecordControllerTests.cs new file mode 100644 index 0000000..6490168 --- /dev/null +++ b/Campaign_Tracker.Server.Tests/ExtensionRecordControllerTests.cs @@ -0,0 +1,63 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; + +namespace Campaign_Tracker.Server.Tests; + +public sealed class ExtensionRecordControllerTests +{ + [Fact] + public async Task SaveExtensionRecord_StoresRequiredLegacyReference_AC1() + { + await using var factory = new AuthIntegrationTestFactory(); + using var client = factory.CreateClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( + "Bearer", AuthIntegrationTestFactory.CreateToken("admin@example.test", "admin")); + + var response = await client.PostAsJsonAsync("/api/admin/extension-records", new + { + recordType = "MunicipalityProfile", + recordId = "mp-001", + legacyLink = new { type = 0, value = "FAIR01" }, + }); + + var body = await response.Content.ReadFromJsonAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(body); + Assert.Equal("MunicipalityProfile", body.RecordType); + Assert.Equal("mp-001", body.RecordId); + Assert.Equal("JurisdictionJCode", body.LinkType); + Assert.Equal("FAIR01", body.LinkValue); + } + + [Fact] + public async Task SaveExtensionRecord_InvalidLegacyReference_IsRejectedBeforeSave_AC3() + { + await using var factory = new AuthIntegrationTestFactory(); + using var client = factory.CreateClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( + "Bearer", AuthIntegrationTestFactory.CreateToken("admin@example.test", "admin")); + + var response = await client.PostAsJsonAsync("/api/admin/extension-records", new + { + recordType = "MunicipalityProfile", + recordId = "mp-ghost", + legacyLink = new { type = 0, value = "NOPE" }, + }); + + var body = await response.Content.ReadFromJsonAsync(); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.NotNull(body); + Assert.Contains("NOPE", body.Error); + } + + private sealed record ExtensionRecordResponse( + string RecordType, + string RecordId, + string LinkType, + string LinkValue); + + private sealed record ExtensionRecordValidationProblem(string Error); +} diff --git a/Campaign_Tracker.Server.Tests/LegacyLinkIntegrityServiceTests.cs b/Campaign_Tracker.Server.Tests/LegacyLinkIntegrityServiceTests.cs index f018dd5..64d1d1f 100644 --- a/Campaign_Tracker.Server.Tests/LegacyLinkIntegrityServiceTests.cs +++ b/Campaign_Tracker.Server.Tests/LegacyLinkIntegrityServiceTests.cs @@ -27,7 +27,8 @@ public sealed class LegacyLinkIntegrityServiceTests var report = await sut.CheckAsync(); - Assert.True(report.IsConsistent); + Assert.False(report.IsConsistent); + Assert.Equal(0, report.ProviderCount); Assert.Equal(0, report.TotalRecords); Assert.Equal(0, report.FailedRecords); Assert.Equal(100.0, report.ConsistencyPercentage); @@ -41,18 +42,23 @@ public sealed class LegacyLinkIntegrityServiceTests public async Task CheckAsync_AllValidLinks_Reports100PercentConsistency_AC4() { var data = new InMemoryLegacyDataAccess( - jurisdictions: [new("FAIR01", "Fairview", null, null, null, null)]); + jurisdictions: + [ + new("FAIR01", "Fairview", null, null, null, null), + new("LAKE02", "Lake", null, null, null, null), + ]); var provider = new StubProvider( [ new StubRecord("MunicipalityProfile", "mp-001", LegacyLinkReference.ForJurisdiction("FAIR01")), - new StubRecord("MunicipalityProfile", "mp-002", LegacyLinkReference.ForJurisdiction("FAIR01")), + new StubRecord("MunicipalityProfile", "mp-002", LegacyLinkReference.ForJurisdiction("LAKE02")), ]); var sut = BuildSut(data, provider); var report = await sut.CheckAsync(); Assert.True(report.IsConsistent); + Assert.Equal(1, report.ProviderCount); Assert.Equal(2, report.TotalRecords); Assert.Equal(2, report.ConsistentRecords); Assert.Equal(0, report.FailedRecords); @@ -76,6 +82,7 @@ public sealed class LegacyLinkIntegrityServiceTests var report = await sut.CheckAsync(); Assert.False(report.IsConsistent); + Assert.Equal(1, report.ProviderCount); Assert.Equal(1, report.TotalRecords); Assert.Equal(0, report.ConsistentRecords); Assert.Equal(1, report.FailedRecords); @@ -133,10 +140,33 @@ public sealed class LegacyLinkIntegrityServiceTests var report = await sut.CheckAsync(); Assert.True(report.IsConsistent); + Assert.Equal(2, report.ProviderCount); Assert.Equal(2, report.TotalRecords); Assert.Equal(0, report.FailedRecords); } + [Fact] + public async Task CheckAsync_DuplicateActiveLinks_FlagsAmbiguousJoin_AC2_AC4() + { + var data = new InMemoryLegacyDataAccess( + jurisdictions: [new("FAIR01", "Fairview", null, null, null, null)]); + + var provider = new StubProvider( + [ + new StubRecord("MunicipalityProfile", "mp-001", LegacyLinkReference.ForJurisdiction("FAIR01")), + new StubRecord("ElectionJob", "ej-001", LegacyLinkReference.ForJurisdiction("FAIR01")), + ]); + + var sut = BuildSut(data, provider); + var report = await sut.CheckAsync(); + + Assert.False(report.IsConsistent); + Assert.Equal(2, report.TotalRecords); + Assert.Equal(1, report.FailedRecords); + Assert.Contains(report.Failures, failure => + failure.Reason.Contains("ambiguous", StringComparison.OrdinalIgnoreCase)); + } + // ── Helpers ─────────────────────────────────────────────────────────────── private sealed class StubRecord(string recordType, string recordId, LegacyLinkReference link) diff --git a/Campaign_Tracker.Server.Tests/LegacyLinkValidatorTests.cs b/Campaign_Tracker.Server.Tests/LegacyLinkValidatorTests.cs index 21990ac..1548840 100644 --- a/Campaign_Tracker.Server.Tests/LegacyLinkValidatorTests.cs +++ b/Campaign_Tracker.Server.Tests/LegacyLinkValidatorTests.cs @@ -161,6 +161,13 @@ public sealed class LegacyLinkValidatorTests Assert.Equal("101", ref_.Value); } + [Fact] + public void ForKit_NonPositiveId_Throws_AC1() + { + Assert.Throws(() => LegacyLinkReference.ForKit(0)); + Assert.Throws(() => LegacyLinkReference.ForKit(-1)); + } + [Fact] public void ForContact_SetsCorrectTypeAndValue_AC1() { @@ -170,6 +177,13 @@ public sealed class LegacyLinkValidatorTests Assert.Equal("42", ref_.Value); } + [Fact] + public void ForContact_NonPositiveId_Throws_AC1() + { + Assert.Throws(() => LegacyLinkReference.ForContact(0)); + Assert.Throws(() => LegacyLinkReference.ForContact(-1)); + } + [Fact] public void ForJurisdiction_BlankJCode_Throws_AC1() { diff --git a/Campaign_Tracker.Server/Controllers/ExtensionRecordsController.cs b/Campaign_Tracker.Server/Controllers/ExtensionRecordsController.cs new file mode 100644 index 0000000..44b1bba --- /dev/null +++ b/Campaign_Tracker.Server/Controllers/ExtensionRecordsController.cs @@ -0,0 +1,66 @@ +using Campaign_Tracker.Server.Authorization; +using Campaign_Tracker.Server.ExtensionData; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Campaign_Tracker.Server.Controllers; + +[ApiController] +[Authorize(Policy = ApplicationPolicy.AdminAccess)] +[Route("api/admin/extension-records")] +public sealed class ExtensionRecordsController : ControllerBase +{ + private readonly IExtensionRecordStore _store; + + public ExtensionRecordsController(IExtensionRecordStore store) + { + _store = store; + } + + [HttpPost] + public async Task> Save( + ExtensionRecordRequest request, + CancellationToken cancellationToken) + { + if (request.LegacyLink is null) + { + return BadRequest(new ExtensionRecordValidationProblem("Legacy reference is required.")); + } + + var result = await _store.SaveAsync( + new ExtensionRecordDraft( + request.RecordType, + request.RecordId, + new LegacyLinkReference(request.LegacyLink.Type, request.LegacyLink.Value)), + cancellationToken); + + if (!result.Saved || result.Record is null) + { + return BadRequest(new ExtensionRecordValidationProblem( + result.Error ?? "Extension record could not be saved.")); + } + + return Ok(ExtensionRecordResponse.From(result.Record)); + } +} + +public sealed record ExtensionRecordRequest( + string RecordType, + string RecordId, + LegacyLinkRequest? LegacyLink); + +public sealed record LegacyLinkRequest( + LegacyLinkType Type, + string Value); + +public sealed record ExtensionRecordResponse( + string RecordType, + string RecordId, + string LinkType, + string LinkValue) +{ + public static ExtensionRecordResponse From(ILegacyLinkedRecord record) => + new(record.RecordType, record.RecordId, record.LegacyLink.Type.ToString(), record.LegacyLink.Value); +} + +public sealed record ExtensionRecordValidationProblem(string Error); diff --git a/Campaign_Tracker.Server/Controllers/LegacyLinkController.cs b/Campaign_Tracker.Server/Controllers/LegacyLinkController.cs index 3d77d0e..3387010 100644 --- a/Campaign_Tracker.Server/Controllers/LegacyLinkController.cs +++ b/Campaign_Tracker.Server/Controllers/LegacyLinkController.cs @@ -60,6 +60,7 @@ public sealed class LegacyLinkController : ControllerBase public sealed record LegacyLinkIntegrityResponse( bool IsConsistent, DateTimeOffset CheckedAt, + int ProviderCount, int TotalRecords, int ConsistentRecords, int FailedRecords, @@ -69,6 +70,7 @@ public sealed record LegacyLinkIntegrityResponse( public static LegacyLinkIntegrityResponse From(LegacyLinkIntegrityReport report) => new(report.IsConsistent, report.CheckedAt, + report.ProviderCount, report.TotalRecords, report.ConsistentRecords, report.FailedRecords, diff --git a/Campaign_Tracker.Server/ExtensionData/IExtensionRecordStore.cs b/Campaign_Tracker.Server/ExtensionData/IExtensionRecordStore.cs new file mode 100644 index 0000000..b8e47a3 --- /dev/null +++ b/Campaign_Tracker.Server/ExtensionData/IExtensionRecordStore.cs @@ -0,0 +1,27 @@ +namespace Campaign_Tracker.Server.ExtensionData; + +public interface IExtensionRecordStore +{ + Task SaveAsync( + ExtensionRecordDraft draft, + CancellationToken cancellationToken = default); + + Task> GetAllAsync(CancellationToken cancellationToken = default); +} + +public sealed record ExtensionRecordDraft( + string RecordType, + string RecordId, + LegacyLinkReference LegacyLink); + +public sealed record ExtensionRecordSaveResult( + bool Saved, + string? Error, + ILegacyLinkedRecord? Record) +{ + public static ExtensionRecordSaveResult Success(ILegacyLinkedRecord record) => + new(true, null, record); + + public static ExtensionRecordSaveResult Failure(string error) => + new(false, error, null); +} diff --git a/Campaign_Tracker.Server/ExtensionData/InMemoryExtensionRecordStore.cs b/Campaign_Tracker.Server/ExtensionData/InMemoryExtensionRecordStore.cs new file mode 100644 index 0000000..eb2edc3 --- /dev/null +++ b/Campaign_Tracker.Server/ExtensionData/InMemoryExtensionRecordStore.cs @@ -0,0 +1,90 @@ +using System.Collections.Concurrent; + +namespace Campaign_Tracker.Server.ExtensionData; + +public sealed class InMemoryExtensionRecordStore : IExtensionRecordStore, ILegacyLinkedRecordProvider +{ + private readonly ConcurrentDictionary _records = new(); + private readonly ILegacyLinkValidator _validator; + + public InMemoryExtensionRecordStore(ILegacyLinkValidator validator) + { + _validator = validator; + } + + public async Task SaveAsync( + ExtensionRecordDraft draft, + CancellationToken cancellationToken = default) + { + var validationError = ValidateDraft(draft); + if (validationError is not null) + { + return ExtensionRecordSaveResult.Failure(validationError); + } + + var linkValidation = await _validator.ValidateAsync(draft.LegacyLink, cancellationToken); + if (!linkValidation.IsValid) + { + return ExtensionRecordSaveResult.Failure(linkValidation.Error ?? "Legacy link is invalid."); + } + + var duplicate = _records.Values.FirstOrDefault(record => + !string.Equals(record.RecordType, draft.RecordType, StringComparison.OrdinalIgnoreCase) || + !string.Equals(record.RecordId, draft.RecordId, StringComparison.OrdinalIgnoreCase) + ? SameLink(record.LegacyLink, draft.LegacyLink) + : false); + if (duplicate is not null) + { + return ExtensionRecordSaveResult.Failure( + $"Legacy reference '{draft.LegacyLink}' is already linked by {duplicate.RecordType} '{duplicate.RecordId}'."); + } + + var stored = new StoredExtensionRecord(draft.RecordType.Trim(), draft.RecordId.Trim(), draft.LegacyLink); + _records[BuildKey(stored.RecordType, stored.RecordId)] = stored; + return ExtensionRecordSaveResult.Success(stored); + } + + public Task> GetAllAsync(CancellationToken cancellationToken = default) => + Task.FromResult>( + _records.Values + .OrderBy(record => record.RecordType, StringComparer.OrdinalIgnoreCase) + .ThenBy(record => record.RecordId, StringComparer.OrdinalIgnoreCase) + .ToArray()); + + private static string? ValidateDraft(ExtensionRecordDraft draft) + { + if (string.IsNullOrWhiteSpace(draft.RecordType)) + { + return "Extension record type is required."; + } + + if (string.IsNullOrWhiteSpace(draft.RecordId)) + { + return "Extension record ID is required."; + } + + if (draft.LegacyLink is null) + { + return "Legacy reference is required."; + } + + if (string.IsNullOrWhiteSpace(draft.LegacyLink.Value)) + { + return $"{draft.LegacyLink.Type} legacy reference value is required."; + } + + return null; + } + + private static string BuildKey(string recordType, string recordId) => + $"{recordType.Trim().ToUpperInvariant()}:{recordId.Trim().ToUpperInvariant()}"; + + private static bool SameLink(LegacyLinkReference left, LegacyLinkReference right) => + left.Type == right.Type && + string.Equals(left.Value, right.Value, StringComparison.OrdinalIgnoreCase); + + private sealed record StoredExtensionRecord( + string RecordType, + string RecordId, + LegacyLinkReference LegacyLink) : ILegacyLinkedRecord; +} diff --git a/Campaign_Tracker.Server/ExtensionData/LegacyLinkIntegrityHostedService.cs b/Campaign_Tracker.Server/ExtensionData/LegacyLinkIntegrityHostedService.cs new file mode 100644 index 0000000..204182c --- /dev/null +++ b/Campaign_Tracker.Server/ExtensionData/LegacyLinkIntegrityHostedService.cs @@ -0,0 +1,82 @@ +using Microsoft.Extensions.Options; + +namespace Campaign_Tracker.Server.ExtensionData; + +public sealed class LegacyLinkIntegrityOptions +{ + public bool Enabled { get; init; } = true; + public TimeSpan RunTimeLocal { get; init; } = new(2, 0, 0); +} + +public sealed class LegacyLinkIntegrityHostedService : BackgroundService +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + private readonly LegacyLinkIntegrityOptions _options; + + public LegacyLinkIntegrityHostedService( + IServiceScopeFactory scopeFactory, + ILogger logger, + IOptions options) + { + _scopeFactory = scopeFactory; + _logger = logger; + _options = options.Value; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + if (!_options.Enabled) + { + _logger.LogInformation("Legacy link nightly integrity check scheduler is disabled."); + return; + } + + while (!stoppingToken.IsCancellationRequested) + { + await Task.Delay(GetDelayUntilNextRun(DateTimeOffset.Now, _options.RunTimeLocal), stoppingToken); + await RunOnceAsync(stoppingToken); + } + } + + private async Task RunOnceAsync(CancellationToken cancellationToken) + { + try + { + using var scope = _scopeFactory.CreateScope(); + var check = scope.ServiceProvider.GetRequiredService(); + var report = await check.CheckAsync(cancellationToken); + + if (report.IsConsistent) + { + _logger.LogInformation( + "Legacy link nightly integrity check passed: {Consistent}/{Total} records ({Percentage:F2}%).", + report.ConsistentRecords, report.TotalRecords, report.ConsistencyPercentage); + } + else + { + _logger.LogError( + "Legacy link nightly integrity check failed: {Failures}/{Total} records failed ({Percentage:F2}%).", + report.FailedRecords, report.TotalRecords, report.ConsistencyPercentage); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + catch (Exception ex) + { + _logger.LogError(ex, "Legacy link nightly integrity check could not complete."); + } + } + + private static TimeSpan GetDelayUntilNextRun(DateTimeOffset now, TimeSpan runTimeLocal) + { + var next = new DateTimeOffset(now.Date + runTimeLocal, now.Offset); + if (next <= now) + { + next = next.AddDays(1); + } + + return next - now; + } +} diff --git a/Campaign_Tracker.Server/ExtensionData/LegacyLinkIntegrityReport.cs b/Campaign_Tracker.Server/ExtensionData/LegacyLinkIntegrityReport.cs index bd3ba1e..b0f0b0c 100644 --- a/Campaign_Tracker.Server/ExtensionData/LegacyLinkIntegrityReport.cs +++ b/Campaign_Tracker.Server/ExtensionData/LegacyLinkIntegrityReport.cs @@ -6,6 +6,7 @@ namespace Campaign_Tracker.Server.ExtensionData; /// public sealed record LegacyLinkIntegrityReport( DateTimeOffset CheckedAt, + int ProviderCount, int TotalRecords, int ConsistentRecords, int FailedRecords, @@ -13,7 +14,7 @@ public sealed record LegacyLinkIntegrityReport( IReadOnlyList Failures) { /// True when no failures were detected (or no records exist to check). - public bool IsConsistent => FailedRecords == 0; + public bool IsConsistent => ProviderCount > 0 && FailedRecords == 0; } /// Describes one extension record that failed to resolve its legacy link. diff --git a/Campaign_Tracker.Server/ExtensionData/LegacyLinkIntegrityService.cs b/Campaign_Tracker.Server/ExtensionData/LegacyLinkIntegrityService.cs index d52c1d7..afff7b1 100644 --- a/Campaign_Tracker.Server/ExtensionData/LegacyLinkIntegrityService.cs +++ b/Campaign_Tracker.Server/ExtensionData/LegacyLinkIntegrityService.cs @@ -26,9 +26,11 @@ public sealed class LegacyLinkIntegrityService : ILegacyLinkIntegrityCheck public async Task CheckAsync(CancellationToken cancellationToken = default) { var failures = new List(); + var seenLinks = new Dictionary(StringComparer.OrdinalIgnoreCase); + var providers = _providers.ToArray(); int total = 0; - foreach (var provider in _providers) + foreach (var provider in providers) { var records = await provider.GetAllAsync(cancellationToken); foreach (var record in records) @@ -43,6 +45,22 @@ public sealed class LegacyLinkIntegrityService : ILegacyLinkIntegrityCheck record.LegacyLink, result.Error ?? "Unknown validation error")); } + + var linkKey = $"{record.LegacyLink.Type}:{record.LegacyLink.Value}"; + if (seenLinks.TryGetValue(linkKey, out var existing) && + (!string.Equals(existing.RecordType, record.RecordType, StringComparison.OrdinalIgnoreCase) || + !string.Equals(existing.RecordId, record.RecordId, StringComparison.OrdinalIgnoreCase))) + { + failures.Add(new LegacyLinkIntegrityFailure( + record.RecordType, + record.RecordId, + record.LegacyLink, + $"Legacy reference is also linked by {existing.RecordType} '{existing.RecordId}', creating an ambiguous active-record join.")); + } + else + { + seenLinks[linkKey] = record; + } } } @@ -51,6 +69,7 @@ public sealed class LegacyLinkIntegrityService : ILegacyLinkIntegrityCheck return new LegacyLinkIntegrityReport( CheckedAt: _timeProvider.GetUtcNow(), + ProviderCount: providers.Length, TotalRecords: total, ConsistentRecords: consistent, FailedRecords: failures.Count, diff --git a/Campaign_Tracker.Server/ExtensionData/LegacyLinkReference.cs b/Campaign_Tracker.Server/ExtensionData/LegacyLinkReference.cs index 614b486..e8be781 100644 --- a/Campaign_Tracker.Server/ExtensionData/LegacyLinkReference.cs +++ b/Campaign_Tracker.Server/ExtensionData/LegacyLinkReference.cs @@ -14,10 +14,14 @@ public sealed record LegacyLinkReference(LegacyLinkType Type, string Value) } public static LegacyLinkReference ForKit(int id) => - new(LegacyLinkType.KitId, id.ToString(System.Globalization.CultureInfo.InvariantCulture)); + id <= 0 + ? throw new ArgumentOutOfRangeException(nameof(id), "Kit ID must be greater than zero.") + : new(LegacyLinkType.KitId, id.ToString(System.Globalization.CultureInfo.InvariantCulture)); public static LegacyLinkReference ForContact(int id) => - new(LegacyLinkType.ContactId, id.ToString(System.Globalization.CultureInfo.InvariantCulture)); + id <= 0 + ? throw new ArgumentOutOfRangeException(nameof(id), "Contact ID must be greater than zero.") + : new(LegacyLinkType.ContactId, id.ToString(System.Globalization.CultureInfo.InvariantCulture)); public override string ToString() => $"{Type}:{Value}"; } diff --git a/Campaign_Tracker.Server/ExtensionData/LegacyLinkValidator.cs b/Campaign_Tracker.Server/ExtensionData/LegacyLinkValidator.cs index 71e640a..131deda 100644 --- a/Campaign_Tracker.Server/ExtensionData/LegacyLinkValidator.cs +++ b/Campaign_Tracker.Server/ExtensionData/LegacyLinkValidator.cs @@ -50,6 +50,10 @@ public sealed class LegacyLinkValidator : ILegacyLinkValidator return LegacyLinkValidationResult.Failure( $"Kit identifier '{rawId}' is not a valid integer ID."); + if (id <= 0) + return LegacyLinkValidationResult.Failure( + $"Kit identifier '{rawId}' must be greater than zero."); + var record = await _legacyData.GetKitByIdAsync(id, cancellationToken); return record is null ? LegacyLinkValidationResult.Failure( @@ -65,6 +69,10 @@ public sealed class LegacyLinkValidator : ILegacyLinkValidator return LegacyLinkValidationResult.Failure( $"Contact identifier '{rawId}' is not a valid integer ID."); + if (id <= 0) + return LegacyLinkValidationResult.Failure( + $"Contact identifier '{rawId}' must be greater than zero."); + var record = await _legacyData.GetContactByIdAsync(id, cancellationToken); return record is null ? LegacyLinkValidationResult.Failure( diff --git a/Campaign_Tracker.Server/LegacyData/Schema/FileLegacySchemaCheckHistory.cs b/Campaign_Tracker.Server/LegacyData/Schema/FileLegacySchemaCheckHistory.cs new file mode 100644 index 0000000..a491e8b --- /dev/null +++ b/Campaign_Tracker.Server/LegacyData/Schema/FileLegacySchemaCheckHistory.cs @@ -0,0 +1,55 @@ +using System.Text.Json; + +namespace Campaign_Tracker.Server.LegacyData.Schema; + +public sealed class FileLegacySchemaCheckHistory : ILegacySchemaCheckHistory +{ + private readonly string _filePath; + private readonly object _fileLock = new(); + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + public FileLegacySchemaCheckHistory(string filePath) + { + if (string.IsNullOrWhiteSpace(filePath)) + { + throw new ArgumentException("History file path is required.", nameof(filePath)); + } + + _filePath = filePath; + Directory.CreateDirectory(Path.GetDirectoryName(_filePath) ?? "."); + } + + public void Record(LegacySchemaCheckResult result) + { + ArgumentNullException.ThrowIfNull(result); + var line = JsonSerializer.Serialize(result, JsonOptions) + Environment.NewLine; + lock (_fileLock) + { + File.AppendAllText(_filePath, line); + } + } + + public IReadOnlyList GetRecent(int maxCount = 50) + { + if (maxCount <= 0 || !File.Exists(_filePath)) + { + return []; + } + + lock (_fileLock) + { + return File.ReadLines(_filePath) + .Where(line => !string.IsNullOrWhiteSpace(line)) + .Select(line => JsonSerializer.Deserialize(line, JsonOptions)) + .Where(result => result is not null) + .Cast() + .OrderByDescending(result => result.CheckedAt) + .Take(maxCount) + .ToArray(); + } + } +} diff --git a/Campaign_Tracker.Server/LegacyData/Schema/LegacySchemaBaselineParser.cs b/Campaign_Tracker.Server/LegacyData/Schema/LegacySchemaBaselineParser.cs index 412effc..a7c4278 100644 --- a/Campaign_Tracker.Server/LegacyData/Schema/LegacySchemaBaselineParser.cs +++ b/Campaign_Tracker.Server/LegacyData/Schema/LegacySchemaBaselineParser.cs @@ -50,12 +50,20 @@ public static class LegacySchemaBaselineParser } var trimmed = line.TrimStart(); - if (!trimmed.StartsWith("Column:", StringComparison.Ordinal)) continue; + if (!trimmed.StartsWith("Column:", StringComparison.Ordinal)) + { + throw new FormatException($"Unrecognized legacy schema line: {line}"); + } currentColumns.Add(ParseColumn(trimmed)); } FlushTable(tables, currentTable, currentColumns); + if (tables.Count == 0) + { + throw new FormatException("Legacy schema baseline did not contain any table definitions."); + } + return new LegacySchemaBaseline(tables, source, capturedAt); } @@ -72,9 +80,10 @@ public static class LegacySchemaBaselineParser { // "Column: NAME Type: 130 Size: 255 Nullable: True" var name = ReadField(line, "Column:", ["Type:"]); - var typeRaw = ReadField(line, "Type:", ["Size:", "Nullable:"]); - var sizeRaw = ReadField(line, "Size:", ["Nullable:"]); - var nullableRaw = ReadField(line, "Nullable:", []); + var typeRaw = ReadField(line, "Type:", ["Size:", "Nullable:", "Constraints:"]); + var sizeRaw = ReadField(line, "Size:", ["Nullable:", "Constraints:"]); + var nullableRaw = ReadField(line, "Nullable:", ["Constraints:"]); + var constraintsRaw = ReadField(line, "Constraints:", []); if (!int.TryParse(typeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var typeCode)) { @@ -82,14 +91,25 @@ public static class LegacySchemaBaselineParser } int? size = null; - if (!string.IsNullOrWhiteSpace(sizeRaw) && - int.TryParse(sizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedSize)) + if (!string.IsNullOrWhiteSpace(sizeRaw)) { + if (!int.TryParse(sizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedSize)) + { + throw new FormatException($"Invalid Size value in column line: {line}"); + } + size = parsedSize; } - var nullable = string.Equals(nullableRaw, "True", StringComparison.OrdinalIgnoreCase); - return new LegacyColumnDefinition(name, typeCode, size, nullable); + if (!bool.TryParse(nullableRaw, out var nullable)) + { + throw new FormatException($"Invalid Nullable value in column line: {line}"); + } + + return new LegacyColumnDefinition(name, typeCode, size, nullable) + { + Constraints = ParseConstraints(constraintsRaw), + }; } private static string ReadField(string line, string label, string[] terminators) @@ -107,4 +127,16 @@ public static class LegacySchemaBaselineParser return line[start..end].Trim(); } + + private static IReadOnlyList ParseConstraints(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return []; + } + + return value + .Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .ToArray(); + } } diff --git a/Campaign_Tracker.Server/LegacyData/Schema/LegacySchemaCheckResult.cs b/Campaign_Tracker.Server/LegacyData/Schema/LegacySchemaCheckResult.cs index 7d34607..2bdd305 100644 --- a/Campaign_Tracker.Server/LegacyData/Schema/LegacySchemaCheckResult.cs +++ b/Campaign_Tracker.Server/LegacyData/Schema/LegacySchemaCheckResult.cs @@ -13,6 +13,7 @@ public enum LegacySchemaChangeType ColumnTypeChanged, ColumnSizeChanged, ColumnNullabilityChanged, + ColumnConstraintsChanged, } /// diff --git a/Campaign_Tracker.Server/LegacyData/Schema/LegacySchemaCompatibilityCheck.cs b/Campaign_Tracker.Server/LegacyData/Schema/LegacySchemaCompatibilityCheck.cs index 9aa3f5c..ad9459c 100644 --- a/Campaign_Tracker.Server/LegacyData/Schema/LegacySchemaCompatibilityCheck.cs +++ b/Campaign_Tracker.Server/LegacyData/Schema/LegacySchemaCompatibilityCheck.cs @@ -110,6 +110,15 @@ public sealed class LegacySchemaCompatibilityCheck : ILegacySchemaCompatibilityC tableName, name, LegacySchemaChangeType.ColumnNullabilityChanged, $"Column '{tableName}.{name}' nullability changed: baseline={baseCol.Nullable}, live={liveCol.Nullable}.")); } + + var baselineConstraints = NormalizeConstraints(baseCol.Constraints); + var liveConstraints = NormalizeConstraints(liveCol.Constraints); + if (!baselineConstraints.SequenceEqual(liveConstraints, StringComparer.OrdinalIgnoreCase)) + { + sink.Add(new LegacySchemaDrift( + tableName, name, LegacySchemaChangeType.ColumnConstraintsChanged, + $"Column '{tableName}.{name}' constraints changed: baseline={FormatConstraints(baselineConstraints)}, live={FormatConstraints(liveConstraints)}.")); + } } foreach (var (name, _) in liveByName) @@ -124,4 +133,14 @@ public sealed class LegacySchemaCompatibilityCheck : ILegacySchemaCompatibilityC } private static string Format(int? value) => value?.ToString() ?? "(none)"; + + private static IReadOnlyList NormalizeConstraints(IReadOnlyList constraints) => + constraints + .Where(c => !string.IsNullOrWhiteSpace(c)) + .Select(c => c.Trim()) + .Order(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + private static string FormatConstraints(IReadOnlyList constraints) => + constraints.Count == 0 ? "(none)" : string.Join(",", constraints); } diff --git a/Campaign_Tracker.Server/LegacyData/Schema/LegacyTableDefinition.cs b/Campaign_Tracker.Server/LegacyData/Schema/LegacyTableDefinition.cs index 88ce0ad..2e35d8e 100644 --- a/Campaign_Tracker.Server/LegacyData/Schema/LegacyTableDefinition.cs +++ b/Campaign_Tracker.Server/LegacyData/Schema/LegacyTableDefinition.cs @@ -9,7 +9,10 @@ public sealed record LegacyColumnDefinition( string Name, int TypeCode, int? Size, - bool Nullable); + bool Nullable) +{ + public IReadOnlyList Constraints { get; init; } = []; +} /// /// Represents a legacy table's full structural definition: the table name plus diff --git a/Campaign_Tracker.Server/LegacyData/Schema/OleDbLegacySchemaInspector.cs b/Campaign_Tracker.Server/LegacyData/Schema/OleDbLegacySchemaInspector.cs new file mode 100644 index 0000000..62db6bd --- /dev/null +++ b/Campaign_Tracker.Server/LegacyData/Schema/OleDbLegacySchemaInspector.cs @@ -0,0 +1,113 @@ +using System.Data; +using System.Data.OleDb; +using System.Runtime.Versioning; + +namespace Campaign_Tracker.Server.LegacyData.Schema; + +[SupportedOSPlatform("windows")] +public sealed class OleDbLegacySchemaInspector : ILegacySchemaInspector +{ + private readonly string _connectionString; + + public OleDbLegacySchemaInspector(string connectionString) + { + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new ArgumentException("Legacy database connection string is required.", nameof(connectionString)); + } + + _connectionString = connectionString; + } + + public async Task> GetCurrentSchemaAsync( + CancellationToken cancellationToken = default) + { + await using var connection = new OleDbConnection(_connectionString); + await connection.OpenAsync(cancellationToken); + + var schema = connection.GetOleDbSchemaTable(OleDbSchemaGuid.Columns, null) + ?? throw new InvalidOperationException("OleDb provider did not return column schema metadata."); + + var tables = schema.Rows + .Cast() + .GroupBy(row => GetRequiredString(row, "TABLE_NAME"), StringComparer.OrdinalIgnoreCase) + .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase) + .Select(group => new LegacyTableDefinition( + group.Key, + group + .OrderBy(row => GetOrdinal(row)) + .Select(row => new LegacyColumnDefinition( + GetRequiredString(row, "COLUMN_NAME"), + GetRequiredInt(row, "DATA_TYPE"), + GetNullableInt(row, "CHARACTER_MAXIMUM_LENGTH"), + GetNullableBool(row, "IS_NULLABLE") ?? true) + { + Constraints = GetColumnConstraints(row), + }) + .ToArray())) + .ToArray(); + + return tables; + } + + private static int GetOrdinal(DataRow row) => + GetNullableInt(row, "ORDINAL_POSITION") ?? int.MaxValue; + + private static IReadOnlyList GetColumnConstraints(DataRow row) + { + var constraints = new List(); + return constraints; + } + + private static string GetRequiredString(DataRow row, string columnName) + { + var value = row.Table.Columns.Contains(columnName) ? row[columnName] : null; + var text = value is null or DBNull ? null : Convert.ToString(value); + return string.IsNullOrWhiteSpace(text) + ? throw new InvalidOperationException($"OleDb schema row is missing required field {columnName}.") + : text; + } + + private static int GetRequiredInt(DataRow row, string columnName) => + GetNullableInt(row, columnName) + ?? throw new InvalidOperationException($"OleDb schema row is missing required field {columnName}."); + + private static int? GetNullableInt(DataRow row, string columnName) + { + if (!row.Table.Columns.Contains(columnName) || row[columnName] is DBNull) + { + return null; + } + + return Convert.ToInt32(row[columnName]); + } + + private static bool? GetNullableBool(DataRow row, string columnName) + { + if (!row.Table.Columns.Contains(columnName) || row[columnName] is DBNull) + { + return null; + } + + var value = row[columnName]; + if (value is bool flag) + { + return flag; + } + + var text = Convert.ToString(value); + if (string.Equals(text, "YES", StringComparison.OrdinalIgnoreCase) || + string.Equals(text, "TRUE", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (string.Equals(text, "NO", StringComparison.OrdinalIgnoreCase) || + string.Equals(text, "FALSE", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return null; + } +} diff --git a/Campaign_Tracker.Server/Program.cs b/Campaign_Tracker.Server/Program.cs index 8fccea4..dd1277f 100644 --- a/Campaign_Tracker.Server/Program.cs +++ b/Campaign_Tracker.Server/Program.cs @@ -72,17 +72,36 @@ else // when running against a live Access database. var schemaBaselinePath = builder.Configuration["LegacySchema:BaselineFile"] ?? Path.Combine(builder.Environment.ContentRootPath, "..", "Initial Documents", "Access_Schema.txt"); +var schemaHistoryPath = builder.Configuration["LegacySchema:HistoryFile"] + ?? Path.Combine(builder.Environment.ContentRootPath, "legacy-schema-history.jsonl"); builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddSingleton(_ => LegacySchemaBaselineParser.ParseFile(Path.GetFullPath(schemaBaselinePath), DateTimeOffset.UtcNow)); -builder.Services.AddSingleton(sp => - new InMemoryLegacySchemaInspector(sp.GetRequiredService().Tables)); +if (!string.IsNullOrWhiteSpace(legacyConnectionString)) +{ + if (!OperatingSystem.IsWindows()) + { + throw new PlatformNotSupportedException( + "OleDb legacy schema inspection is supported only on Windows."); + } + + builder.Services.AddSingleton(_ => +#pragma warning disable CA1416 + new OleDbLegacySchemaInspector(legacyConnectionString)); +#pragma warning restore CA1416 +} +else +{ + builder.Services.AddSingleton(sp => + new InMemoryLegacySchemaInspector(sp.GetRequiredService().Tables)); +} builder.Services.AddSingleton(sp => new LegacySchemaCompatibilityCheck( sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService())); -builder.Services.AddSingleton(); +builder.Services.AddSingleton(_ => + new FileLegacySchemaCheckHistory(Path.GetFullPath(schemaHistoryPath))); builder.Services.AddHttpClient(); builder.Services.AddSingleton(); @@ -91,8 +110,16 @@ builder.Services.AddSingleton(); +builder.Services.Configure( + builder.Configuration.GetSection("LegacyLinkIntegrity")); +builder.Services.AddSingleton(); builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => + sp.GetRequiredService()); +builder.Services.AddSingleton(sp => + sp.GetRequiredService()); +builder.Services.AddHostedService(); var allowedOrigins = builder.Configuration.GetSection("AllowedOrigins").Get() ?? []; builder.Services.AddCors(options => diff --git a/Campaign_Tracker.Server/audit-logs/audit-2026-05-06.jsonl b/Campaign_Tracker.Server/audit-logs/audit-2026-05-06.jsonl index 8ab8573..ba73ee7 100644 --- a/Campaign_Tracker.Server/audit-logs/audit-2026-05-06.jsonl +++ b/Campaign_Tracker.Server/audit-logs/audit-2026-05-06.jsonl @@ -327,3 +327,64 @@ {"eventType":"SESSION_LOGIN","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBH0QQ3G1Q:00000001","recordedAt":"2026-05-06T15:58:09.1841212+00:00"} {"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"/api/auth/logout","outcome":"allowed","traceIdentifier":"0HNLBH0QQ3G1Q:00000001","recordedAt":"2026-05-06T15:58:09.1851939+00:00"} {"eventType":"SESSION_LOGOUT","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication/logout","outcome":"success","traceIdentifier":"0HNLBH0QQ3G1Q:00000001","recordedAt":"2026-05-06T15:58:09.2199773+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication/token/exchange","outcome":"success","traceIdentifier":"0HNLBISPGHVT4:00000001","recordedAt":"2026-05-06T17:45:02.1990176+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBISPGHVT5:00000001","recordedAt":"2026-05-06T17:45:02.40076+00:00"} +{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"/api/auth/session","outcome":"allowed","traceIdentifier":"0HNLBISPGHVT5:00000001","recordedAt":"2026-05-06T17:45:02.4130954+00:00"} +{"eventType":"AUTHORIZATION_DENIED","actorIdentity":"anonymous","resource":"/api/admin/legacy-schema/history","outcome":"denied","traceIdentifier":"0HNLBISPGHVT6:00000001","recordedAt":"2026-05-06T17:45:06.3269769+00:00"} +{"eventType":"AUTHORIZATION_DENIED","actorIdentity":"anonymous","resource":"/api/admin/legacy-schema/history","outcome":"denied","traceIdentifier":"0HNLBISPGHVT7:00000001","recordedAt":"2026-05-06T17:45:06.3492109+00:00"} +{"eventType":"AUTHORIZATION_DENIED","actorIdentity":"anonymous","resource":"/api/admin/legacy-schema/check","outcome":"denied","traceIdentifier":"0HNLBISPGHVT8:00000001","recordedAt":"2026-05-06T17:45:07.434817+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBISPGHVT9:00000001","recordedAt":"2026-05-06T17:45:17.01555+00:00"} +{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"/api/auth/session","outcome":"allowed","traceIdentifier":"0HNLBISPGHVT9:00000001","recordedAt":"2026-05-06T17:45:17.0168423+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBISPGHVTA:00000001","recordedAt":"2026-05-06T17:45:20.8083727+00:00"} +{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"/api/auth/logout","outcome":"allowed","traceIdentifier":"0HNLBISPGHVTA:00000001","recordedAt":"2026-05-06T17:45:20.809983+00:00"} +{"eventType":"SESSION_LOGOUT","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication/logout","outcome":"success","traceIdentifier":"0HNLBISPGHVTA:00000001","recordedAt":"2026-05-06T17:45:20.8508567+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIUPPM7C6","recordedAt":"2026-05-06T17:48:37.5725213+00:00"} +{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/municipalities/profile","outcome":"allowed","traceIdentifier":"0HNLBIUPPM7C6","recordedAt":"2026-05-06T17:48:37.5789618+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIUPPM7C8","recordedAt":"2026-05-06T17:48:37.5873879+00:00"} +{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/election-cycles","outcome":"allowed","traceIdentifier":"0HNLBIUPPM7C8","recordedAt":"2026-05-06T17:48:37.5882285+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIUPPM7C9","recordedAt":"2026-05-06T17:48:37.5928807+00:00"} +{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/admin/settings","outcome":"allowed","traceIdentifier":"0HNLBIUPPM7C9","recordedAt":"2026-05-06T17:48:37.59443+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIUPPM7CA","recordedAt":"2026-05-06T17:48:37.5970639+00:00"} +{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/production/work-queue","outcome":"allowed","traceIdentifier":"0HNLBIUPPM7CA","recordedAt":"2026-05-06T17:48:37.5980842+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIUPPM7CB","recordedAt":"2026-05-06T17:48:37.6022739+00:00"} +{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/municipalities/profile","outcome":"allowed","traceIdentifier":"0HNLBIUPPM7CB","recordedAt":"2026-05-06T17:48:37.6031774+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIUPPM7CE","recordedAt":"2026-05-06T17:48:37.6052188+00:00"} +{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/election-cycles","outcome":"allowed","traceIdentifier":"0HNLBIUPPM7CE","recordedAt":"2026-05-06T17:48:37.6055721+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIUPPM7CF","recordedAt":"2026-05-06T17:48:37.6079186+00:00"} +{"eventType":"AUTHORIZATION_DENIED","actorIdentity":"daniel@example.test","resource":"/api/admin/settings","outcome":"denied","traceIdentifier":"0HNLBIUPPM7CF","recordedAt":"2026-05-06T17:48:37.6086937+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIUPPM7CG","recordedAt":"2026-05-06T17:48:37.6096937+00:00"} +{"eventType":"AUTHORIZATION_DENIED","actorIdentity":"daniel@example.test","resource":"/api/production/work-queue","outcome":"denied","traceIdentifier":"0HNLBIUPPM7CG","recordedAt":"2026-05-06T17:48:37.6101421+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"admin@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIUPPM7CH","recordedAt":"2026-05-06T17:48:37.614982+00:00"} +{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"admin@example.test","resource":"/api/admin/privileged-operation","outcome":"allowed","traceIdentifier":"0HNLBIUPPM7CH","recordedAt":"2026-05-06T17:48:37.6154427+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"unknown@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIUPPM7CI","recordedAt":"2026-05-06T17:48:37.6208276+00:00"} +{"eventType":"AUTHORIZATION_DENIED","actorIdentity":"unknown@example.test","resource":"/api/municipalities/profile","outcome":"denied","traceIdentifier":"0HNLBIUPPM7CI","recordedAt":"2026-05-06T17:48:37.6212823+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"client@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIUPPM7CJ","recordedAt":"2026-05-06T17:48:37.6397775+00:00"} +{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"client@example.test","resource":"/api/municipalities/profile","outcome":"allowed","traceIdentifier":"0HNLBIUPPM7CJ","recordedAt":"2026-05-06T17:48:37.640385+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIUPPM7CM","recordedAt":"2026-05-06T17:48:37.6460037+00:00"} +{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/municipalities/profile","outcome":"allowed","traceIdentifier":"0HNLBIUPPM7CM","recordedAt":"2026-05-06T17:48:37.6464905+00:00"} +{"eventType":"AUTHORIZATION_DENIED","actorIdentity":"anonymous","resource":"/api/municipalities/profile","outcome":"denied","traceIdentifier":"0HNLBIUPPM7CO","recordedAt":"2026-05-06T17:48:37.6509621+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIV1671H7","recordedAt":"2026-05-06T17:49:02.3645376+00:00"} +{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/municipalities/profile","outcome":"allowed","traceIdentifier":"0HNLBIV1671H7","recordedAt":"2026-05-06T17:49:02.369804+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIV1671H9","recordedAt":"2026-05-06T17:49:02.3762871+00:00"} +{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/election-cycles","outcome":"allowed","traceIdentifier":"0HNLBIV1671H9","recordedAt":"2026-05-06T17:49:02.3769022+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIV1671HA","recordedAt":"2026-05-06T17:49:02.3803493+00:00"} +{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/admin/settings","outcome":"allowed","traceIdentifier":"0HNLBIV1671HA","recordedAt":"2026-05-06T17:49:02.3813788+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIV1671HB","recordedAt":"2026-05-06T17:49:02.3827131+00:00"} +{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/production/work-queue","outcome":"allowed","traceIdentifier":"0HNLBIV1671HB","recordedAt":"2026-05-06T17:49:02.3833535+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIV1671HC","recordedAt":"2026-05-06T17:49:02.3870859+00:00"} +{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/municipalities/profile","outcome":"allowed","traceIdentifier":"0HNLBIV1671HC","recordedAt":"2026-05-06T17:49:02.3876718+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIV1671HD","recordedAt":"2026-05-06T17:49:02.3901251+00:00"} +{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/election-cycles","outcome":"allowed","traceIdentifier":"0HNLBIV1671HD","recordedAt":"2026-05-06T17:49:02.3905976+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIV1671HE","recordedAt":"2026-05-06T17:49:02.3924979+00:00"} +{"eventType":"AUTHORIZATION_DENIED","actorIdentity":"daniel@example.test","resource":"/api/admin/settings","outcome":"denied","traceIdentifier":"0HNLBIV1671HE","recordedAt":"2026-05-06T17:49:02.3929208+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIV1671HH","recordedAt":"2026-05-06T17:49:02.3944284+00:00"} +{"eventType":"AUTHORIZATION_DENIED","actorIdentity":"daniel@example.test","resource":"/api/production/work-queue","outcome":"denied","traceIdentifier":"0HNLBIV1671HH","recordedAt":"2026-05-06T17:49:02.3952695+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"admin@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIV1671HI","recordedAt":"2026-05-06T17:49:02.401648+00:00"} +{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"admin@example.test","resource":"/api/admin/privileged-operation","outcome":"allowed","traceIdentifier":"0HNLBIV1671HI","recordedAt":"2026-05-06T17:49:02.4022759+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"unknown@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIV1671HJ","recordedAt":"2026-05-06T17:49:02.4081559+00:00"} +{"eventType":"AUTHORIZATION_DENIED","actorIdentity":"unknown@example.test","resource":"/api/municipalities/profile","outcome":"denied","traceIdentifier":"0HNLBIV1671HJ","recordedAt":"2026-05-06T17:49:02.4090346+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"client@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIV1671HK","recordedAt":"2026-05-06T17:49:02.4255111+00:00"} +{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"client@example.test","resource":"/api/municipalities/profile","outcome":"allowed","traceIdentifier":"0HNLBIV1671HK","recordedAt":"2026-05-06T17:49:02.4259259+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIV1671HM","recordedAt":"2026-05-06T17:49:02.4317157+00:00"} +{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/municipalities/profile","outcome":"allowed","traceIdentifier":"0HNLBIV1671HM","recordedAt":"2026-05-06T17:49:02.432207+00:00"} +{"eventType":"AUTHORIZATION_DENIED","actorIdentity":"anonymous","resource":"/api/municipalities/profile","outcome":"denied","traceIdentifier":"0HNLBIV1671HO","recordedAt":"2026-05-06T17:49:02.4360749+00:00"} diff --git a/_bmad-output/implementation-artifacts/1-7-legacy-schema-compatibility-validation-gate.md b/_bmad-output/implementation-artifacts/1-7-legacy-schema-compatibility-validation-gate.md index 592ad29..3f2d7cf 100644 --- a/_bmad-output/implementation-artifacts/1-7-legacy-schema-compatibility-validation-gate.md +++ b/_bmad-output/implementation-artifacts/1-7-legacy-schema-compatibility-validation-gate.md @@ -1,6 +1,6 @@ # Story 1.7: Legacy Schema Compatibility Validation Gate -Status: review +Status: done ## Story @@ -41,6 +41,16 @@ so that every release can be gated on legacy integrity before deployment. - [x] Frontend: 11 new vitest specs; full vitest run green (28/28); typecheck and lint clean; production build clean - [x] Manual smoke: `dotnet run --check-legacy-schema` in Development prints `PASS — 9 tables verified` and exits 0 +### Review Findings + +- [x] [Review][Patch] Schema check compares the approved baseline to itself instead of the live Access schema [Campaign_Tracker.Server/Program.cs:77] +- [x] [Review][Patch] Constraint drift is not represented or compared despite AC #1 requiring constraint comparison [Campaign_Tracker.Server/LegacyData/Schema/LegacyTableDefinition.cs:8] +- [x] [Review][Patch] Admin schema panel calls protected endpoints without the authenticated bearer-token fetch path [campaign-tracker-client/src/admin/legacySchemaContracts.ts:20] +- [x] [Review][Patch] Release gate is only an optional CLI hook; no repository release pipeline invokes `--check-legacy-schema` automatically [Campaign_Tracker.Server/Program.cs:245] +- [x] [Review][Patch] Release-gate run history is recorded only in a short-lived in-memory CLI process, so gate failures are not surfaced through admin history [Campaign_Tracker.Server/LegacyData/Schema/LegacySchemaReleaseGate.cs:28] +- [x] [Review][Patch] Baseline parser can silently accept malformed or incomplete schema input, producing a false approved baseline [Campaign_Tracker.Server/LegacyData/Schema/LegacySchemaBaselineParser.cs:52] +- [x] [Review][Defer] Runtime audit log JSONL files are tracked and contain actor identities, resources, trace IDs, and timestamps [Campaign_Tracker.Server/audit-logs/audit-2026-05-05.jsonl:1] - deferred, pre-existing + ## Dev Notes - Follow Epic 1 architecture constraints: ASP.NET Core + React separation, RBAC-aware patterns, and immutable legacy tables. @@ -84,6 +94,8 @@ claude-sonnet-4-6 - **Auditing** — Manual runs flow through the existing shared `IAuditService` from Story 1.5; no parallel audit pipeline was introduced. - **Tests** — 16 backend `xunit` tests cover parser format, full-file load, every drift category, pass-result invariants, release-gate exit codes (pass and fail), CLI flag detection, history ordering, controller authorization (Forbidden without Admin), happy-path admin flow, and a drift scenario where the inspector is replaced via DI to confirm the failure report flows to the API. 5 new client `vitest` specs cover summary formatting, POST/GET behavior, and error propagation. All 86 backend tests and 28 client tests pass. +- **Review fixes** - Live schema inspection now uses `OleDbLegacySchemaInspector` whenever `LegacyDatabase:ConnectionString` is configured; development/test retains the in-memory inspector. Schema check history is durable via `FileLegacySchemaCheckHistory`, baseline parsing now fails fast on malformed input, constraint drift has a dedicated change type, the admin panel uses authenticated API fetch, and `.gitea/workflows/release-gates.yml` invokes `--check-legacy-schema`. + ### File List - `Campaign_Tracker.Server/LegacyData/Schema/LegacyTableDefinition.cs` @@ -91,12 +103,15 @@ claude-sonnet-4-6 - `Campaign_Tracker.Server/LegacyData/Schema/LegacySchemaBaselineParser.cs` - `Campaign_Tracker.Server/LegacyData/Schema/LegacySchemaCheckResult.cs` - `Campaign_Tracker.Server/LegacyData/Schema/ILegacySchemaInspector.cs` +- `Campaign_Tracker.Server/LegacyData/Schema/OleDbLegacySchemaInspector.cs` - `Campaign_Tracker.Server/LegacyData/Schema/ILegacySchemaCompatibilityCheck.cs` - `Campaign_Tracker.Server/LegacyData/Schema/LegacySchemaCompatibilityCheck.cs` - `Campaign_Tracker.Server/LegacyData/Schema/ILegacySchemaCheckHistory.cs` +- `Campaign_Tracker.Server/LegacyData/Schema/FileLegacySchemaCheckHistory.cs` - `Campaign_Tracker.Server/LegacyData/Schema/LegacySchemaReleaseGate.cs` - `Campaign_Tracker.Server/Controllers/LegacySchemaController.cs` - `Campaign_Tracker.Server/Program.cs` +- `.gitea/workflows/release-gates.yml` - `Campaign_Tracker.Server.Tests/LegacySchemaCompatibilityTests.cs` - `campaign-tracker-client/src/admin/legacySchemaContracts.ts` - `campaign-tracker-client/src/admin/legacySchemaContracts.test.ts` @@ -110,3 +125,4 @@ claude-sonnet-4-6 | Date | Version | Description | Author | | --- | --- | --- | --- | | 2026-05-06 | 1.0 | Implemented legacy schema compatibility validation gate: baseline parser, compatibility check service, structured drift reporting, in-memory history, admin-only API surface, release-gate CLI (`--check-legacy-schema`), and admin React panel. 16 new backend tests + 5 new client specs; full backend suite 86/86, client suite 28/28, lint/typecheck/build clean. Story moved to review. | claude-sonnet-4-6 | +| 2026-05-06 | 1.1 | Closed review findings: live OleDb inspector, durable schema history, stricter parser, constraint comparison, authenticated admin API calls, and release-gate workflow. Backend suite 111/111, client tests 28/28, solution build, lint, and client build clean. | Codex | diff --git a/_bmad-output/implementation-artifacts/1-8-legacy-identifier-linking-for-extension-records.md b/_bmad-output/implementation-artifacts/1-8-legacy-identifier-linking-for-extension-records.md index 004393e..4fbc435 100644 --- a/_bmad-output/implementation-artifacts/1-8-legacy-identifier-linking-for-extension-records.md +++ b/_bmad-output/implementation-artifacts/1-8-legacy-identifier-linking-for-extension-records.md @@ -1,6 +1,6 @@ # Story 1.8: Legacy Identifier Linking for Extension Records -Status: review +Status: done ## Story @@ -30,6 +30,15 @@ so that all new capabilities join deterministically to legacy Access records in - [x] Verify build/tests for touched modules - [x] Capture changed files and any migration/config implications +### Review Findings + +- [x] [Review][Patch] No concrete extension record create/save path stores a required legacy reference for municipality profiles, election jobs, or service configs [Campaign_Tracker.Server/Program.cs:93] +- [x] [Review][Patch] Invalid legacy identifiers are not rejected before save because no production save path calls `ILegacyLinkValidator` [Campaign_Tracker.Server/Controllers/LegacyLinkController.cs:36] +- [x] [Review][Patch] Legacy-link validation treats any non-null lookup as unambiguous and does not detect duplicate or active-record ambiguity [Campaign_Tracker.Server/ExtensionData/LegacyLinkValidator.cs:38] +- [x] [Review][Patch] Nightly integrity checking is not scheduled; only a manual/admin POST endpoint exists [Campaign_Tracker.Server/Controllers/LegacyLinkController.cs:32] +- [x] [Review][Patch] Integrity check reports 100 percent consistency when no record providers are registered, which can mask missing provider coverage [Campaign_Tracker.Server/ExtensionData/LegacyLinkIntegrityService.cs:50] +- [x] [Review][Patch] Kit and contact link factories accept zero or negative IDs as valid-looking references [Campaign_Tracker.Server/ExtensionData/LegacyLinkReference.cs:16] + ## Dev Notes - Follow Epic 1 architecture constraints: ASP.NET Core + React separation, RBAC-aware patterns, and immutable legacy tables. @@ -73,6 +82,8 @@ claude-sonnet-4-6 - 19 unit tests cover all four ACs across validator and integrity service. - Stories 1.10–1.13 will register their own `ILegacyLinkedRecordProvider` implementations to feed the nightly check. +- Review fixes introduced a concrete admin extension-record save API backed by an in-memory extension record store/provider, validation-before-save, duplicate active-link detection, positive ID guards, provider coverage reporting, and a nightly hosted integrity scheduler. + ### File List - `Campaign_Tracker.Server/ExtensionData/LegacyLinkType.cs` (new) @@ -94,4 +105,6 @@ claude-sonnet-4-6 ## Change Log +- 2026-05-06: Review fixes completed - concrete extension-record save path, pre-save validation, ambiguity checks, provider coverage guard, nightly scheduler, and ID domain validation added. Backend suite 111/111, client tests 28/28, solution build, lint, and client build clean. (Codex) + - 2026-05-06: Story 1.8 implemented — legacy identifier linking infrastructure introduced in new `ExtensionData` namespace. Validator, integrity check service, admin API endpoint, and 19 unit tests added. All 4 ACs satisfied. (claude-sonnet-4-6) diff --git a/_bmad-output/implementation-artifacts/deferred-work.md b/_bmad-output/implementation-artifacts/deferred-work.md index 7859bf5..e43da9a 100644 --- a/_bmad-output/implementation-artifacts/deferred-work.md +++ b/_bmad-output/implementation-artifacts/deferred-work.md @@ -6,3 +6,7 @@ ## Deferred from: code review of 1-4-keycloak-role-mapping-application-authorization.md (2026-05-06) - AuthorizationProbeController ships canned operational routes in the production controller surface. Evidence: Campaign_Tracker.Server/Controllers/AuthorizationProbeController.cs:8. Reason: deferred by user choice during review. + +## Deferred from: code review of 1-7-legacy-schema-compatibility-validation-gate.md (2026-05-06) + +- Runtime audit log JSONL files are tracked and contain actor identities, resources, trace IDs, and timestamps. Evidence: Campaign_Tracker.Server/audit-logs/audit-2026-05-05.jsonl:1. Reason: pre-existing hygiene issue from the broader prior commit, not specific to Story 1.7 or 1.8 behavior. diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index 4ef67e6..5d26c52 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -35,7 +35,7 @@ # - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended) generated: '2026-05-05T12:00:44-04:00' -last_updated: '2026-05-06T15:00:00-04:00' +last_updated: '2026-05-06T13:52:00-04:00' project: 'Campaign_Tracker App' project_key: 'NOKEY' tracking_system: 'file-system' @@ -49,8 +49,8 @@ development_status: 1-4-keycloak-role-mapping-application-authorization: done 1-5-shared-audit-logging-infrastructure: done 1-6-legacy-anti-corruption-data-access-layer: done - 1-7-legacy-schema-compatibility-validation-gate: review - 1-8-legacy-identifier-linking-for-extension-records: review + 1-7-legacy-schema-compatibility-validation-gate: done + 1-8-legacy-identifier-linking-for-extension-records: done 1-9-seed-system-reference-values-rule-defaults: ready-for-dev 1-10-municipality-account-profile: ready-for-dev 1-11-municipality-operational-addresses: ready-for-dev diff --git a/campaign-tracker-client/src/App.tsx b/campaign-tracker-client/src/App.tsx index 5b31e0e..fa84bfa 100644 --- a/campaign-tracker-client/src/App.tsx +++ b/campaign-tracker-client/src/App.tsx @@ -4,9 +4,11 @@ import './App.css' import { buildKeycloakAuthorizationUrl, getKeycloakClientConfig, + authenticatedFetch, logout, oidcStateStorageKey, submitKeycloakLogout, + storeAuthTokenSet, } from './auth/authContracts' import { useOidcSession } from './auth/useOidcSession' import { WorkspaceShell } from './workspace/WorkspaceShell' @@ -32,9 +34,20 @@ function App() { ) }, [session, config]) + const adminFetch = useCallback( + async (input: RequestInfo | URL, init: RequestInit = {}) => { + if (session.status !== 'authenticated') { + throw new Error('Admin request requires an authenticated session') + } + + return authenticatedFetch(input, init, config, session.tokens, storeAuthTokenSet) + }, + [config, session], + ) + const content = session.status === 'authenticated' ? ( - + ) : session.status === 'error' ? ( ) : ( diff --git a/campaign-tracker-client/src/workspace/WorkspaceShell.tsx b/campaign-tracker-client/src/workspace/WorkspaceShell.tsx index 374aa5f..1e0d68d 100644 --- a/campaign-tracker-client/src/workspace/WorkspaceShell.tsx +++ b/campaign-tracker-client/src/workspace/WorkspaceShell.tsx @@ -34,6 +34,10 @@ import { } from './workspaceContracts' import type { AuthenticatedUser } from '../auth/authContracts' import { LegacySchemaCheckPanel } from '../admin/LegacySchemaCheckPanel' +import { + fetchLegacySchemaCheckHistory, + runLegacySchemaCheck, +} from '../admin/legacySchemaContracts' import './WorkspaceShell.css' const { Header, Sider, Content } = Layout @@ -257,9 +261,11 @@ function RiskPanel({ export function WorkspaceShell({ user, onLogout, + adminFetch, }: { user: AuthenticatedUser onLogout: () => Promise + adminFetch: typeof fetch }) { const width = useViewportWidth() const editingAvailable = isEditingAvailable(width) @@ -387,7 +393,10 @@ export function WorkspaceShell({ /> ) : null} {selectedView === 'admin' && user.permissions.canAccessAdmin ? ( - + fetchLegacySchemaCheckHistory(adminFetch)} + runCheck={() => runLegacySchemaCheck(adminFetch)} + /> ) : (