fix(management): ListInstances pages, site-scopes and prefix-searches in SQL (review 2026-10-21 08/P2)

This commit is contained in:
Joseph Doherty
2026-10-22 09:23:39 -04:00
parent b0c863c6a1
commit baab6ac78e
4 changed files with 69 additions and 24 deletions
@@ -2490,34 +2490,67 @@ public class ManagementActorTests : TestKit, IDisposable
Assert.Contains("T-030", response.JsonData);
}
/// <summary>Verifies that list instances: paging applies after site scope filter.</summary>
/// <summary>
/// Verifies that list instances pages and site-scopes IN SQL (review 2026-10-21 08/P2): a
/// scoped user's permitted sites are passed as the repository's site filter, so paging
/// can never surface an out-of-scope instance, and Skip/Take/search reach the query.
/// </summary>
[Fact]
public void ListInstances_PagingAppliesAfterSiteScopeFilter()
{
// Scoped user permitted to site 1 only. The repo returns 3 in-scope (site 1)
// instances plus 2 out-of-scope (site 2). Take:2 must yield 2 of THEIR
// instances and never an out-of-scope one.
var uiRepo = Substitute.For<ICentralUiRepository>();
uiRepo.GetInstancesFilteredAsync(Arg.Any<int?>(), Arg.Any<int?>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(new List<Instance>
IReadOnlyCollection<int>? passedSites = null;
uiRepo.GetInstancesPagedAsync(
Arg.Do<IReadOnlyCollection<int>?>(s => passedSites = s), null, "I-", 0, 2, Arg.Any<CancellationToken>())
.Returns((new List<Instance>
{
new("I-001") { Id = 1, SiteId = 1 },
new("I-002") { Id = 2, SiteId = 1 },
new("I-003") { Id = 3, SiteId = 1 },
new("I-101") { Id = 101, SiteId = 2 },
new("I-102") { Id = 102, SiteId = 2 },
});
}, 3));
_services.AddScoped(_ => uiRepo);
var actor = CreateActor();
actor.Tell(ScopedEnvelope(new ListInstancesCommand(Take: 2), new[] { "1" }, "Deployer"));
actor.Tell(ScopedEnvelope(new ListInstancesCommand(SearchTerm: "I-", Take: 2), new[] { "1", "not-an-int" }, "Deployer"));
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Contains("I-001", response.JsonData);
Assert.Contains("I-002", response.JsonData);
Assert.DoesNotContain("I-003", response.JsonData); // paged out
Assert.DoesNotContain("I-101", response.JsonData); // out of scope
Assert.DoesNotContain("I-102", response.JsonData); // out of scope
Assert.StartsWith("[", response.JsonData.TrimStart()); // response shape unchanged: a bare array
Assert.Equal(new[] { 1 }, passedSites); // non-integer grant dropped, never widened
uiRepo.DidNotReceive().GetInstancesFilteredAsync(
Arg.Any<int?>(), Arg.Any<int?>(), Arg.Any<string?>(), Arg.Any<CancellationToken>());
}
/// <summary>
/// A scoped user's explicit SiteId is intersected with their grant; an out-of-grant site
/// yields an empty filter (nothing), and an unscoped caller's SiteId passes through.
/// </summary>
[Fact]
public void ListInstances_SiteIdIsIntersectedWithTheScope_AndUnlimitedTakeIsUnbounded()
{
var uiRepo = Substitute.For<ICentralUiRepository>();
var calls = new List<(IReadOnlyCollection<int>? Sites, int Take)>();
uiRepo.GetInstancesPagedAsync(Arg.Any<IReadOnlyCollection<int>?>(), Arg.Any<int?>(), Arg.Any<string?>(),
Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(ci =>
{
calls.Add(((IReadOnlyCollection<int>?)ci[0], (int)ci[4]));
return (Array.Empty<Instance>(), 0);
});
_services.AddScoped(_ => uiRepo);
var actor = CreateActor();
actor.Tell(ScopedEnvelope(new ListInstancesCommand(SiteId: 2), new[] { "1" }, "Deployer"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
actor.Tell(Envelope(new ListInstancesCommand(SiteId: 2), "Deployer"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
actor.Tell(Envelope(new ListInstancesCommand(), "Deployer"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Empty(calls[0].Sites!);
Assert.Equal(new[] { 2 }, calls[1].Sites);
Assert.Null(calls[2].Sites);
Assert.Equal(int.MaxValue, calls[2].Take);
}
// ========================================================================