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:
@@ -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);
|
||||
}
|
||||
}
|
||||
+134
@@ -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&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);
|
||||
|
||||
Reference in New Issue
Block a user