merge(agent-a): rows 37/86/87/88 — deployment reads consolidated on QueryDeploymentListPageAsync (QueryDeploymentSummariesAsync deleted); SiteScopeService honors the Administrator system-wide rule (claims no longer scope admins — the documented ManagementActor semantic now holds on both surfaces); site scope rides the query join (whole-table instance read gone), PageSize bounded (default 50, cap 1000); unparseable --status fails loudly naming valid values
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the <c>deploy status</c> table projection (deferred-work register rows
|
||||
/// 37/87). The handler now returns a <see cref="DeploymentQueryResult"/> OBJECT where
|
||||
/// it used to return a bare row array; without a projector the generic renderer takes
|
||||
/// its Property/Value branch and prints the whole <c>deployments</c> array as raw JSON
|
||||
/// inside one cell. The projection unwraps the envelope and keeps the total visible as
|
||||
/// a trailing note — which matters more here than for the audit query, because the
|
||||
/// page size is bounded server-side and a full page is no longer proof there is
|
||||
/// nothing more.
|
||||
/// </summary>
|
||||
public class DeploymentStatusTableProjectionTests
|
||||
{
|
||||
/// <summary>
|
||||
/// The real server shape, serialised from the REAL <see cref="DeploymentQueryResult"/>
|
||||
/// / <see cref="DeploymentListRow"/> types rather than hand-written JSON, so renaming
|
||||
/// one of their properties fails this test instead of silently blanking a column.
|
||||
/// </summary>
|
||||
private static string RealPayload(int rowCount, int totalCount) =>
|
||||
JsonSerializer.Serialize(
|
||||
new DeploymentQueryResult(
|
||||
Enumerable.Range(1, rowCount).Select(i => new DeploymentListRow(
|
||||
i, $"dep-{i:D3}", i, $"Line3.Pump{i}", 2, DeploymentStatus.Success,
|
||||
$"rev-{i}", "alice",
|
||||
new DateTimeOffset(2026, 8, 15, 12, 0, 0, TimeSpan.Zero), null, null)).ToList(),
|
||||
totalCount,
|
||||
Page: 1,
|
||||
PageSize: rowCount),
|
||||
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
|
||||
|
||||
[Fact]
|
||||
public void Projection_UnwrapsTheEnvelopeToARowArray()
|
||||
{
|
||||
var projected = DeploymentStatusTableProjection.ProjectRows(RealPayload(2, 2));
|
||||
|
||||
using var doc = JsonDocument.Parse(projected);
|
||||
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
|
||||
Assert.Equal(2, doc.RootElement.GetArrayLength());
|
||||
Assert.Equal("dep-001", doc.RootElement[0].GetProperty("deploymentId").GetString());
|
||||
Assert.Equal("Line3.Pump1", doc.RootElement[0].GetProperty("instanceUniqueName").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Projection_WhenPageIsAStrictSubset_AppendsATotalNote()
|
||||
{
|
||||
var projected = DeploymentStatusTableProjection.ProjectRows(RealPayload(2, 57));
|
||||
|
||||
using var doc = JsonDocument.Parse(projected);
|
||||
Assert.Equal(3, doc.RootElement.GetArrayLength());
|
||||
var note = doc.RootElement[2].GetProperty("note").GetString();
|
||||
Assert.Contains("showing 2 of 57", note);
|
||||
Assert.Contains("--page-size", note);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Projection_WhenPageIsTheWholeResult_EmitsNoNoteColumn()
|
||||
{
|
||||
var projected = DeploymentStatusTableProjection.ProjectRows(RealPayload(2, 2));
|
||||
|
||||
using var doc = JsonDocument.Parse(projected);
|
||||
Assert.All(doc.RootElement.EnumerateArray(),
|
||||
e => Assert.False(e.TryGetProperty("note", out _)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Projection_AcceptsABareRowArray_FromAPreEnvelopeServer()
|
||||
{
|
||||
var projected = DeploymentStatusTableProjection.ProjectRows("""[{"deploymentId":"dep-001"}]""");
|
||||
|
||||
using var doc = JsonDocument.Parse(projected);
|
||||
Assert.Equal("dep-001", Assert.Single(doc.RootElement.EnumerateArray())
|
||||
.GetProperty("deploymentId").GetString());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("not json at all")]
|
||||
[InlineData("""{"error":"boom"}""")]
|
||||
public void Projection_LeavesUnrecognisedBodiesUntouched(string body)
|
||||
{
|
||||
Assert.Equal(body, DeploymentStatusTableProjection.ProjectRows(body));
|
||||
}
|
||||
}
|
||||
@@ -90,4 +90,75 @@ public class SiteScopeServiceTests
|
||||
Assert.True(await svc.IsSiteAllowedAsync(1));
|
||||
Assert.True(await svc.IsSiteAllowedAsync(999));
|
||||
}
|
||||
|
||||
// ── Administrator is system-wide regardless of SiteId claims (register 86) ──
|
||||
//
|
||||
// ManagementActor has always exempted Administrator from site scoping
|
||||
// (EnforceSiteScope and every handler that calls it). This service was
|
||||
// claims-only, so an Administrator who ALSO carried a scoped Deployer grant
|
||||
// could act fleet-wide through the CLI/HTTP surface while the UI hid the same
|
||||
// sites. The UI was the side that disagreed with the documented rule.
|
||||
|
||||
[Fact]
|
||||
public async Task AdministratorWithSiteClaims_IsSystemWide()
|
||||
{
|
||||
var svc = ForUser(Role("Administrator"), Role("Deployer"), SiteClaim(1), SiteClaim(3));
|
||||
|
||||
Assert.True(await svc.IsSystemWideAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdministratorWithSiteClaims_FilterSites_ReturnsAllSites()
|
||||
{
|
||||
var svc = ForUser(Role("Administrator"), Role("Deployer"), SiteClaim(1));
|
||||
|
||||
var filtered = await svc.FilterSitesAsync(Sites(1, 2, 3));
|
||||
|
||||
Assert.Equal(new[] { 1, 2, 3 }, filtered.Select(s => s.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdministratorWithSiteClaims_IsSiteAllowed_ForSitesOutsideTheClaims()
|
||||
{
|
||||
var svc = ForUser(Role("Administrator"), SiteClaim(1));
|
||||
|
||||
Assert.True(await svc.IsSiteAllowedAsync(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdministratorWithSiteClaims_ReportsNoPermittedIds()
|
||||
{
|
||||
// The invariant "system-wide => empty id set" must hold for every caller, so
|
||||
// a consumer that reads the ids without consulting IsSystemWide cannot
|
||||
// silently narrow an Administrator to a subset of sites.
|
||||
var svc = ForUser(Role("Administrator"), SiteClaim(1), SiteClaim(3));
|
||||
|
||||
Assert.Empty(await svc.PermittedSiteIdsAsync());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("administrator")]
|
||||
[InlineData("ADMINISTRATOR")]
|
||||
public async Task AdministratorRoleMatch_IsCaseInsensitive(string role)
|
||||
{
|
||||
// ManagementActor compares roles with OrdinalIgnoreCase. ClaimsPrincipal
|
||||
// .IsInRole compares claim VALUES ordinally, so matching the actor requires
|
||||
// reading the role claims explicitly — otherwise the two sides would
|
||||
// disagree on a differently-cased role name.
|
||||
var svc = ForUser(Role(role), SiteClaim(1));
|
||||
|
||||
Assert.True(await svc.IsSystemWideAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DesignerWithSiteClaims_IsStillScoped()
|
||||
{
|
||||
// The class doc used to claim "any Admin/Design user" is system-wide. Only
|
||||
// Administrator is — on either side of the rule — and the doc was corrected
|
||||
// rather than the bypass widened.
|
||||
var svc = ForUser(Role("Designer"), SiteClaim(1));
|
||||
|
||||
Assert.False(await svc.IsSystemWideAsync());
|
||||
Assert.False(await svc.IsSiteAllowedAsync(2));
|
||||
}
|
||||
}
|
||||
|
||||
+32
-1
@@ -164,7 +164,7 @@ public class DeploymentListQueryRepositoryTests : IDisposable
|
||||
new DeploymentListFilter(), pageNumber: 1, pageSize: int.MaxValue);
|
||||
|
||||
Assert.Single(page.Rows);
|
||||
Assert.True(DeploymentRecordSummary.MaxPageSize > 0);
|
||||
Assert.True(DeploymentListPage.MaxPageSize > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -300,6 +300,37 @@ public class DeploymentListQueryRepositoryTests : IDisposable
|
||||
Assert.Empty(page.Rows);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryPage_SiteScopeAndStatus_Compose_ForTheCliQueryPath()
|
||||
{
|
||||
// Register row 37: this is now the ONLY paged read of DeploymentRecords —
|
||||
// the CLI/HTTP QueryDeployments command reaches it through the same filter
|
||||
// the UI page uses, where it used to have a second repository method with
|
||||
// instance-id-list scoping and no total. Its combination (site scope AND a
|
||||
// status filter, over more rows than one page holds) is pinned here.
|
||||
var s1 = await SeedSiteAsync("S1");
|
||||
var s2 = await SeedSiteAsync("S2");
|
||||
var a = await SeedInstanceAsync("Inst-A", s1);
|
||||
var b = await SeedInstanceAsync("Inst-B", s2);
|
||||
for (var i = 0; i < 4; i++)
|
||||
{
|
||||
await SeedRecordAsync($"dep-a{i}", a, DeploymentStatus.Failed, _base.AddMinutes(i));
|
||||
await SeedRecordAsync($"dep-b{i}", b, DeploymentStatus.Failed, _base.AddMinutes(i));
|
||||
}
|
||||
await SeedRecordAsync("dep-a-ok", a, DeploymentStatus.Success, _base.AddHours(1));
|
||||
|
||||
var page = await _repository.QueryDeploymentListPageAsync(
|
||||
new DeploymentListFilter(Status: DeploymentStatus.Failed, SiteIdScope: new[] { s1 }),
|
||||
pageNumber: 1, pageSize: 2);
|
||||
|
||||
// Four in-scope failures exist; the page is bounded to two and the total
|
||||
// reports the rest, so a bounded page is never mistaken for a complete one.
|
||||
Assert.Equal(4, page.TotalCount);
|
||||
Assert.Equal(2, page.Rows.Count);
|
||||
Assert.All(page.Rows, r => Assert.Equal(s1, r.SiteId));
|
||||
Assert.All(page.Rows, r => Assert.Equal(DeploymentStatus.Failed, r.Status));
|
||||
}
|
||||
|
||||
// ── Status counts ─────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Text.Json;
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -1222,51 +1223,63 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
new("deploy-" + instanceId, "operator") { Id = instanceId, InstanceId = instanceId };
|
||||
|
||||
/// <summary>
|
||||
/// Row-summary equivalent of <see cref="DeploymentRecordFor"/>. QueryDeployments
|
||||
/// now returns DB-side projections instead of whole tracked entities.
|
||||
/// Row-projection equivalent of <see cref="DeploymentRecordFor"/>, on the site
|
||||
/// given. QueryDeployments returns DB-side projections joined to their instance,
|
||||
/// not whole tracked entities — and the join is what carries the
|
||||
/// <c>SiteId</c> the scope filter is evaluated against.
|
||||
/// </summary>
|
||||
private static DeploymentRecordSummary DeploymentSummaryFor(int instanceId) =>
|
||||
new(instanceId, "deploy-" + instanceId, instanceId, DeploymentStatus.Success,
|
||||
null, "operator", DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, null);
|
||||
private static DeploymentListRow DeploymentRowFor(int instanceId, int siteId) =>
|
||||
new(instanceId, "deploy-" + instanceId, instanceId, "Pump" + instanceId, siteId,
|
||||
DeploymentStatus.Success, null, "operator", DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, null);
|
||||
|
||||
/// <summary>
|
||||
/// Stubs <c>QueryDeploymentSummariesAsync</c> so it applies the same
|
||||
/// instance / status / scope / paging predicates the database would.
|
||||
/// Stubs the single paged deployment read so it applies the same
|
||||
/// instance / status / site-scope / paging predicates the database would.
|
||||
///
|
||||
/// <para>
|
||||
/// The filtering moved OUT of the handler and INTO the query, so a stub that
|
||||
/// ignored the arguments would make these tests pass no matter what the handler
|
||||
/// passed down. Honouring them here is what keeps the scope-enforcement
|
||||
/// assertions meaningful.
|
||||
/// The filtering lives entirely in the query now (register row 37 folded the
|
||||
/// handler's own second repository method away), so a stub that ignored the
|
||||
/// filter would make these tests pass no matter what the handler passed down.
|
||||
/// Honouring it here is what keeps the scope-enforcement assertions meaningful.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private static void StubDeploymentSummaryQuery(
|
||||
/// <param name="repo">The substituted repository to stub.</param>
|
||||
/// <param name="rows">The seed rows, as (instance id, site id) pairs.</param>
|
||||
private static void StubDeploymentPageQuery(
|
||||
IDeploymentManagerRepository repo,
|
||||
IEnumerable<int> instanceIds)
|
||||
params (int InstanceId, int SiteId)[] rows)
|
||||
{
|
||||
var rows = instanceIds.Select(DeploymentSummaryFor).ToList();
|
||||
repo.QueryDeploymentSummariesAsync(
|
||||
Arg.Any<int?>(), Arg.Any<DeploymentStatus?>(), Arg.Any<IReadOnlyCollection<int>?>(),
|
||||
Arg.Any<int>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
|
||||
var all = rows.Select(r => DeploymentRowFor(r.InstanceId, r.SiteId)).ToList();
|
||||
repo.QueryDeploymentListPageAsync(
|
||||
Arg.Any<DeploymentListFilter>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci =>
|
||||
{
|
||||
var instanceId = ci.ArgAt<int?>(0);
|
||||
var scope = ci.ArgAt<IReadOnlyCollection<int>?>(2);
|
||||
var skip = ci.ArgAt<int>(3);
|
||||
var take = ci.ArgAt<int?>(4);
|
||||
var filter = ci.ArgAt<DeploymentListFilter>(0);
|
||||
var page = Math.Max(1, ci.ArgAt<int>(1));
|
||||
var size = Math.Max(1, ci.ArgAt<int>(2));
|
||||
|
||||
IEnumerable<DeploymentRecordSummary> q = rows;
|
||||
if (instanceId.HasValue)
|
||||
q = q.Where(r => r.InstanceId == instanceId.Value);
|
||||
if (scope != null)
|
||||
q = q.Where(r => scope.Contains(r.InstanceId));
|
||||
q = q.Skip(Math.Max(0, skip));
|
||||
if (take is > 0)
|
||||
q = q.Take(take.Value);
|
||||
return (IReadOnlyList<DeploymentRecordSummary>)q.ToList();
|
||||
IEnumerable<DeploymentListRow> q = all;
|
||||
if (filter.InstanceId.HasValue)
|
||||
q = q.Where(r => r.InstanceId == filter.InstanceId.Value);
|
||||
if (filter.Status.HasValue)
|
||||
q = q.Where(r => r.Status == filter.Status.Value);
|
||||
if (filter.SiteIdScope != null)
|
||||
q = q.Where(r => filter.SiteIdScope.Contains(r.SiteId));
|
||||
|
||||
var matched = q.ToList();
|
||||
var slice = matched.Skip((page - 1) * size).Take(size).ToList();
|
||||
return new DeploymentListPage(slice, matched.Count);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Captures the filter the handler pushed into the paged read.</summary>
|
||||
private static DeploymentListFilter CapturedDeploymentFilter(IDeploymentManagerRepository repo)
|
||||
{
|
||||
var call = repo.ReceivedCalls()
|
||||
.Single(c => c.GetMethodInfo().Name == nameof(IDeploymentManagerRepository.QueryDeploymentListPageAsync));
|
||||
return (DeploymentListFilter)call.GetArguments()[0]!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stubs <c>GetTemplateSummariesAsync</c> over <paramref name="count"/> synthetic
|
||||
/// templates, applying the requested Skip/Take exactly as the database would.
|
||||
@@ -1305,7 +1318,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
public void QueryDeployments_UnfilteredWithDeploymentRole_ReturnsAllRecords()
|
||||
{
|
||||
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
|
||||
StubDeploymentSummaryQuery(deployRepo, [1, 2]);
|
||||
StubDeploymentPageQuery(deployRepo, (1, 1), (2, 2));
|
||||
_services.AddScoped(_ => deployRepo);
|
||||
|
||||
var actor = CreateActor();
|
||||
@@ -1322,7 +1335,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
public void QueryDeployments_FilteredByInstanceId_ReturnsInstanceRecords()
|
||||
{
|
||||
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
|
||||
StubDeploymentSummaryQuery(deployRepo, [5]);
|
||||
StubDeploymentPageQuery(deployRepo, (5, 1));
|
||||
_services.AddScoped(_ => deployRepo);
|
||||
|
||||
var actor = CreateActor();
|
||||
@@ -1351,8 +1364,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
||||
// The out-of-scope instance's deployment history must not be queried.
|
||||
deployRepo.DidNotReceiveWithAnyArgs().QueryDeploymentSummariesAsync(
|
||||
default, default, default, default, default);
|
||||
deployRepo.DidNotReceiveWithAnyArgs().QueryDeploymentListPageAsync(default!, default, default);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -1361,7 +1373,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
_templateRepo.GetInstanceByIdAsync(5, Arg.Any<CancellationToken>())
|
||||
.Returns(new Instance("Pump5") { Id = 5, SiteId = 1 });
|
||||
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
|
||||
StubDeploymentSummaryQuery(deployRepo, [5]);
|
||||
StubDeploymentPageQuery(deployRepo, (5, 1));
|
||||
_services.AddScoped(_ => deployRepo);
|
||||
|
||||
var actor = CreateActor();
|
||||
@@ -1376,17 +1388,8 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
[Fact]
|
||||
public void QueryDeployments_UnfilteredForSiteScopedUser_DropsOutOfScopeRecords()
|
||||
{
|
||||
// ManagementService-023: the unfiltered branch now bulk-loads instances
|
||||
// once via GetAllInstancesAsync (instead of N+1 GetInstanceByIdAsync per
|
||||
// distinct InstanceId). Mock the bulk path accordingly.
|
||||
_templateRepo.GetAllInstancesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Instance>
|
||||
{
|
||||
new("Pump1") { Id = 1, SiteId = 1 },
|
||||
new("Pump2") { Id = 2, SiteId = 2 },
|
||||
});
|
||||
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
|
||||
StubDeploymentSummaryQuery(deployRepo, [1, 2]);
|
||||
StubDeploymentPageQuery(deployRepo, (1, 1), (2, 2));
|
||||
_services.AddScoped(_ => deployRepo);
|
||||
|
||||
var actor = CreateActor();
|
||||
@@ -1397,28 +1400,19 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("deploy-1", response.JsonData);
|
||||
Assert.DoesNotContain("deploy-2", response.JsonData);
|
||||
// The per-instance lookup must NOT have been used for the unfiltered
|
||||
// branch — that was the N+1 the bulk load replaced.
|
||||
_templateRepo.DidNotReceiveWithAnyArgs().GetInstanceByIdAsync(default);
|
||||
Assert.Equal(new[] { 1 }, CapturedDeploymentFilter(deployRepo).SiteIdScope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QueryDeployments_UnfilteredForSiteScopedUser_UsesBulkInstanceLoad_NotPerRecordLookup()
|
||||
public void QueryDeployments_UnfilteredForSiteScopedUser_ReadsNoInstancesAtAll()
|
||||
{
|
||||
// ManagementService-023 regression pin: the unfiltered branch must issue
|
||||
// GetAllInstancesAsync ONCE and never call GetInstanceByIdAsync, regardless
|
||||
// of how many DeploymentRecords reference distinct InstanceIds. Before the
|
||||
// fix, three distinct instance ids would have produced three per-instance
|
||||
// lookups (textbook N+1).
|
||||
_templateRepo.GetAllInstancesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Instance>
|
||||
{
|
||||
new("Pump1") { Id = 1, SiteId = 1 },
|
||||
new("Pump2") { Id = 2, SiteId = 2 },
|
||||
new("Pump3") { Id = 3, SiteId = 1 },
|
||||
});
|
||||
// Register row 87. The scoped branch used to resolve the site grant into an
|
||||
// instance-id LIST, which meant loading EVERY instance in the system on
|
||||
// every scoped query (a bulk load that had itself replaced an N+1 of
|
||||
// per-record lookups). The grant now rides as a site-id filter resolved by
|
||||
// the query's own join, so neither instance read may happen.
|
||||
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
|
||||
StubDeploymentSummaryQuery(deployRepo, [1, 2, 3]);
|
||||
StubDeploymentPageQuery(deployRepo, (1, 1), (2, 2), (3, 1));
|
||||
_services.AddScoped(_ => deployRepo);
|
||||
|
||||
var actor = CreateActor();
|
||||
@@ -1427,17 +1421,41 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
actor.Tell(envelope);
|
||||
|
||||
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
_templateRepo.Received(1).GetAllInstancesAsync(Arg.Any<CancellationToken>());
|
||||
_templateRepo.DidNotReceiveWithAnyArgs().GetAllInstancesAsync();
|
||||
_templateRepo.DidNotReceiveWithAnyArgs().GetInstanceByIdAsync(default);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QueryDeployments_SiteScopedUserWithUnparseableGrant_SeesNothing()
|
||||
{
|
||||
// A permitted-site id that is not an integer is dropped, never ignored:
|
||||
// ignoring it would silently widen the grant to fleet-wide. A grant of
|
||||
// nothing but garbage therefore yields an EMPTY scope — a real "sees
|
||||
// nothing" filter, not "no filter".
|
||||
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
|
||||
StubDeploymentPageQuery(deployRepo, (1, 1), (2, 2));
|
||||
_services.AddScoped(_ => deployRepo);
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = ScopedEnvelope(new QueryDeploymentsCommand(), new[] { "not-an-id" }, "Deployer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
Assert.DoesNotContain("deploy-1", response.JsonData);
|
||||
Assert.DoesNotContain("deploy-2", response.JsonData);
|
||||
Assert.Empty(CapturedDeploymentFilter(deployRepo).SiteIdScope!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QueryDeployments_UnfilteredForAdminUser_ReturnsAllRecords()
|
||||
{
|
||||
// Admin role bypasses site scoping even with PermittedSiteIds set.
|
||||
// (The user also holds Deployment so it passes the role gate.)
|
||||
// The UI's SiteScopeService now agrees — see SiteScopeServiceTests
|
||||
// (register row 86).
|
||||
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
|
||||
StubDeploymentSummaryQuery(deployRepo, [1, 2]);
|
||||
StubDeploymentPageQuery(deployRepo, (1, 1), (2, 2));
|
||||
_services.AddScoped(_ => deployRepo);
|
||||
|
||||
var actor = CreateActor();
|
||||
@@ -1448,6 +1466,119 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("deploy-1", response.JsonData);
|
||||
Assert.Contains("deploy-2", response.JsonData);
|
||||
Assert.Null(CapturedDeploymentFilter(deployRepo).SiteIdScope);
|
||||
}
|
||||
|
||||
// ── Loud status parsing + bounded page size (register rows 87 / 88) ────────
|
||||
|
||||
[Fact]
|
||||
public void QueryDeployments_UnknownStatus_FailsLoudlyNamingTheValidValues()
|
||||
{
|
||||
// Register row 88: `--status Sucess` used to fall back to "no filter" and
|
||||
// return EVERY status — a plausible, complete-looking answer to a question
|
||||
// the caller did not ask.
|
||||
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
|
||||
StubDeploymentPageQuery(deployRepo, (1, 1));
|
||||
_services.AddScoped(_ => deployRepo);
|
||||
|
||||
var actor = CreateActor();
|
||||
actor.Tell(Envelope(new QueryDeploymentsCommand(Status: "Sucess"), "Deployer"));
|
||||
|
||||
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("Sucess", response.Error);
|
||||
Assert.Contains("Success", response.Error);
|
||||
Assert.Contains("Failed", response.Error);
|
||||
deployRepo.DidNotReceiveWithAnyArgs().QueryDeploymentListPageAsync(default!, default, default);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("success")]
|
||||
[InlineData("SUCCESS")]
|
||||
public void QueryDeployments_KnownStatus_IsCaseInsensitive(string status)
|
||||
{
|
||||
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
|
||||
StubDeploymentPageQuery(deployRepo, (1, 1));
|
||||
_services.AddScoped(_ => deployRepo);
|
||||
|
||||
var actor = CreateActor();
|
||||
actor.Tell(Envelope(new QueryDeploymentsCommand(Status: status), "Deployer"));
|
||||
|
||||
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(DeploymentStatus.Success, CapturedDeploymentFilter(deployRepo).Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QueryDeployments_NumericStatus_IsRejected()
|
||||
{
|
||||
// Enum.TryParse happily accepts "7" and yields an undefined enum value, so
|
||||
// the parse needs the IsDefined guard as well as the TryParse.
|
||||
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
|
||||
StubDeploymentPageQuery(deployRepo, (1, 1));
|
||||
_services.AddScoped(_ => deployRepo);
|
||||
|
||||
var actor = CreateActor();
|
||||
actor.Tell(Envelope(new QueryDeploymentsCommand(Status: "7"), "Deployer"));
|
||||
|
||||
ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
public void QueryDeployments_NonPositivePageSize_TakesTheDefault_NotUnlimited(int requested)
|
||||
{
|
||||
// Register row 87: a non-positive page size used to mean "unlimited" all the
|
||||
// way down to the repository, materializing the whole retention window.
|
||||
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
|
||||
StubDeploymentPageQuery(deployRepo, Enumerable.Range(1, 200).Select(i => (i, 1)).ToArray());
|
||||
_services.AddScoped(_ => deployRepo);
|
||||
|
||||
var actor = CreateActor();
|
||||
actor.Tell(Envelope(new QueryDeploymentsCommand(PageSize: requested), "Deployer"));
|
||||
|
||||
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
var call = deployRepo.ReceivedCalls()
|
||||
.Single(c => c.GetMethodInfo().Name == nameof(IDeploymentManagerRepository.QueryDeploymentListPageAsync));
|
||||
Assert.Equal(DeploymentListPage.DefaultPageSize, (int)call.GetArguments()[2]!);
|
||||
|
||||
// The total travels with the bounded page, so a caller can tell a truncated
|
||||
// page from a complete one.
|
||||
var result = JsonSerializer.Deserialize<JsonElement>(response.JsonData);
|
||||
Assert.Equal(200, result.GetProperty("totalCount").GetInt32());
|
||||
Assert.Equal(DeploymentListPage.DefaultPageSize, result.GetProperty("deployments").GetArrayLength());
|
||||
Assert.Equal(DeploymentListPage.DefaultPageSize, result.GetProperty("pageSize").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QueryDeployments_OversizedPageSize_IsCappedAtTheMaximum()
|
||||
{
|
||||
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
|
||||
StubDeploymentPageQuery(deployRepo, (1, 1));
|
||||
_services.AddScoped(_ => deployRepo);
|
||||
|
||||
var actor = CreateActor();
|
||||
actor.Tell(Envelope(new QueryDeploymentsCommand(PageSize: int.MaxValue), "Deployer"));
|
||||
|
||||
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
var call = deployRepo.ReceivedCalls()
|
||||
.Single(c => c.GetMethodInfo().Name == nameof(IDeploymentManagerRepository.QueryDeploymentListPageAsync));
|
||||
Assert.Equal(DeploymentListPage.MaxPageSize, (int)call.GetArguments()[2]!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QueryDeployments_PageIsFlooredAtOne()
|
||||
{
|
||||
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
|
||||
StubDeploymentPageQuery(deployRepo, (1, 1));
|
||||
_services.AddScoped(_ => deployRepo);
|
||||
|
||||
var actor = CreateActor();
|
||||
actor.Tell(Envelope(new QueryDeploymentsCommand(Page: 0), "Deployer"));
|
||||
|
||||
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("deploy-1", response.JsonData);
|
||||
var result = JsonSerializer.Deserialize<JsonElement>(response.JsonData);
|
||||
Assert.Equal(1, result.GetProperty("page").GetInt32());
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
|
||||
Reference in New Issue
Block a user