test(coverage): close Theme 8 — 13 test-coverage findings, +35 tests

13 well-bounded test-coverage gaps closed across 11 test projects.
Net +35 regression tests; no production code changes except the
SiteEventLogger src reference unchanged (W3 redacted only test code).

Test additions:
- CLI-022: CommandTreeTests pinned-count assertion bumped 14→16 and
  3 InlineData rows added for the audit + bundle command groups.
- Commons-020: new TransportRecordsTests covers BundleManifest /
  ExportSelection / ImportPreview / ImportResolution / ImportResult —
  ctor + System.Text.Json round-trip + record-equality (14 tests).
- CD-024: SPLIT-RANGE failure-continuation now under
  EnsureLookahead_SecondSplitThrows_LoopAborts_FirstBoundaryStillCommitted
  (Skippable MS-SQL fixture); production-shape rowversion delete
  asserted by DeleteDeploymentRecord_CurrentRowVersion_StubAttachPath_DeleteSucceeds.
- CentralUI-033: new QueryStringDrillInTests with 4 bUnit cases for
  Transport + SiteCalls drill-in / query-string handling.
- DM-024: probe actors (ReconcileProbeActor, SerializationProbeActor,
  ArtifactProbeActor) refactored from static fields to per-test instances
  (Interlocked on counter) — all 31 callers updated; no production
  changes required.
- HM-022: real-time PeriodicTimer test flake fixed by replacing
  fixed-budget Task.Delay with a RunLoopUntil poll-until-condition
  helper (5s/25ms). Production loop untouched.
- InboundAPI-023: new EndpointExtensionsTests covers the
  POST /api/{methodName} composition wiring via TestServer (7 cases:
  happy path, missing key 401, unknown method 403, invalid JSON 400,
  missing param 400, script-throws 500 sanitised, AuditActorItemKey
  stash invariant).
- MgmtSvc-021: 6 new ManagementActorTests cover the Transport bundle
  handlers (role gate for Export/Preview/Import, unknown-name
  ManagementCommandException, blocker-rejection, dedupe last-write-wins).
- SCA-006: SiteCallQueryRequest_StuckOnly_CursorAtNonStuckBoundary_SkipsToNextStuckRow
  pins the missing boundary case.
- SEL-023: stress-test `bool stop` promoted to `volatile bool` for
  cross-thread visibility under release/JIT.

Verify-only resolutions:
- NS-024: closed by NS-019 (commit ac96b83 deletion of
  NotificationDeliveryService + its test file). No edits needed.
- NotifOutbox-008: FallbackMaxRetries/FallbackRetryDelay are private
  forward-compat constants returned only when no SMTP-config row exists
  (in which case EmailNotificationDeliveryAdapter returns Permanent,
  bypassing the values entirely). Marked Resolved with note.
- Transport-010: Overwrite child-collection sync covered by the T-001/
  T-002 tests added in commit e3ca9af; per-IP throttle by
  BundleUnlockRateLimiterTests; failed-session retention by
  BundleSessionStoreTests; T-009 closed structurally via AsyncLocal.
  Marked Resolved by reference.

Build clean; all 11 affected test suites green. README regenerated:
33 open (was 46).
This commit is contained in:
Joseph Doherty
2026-05-28 08:21:03 -04:00
parent 46cb6965ac
commit d190345ef0
26 changed files with 1725 additions and 155 deletions
+49 -1
View File
@@ -18,6 +18,10 @@ public class CommandTreeTests
private static readonly Option<string> Password = new("--password") { Recursive = true };
private static readonly Option<string> Format = CliOptions.CreateFormatOption();
// NOTE: this list MUST stay in sync with the rootCommand.Add(...) calls in
// src/ScadaLink.CLI/Program.cs. When a new command group is added (or one is
// removed/renamed), update this array and bump the count assertion in
// AllCommandGroups_Build_WithoutThrowing accordingly.
private static IEnumerable<Command> AllCommandGroups() => new[]
{
TemplateCommands.Build(Url, Format, Username, Password),
@@ -29,11 +33,13 @@ public class CommandTreeTests
NotificationCommands.Build(Url, Format, Username, Password),
SecurityCommands.Build(Url, Format, Username, Password),
AuditLogCommands.Build(Url, Format, Username, Password),
AuditCommands.Build(Url, Format, Username, Password),
HealthCommands.Build(Url, Format, Username, Password),
DebugCommands.Build(Url, Format, Username, Password),
SharedScriptCommands.Build(Url, Format, Username, Password),
DbConnectionCommands.Build(Url, Format, Username, Password),
ApiMethodCommands.Build(Url, Format, Username, Password),
BundleCommands.Build(Url, Format, Username, Password),
};
private static IEnumerable<Command> LeafCommands(Command command)
@@ -53,10 +59,49 @@ public class CommandTreeTests
public void AllCommandGroups_Build_WithoutThrowing()
{
var groups = AllCommandGroups().ToList();
Assert.Equal(14, groups.Count);
// CLI-022: bump this count whenever a new top-level command group is
// registered in Program.cs. Current registered groups (16):
// template, instance, site, deploy, data-connection, external-system,
// notification, security, audit-config, audit, health, debug,
// shared-script, db-connection, api-method, bundle.
Assert.Equal(16, groups.Count);
Assert.All(groups, g => Assert.False(string.IsNullOrWhiteSpace(g.Name)));
}
[Fact]
public void AllCommandGroups_Contains_AuditAndBundle()
{
// CLI-022: explicit group-presence assertion so the harness does not
// silently drift back to excluding new groups. Use names because that
// is what users actually type at the prompt.
var groupNames = AllCommandGroups().Select(g => g.Name).ToHashSet();
Assert.Contains("audit", groupNames);
Assert.Contains("bundle", groupNames);
}
[Fact]
public void AuditCommandGroup_HasQueryExportAndVerifyChain()
{
// CLI-022: pin the audit sub-command surface so a rename / accidental
// removal of one of these is caught.
var audit = AuditCommands.Build(Url, Format, Username, Password);
var subNames = audit.Subcommands.Select(c => c.Name).ToHashSet();
Assert.Contains("query", subNames);
Assert.Contains("export", subNames);
Assert.Contains("verify-chain", subNames);
}
[Fact]
public void BundleCommandGroup_HasExportPreviewAndImport()
{
// CLI-022: pin the bundle sub-command surface.
var bundle = BundleCommands.Build(Url, Format, Username, Password);
var subNames = bundle.Subcommands.Select(c => c.Name).ToHashSet();
Assert.Contains("export", subNames);
Assert.Contains("preview", subNames);
Assert.Contains("import", subNames);
}
[Fact]
public void EveryLeafCommand_HasAnAction()
{
@@ -93,6 +138,9 @@ public class CommandTreeTests
[InlineData(typeof(DebugSnapshotCommand))]
[InlineData(typeof(MgmtDeployInstanceCommand))]
[InlineData(typeof(QueryAuditLogCommand))]
[InlineData(typeof(ExportBundleCommand))]
[InlineData(typeof(PreviewBundleCommand))]
[InlineData(typeof(ImportBundleCommand))]
public void CommandPayloadTypes_ResolveViaRegistry(Type commandType)
{
// GetCommandName throws ArgumentException for an unregistered type — the CLI
@@ -0,0 +1,250 @@
using System.Security.Claims;
using Akka.Actor;
using Bunit;
using Bunit.TestDoubles;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Forms;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ScadaLink.CentralUI.Components.Shared;
using ScadaLink.Commons.Entities.Sites;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Interfaces.Transport;
using ScadaLink.Commons.Messages.Audit;
using ScadaLink.Commons.Types.Transport;
using ScadaLink.Communication;
using ScadaLink.ConfigurationDatabase;
using ScadaLink.Transport;
using SiteCallsReportPage = ScadaLink.CentralUI.Components.Pages.SiteCalls.SiteCallsReport;
using TransportImportPage = ScadaLink.CentralUI.Components.Pages.Design.TransportImport;
namespace ScadaLink.CentralUI.Tests.Pages;
/// <summary>
/// CentralUI-033: tests for the drill-in / query-string code paths on the two
/// newest pages (TransportImport + SiteCallsReport). The base happy-path cases
/// (Parked, stuck=true, no params) live next to the rest of the page's tests in
/// <c>SiteCallsReportPageTests</c> / <c>TransportImportPageTests</c>; this file
/// fills the remaining gaps the finding called out — unrecognised values, case
/// handling, and the no-query-string default for the Transport wizard.
/// </summary>
public sealed class QueryStringDrillInTests
{
// STM: CentralUI-033-QueryStringDrillIn marker — used by grep verification.
// -----------------------------------------------------------------
// SiteCallsReport — ?status=
// -----------------------------------------------------------------
[Fact]
public void SiteCallsReport_StatusParam_CaseInsensitiveMatch_NormalisesToCanonicalCasing()
{
// The dropdown options use canonical casing ("Parked"). The KPI tiles
// emit canonical, but a hand-crafted ?status=parked URL must still seed
// the filter — the parser is case-insensitive and the seeded value uses
// the canonical casing so the <select> can bind it.
using var ctx = new SiteCallsReportFixture();
var nav = (BunitNavigationManager)ctx.Services.GetRequiredService<NavigationManager>();
nav.NavigateTo("/site-calls/report?status=parked");
var cut = ctx.Render<SiteCallsReportPage>();
cut.WaitForAssertion(() =>
{
Assert.Single(ctx.QueryRequests);
// Normalised to canonical casing (the dropdown's option text), not
// the URL's raw "parked".
Assert.Equal("Parked", ctx.QueryRequests[0].StatusFilter);
});
}
[Fact]
public void SiteCallsReport_StatusParam_Unrecognised_IsSilentlyDropped()
{
// Lax parsing: an unrecognised status value is ignored, leaving the
// filter empty so the page loads unfiltered. Mirrors AuditLogPage's
// drill-in convention — a hand-crafted bad URL must not break the page.
using var ctx = new SiteCallsReportFixture();
var nav = (BunitNavigationManager)ctx.Services.GetRequiredService<NavigationManager>();
nav.NavigateTo("/site-calls/report?status=NotARealStatus");
var cut = ctx.Render<SiteCallsReportPage>();
cut.WaitForAssertion(() =>
{
Assert.Single(ctx.QueryRequests);
Assert.Null(ctx.QueryRequests[0].StatusFilter);
Assert.False(ctx.QueryRequests[0].StuckOnly);
});
}
[Fact]
public void SiteCallsReport_StuckParam_NonBoolean_IsSilentlyDropped()
{
// bool.TryParse fails for "yes"/"1" — the parser drops the value and
// leaves StuckOnly = false, mirroring the unrecognised-status path.
using var ctx = new SiteCallsReportFixture();
var nav = (BunitNavigationManager)ctx.Services.GetRequiredService<NavigationManager>();
nav.NavigateTo("/site-calls/report?stuck=yes");
var cut = ctx.Render<SiteCallsReportPage>();
cut.WaitForAssertion(() =>
{
Assert.Single(ctx.QueryRequests);
Assert.False(ctx.QueryRequests[0].StuckOnly);
});
}
// -----------------------------------------------------------------
// TransportImport — no query-string parameters on this route
// -----------------------------------------------------------------
/// <summary>
/// CentralUI-033: TransportImport.razor declares no <c>[Parameter]</c> /
/// <c>SupplyParameterFromQuery</c> bindings — the wizard's initial state is
/// purely <c>ImportWizardStep.Upload</c> regardless of the query-string. This
/// test pins that contract: navigating with an unrecognised query-string
/// param does not throw and does not change the initial step.
/// </summary>
[Fact]
public void TransportImport_UnrecognisedQueryStringParam_DoesNotChangeInitialStep()
{
using var ctx = new TransportImportFixture();
var nav = (BunitNavigationManager)ctx.Services.GetRequiredService<NavigationManager>();
nav.NavigateTo("/design/transport/import?bundleImportId=11111111-1111-1111-1111-111111111111&foo=bar");
var cut = ctx.Render<TransportImportPage>();
// The wizard starts at Upload regardless of any drill-in query string —
// the page has no [Parameter]-bound properties so unknown keys are
// silently ignored by Blazor's parameter binding.
var step = (TransportImportPage.ImportWizardStep)typeof(TransportImportPage)
.GetField("_step",
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!
.GetValue(cut.Instance)!;
Assert.Equal(TransportImportPage.ImportWizardStep.Upload, step);
// And the Step-1 InputFile control is rendered — the page came up clean.
Assert.NotNull(cut.Find("input[type='file']"));
}
// -----------------------------------------------------------------
// Test-scoped fixtures — kept inside this file to bound the diff.
// The existing page-level test files have their own larger fixtures;
// these copies are intentionally minimal (only what the drill-in
// tests need).
// -----------------------------------------------------------------
private sealed class SiteCallsReportFixture : BunitContext
{
private readonly ActorSystem _system = ActorSystem.Create("qs-drillin-tests");
public readonly CommunicationService Comms;
public readonly List<SiteCallQueryRequest> QueryRequests = new();
public SiteCallsReportFixture()
{
Comms = new CommunicationService(
Options.Create(new CommunicationOptions()),
NullLogger<CommunicationService>.Instance);
var auditProxy = _system.ActorOf(Props.Create(() => new ScriptedSiteCallAuditActor(this)));
Comms.SetSiteCallAudit(auditProxy);
Services.AddSingleton(Comms);
Services.AddSingleton<IDialogService>(new AlwaysConfirmDialogService());
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>
{
new("Plant A", "plant-a") { Id = 1 },
}));
Services.AddSingleton(siteRepo);
var claims = new[]
{
new Claim("Username", "tester"),
new Claim(ClaimTypes.Role, "Deployment"),
};
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
Services.AddScoped<ScadaLink.CentralUI.Auth.SiteScopeService>();
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_system.Terminate().Wait(TimeSpan.FromSeconds(5));
}
base.Dispose(disposing);
}
}
private sealed class ScriptedSiteCallAuditActor : ReceiveActor
{
public ScriptedSiteCallAuditActor(SiteCallsReportFixture fixture)
{
Receive<SiteCallQueryRequest>(req =>
{
fixture.QueryRequests.Add(req);
Sender.Tell(new SiteCallQueryResponse(
req.CorrelationId, true, null,
new List<SiteCallSummary>(), null, null));
});
}
}
private sealed class AlwaysConfirmDialogService : IDialogService
{
public Task<bool> ConfirmAsync(string title, string message, bool danger = false)
=> Task.FromResult(true);
public Task<string?> PromptAsync(string title, string label, string initialValue = "", string? placeholder = null)
=> Task.FromResult<string?>(null);
}
private sealed class TransportImportFixture : BunitContext
{
public TransportImportFixture()
{
JSInterop.Mode = JSRuntimeMode.Loose;
var importer = Substitute.For<IBundleImporter>();
Services.AddSingleton(importer);
Services.AddSingleton(Substitute.For<IAuditService>());
Services.AddSingleton<IOptions<TransportOptions>>(
Microsoft.Extensions.Options.Options.Create(new TransportOptions
{
MaxBundleSizeMb = 10,
MaxUnlockAttemptsPerSession = 3,
}));
var dbOptions = new DbContextOptionsBuilder<ScadaLinkDbContext>()
.UseSqlite("DataSource=:memory:")
.ConfigureWarnings(w => w.Ignore(RelationalEventId.AmbientTransactionWarning))
.Options;
var dbContext = new ScadaLinkDbContext(dbOptions);
dbContext.Database.OpenConnection();
dbContext.Database.EnsureCreated();
Services.AddSingleton(dbContext);
var claims = new List<Claim>
{
new(ScadaLink.Security.JwtTokenService.UsernameClaimType, "alice"),
new(ScadaLink.Security.JwtTokenService.RoleClaimType, "Admin"),
};
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(principal));
Services.AddAuthorizationCore();
}
}
}
@@ -0,0 +1,296 @@
using System.Text.Json;
using ScadaLink.Commons.Types.Transport;
namespace ScadaLink.Commons.Tests.Types.Transport;
/// <summary>
/// Commons-020: focused shape / round-trip tests for the Transport (#24) record DTOs
/// — <see cref="BundleManifest"/>, <see cref="ExportSelection"/>,
/// <see cref="ImportPreview"/>, <see cref="ImportResolution"/>, and
/// <see cref="ImportResult"/>. These records cross the Central UI ⇆ bundle file boundary
/// via System.Text.Json, so a positional/tuple slip would break bundles in the field.
/// EncryptionMetadata has its own focused tests under EncryptionMetadataTests.cs
/// (Commons-015) and is reused here only to populate manifest fixtures.
/// </summary>
public sealed class TransportRecordsTests
{
// STM: TransportRecordsTests-Commons-020 marker — used by grep verification.
private static readonly JsonSerializerOptions JsonOpts = new()
{
WriteIndented = false,
};
// --------------------------------------------------------------
// BundleManifest
// --------------------------------------------------------------
[Fact]
public void BundleManifest_Constructor_RoundTripsAllFields()
{
var summary = new BundleSummary(
Templates: 2, TemplateFolders: 1, SharedScripts: 3,
ExternalSystems: 1, DbConnections: 0, NotificationLists: 1,
SmtpConfigs: 1, ApiKeys: 0, ApiMethods: 4);
var contents = new List<ManifestContentEntry>
{
new("Template", "Pump", 1, new List<string> { "Shared.Helpers" }),
new("Template", "Valve", 2, Array.Empty<string>()),
};
var manifest = new BundleManifest(
BundleFormatVersion: 1,
SchemaVersion: "1.0",
CreatedAtUtc: new DateTimeOffset(2026, 5, 28, 12, 0, 0, TimeSpan.Zero),
SourceEnvironment: "cli",
ExportedBy: "alice",
ScadaLinkVersion: "0.9.0",
ContentHash: "sha256:deadbeef",
Encryption: null,
Summary: summary,
Contents: contents);
Assert.Equal(1, manifest.BundleFormatVersion);
Assert.Equal("1.0", manifest.SchemaVersion);
Assert.Equal("cli", manifest.SourceEnvironment);
Assert.Equal("alice", manifest.ExportedBy);
Assert.Equal("0.9.0", manifest.ScadaLinkVersion);
Assert.Equal("sha256:deadbeef", manifest.ContentHash);
Assert.Null(manifest.Encryption);
Assert.Equal(summary, manifest.Summary);
Assert.Equal(2, manifest.Contents.Count);
Assert.Equal("Pump", manifest.Contents[0].Name);
}
[Fact]
public void BundleManifest_JsonRoundTrip_PreservesAllFields()
{
var encryption = new EncryptionMetadata(
Algorithm: "AES-256-GCM",
Kdf: "PBKDF2-SHA256",
Iterations: 600_000,
SaltB64: "c2FsdA==",
IvB64: "aXY=");
var summary = new BundleSummary(1, 0, 0, 0, 0, 0, 0, 0, 0);
var manifest = new BundleManifest(
BundleFormatVersion: 1,
SchemaVersion: "1.0",
CreatedAtUtc: new DateTimeOffset(2026, 5, 28, 12, 0, 0, TimeSpan.Zero),
SourceEnvironment: "ui",
ExportedBy: "bob",
ScadaLinkVersion: "0.9.0",
ContentHash: "sha256:abc",
Encryption: encryption,
Summary: summary,
Contents: new List<ManifestContentEntry>
{
new("Template", "Pump", 7, new List<string> { "dep-a" }),
});
var json = JsonSerializer.Serialize(manifest, JsonOpts);
var rt = JsonSerializer.Deserialize<BundleManifest>(json, JsonOpts);
Assert.NotNull(rt);
Assert.Equal(manifest.SourceEnvironment, rt!.SourceEnvironment);
Assert.Equal(manifest.ContentHash, rt.ContentHash);
Assert.Equal(manifest.Summary, rt.Summary);
Assert.Single(rt.Contents);
Assert.Equal("Pump", rt.Contents[0].Name);
Assert.Equal(7, rt.Contents[0].Version);
Assert.NotNull(rt.Encryption);
Assert.Equal("AES-256-GCM", rt.Encryption!.Algorithm);
Assert.Equal(600_000, rt.Encryption.Iterations);
}
// --------------------------------------------------------------
// ExportSelection
// --------------------------------------------------------------
[Fact]
public void ExportSelection_Constructor_PreservesAllIdLists()
{
var sel = new ExportSelection(
TemplateIds: new[] { 1, 2, 3 },
SharedScriptIds: new[] { 10 },
ExternalSystemIds: Array.Empty<int>(),
DatabaseConnectionIds: new[] { 20, 21 },
NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: new[] { 30 },
ApiKeyIds: new[] { 40, 41 },
ApiMethodIds: new[] { 50 },
IncludeDependencies: true);
Assert.Equal(new[] { 1, 2, 3 }, sel.TemplateIds);
Assert.Single(sel.SharedScriptIds);
Assert.Empty(sel.ExternalSystemIds);
Assert.Equal(2, sel.DatabaseConnectionIds.Count);
Assert.True(sel.IncludeDependencies);
}
[Fact]
public void ExportSelection_JsonRoundTrip_PreservesIncludeDependenciesAndIds()
{
var sel = new ExportSelection(
TemplateIds: new[] { 1, 2 },
SharedScriptIds: Array.Empty<int>(),
ExternalSystemIds: new[] { 5 },
DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: Array.Empty<int>(),
ApiKeyIds: Array.Empty<int>(),
ApiMethodIds: Array.Empty<int>(),
IncludeDependencies: false);
var json = JsonSerializer.Serialize(sel, JsonOpts);
var rt = JsonSerializer.Deserialize<ExportSelection>(json, JsonOpts);
Assert.NotNull(rt);
Assert.Equal(sel.TemplateIds, rt!.TemplateIds);
Assert.Equal(sel.ExternalSystemIds, rt.ExternalSystemIds);
Assert.False(rt.IncludeDependencies);
}
// --------------------------------------------------------------
// ImportPreview
// --------------------------------------------------------------
[Fact]
public void ImportPreview_Constructor_AllowsAllConflictKinds()
{
var sessionId = Guid.NewGuid();
var items = new List<ImportPreviewItem>
{
new("Template", "Pump", ExistingVersion: 1, IncomingVersion: 1, Kind: ConflictKind.Identical, FieldDiffJson: null, BlockerReason: null),
new("Template", "Valve", ExistingVersion: 1, IncomingVersion: 2, Kind: ConflictKind.Modified, FieldDiffJson: "{\"name\":\"Valve\"}", BlockerReason: null),
new("Template", "New", ExistingVersion: null, IncomingVersion: 1, Kind: ConflictKind.New, FieldDiffJson: null, BlockerReason: null),
new("Template", "Bad", ExistingVersion: 1, IncomingVersion: 5, Kind: ConflictKind.Blocker, FieldDiffJson: null, BlockerReason: "Parameters property mismatch"),
};
var preview = new ImportPreview(sessionId, items);
Assert.Equal(sessionId, preview.SessionId);
Assert.Equal(4, preview.Items.Count);
Assert.Equal(ConflictKind.Identical, preview.Items[0].Kind);
Assert.Equal(ConflictKind.Modified, preview.Items[1].Kind);
Assert.Equal(ConflictKind.New, preview.Items[2].Kind);
Assert.Equal(ConflictKind.Blocker, preview.Items[3].Kind);
Assert.Equal("Parameters property mismatch", preview.Items[3].BlockerReason);
}
[Fact]
public void ImportPreview_JsonRoundTrip_PreservesConflictKindAndOptionalFields()
{
var preview = new ImportPreview(
SessionId: Guid.NewGuid(),
Items: new List<ImportPreviewItem>
{
new("Template", "X", 1, 2, ConflictKind.Modified, "{}", null),
new("Template", "Y", null, 1, ConflictKind.New, null, null),
});
var json = JsonSerializer.Serialize(preview, JsonOpts);
var rt = JsonSerializer.Deserialize<ImportPreview>(json, JsonOpts);
Assert.NotNull(rt);
Assert.Equal(preview.SessionId, rt!.SessionId);
Assert.Equal(2, rt.Items.Count);
Assert.Equal(ConflictKind.Modified, rt.Items[0].Kind);
Assert.Equal(ConflictKind.New, rt.Items[1].Kind);
Assert.Null(rt.Items[1].ExistingVersion);
}
// --------------------------------------------------------------
// ImportResolution
// --------------------------------------------------------------
[Theory]
[InlineData(ResolutionAction.Add, null)]
[InlineData(ResolutionAction.Overwrite, null)]
[InlineData(ResolutionAction.Skip, null)]
[InlineData(ResolutionAction.Rename, "NewName")]
public void ImportResolution_Constructor_PreservesAllActions(ResolutionAction action, string? renameTo)
{
var res = new ImportResolution("Template", "Pump", action, renameTo);
Assert.Equal("Template", res.EntityType);
Assert.Equal("Pump", res.Name);
Assert.Equal(action, res.Action);
Assert.Equal(renameTo, res.RenameTo);
}
[Fact]
public void ImportResolution_JsonRoundTrip_PreservesRenameTo()
{
var res = new ImportResolution("Template", "Pump", ResolutionAction.Rename, "Pump_v2");
var json = JsonSerializer.Serialize(res, JsonOpts);
var rt = JsonSerializer.Deserialize<ImportResolution>(json, JsonOpts);
Assert.NotNull(rt);
Assert.Equal(ResolutionAction.Rename, rt!.Action);
Assert.Equal("Pump_v2", rt.RenameTo);
}
// --------------------------------------------------------------
// ImportResult
// --------------------------------------------------------------
[Fact]
public void ImportResult_Constructor_PreservesAllCountersAndStaleIds()
{
var bundleImportId = Guid.NewGuid();
var result = new ImportResult(
BundleImportId: bundleImportId,
Added: 3,
Overwritten: 1,
Skipped: 2,
Renamed: 1,
StaleInstanceIds: new List<int> { 100, 200, 300 },
AuditEventCorrelation: "audit-corr-001");
Assert.Equal(bundleImportId, result.BundleImportId);
Assert.Equal(3, result.Added);
Assert.Equal(1, result.Overwritten);
Assert.Equal(2, result.Skipped);
Assert.Equal(1, result.Renamed);
Assert.Equal(new[] { 100, 200, 300 }, result.StaleInstanceIds);
Assert.Equal("audit-corr-001", result.AuditEventCorrelation);
}
[Fact]
public void ImportResult_JsonRoundTrip_PreservesCountsAndCorrelation()
{
var result = new ImportResult(
BundleImportId: Guid.NewGuid(),
Added: 5,
Overwritten: 0,
Skipped: 0,
Renamed: 0,
StaleInstanceIds: Array.Empty<int>(),
AuditEventCorrelation: "audit-corr-xyz");
var json = JsonSerializer.Serialize(result, JsonOpts);
var rt = JsonSerializer.Deserialize<ImportResult>(json, JsonOpts);
Assert.NotNull(rt);
Assert.Equal(result.BundleImportId, rt!.BundleImportId);
Assert.Equal(5, rt.Added);
Assert.Empty(rt.StaleInstanceIds);
Assert.Equal("audit-corr-xyz", rt.AuditEventCorrelation);
}
// --------------------------------------------------------------
// Record equality sanity (catches positional/tuple slip)
// --------------------------------------------------------------
[Fact]
public void TransportRecords_RecordValueEquality()
{
var a = new ImportResolution("Template", "Pump", ResolutionAction.Add, null);
var b = new ImportResolution("Template", "Pump", ResolutionAction.Add, null);
Assert.Equal(a, b);
Assert.Equal(a.GetHashCode(), b.GetHashCode());
var c = a with { Action = ResolutionAction.Overwrite };
Assert.NotEqual(a, c);
}
}
@@ -1,4 +1,6 @@
using System.Data.Common;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Logging.Abstractions;
using ScadaLink.ConfigurationDatabase.Maintenance;
using ScadaLink.ConfigurationDatabase.Tests.Migrations;
@@ -150,6 +152,138 @@ public class AuditLogPartitionMaintenanceTests : IClassFixture<MsSqlMigrationFix
Assert.Equal(maxBefore.Value.AddMonths(3), maxAfter);
}
/// <summary>
/// ConfigurationDatabase-024: CD-019 removed the try/catch around the per-month
/// SPLIT call so a genuine SQL failure (deadlock, permission, log full, transient
/// connection drop) now aborts the loop instead of leaving partition holes. This
/// test pins that abort behaviour: with an interceptor that throws on the SECOND
/// SPLIT, the call must propagate the exception AND the first SPLIT's boundary
/// must already be persisted in <c>pf_AuditLog_Month</c> (visible to a fresh
/// <see cref="AuditLogPartitionMaintenance"/> instance) — proof that the loop did
/// commit boundary N before throwing, and that the next tick can resume from
/// boundary N+1 at-least-once with no holes.
/// </summary>
[SkippableFact]
public async Task EnsureLookahead_SecondSplitThrows_LoopAborts_FirstBoundaryStillCommitted()
{
// STM: CD-024-SecondSplitThrowsAbortsLoop marker.
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
// Baseline max-boundary observed via a clean context.
await using var baselineCtx = CreateContext();
var maxBefore = await NewMaintenance(baselineCtx).GetMaxBoundaryAsync();
Assert.NotNull(maxBefore);
var lookahead = LookaheadForExtraBoundaries(maxBefore!.Value, extraBoundaries: 3);
var expectedFirst = maxBefore.Value.AddMonths(1);
// Build a fresh context with an interceptor that throws on the 2nd ALTER
// PARTITION FUNCTION SPLIT RANGE. EF Core surfaces the throw through
// ExecuteSqlRawAsync exactly as a SqlException would — the loop has no
// try/catch (CD-019), so the exception propagates after the first SPLIT
// has already committed.
var interceptor = new SecondSplitThrowsInterceptor();
var options = new DbContextOptionsBuilder<ScadaLinkDbContext>()
.UseSqlServer(_fixture.ConnectionString)
.AddInterceptors(interceptor)
.Options;
await using var ctx = new ScadaLinkDbContext(options);
var maintenance = NewMaintenance(ctx);
await Assert.ThrowsAsync<InvalidOperationException>(
() => maintenance.EnsureLookaheadAsync(lookahead));
// Verify exactly one ALTER PARTITION FUNCTION SPLIT RANGE actually ran
// before the interceptor's throw: split #1 committed, split #2 threw,
// split #3 was never attempted.
Assert.Equal(1, interceptor.SuccessfulSplits);
// And verify the first boundary IS now persisted — the loop aborted but
// boundary N is durable so the next tick resumes from N+1 (no holes).
await using var verifyCtx = CreateContext();
var maxAfter = await NewMaintenance(verifyCtx).GetMaxBoundaryAsync();
Assert.Equal(expectedFirst, maxAfter);
}
/// <summary>
/// EF Core command interceptor: lets the first <c>ALTER PARTITION FUNCTION
/// pf_AuditLog_Month() SPLIT RANGE</c> through and throws <see cref="InvalidOperationException"/>
/// on the second one. Threads through synchronous + async + scalar + reader
/// entry-points because <c>ExecuteSqlRawAsync</c> routes through the
/// non-query async path but other code paths still go through the same
/// interceptor pipeline. <see cref="SuccessfulSplits"/> counts the splits
/// that were allowed to run so the test can pin the abort-after-one
/// behaviour.
/// </summary>
private sealed class SecondSplitThrowsInterceptor : DbCommandInterceptor
{
public int SuccessfulSplits { get; private set; }
private bool IsTargetSplit(DbCommand command) =>
command.CommandText.Contains("SPLIT RANGE", StringComparison.OrdinalIgnoreCase)
&& command.CommandText.Contains("pf_AuditLog_Month", StringComparison.OrdinalIgnoreCase);
public override InterceptionResult<int> NonQueryExecuting(
DbCommand command,
CommandEventData eventData,
InterceptionResult<int> result)
{
ThrowIfSecondSplit(command);
return base.NonQueryExecuting(command, eventData, result);
}
public override ValueTask<InterceptionResult<int>> NonQueryExecutingAsync(
DbCommand command,
CommandEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
ThrowIfSecondSplit(command);
return base.NonQueryExecutingAsync(command, eventData, result, cancellationToken);
}
public override int NonQueryExecuted(
DbCommand command,
CommandExecutedEventData eventData,
int result)
{
if (IsTargetSplit(command))
{
SuccessfulSplits++;
}
return base.NonQueryExecuted(command, eventData, result);
}
public override ValueTask<int> NonQueryExecutedAsync(
DbCommand command,
CommandExecutedEventData eventData,
int result,
CancellationToken cancellationToken = default)
{
if (IsTargetSplit(command))
{
SuccessfulSplits++;
}
return base.NonQueryExecutedAsync(command, eventData, result, cancellationToken);
}
private void ThrowIfSecondSplit(DbCommand command)
{
if (!IsTargetSplit(command))
{
return;
}
// Allow the first SPLIT through; throw on the second so the loop's
// post-CD-019 "let it propagate" behaviour can be asserted.
if (SuccessfulSplits >= 1)
{
throw new InvalidOperationException(
"Simulated SqlException on the second SPLIT RANGE — exercising CD-019's no-try/catch abort path.");
}
}
}
[SkippableFact]
public async Task EnsureLookahead_BoundaryAlreadyExists_NoError_Idempotent()
{
@@ -847,6 +847,44 @@ public class DeploymentManagerRepositoryTests : IDisposable
Assert.Null(await _repository.GetDeploymentRecordByIdAsync(id));
}
/// <summary>
/// ConfigurationDatabase-024: CD-017 added optimistic-concurrency to
/// <c>DeleteDeploymentRecordAsync(int id, byte[] expectedRowVersion)</c> — the stub-attach
/// path now seeds <c>OriginalValues["RowVersion"]</c> from the caller's last-observed
/// value so the generated SQL becomes <c>DELETE … WHERE Id = @id AND RowVersion = @prior</c>.
/// This test pins the production-shape happy path: caller holds the entity's CURRENT
/// RowVersion, clears the change-tracker (i.e. no tracked instance — exactly the M&amp;V
/// admin / handler shape), calls Delete with that token, and the delete completes
/// without throwing <see cref="Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException"/>.
/// </summary>
[Fact]
public async Task DeleteDeploymentRecord_CurrentRowVersion_StubAttachPath_DeleteSucceeds()
{
// STM: CD-024-RowVersionDeleteHappyPath marker.
var instance = await SeedInstanceAsync();
var record = new DeploymentRecord("d-rv-001", "admin")
{
InstanceId = instance.Id,
DeployedAt = DateTimeOffset.UtcNow,
};
await _repository.AddDeploymentRecordAsync(record);
await _repository.SaveChangesAsync();
// Capture the entity's CURRENT RowVersion (the one the caller would have
// read from a prior GetDeploymentRecordByIdAsync), then detach so the
// delete travels through the stub-attach branch (no tracked entity).
var id = record.Id;
var currentRowVersion = record.RowVersion ?? Array.Empty<byte>();
_context.ChangeTracker.Clear();
// No NotSupported/Concurrency exception should fire on this code path.
await _repository.DeleteDeploymentRecordAsync(id, currentRowVersion);
var affected = await _repository.SaveChangesAsync();
Assert.Equal(1, affected);
Assert.Null(await _repository.GetDeploymentRecordByIdAsync(id));
}
[Fact]
public async Task DeleteInstance_RemovesRestrictFkDeploymentRecordsFirst()
{
@@ -90,13 +90,14 @@ public class ArtifactDeploymentServiceTests : TestKit
new("Site Two", "site-2") { Id = 2 }
};
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(sites);
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor()));
var recorder = new ArtifactProbeRecorder();
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor(recorder)));
var service = CreateServiceWithCommActor(probe);
var result = await service.DeployToAllSitesAsync("admin");
Assert.True(result.IsSuccess);
var commands = ArtifactProbeActor.Received;
var commands = recorder.Received;
Assert.Equal(2, commands.Count);
// All per-site commands carry one shared id, equal to the summary id.
@@ -128,7 +129,8 @@ public class ArtifactDeploymentServiceTests : TestKit
new("Site Two", "fail-site") { Id = 2 }
};
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(sites);
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor("fail-site")));
var recorder = new ArtifactProbeRecorder();
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor(recorder, "fail-site")));
var service = CreateServiceWithCommActor(probe);
var result = await service.DeployToAllSitesAsync("admin");
@@ -157,7 +159,8 @@ public class ArtifactDeploymentServiceTests : TestKit
new("Site Three", "site-3") { Id = 3 },
};
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(sites);
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor()));
var recorder = new ArtifactProbeRecorder();
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor(recorder)));
var service = CreateServiceWithCommActor(probe);
var result = await service.DeployToAllSitesAsync("admin");
@@ -184,7 +187,8 @@ public class ArtifactDeploymentServiceTests : TestKit
// global query is issued exactly once. Pin this so a future refactor cannot
// accidentally route RetryForSiteAsync through the multi-site loop and lose
// the audit row's deploymentId guarantee.
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor()));
var recorder = new ArtifactProbeRecorder();
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor(recorder)));
var service = CreateServiceWithCommActor(probe);
var result = await service.RetryForSiteAsync(1, "retry-site", "admin");
@@ -200,7 +204,8 @@ public class ArtifactDeploymentServiceTests : TestKit
[Fact]
public async Task RetryForSiteAsync_SiteSucceeds_ReturnsSuccessAndAudits()
{
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor()));
var recorder = new ArtifactProbeRecorder();
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor(recorder)));
var service = CreateServiceWithCommActor(probe);
var result = await service.RetryForSiteAsync(1, "retry-site", "admin");
@@ -245,25 +250,34 @@ public class ArtifactDeploymentServiceTests : TestKit
NullLogger<ArtifactDeploymentService>.Instance);
}
/// <summary>
/// Per-test recorder for <see cref="ArtifactProbeActor"/>. DeploymentManager-024:
/// each test owns its own instance, passed into the actor's constructor, so
/// the received-command list is no longer shared static state that races
/// under parallel test execution.
/// </summary>
private sealed class ArtifactProbeRecorder
{
public readonly ConcurrentBag<DeployArtifactsCommand> Received = new();
}
/// <summary>
/// Stand-in CentralCommunicationActor for artifact deployment. Records every
/// <see cref="DeployArtifactsCommand"/> it receives and replies success
/// unless the target site id is in the configured failure set.
/// <see cref="DeployArtifactsCommand"/> it receives into the per-test
/// <see cref="ArtifactProbeRecorder"/> and replies success unless the target
/// site id is in the configured failure set.
/// </summary>
private class ArtifactProbeActor : ReceiveActor
{
public static readonly ConcurrentBag<DeployArtifactsCommand> Received = new();
public ArtifactProbeActor(params string[] failingSites)
public ArtifactProbeActor(ArtifactProbeRecorder recorder, params string[] failingSites)
{
Received.Clear();
var failSet = new HashSet<string>(failingSites);
Receive<SiteEnvelope>(env =>
{
if (env.Message is DeployArtifactsCommand cmd)
{
Received.Add(cmd);
recorder.Received.Add(cmd);
var success = !failSet.Contains(env.SiteId);
Sender.Tell(new ArtifactDeploymentResponse(
cmd.DeploymentId, env.SiteId, success,
@@ -295,8 +295,9 @@ public class DeploymentServiceTests : TestKit
_repo.DeleteInstanceAsync(30, Arg.Any<CancellationToken>())
.Returns<Task>(_ => throw new InvalidOperationException("db unavailable"));
var counters = new ReconcileProbeCounters();
var commActor = Sys.ActorOf(Props.Create(() =>
new ReconcileProbeActor(siteHash: "sha256:x", failQuery: false)));
new ReconcileProbeActor(counters, siteHash: "sha256:x", failQuery: false)));
var service = CreateServiceWithCommActor(commActor);
var result = await service.DeleteInstanceAsync(30, "admin");
@@ -458,8 +459,9 @@ public class DeploymentServiceTests : TestKit
_repo.GetCurrentDeploymentStatusAsync(50, Arg.Any<CancellationToken>())
.Returns((DeploymentRecord?)null);
var counters = new ReconcileProbeCounters();
var commActor = Sys.ActorOf(Props.Create(() =>
new ReconcileProbeActor(siteHash: "sha256:target", failQuery: false)));
new ReconcileProbeActor(counters, siteHash: "sha256:target", failQuery: false)));
var service = CreateServiceWithCommActor(commActor);
var result = await service.DeployInstanceAsync(50, "admin");
@@ -478,8 +480,9 @@ public class DeploymentServiceTests : TestKit
var instance = new Instance("DisInst") { Id = 51, SiteId = 1, State = InstanceState.Enabled };
_repo.GetInstanceByIdAsync(51, Arg.Any<CancellationToken>()).Returns(instance);
var counters = new ReconcileProbeCounters();
var commActor = Sys.ActorOf(Props.Create(() =>
new ReconcileProbeActor(siteHash: "x", failQuery: false)));
new ReconcileProbeActor(counters, siteHash: "x", failQuery: false)));
var service = CreateServiceWithCommActor(commActor);
var result = await service.DisableInstanceAsync(51, "admin");
@@ -498,8 +501,9 @@ public class DeploymentServiceTests : TestKit
var instance = new Instance("EnInst") { Id = 52, SiteId = 1, State = InstanceState.Disabled };
_repo.GetInstanceByIdAsync(52, Arg.Any<CancellationToken>()).Returns(instance);
var counters = new ReconcileProbeCounters();
var commActor = Sys.ActorOf(Props.Create(() =>
new ReconcileProbeActor(siteHash: "x", failQuery: false)));
new ReconcileProbeActor(counters, siteHash: "x", failQuery: false)));
var service = CreateServiceWithCommActor(commActor);
var result = await service.EnableInstanceAsync(52, "admin");
@@ -518,8 +522,9 @@ public class DeploymentServiceTests : TestKit
var instance = new Instance("DelInst") { Id = 53, SiteId = 1, State = InstanceState.Enabled };
_repo.GetInstanceByIdAsync(53, Arg.Any<CancellationToken>()).Returns(instance);
var counters = new ReconcileProbeCounters();
var commActor = Sys.ActorOf(Props.Create(() =>
new ReconcileProbeActor(siteHash: "x", failQuery: false)));
new ReconcileProbeActor(counters, siteHash: "x", failQuery: false)));
var service = CreateServiceWithCommActor(commActor);
var result = await service.DeleteInstanceAsync(53, "admin");
@@ -543,8 +548,9 @@ public class DeploymentServiceTests : TestKit
_repo.GetCurrentDeploymentStatusAsync(54, Arg.Any<CancellationToken>())
.Returns((DeploymentRecord?)null);
var serializationCounters = new SerializationProbeCounters();
var commActor = Sys.ActorOf(Props.Create(() =>
new SerializationProbeActor()));
new SerializationProbeActor(serializationCounters)));
var service = CreateServiceWithCommActor(commActor);
var deploy1 = service.DeployInstanceAsync(54, "admin");
@@ -555,7 +561,7 @@ public class DeploymentServiceTests : TestKit
Assert.True(results[1].IsSuccess);
// The probe records the maximum concurrency observed; the lock must
// keep it at 1 for a single instance.
Assert.Equal(1, SerializationProbeActor.MaxConcurrent);
Assert.Equal(1, serializationCounters.MaxConcurrent);
}
// ── DeploymentManager-006: query-the-site-before-redeploy idempotency ──
@@ -610,8 +616,9 @@ public class DeploymentServiceTests : TestKit
};
_repo.GetCurrentDeploymentStatusAsync(7, Arg.Any<CancellationToken>()).Returns(prior);
var counters = new ReconcileProbeCounters();
var commActor = Sys.ActorOf(Props.Create(() =>
new ReconcileProbeActor(siteHash: "sha256:target", failQuery: false)));
new ReconcileProbeActor(counters, siteHash: "sha256:target", failQuery: false)));
var service = CreateServiceWithCommActor(commActor);
var result = await service.DeployInstanceAsync(7, "admin");
@@ -619,8 +626,8 @@ public class DeploymentServiceTests : TestKit
Assert.True(result.IsSuccess);
Assert.Equal(DeploymentStatus.Success, prior.Status);
// The site query was issued, but no new deploy command was sent.
Assert.Equal(1, ReconcileProbeActor.QueryCount);
Assert.Equal(0, ReconcileProbeActor.DeployCount);
Assert.Equal(1, counters.QueryCount);
Assert.Equal(0, counters.DeployCount);
// No new deployment record was created — the prior one was reconciled.
await _repo.DidNotReceive().AddDeploymentRecordAsync(
Arg.Any<DeploymentRecord>(), Arg.Any<CancellationToken>());
@@ -643,16 +650,17 @@ public class DeploymentServiceTests : TestKit
};
_repo.GetCurrentDeploymentStatusAsync(8, Arg.Any<CancellationToken>()).Returns(prior);
var counters = new ReconcileProbeCounters();
var commActor = Sys.ActorOf(Props.Create(() =>
new ReconcileProbeActor(siteHash: "sha256:old", failQuery: false)));
new ReconcileProbeActor(counters, siteHash: "sha256:old", failQuery: false)));
var service = CreateServiceWithCommActor(commActor);
var result = await service.DeployInstanceAsync(8, "admin");
Assert.True(result.IsSuccess);
Assert.Equal(1, ReconcileProbeActor.QueryCount);
Assert.Equal(1, counters.QueryCount);
// The normal deploy proceeded — a new command was sent.
Assert.Equal(1, ReconcileProbeActor.DeployCount);
Assert.Equal(1, counters.DeployCount);
await _repo.Received().AddDeploymentRecordAsync(
Arg.Any<DeploymentRecord>(), Arg.Any<CancellationToken>());
}
@@ -674,15 +682,16 @@ public class DeploymentServiceTests : TestKit
};
_repo.GetCurrentDeploymentStatusAsync(9, Arg.Any<CancellationToken>()).Returns(prior);
var counters = new ReconcileProbeCounters();
var commActor = Sys.ActorOf(Props.Create(() =>
new ReconcileProbeActor(siteHash: "sha256:target", failQuery: false)));
new ReconcileProbeActor(counters, siteHash: "sha256:target", failQuery: false)));
var service = CreateServiceWithCommActor(commActor);
var result = await service.DeployInstanceAsync(9, "admin");
Assert.True(result.IsSuccess);
Assert.Equal(1, ReconcileProbeActor.QueryCount);
Assert.Equal(0, ReconcileProbeActor.DeployCount);
Assert.Equal(1, counters.QueryCount);
Assert.Equal(0, counters.DeployCount);
Assert.Equal(DeploymentStatus.Success, prior.Status);
}
@@ -702,16 +711,17 @@ public class DeploymentServiceTests : TestKit
};
_repo.GetCurrentDeploymentStatusAsync(10, Arg.Any<CancellationToken>()).Returns(prior);
var counters = new ReconcileProbeCounters();
var commActor = Sys.ActorOf(Props.Create(() =>
new ReconcileProbeActor(siteHash: "sha256:target", failQuery: false)));
new ReconcileProbeActor(counters, siteHash: "sha256:target", failQuery: false)));
var service = CreateServiceWithCommActor(commActor);
var result = await service.DeployInstanceAsync(10, "admin");
Assert.True(result.IsSuccess);
// No site query — the prior deploy completed cleanly.
Assert.Equal(0, ReconcileProbeActor.QueryCount);
Assert.Equal(1, ReconcileProbeActor.DeployCount);
Assert.Equal(0, counters.QueryCount);
Assert.Equal(1, counters.DeployCount);
}
[Fact]
@@ -724,15 +734,16 @@ public class DeploymentServiceTests : TestKit
_repo.GetCurrentDeploymentStatusAsync(11, Arg.Any<CancellationToken>())
.Returns((DeploymentRecord?)null);
var counters = new ReconcileProbeCounters();
var commActor = Sys.ActorOf(Props.Create(() =>
new ReconcileProbeActor(siteHash: "sha256:target", failQuery: false)));
new ReconcileProbeActor(counters, siteHash: "sha256:target", failQuery: false)));
var service = CreateServiceWithCommActor(commActor);
var result = await service.DeployInstanceAsync(11, "admin");
Assert.True(result.IsSuccess);
Assert.Equal(0, ReconcileProbeActor.QueryCount);
Assert.Equal(1, ReconcileProbeActor.DeployCount);
Assert.Equal(0, counters.QueryCount);
Assert.Equal(1, counters.DeployCount);
}
[Fact]
@@ -754,16 +765,17 @@ public class DeploymentServiceTests : TestKit
_repo.GetCurrentDeploymentStatusAsync(12, Arg.Any<CancellationToken>()).Returns(prior);
// The probe drops the query (no reply) -> the Ask times out.
var counters = new ReconcileProbeCounters();
var commActor = Sys.ActorOf(Props.Create(() =>
new ReconcileProbeActor(siteHash: "sha256:target", failQuery: true)));
new ReconcileProbeActor(counters, siteHash: "sha256:target", failQuery: true)));
var service = CreateServiceWithCommActor(commActor);
var result = await service.DeployInstanceAsync(12, "admin");
// Did not abort — the deploy proceeded after the failed query.
Assert.True(result.IsSuccess);
Assert.Equal(1, ReconcileProbeActor.QueryCount);
Assert.Equal(1, ReconcileProbeActor.DeployCount);
Assert.Equal(1, counters.QueryCount);
Assert.Equal(1, counters.DeployCount);
}
// ── DeploymentManager-015: reconciliation must perform the normal success side effects ──
@@ -797,16 +809,17 @@ public class DeploymentServiceTests : TestKit
await _repo.AddDeployedSnapshotAsync(
Arg.Do<DeployedConfigSnapshot>(s => storedSnapshot = s), Arg.Any<CancellationToken>());
var counters = new ReconcileProbeCounters();
var commActor = Sys.ActorOf(Props.Create(() =>
new ReconcileProbeActor(siteHash: "sha256:target", failQuery: false)));
new ReconcileProbeActor(counters, siteHash: "sha256:target", failQuery: false)));
var service = CreateServiceWithCommActor(commActor);
var result = await service.DeployInstanceAsync(70, "admin");
Assert.True(result.IsSuccess);
// No re-deploy was sent -- this was reconciled.
Assert.Equal(1, ReconcileProbeActor.QueryCount);
Assert.Equal(0, ReconcileProbeActor.DeployCount);
Assert.Equal(1, counters.QueryCount);
Assert.Equal(0, counters.DeployCount);
// DeploymentManager-015: the instance State must reflect the deployed
// config the site is actually running.
@@ -851,8 +864,9 @@ public class DeploymentServiceTests : TestKit
_repo.GetDeployedSnapshotByInstanceIdAsync(72, Arg.Any<CancellationToken>())
.Returns((DeployedConfigSnapshot?)null);
var counters = new ReconcileProbeCounters();
var commActor = Sys.ActorOf(Props.Create(() =>
new ReconcileProbeActor(siteHash: "sha256:target", failQuery: false)));
new ReconcileProbeActor(counters, siteHash: "sha256:target", failQuery: false)));
var service = CreateServiceWithCommActor(commActor);
var result = await service.DeployInstanceAsync(72, "admin");
@@ -861,8 +875,8 @@ public class DeploymentServiceTests : TestKit
// Success — central and site agree on the applied config.
Assert.True(result.IsSuccess);
Assert.Equal(DeploymentStatus.Success, prior.Status);
Assert.Equal(1, ReconcileProbeActor.QueryCount);
Assert.Equal(0, ReconcileProbeActor.DeployCount);
Assert.Equal(1, counters.QueryCount);
Assert.Equal(0, counters.DeployCount);
// DeploymentManager-018: the operator's explicit Disable must survive
// the reconciliation — Instance.State stays Disabled, not silently
@@ -896,8 +910,9 @@ public class DeploymentServiceTests : TestKit
_repo.GetDeployedSnapshotByInstanceIdAsync(71, Arg.Any<CancellationToken>())
.Returns((DeployedConfigSnapshot?)null);
var counters = new ReconcileProbeCounters();
var commActor = Sys.ActorOf(Props.Create(() =>
new ReconcileProbeActor(siteHash: "sha256:target", failQuery: false)));
new ReconcileProbeActor(counters, siteHash: "sha256:target", failQuery: false)));
var service = CreateServiceWithCommActor(commActor);
var result = await service.DeployInstanceAsync(71, "admin");
@@ -936,8 +951,9 @@ public class DeploymentServiceTests : TestKit
_repo.GetDeployedSnapshotByInstanceIdAsync(73, Arg.Any<CancellationToken>())
.Returns((DeployedConfigSnapshot?)null);
var counters = new ReconcileProbeCounters();
var commActor = Sys.ActorOf(Props.Create(() =>
new ReconcileProbeActor(siteHash: "sha256:target", failQuery: false)));
new ReconcileProbeActor(counters, siteHash: "sha256:target", failQuery: false)));
var service = CreateServiceWithCommActor(commActor);
var result = await service.DeployInstanceAsync(73, "currentUser");
@@ -1178,8 +1194,9 @@ public class DeploymentServiceTests : TestKit
_repo.AddDeployedSnapshotAsync(Arg.Any<DeployedConfigSnapshot>(), Arg.Any<CancellationToken>())
.Returns<Task>(_ => throw new InvalidOperationException("snapshot store unavailable"));
var counters = new ReconcileProbeCounters();
var commActor = Sys.ActorOf(Props.Create(() =>
new ReconcileProbeActor(siteHash: "sha256:target", failQuery: false)));
new ReconcileProbeActor(counters, siteHash: "sha256:target", failQuery: false)));
var service = CreateServiceWithCommActor(commActor);
var result = await service.DeployInstanceAsync(20, "admin");
@@ -1196,33 +1213,40 @@ public class DeploymentServiceTests : TestKit
Arg.Any<CancellationToken>());
}
/// <summary>
/// Per-test counters for <see cref="SerializationProbeActor"/>. DeploymentManager-024:
/// each test owns its own instance, passed into the actor's constructor, so
/// counters are no longer shared static state that races under parallel
/// test execution.
/// </summary>
private sealed class SerializationProbeCounters
{
public int MaxConcurrent;
public int Current;
public readonly object Gate = new();
}
/// <summary>
/// Stand-in CentralCommunicationActor that measures deploy concurrency. It
/// defers each deploy reply via the scheduler, so if two deploys for the
/// same instance were NOT serialized by the operation lock their windows
/// would overlap and <see cref="MaxConcurrent"/> would exceed 1.
/// would overlap and <c>MaxConcurrent</c> would exceed 1.
/// </summary>
private class SerializationProbeActor : ReceiveActor, IWithTimers
{
public static int MaxConcurrent;
private static int _current;
private static readonly object Gate = new();
public ITimerScheduler Timers { get; set; } = null!;
public SerializationProbeActor()
public SerializationProbeActor(SerializationProbeCounters counters)
{
MaxConcurrent = 0;
_current = 0;
Receive<SiteEnvelope>(env =>
{
if (env.Message is DeployInstanceCommand d)
{
lock (Gate)
lock (counters.Gate)
{
_current++;
if (_current > MaxConcurrent) MaxConcurrent = _current;
counters.Current++;
if (counters.Current > counters.MaxConcurrent)
counters.MaxConcurrent = counters.Current;
}
var replyTo = Sender;
@@ -1242,9 +1266,9 @@ public class DeploymentServiceTests : TestKit
Receive<CompleteDeploy>(c =>
{
lock (Gate)
lock (counters.Gate)
{
_current--;
counters.Current--;
}
c.ReplyTo.Tell(new DeploymentStatusResponse(
c.Command.DeploymentId, c.Command.InstanceUniqueName,
@@ -1255,29 +1279,34 @@ public class DeploymentServiceTests : TestKit
private sealed record CompleteDeploy(DeployInstanceCommand Command, IActorRef ReplyTo);
}
/// <summary>
/// Per-test counters for <see cref="ReconcileProbeActor"/>. DeploymentManager-024:
/// each test owns its own instance so counter assertions cannot race across
/// tests running in parallel.
/// </summary>
private sealed class ReconcileProbeCounters
{
public int QueryCount;
public int DeployCount;
}
/// <summary>
/// Stand-in CentralCommunicationActor for reconciliation tests. Counts the
/// site queries and deploy commands it receives, answers queries with a
/// site queries and deploy commands it receives (into a per-test
/// <see cref="ReconcileProbeCounters"/> instance), answers queries with a
/// configurable applied revision hash, and (optionally) drops the query to
/// simulate an unreachable site so the central Ask times out.
/// </summary>
private class ReconcileProbeActor : ReceiveActor
{
public static int QueryCount;
public static int DeployCount;
public ReconcileProbeActor(string siteHash, bool failQuery)
public ReconcileProbeActor(ReconcileProbeCounters counters, string siteHash, bool failQuery)
{
// Each test creates a fresh actor; reset the shared counters.
QueryCount = 0;
DeployCount = 0;
Receive<SiteEnvelope>(env =>
{
switch (env.Message)
{
case DeploymentStateQueryRequest q:
QueryCount++;
Interlocked.Increment(ref counters.QueryCount);
if (!failQuery)
{
Sender.Tell(new DeploymentStateQueryResponse(
@@ -1288,7 +1317,7 @@ public class DeploymentServiceTests : TestKit
break;
case DeployInstanceCommand d:
DeployCount++;
Interlocked.Increment(ref counters.DeployCount);
Sender.Tell(new DeploymentStatusResponse(
d.DeploymentId, d.InstanceUniqueName,
DeploymentStatus.Success, null, DateTimeOffset.UtcNow));
@@ -29,13 +29,53 @@ public class CentralHealthReportLoopTests
public SiteHealthState? GetSiteState(string siteId) => null;
}
private static async Task RunLoopBriefly(CentralHealthReportLoop loop, int runForMs)
/// <summary>
/// HealthMonitoring-022 de-flake: <see cref="CentralHealthReportLoop"/>'s
/// internal cadence is a real <see cref="PeriodicTimer"/>, so the loop is
/// timing-sensitive. We can't drive a virtual clock (PeriodicTimer doesn't
/// consume <see cref="TimeProvider"/>) without refactoring the production
/// loop, so we keep wall-clock waits but use a *generous* budget: a 5 s
/// outer cancellation cap with a poll-until-condition wait, instead of a
/// fixed <see cref="Task.Delay"/> that fails fast on a slow CI runner. The
/// loop's <c>ReportInterval</c> is set to 50 ms in each test, so under
/// normal conditions the condition is met almost immediately; under heavy
/// CI load the poll loop tolerates the slow tick instead of asserting on a
/// timed-out empty list.
/// </summary>
private static async Task RunLoopUntil(
CentralHealthReportLoop loop,
Func<bool> condition,
TimeSpan? maxWait = null)
{
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(runForMs + 100));
var deadline = maxWait ?? TimeSpan.FromSeconds(5);
using var cts = new CancellationTokenSource(deadline + TimeSpan.FromSeconds(1));
try
{
await loop.StartAsync(cts.Token);
await Task.Delay(runForMs, CancellationToken.None);
var sw = System.Diagnostics.Stopwatch.StartNew();
while (sw.Elapsed < deadline && !condition())
{
await Task.Delay(25, CancellationToken.None);
}
await loop.StopAsync(CancellationToken.None);
}
catch (OperationCanceledException) { }
}
/// <summary>
/// Used by tests that need the loop to run for a bounded period without
/// waiting on a specific condition (e.g. asserting <i>no</i> reports were
/// produced). The wait is generous (1 s default) — see
/// <see cref="RunLoopUntil"/> for the rationale.
/// </summary>
private static async Task RunLoopBriefly(CentralHealthReportLoop loop, int runForMs)
{
var totalMs = Math.Max(runForMs, 1000);
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(totalMs + 1000));
try
{
await loop.StartAsync(cts.Token);
await Task.Delay(totalMs, CancellationToken.None);
await loop.StopAsync(CancellationToken.None);
}
catch (OperationCanceledException) { }
@@ -56,7 +96,9 @@ public class CentralHealthReportLoopTests
collector, aggregator, clusterNodes, options,
NullLogger<CentralHealthReportLoop>.Instance);
await RunLoopBriefly(loop, 250);
// HealthMonitoring-022: wait up to 5 s for at least one report to fire
// rather than fixed-budget Task.Delay; tolerates slow CI runners.
await RunLoopUntil(loop, () => aggregator.Processed.Count >= 1);
Assert.NotEmpty(aggregator.Processed);
Assert.All(aggregator.Processed,
@@ -98,7 +140,10 @@ public class CentralHealthReportLoopTests
collector, aggregator, clusterNodes, options,
NullLogger<CentralHealthReportLoop>.Instance);
await RunLoopBriefly(loop, 300);
// HealthMonitoring-022: wait up to 5 s for at least 2 reports rather
// than a fixed 300 ms window that could miss the second tick on a
// slow CI runner; the assertion below proves the sequence is monotonic.
await RunLoopUntil(loop, () => aggregator.Processed.Count >= 2);
Assert.True(aggregator.Processed.Count >= 2,
$"Expected at least 2 reports, got {aggregator.Processed.Count}");
@@ -170,7 +215,10 @@ public class CentralHealthReportLoopTests
collector, aggregator, clusterNodes, options,
NullLogger<CentralHealthReportLoop>.Instance);
await RunLoopBriefly(loop, 450);
// HealthMonitoring-022: the first ProcessReport call throws (counters
// get restored), the second succeeds. Wait up to 5 s for that second
// (successful) call rather than a fixed 450 ms budget.
await RunLoopUntil(loop, () => aggregator.Processed.Count >= 1);
// First call threw, later succeeded — the first successful report
// must carry the previously-failed interval's accumulated counts.
@@ -0,0 +1,291 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using NSubstitute;
using ScadaLink.Commons.Entities.InboundApi;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Types.InboundApi;
using ScadaLink.InboundAPI.Middleware;
using System.Net;
using System.Text;
namespace ScadaLink.InboundAPI.Tests;
/// <summary>
/// InboundAPI-023: <see cref="EndpointExtensions.HandleInboundApiRequest"/> is
/// the composition wiring that ties validator → JSON parse → ParameterValidator →
/// InboundScriptExecutor → response shaping together. Each composed component
/// has its own unit tests, but the wiring itself was uncovered. These tests
/// drive the end-to-end POST /api/{methodName} flow through a TestServer so a
/// regression in any of the seams below would be caught here:
///
/// 1. happy path — 200 + script result body
/// 2. auth failures — validator status code propagates verbatim
/// 3. invalid JSON body — 400 + sanitized error
/// 4. parameter validation failure — 400 + ParameterValidator's error message
/// 5. script failure — 500 + ErrorMessage in body
/// 6. successful auth must publish the resolved API key name into
/// <c>HttpContext.Items[AuditWriteMiddleware.AuditActorItemKey]</c> (so the
/// AuditWriteMiddleware sees a non-null Actor when it emits the audit row).
/// </summary>
public class EndpointExtensionsTests
{
/// <summary>
/// Stub hasher that returns its input unchanged. Same pattern as
/// <see cref="EndpointContentTypeTests"/> — lets us seed an ApiKey with a
/// known "hash" without depending on the configured HMAC pepper.
/// </summary>
private sealed class IdentityHasher : IApiKeyHasher
{
public string Hash(string keyValue) => keyValue;
}
/// <summary>
/// Inline middleware that captures the value at
/// <c>HttpContext.Items[AuditWriteMiddleware.AuditActorItemKey]</c> after the
/// inbound endpoint runs, so the actor-stash invariant can be asserted from
/// the test without running the real AuditWriteMiddleware.
/// </summary>
private sealed class AuditActorCapture
{
public string? CapturedActor { get; set; }
}
private const string ApiKeyValue = "test-key";
private static ApiKey SeedKey(int id = 1, string name = "test")
{
var key = ApiKey.FromHash(name, ApiKeyValue);
key.IsEnabled = true;
key.Id = id;
return key;
}
private static ApiMethod SeedMethod(
int id, string name, string script, string? paramDefs = null)
{
return new ApiMethod(name, script)
{
Id = id,
TimeoutSeconds = 10,
ParameterDefinitions = paramDefs,
};
}
[Fact]
public async Task HappyPath_Returns200WithScriptResultJson()
{
var key = SeedKey();
var method = SeedMethod(1, "echo", "return Parameters[\"value\"];",
"""[{"name":"value","type":"Integer","required":true}]""");
using var host = await BuildHostAsync(key, method);
var client = host.GetTestClient();
var request = BuildPost("echo", """{"value":7}""");
var response = await client.SendAsync(request);
var body = await response.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Contains("7", body);
}
[Fact]
public async Task MissingApiKey_Returns401()
{
var key = SeedKey();
var method = SeedMethod(1, "noKey", "return 1;");
using var host = await BuildHostAsync(key, method);
var client = host.GetTestClient();
// No X-API-Key header — auth should reject with 401.
var request = new HttpRequestMessage(HttpMethod.Post, "/api/noKey")
{
Content = new StringContent("{}", Encoding.UTF8, "application/json"),
};
var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task UnknownMethod_Returns403_IndistinguishableFromNotApproved()
{
// InboundAPI-011: method existence is intentionally not observable —
// both "method not found" and "key not approved" surface as 403.
var key = SeedKey();
var method = SeedMethod(1, "knownMethod", "return 1;");
using var host = await BuildHostAsync(key, method);
var client = host.GetTestClient();
var request = BuildPost("unknownMethod", "{}");
var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task InvalidJsonBody_Returns400()
{
var key = SeedKey();
var method = SeedMethod(1, "badJson", "return 1;");
using var host = await BuildHostAsync(key, method);
var client = host.GetTestClient();
var request = BuildPost("badJson", "{ not json");
var response = await client.SendAsync(request);
var body = await response.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Contains("Invalid JSON", body);
}
[Fact]
public async Task MissingRequiredParameter_Returns400_FromParameterValidator()
{
var key = SeedKey();
var method = SeedMethod(1, "needsParam", "return Parameters[\"value\"];",
"""[{"name":"value","type":"Integer","required":true}]""");
using var host = await BuildHostAsync(key, method);
var client = host.GetTestClient();
// Body is empty object — required parameter "value" is missing.
var request = BuildPost("needsParam", "{}");
var response = await client.SendAsync(request);
var body = await response.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
// ParameterValidator's error message is surfaced.
Assert.Contains("value", body);
}
[Fact]
public async Task ScriptThrows_Returns500_WithSanitizedErrorBody()
{
var key = SeedKey();
// Throws inside the script body — InboundScriptExecutor catches the
// exception, logs it server-side, and surfaces the generic "Internal
// script error" message to the caller (the executor deliberately does
// not leak raw exception details — see InboundScriptExecutor.ExecuteAsync's
// catch block). The endpoint maps the script failure to HTTP 500.
var method = SeedMethod(1, "boom",
"""throw new System.InvalidOperationException("boom-msg");""");
using var host = await BuildHostAsync(key, method);
var client = host.GetTestClient();
var request = BuildPost("boom", "{}");
var response = await client.SendAsync(request);
var body = await response.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
// Wiring contract: error body is JSON-shaped and the raw exception
// message is not leaked (the executor sanitises before this point).
Assert.Contains("error", body);
Assert.DoesNotContain("boom-msg", body);
}
[Fact]
public async Task SuccessfulAuth_StashesResolvedApiKeyNameOnHttpContextItems()
{
// InboundAPI-023: the handler stashes the resolved API key's display name
// at HttpContext.Items[AuditWriteMiddleware.AuditActorItemKey] AFTER auth
// succeeded, so AuditWriteMiddleware sees a populated Actor when it
// emits the audit row. A capture middleware reads the slot once the
// endpoint finishes, proving the wiring still publishes it.
var key = SeedKey(id: 99, name: "audit-actor-name");
var method = SeedMethod(1, "stamp", "return 1;");
var capture = new AuditActorCapture();
using var host = await BuildHostAsync(key, method, customize: builder =>
{
builder.Use(async (ctx, next) =>
{
await next();
if (ctx.Items.TryGetValue(
AuditWriteMiddleware.AuditActorItemKey, out var stashed)
&& stashed is string actorName)
{
capture.CapturedActor = actorName;
}
});
}, additionalServices: services =>
{
services.AddSingleton(capture);
});
var client = host.GetTestClient();
var request = BuildPost("stamp", "{}");
var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("audit-actor-name", capture.CapturedActor);
}
private static HttpRequestMessage BuildPost(string methodName, string body)
{
var request = new HttpRequestMessage(HttpMethod.Post, "/api/" + methodName)
{
Content = new StringContent(body, Encoding.UTF8, "application/json"),
};
request.Headers.Add("X-API-Key", ApiKeyValue);
return request;
}
private static async Task<IHost> BuildHostAsync(
ApiKey key,
ApiMethod method,
Action<IApplicationBuilder>? customize = null,
Action<IServiceCollection>? additionalServices = null)
{
var repo = Substitute.For<IInboundApiRepository>();
repo.GetAllApiKeysAsync(Arg.Any<CancellationToken>())
.Returns(new List<ApiKey> { key });
repo.GetMethodByNameAsync(method.Name, Arg.Any<CancellationToken>())
.Returns(method);
repo.GetApprovedKeysForMethodAsync(method.Id, Arg.Any<CancellationToken>())
.Returns(new List<ApiKey> { key });
var hostBuilder = new HostBuilder()
.ConfigureWebHost(webBuilder =>
{
webBuilder
.UseTestServer()
.ConfigureServices(services =>
{
services.AddRouting();
services.AddSingleton(repo);
services.AddSingleton(Substitute.For<IInstanceLocator>());
services.Configure<InboundApiOptions>(_ => { });
services.AddInboundAPI();
// Replace the production CommunicationService-backed
// router and the configured HMAC hasher with test stubs
// (same pattern as EndpointContentTypeTests).
services.RemoveAll<IInstanceRouter>();
services.AddSingleton(Substitute.For<IInstanceRouter>());
services.RemoveAll<IApiKeyHasher>();
services.AddSingleton<IApiKeyHasher>(new IdentityHasher());
services.AddLogging();
additionalServices?.Invoke(services);
})
.Configure(app =>
{
app.UseRouting();
customize?.Invoke(app);
app.UseEndpoints(endpoints => endpoints.MapInboundAPI());
});
});
return await hostBuilder.StartAsync();
}
}
@@ -1157,4 +1157,263 @@ public class ManagementActorTests : TestKit, IDisposable
// The curated InstanceService failure message is still surfaced verbatim.
Assert.Contains("99", response.Error);
}
// ========================================================================
// ManagementService-021: Transport (#24) bundle handler coverage
//
// The three bundle handlers (HandleExportBundle / HandlePreviewBundle /
// HandleImportBundle) at ManagementActor.cs:1717-1897 previously had zero
// tests. The cases below pin the load-bearing behaviours: role gating,
// ExportBundle name resolution, ImportBundle blocker rejection, and the
// ImportBundle (EntityType, Name) dedupe.
// ========================================================================
/// <summary>
/// Adds a substituted <see cref="Commons.Interfaces.Transport.IBundleExporter"/> and
/// <see cref="Commons.Interfaces.Transport.IBundleImporter"/> to the test
/// service collection plus minimal repositories the bundle handlers query
/// from the export side. Returns both substitutes so a test can configure
/// their behaviour.
/// </summary>
private (Commons.Interfaces.Transport.IBundleExporter Exporter,
Commons.Interfaces.Transport.IBundleImporter Importer)
AddBundleSubstitutes()
{
var exporter = Substitute.For<Commons.Interfaces.Transport.IBundleExporter>();
var importer = Substitute.For<Commons.Interfaces.Transport.IBundleImporter>();
_services.AddSingleton(exporter);
_services.AddSingleton(importer);
// The repository fan-out HandleExportBundle does at the top of its body.
// Tests that only exercise role gating still need these resolved.
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Template>());
_templateRepo.GetAllSharedScriptsAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Scripts.SharedScript>());
var externalRepo = Substitute.For<IExternalSystemRepository>();
externalRepo.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.ExternalSystems.ExternalSystemDefinition>());
externalRepo.GetAllDatabaseConnectionsAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.ExternalSystems.DatabaseConnectionDefinition>());
_services.AddScoped(_ => externalRepo);
var notifRepo = Substitute.For<INotificationRepository>();
notifRepo.GetAllNotificationListsAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Notifications.NotificationList>());
notifRepo.GetAllSmtpConfigurationsAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Notifications.SmtpConfiguration>());
_services.AddScoped(_ => notifRepo);
var inboundRepo = Substitute.For<IInboundApiRepository>();
inboundRepo.GetAllApiKeysAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.InboundApi.ApiKey>());
inboundRepo.GetAllApiMethodsAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.InboundApi.ApiMethod>());
_services.AddScoped(_ => inboundRepo);
return (exporter, importer);
}
private static ExportBundleCommand AllExportCommand() =>
new(All: true,
TemplateNames: null, SharedScriptNames: null,
ExternalSystemNames: null, DatabaseConnectionNames: null,
NotificationListNames: null, SmtpConfigurationNames: null,
ApiKeyNames: null, ApiMethodNames: null,
IncludeDependencies: false, Passphrase: null,
SourceEnvironment: "test-env");
[Fact]
public void ExportBundleCommand_WithAdminRole_ReturnsUnauthorized()
{
// ExportBundle requires the Design role; an Admin-only caller is rejected.
AddBundleSubstitutes();
var actor = CreateActor();
var envelope = Envelope(AllExportCommand(), "Admin");
actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Design", response.Message);
}
[Fact]
public void PreviewBundleCommand_WithDesignRole_ReturnsUnauthorized()
{
// PreviewBundle requires the Admin role (Design role isn't enough,
// mirroring the Central UI gating — only Admin imports cross-cutting
// configuration).
AddBundleSubstitutes();
var actor = CreateActor();
var envelope = Envelope(new PreviewBundleCommand("AA==", null), "Design");
actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Admin", response.Message);
}
[Fact]
public void ImportBundleCommand_WithDesignRole_ReturnsUnauthorized()
{
AddBundleSubstitutes();
var actor = CreateActor();
var envelope = Envelope(new ImportBundleCommand("AA==", null, "skip"), "Design");
actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Admin", response.Message);
}
[Fact]
public void ExportBundleCommand_WithUnknownTemplateName_ReturnsManagementError()
{
// ResolveIds throws ManagementCommandException for unknown names; that
// curated message must surface verbatim to the caller.
AddBundleSubstitutes();
// No templates in the repo; the export selection names one anyway.
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Template>());
var cmd = new ExportBundleCommand(
All: false,
TemplateNames: new[] { "DoesNotExist" },
SharedScriptNames: null,
ExternalSystemNames: null, DatabaseConnectionNames: null,
NotificationListNames: null, SmtpConfigurationNames: null,
ApiKeyNames: null, ApiMethodNames: null,
IncludeDependencies: false, Passphrase: null,
SourceEnvironment: "test-env");
var actor = CreateActor();
var envelope = Envelope(cmd, "Design");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
// The ManagementCommandException message surfaces verbatim — it names
// the missing entity type and the missing name.
Assert.Contains("template", response.Error, StringComparison.OrdinalIgnoreCase);
Assert.Contains("DoesNotExist", response.Error);
}
[Fact]
public void ImportBundleCommand_WithBlockerRow_AbortsBeforeApply()
{
// A ConflictKind.Blocker in the preview must abort the import — the
// handler throws ManagementCommandException before calling ApplyAsync.
var (_, importer) = AddBundleSubstitutes();
var sessionId = Guid.NewGuid();
importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(new Commons.Types.Transport.BundleSession
{
SessionId = sessionId,
Manifest = null!,
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5),
});
var blockerItem = new Commons.Types.Transport.ImportPreviewItem(
EntityType: "Template",
Name: "BlockedTemplate",
ExistingVersion: null,
IncomingVersion: 1,
Kind: Commons.Types.Transport.ConflictKind.Blocker,
FieldDiffJson: null,
BlockerReason: "FK to missing site");
importer.PreviewAsync(sessionId, Arg.Any<CancellationToken>())
.Returns(new Commons.Types.Transport.ImportPreview(
sessionId,
new[] { blockerItem }));
// A non-empty base64 payload that decodes — the handler does its own
// base64 check before reaching the importer.
var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
var actor = CreateActor();
var envelope = Envelope(new ImportBundleCommand(payload, null, "skip"), "Admin");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("blocker", response.Error, StringComparison.OrdinalIgnoreCase);
Assert.Contains("BlockedTemplate", response.Error);
// Apply must NOT have been called — the handler aborts before it.
importer.DidNotReceive().ApplyAsync(
Arg.Any<Guid>(),
Arg.Any<IReadOnlyList<Commons.Types.Transport.ImportResolution>>(),
Arg.Any<string>(),
Arg.Any<CancellationToken>());
}
[Fact]
public void ImportBundleCommand_DuplicatePreviewItems_DedupePerEntityTypeAndName()
{
// The handler dedupes by (EntityType, Name) before calling ApplyAsync —
// last-write-wins, matching the Central UI's TransportImport behavior.
// The preview here emits THREE rows for the same (Template, "Dup"):
// an Identical then a Modified then an Identical. After dedupe, only
// one resolution must reach the importer for that key.
var (_, importer) = AddBundleSubstitutes();
var sessionId = Guid.NewGuid();
importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(new Commons.Types.Transport.BundleSession
{
SessionId = sessionId,
Manifest = null!,
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5),
});
Commons.Types.Transport.ImportPreviewItem Row(
Commons.Types.Transport.ConflictKind kind) =>
new("Template", "Dup", ExistingVersion: 1, IncomingVersion: 2,
Kind: kind, FieldDiffJson: null, BlockerReason: null);
importer.PreviewAsync(sessionId, Arg.Any<CancellationToken>())
.Returns(new Commons.Types.Transport.ImportPreview(
sessionId,
new[]
{
Row(Commons.Types.Transport.ConflictKind.Identical),
Row(Commons.Types.Transport.ConflictKind.Modified),
Row(Commons.Types.Transport.ConflictKind.Identical),
}));
IReadOnlyList<Commons.Types.Transport.ImportResolution>? captured = null;
importer.ApplyAsync(
Arg.Any<Guid>(),
Arg.Do<IReadOnlyList<Commons.Types.Transport.ImportResolution>>(
r => captured = r),
Arg.Any<string>(),
Arg.Any<CancellationToken>())
.Returns(new Commons.Types.Transport.ImportResult(
BundleImportId: Guid.NewGuid(),
Added: 0, Overwritten: 0, Skipped: 0, Renamed: 0,
StaleInstanceIds: Array.Empty<int>(),
AuditEventCorrelation: "correlation"));
var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
var actor = CreateActor();
// "overwrite" policy so the final (Identical) row would otherwise differ
// from the Modified row's action — proves the last-write-wins semantics.
var envelope = Envelope(new ImportBundleCommand(payload, null, "overwrite"), "Admin");
actor.Tell(envelope);
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.NotNull(captured);
// Only ONE resolution survives for the (Template, "Dup") key.
var dupResolutions = captured!
.Where(r => r.EntityType == "Template" && r.Name == "Dup")
.ToList();
Assert.Single(dupResolutions);
// Last-write-wins: the final Identical row's Skip action overrides the
// earlier Modified row's Overwrite action.
Assert.Equal(Commons.Types.Transport.ResolutionAction.Skip, dupResolutions[0].Action);
}
}
@@ -391,6 +391,121 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
Assert.Equal(3, allIds.Count);
}
[SkippableFact]
public async Task SiteCallQueryRequest_StuckOnly_CursorAtNonStuckBoundary_SkipsToNextStuckRow()
{
// SiteCallAudit-006 boundary regression. The earlier paging test
// interleaves stuck/non-stuck rows but the cursor between page-1 and
// page-2 always lands on a stuck row. This test forces the cursor to
// sit on a NON-stuck row (page-size=1 over a strict
// stuck-not_stuck-stuck-not_stuck-stuck-not_stuck pattern oldest-first)
// so the SQL-side composition of the stuck predicate AND the keyset
// cursor predicate (CreatedAtUtc < cursor OR =cursor AND id < ...) must
// honestly skip the non-stuck rows between each page. Each page must
// return exactly one stuck row, with no overlap and all three stuck
// rows visited across three pages.
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
await using var context = CreateContext();
var repo = new SiteCallAuditRepository(context);
var actor = CreateActor(repo, new SiteCallAuditOptions { StuckAgeThreshold = TimeSpan.FromMinutes(10) });
var now = DateTime.UtcNow;
var stuckIds = new List<Guid>();
// Insert pattern (relative-newest first, the order the actor returns
// them in DESC-by-CreatedAtUtc):
// t=-1m (non-stuck — Attempted but only 1m old, < 10m threshold),
// t=-15m (stuck — Attempted, 15m > 10m: stuckA),
// t=-20m (non-stuck — terminal Delivered between stuckA & stuckB),
// t=-25m (stuck — Attempted, 25m > 10m: stuckB),
// t=-30m (non-stuck — terminal Delivered between stuckB & stuckC),
// t=-35m (stuck — Attempted, 35m > 10m: stuckC),
// t=-40m (non-stuck — terminal Delivered, oldest of all).
// The non-stuck rows at -20m and -30m sit between consecutive stuck
// rows, so the page-size-1 cursor lands ON a non-stuck row between
// pages — exactly the boundary the predicate composition must skip.
var stuckA = TrackedOperationId.New();
var stuckB = TrackedOperationId.New();
var stuckC = TrackedOperationId.New();
// Expected DESC-by-CreatedAtUtc order: A (-15m newest), B (-25m), C (-35m oldest).
stuckIds.Add(stuckA.Value);
stuckIds.Add(stuckB.Value);
stuckIds.Add(stuckC.Value);
await repo.UpsertAsync(NewRow(
TrackedOperationId.New(), siteId, status: "Delivered",
createdAtUtc: now.AddMinutes(-40), terminal: true));
await repo.UpsertAsync(NewRow(
stuckC, siteId, status: "Attempted",
createdAtUtc: now.AddMinutes(-35)));
await repo.UpsertAsync(NewRow(
TrackedOperationId.New(), siteId, status: "Delivered",
createdAtUtc: now.AddMinutes(-30), terminal: true));
await repo.UpsertAsync(NewRow(
stuckB, siteId, status: "Attempted",
createdAtUtc: now.AddMinutes(-25)));
await repo.UpsertAsync(NewRow(
TrackedOperationId.New(), siteId, status: "Delivered",
createdAtUtc: now.AddMinutes(-20), terminal: true));
await repo.UpsertAsync(NewRow(
stuckA, siteId, status: "Attempted",
createdAtUtc: now.AddMinutes(-15)));
await repo.UpsertAsync(NewRow(
TrackedOperationId.New(), siteId, status: "Attempted",
createdAtUtc: now.AddMinutes(-1)));
// Page-1: page-size=1, expect stuckA (newest stuck row).
actor.Tell(
new SiteCallQueryRequest(
"corr-stuck-b1", null, siteId, null, null, StuckOnly: true,
null, null, null, null, PageSize: 1),
TestActor);
var page1 = ExpectMsg<SiteCallQueryResponse>(TimeSpan.FromSeconds(10));
Assert.True(page1.Success);
Assert.Single(page1.SiteCalls);
Assert.True(page1.SiteCalls[0].IsStuck);
Assert.Equal(stuckA.Value, page1.SiteCalls[0].TrackedOperationId);
Assert.NotNull(page1.NextAfterCreatedAtUtc);
// Page-2: between stuckA and stuckB the non-stuck terminal row at -20m
// sits at the cursor — the SQL must skip it, NOT return it.
actor.Tell(
new SiteCallQueryRequest(
"corr-stuck-b2", null, siteId, null, null, StuckOnly: true,
null, null, page1.NextAfterCreatedAtUtc, page1.NextAfterId,
PageSize: 1),
TestActor);
var page2 = ExpectMsg<SiteCallQueryResponse>(TimeSpan.FromSeconds(10));
Assert.True(page2.Success);
Assert.Single(page2.SiteCalls);
Assert.True(page2.SiteCalls[0].IsStuck);
Assert.Equal(stuckB.Value, page2.SiteCalls[0].TrackedOperationId);
Assert.NotNull(page2.NextAfterCreatedAtUtc);
// Page-3: between stuckB and stuckC the non-stuck row at -30m sits at
// the cursor — again must be skipped.
actor.Tell(
new SiteCallQueryRequest(
"corr-stuck-b3", null, siteId, null, null, StuckOnly: true,
null, null, page2.NextAfterCreatedAtUtc, page2.NextAfterId,
PageSize: 1),
TestActor);
var page3 = ExpectMsg<SiteCallQueryResponse>(TimeSpan.FromSeconds(10));
Assert.True(page3.Success);
Assert.Single(page3.SiteCalls);
Assert.True(page3.SiteCalls[0].IsStuck);
Assert.Equal(stuckC.Value, page3.SiteCalls[0].TrackedOperationId);
// All three stuck rows visited exactly once, in DESC order, with no
// non-stuck rows leaking through despite the cursor sitting on them.
var visited = new[] { page1, page2, page3 }
.SelectMany(p => p.SiteCalls)
.Select(s => s.TrackedOperationId)
.ToList();
Assert.Equal(stuckIds, visited);
}
[SkippableFact]
public async Task SiteCallDetailRequest_KnownId_ReturnsFullDetail()
{
@@ -9,6 +9,15 @@ public class EventLogPurgeServiceTests : IDisposable
private readonly string _dbPath;
private readonly SiteEventLogOptions _options;
/// <summary>
/// SiteEventLogging-023: stop flag for the concurrent stress test. Declared as
/// a <c>volatile</c> field so every writer thread observes the main thread's
/// `_stop = true` write without depending on JIT/runtime quirks. A plain
/// <c>bool</c> local would be legal-cached in a register inside the tight
/// <c>while (!_stop)</c> loop under release-mode optimisation.
/// </summary>
private volatile bool _stop;
public EventLogPurgeServiceTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"test_purge_{Guid.NewGuid()}.db");
@@ -282,7 +291,13 @@ public class EventLogPurgeServiceTests : IDisposable
};
var exceptions = new System.Collections.Concurrent.ConcurrentBag<Exception>();
var stop = false;
// SiteEventLogging-023: must be volatile so the writer threads observe the
// main thread's `stop = true` flip after the purge task completes. Without
// it, a release-mode JIT is permitted to cache the `stop = false` read in
// a register inside the tight `while (!stop)` loop and never see the flip,
// causing the writer tasks to hang past xUnit's per-test timeout instead
// of asserting `Empty(exceptions)`.
_stop = false;
var purgeTask = Task.Run(() =>
{
@@ -298,7 +313,7 @@ public class EventLogPurgeServiceTests : IDisposable
{
try
{
while (!stop)
while (!_stop)
{
await _eventLogger.LogEventAsync("script", "Info", null, "Concurrent", "Concurrent write");
}
@@ -307,7 +322,7 @@ public class EventLogPurgeServiceTests : IDisposable
})).ToArray();
await purgeTask;
stop = true;
_stop = true;
await Task.WhenAll(writeTasks);
Assert.Empty(exceptions);