diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ISecuredWriteRepository.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ISecuredWriteRepository.cs index fe431e10..52f7c71f 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ISecuredWriteRepository.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ISecuredWriteRepository.cs @@ -46,6 +46,11 @@ public interface ISecuredWriteRepository /// Site id filter; null matches every site. /// Number of rows to skip (offset paging). /// Maximum number of rows to return. + /// When non-null, only rows whose SiteId (site + /// identifier) is in the set match; null matches every site. Applied IN ADDITION + /// to . Used by the ManagementActor to constrain an unfiltered + /// list to a site-scoped caller's permitted sites at the query level (arch-review R2 N3), + /// so paging and totals stay correct. /// Cancellation token. /// A task that resolves to a page of matching rows, newest submission first. Task> QueryAsync( @@ -53,6 +58,7 @@ public interface ISecuredWriteRepository string? siteId, int skip, int take, + IReadOnlyCollection? permittedSiteIds = null, CancellationToken ct = default); /// @@ -106,7 +112,16 @@ public interface ISecuredWriteRepository /// /// Status filter; null matches every status. /// Site id filter; null matches every site. + /// When non-null, only rows whose SiteId (site + /// identifier) is in the set match; null matches every site. Applied IN ADDITION + /// to . Used by the ManagementActor to constrain an unfiltered + /// list to a site-scoped caller's permitted sites at the query level (arch-review R2 N3), + /// so paging and totals stay correct. /// Cancellation token. /// A task that resolves to the count of matching rows. - Task CountAsync(string? status, string? siteId, CancellationToken ct = default); + Task CountAsync( + string? status, + string? siteId, + IReadOnlyCollection? permittedSiteIds = null, + CancellationToken ct = default); } diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SecuredWriteRepository.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SecuredWriteRepository.cs index cab9d433..1ad871e1 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SecuredWriteRepository.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SecuredWriteRepository.cs @@ -57,6 +57,7 @@ public class SecuredWriteRepository : ISecuredWriteRepository string? siteId, int skip, int take, + IReadOnlyCollection? permittedSiteIds = null, CancellationToken ct = default) { IQueryable query = _context.Set() @@ -72,6 +73,11 @@ public class SecuredWriteRepository : ISecuredWriteRepository query = query.Where(p => p.SiteId == siteId); } + if (permittedSiteIds is not null) + { + query = query.Where(p => permittedSiteIds.Contains(p.SiteId)); + } + return await query .OrderByDescending(p => p.SubmittedAtUtc) .ThenByDescending(p => p.Id) @@ -128,7 +134,11 @@ WHERE Id = {id} } /// - public async Task CountAsync(string? status, string? siteId, CancellationToken ct = default) + public async Task CountAsync( + string? status, + string? siteId, + IReadOnlyCollection? permittedSiteIds = null, + CancellationToken ct = default) { IQueryable query = _context.Set() .AsNoTracking(); @@ -143,6 +153,11 @@ WHERE Id = {id} query = query.Where(p => p.SiteId == siteId); } + if (permittedSiteIds is not null) + { + query = query.Where(p => permittedSiteIds.Contains(p.SiteId)); + } + return await query.CountAsync(ct); } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SecuredWriteRepositoryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SecuredWriteRepositoryTests.cs index 3ccff9fa..2788fac8 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SecuredWriteRepositoryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SecuredWriteRepositoryTests.cs @@ -164,6 +164,72 @@ public class SecuredWriteRepositoryTests : IClassFixture Assert.Equal(0, await repo.CountAsync(status: "Executed", siteId: siteId)); } + [SkippableFact] + public async Task QueryAsync_PermittedSiteIds_ReturnsOnlyPermittedRows() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + // Distinct per-run site identifiers stand in for "SITE1"/"SITE2"/"SITE3" so the + // permitted-set filter is asserted deterministically against a shared MSSQL. + var site1 = NewSiteId(); + var site2 = NewSiteId(); + var site3 = NewSiteId(); + await using var context = CreateContext(); + var repo = new SecuredWriteRepository(context); + + await repo.AddAsync(NewRow(site1, status: "Pending")); + await repo.AddAsync(NewRow(site1, status: "Pending")); + await repo.AddAsync(NewRow(site2, status: "Pending")); + await repo.AddAsync(NewRow(site3, status: "Pending")); + + var rows = await repo.QueryAsync(status: null, siteId: null, skip: 0, take: 100, + permittedSiteIds: new[] { site1, site3 }); + Assert.Equal(3, rows.Count); + Assert.DoesNotContain(rows, r => r.SiteId == site2); + } + + [SkippableFact] + public async Task CountAsync_PermittedSiteIds_CountsOnlyPermittedRows() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var site1 = NewSiteId(); + var site2 = NewSiteId(); + var site3 = NewSiteId(); + await using var context = CreateContext(); + var repo = new SecuredWriteRepository(context); + + await repo.AddAsync(NewRow(site1, status: "Pending")); + await repo.AddAsync(NewRow(site1, status: "Pending")); + await repo.AddAsync(NewRow(site2, status: "Pending")); + await repo.AddAsync(NewRow(site3, status: "Pending")); + + var count = await repo.CountAsync(status: null, siteId: null, + permittedSiteIds: new[] { site1, site3 }); + Assert.Equal(3, count); + } + + [SkippableFact] + public async Task QueryAsync_NullPermittedSiteIds_IsUnfiltered_BackCompat() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + // 4 rows on a single unique site; null permittedSiteIds must not filter them + // out — exactly today's behavior. + var siteId = NewSiteId(); + await using var context = CreateContext(); + var repo = new SecuredWriteRepository(context); + + await repo.AddAsync(NewRow(siteId, status: "Pending")); + await repo.AddAsync(NewRow(siteId, status: "Pending")); + await repo.AddAsync(NewRow(siteId, status: "Executed")); + await repo.AddAsync(NewRow(siteId, status: "Rejected")); + + var rows = await repo.QueryAsync(status: null, siteId: siteId, skip: 0, take: 100, + permittedSiteIds: null); + Assert.Equal(4, rows.Count); + } + // --- helpers ------------------------------------------------------------ private ScadaBridgeDbContext CreateContext()