mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
test(backend): cover delegated config/all subset + redaction seams [Phase 2b follow-up]
Addresses a Codex finding: no handler-level coverage existed for the delegated config/all filtering/redaction path (coverage was policy-context + projection only). Following the repo convention (handler HTTP entry not unit-tested — see GetAllBlockedDevicesFunctionTests), the security-relevant transform is extracted into two pure, testable seams on GetAllTenantConfigurationsFunction and tested directly: - ExistingManagedConfigs: from the per-tenant point-read results, keep only managed tenants that actually have a config row (exists==true); drop the rest. Tested: drops missing rows, keeps present ones, empty when none exist. - DelegatedBareArrayView: redact every managed config's secrets (a delegated admin is never a Global Admin). Tested: secrets -> placeholder, non-secrets preserved, original instance not mutated. 5 new tests; full Functions suite green (2469). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+91
@@ -0,0 +1,91 @@
|
||||
using AutopilotMonitor.Functions.Functions.Config;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the delegated ("MSP") config/all transformation seams in
|
||||
/// <see cref="GetAllTenantConfigurationsFunction"/>. The HTTP entry point is intentionally not exercised
|
||||
/// (mocking HttpRequestData + the middleware chain is more setup than the test is worth — same rationale as
|
||||
/// <see cref="GetAllBlockedDevicesFunctionTests"/>); the security-relevant logic is the pure subset/redaction
|
||||
/// transform, which is tested directly.
|
||||
///
|
||||
/// Guards two invariants for a delegated caller: (1) only managed tenants that actually have a config row
|
||||
/// are returned (no phantom/empty entries), and (2) per-tenant secrets are ALWAYS redacted (a delegated
|
||||
/// admin is never a Global Admin, so it must never receive SAS/webhook/header secrets for a managed tenant).
|
||||
/// </summary>
|
||||
public class GetAllTenantConfigurationsFunctionTests
|
||||
{
|
||||
private const string TenantB = "22222222-2222-2222-2222-222222222222";
|
||||
private const string TenantC = "33333333-3333-3333-3333-333333333333";
|
||||
|
||||
[Fact]
|
||||
public void ExistingManagedConfigs_DropsTenantsWithoutConfigRow()
|
||||
{
|
||||
var reads = new (TenantConfiguration config, bool exists)[]
|
||||
{
|
||||
(new TenantConfiguration { TenantId = TenantB }, true),
|
||||
(new TenantConfiguration { TenantId = TenantC }, false), // no config row → must be dropped
|
||||
};
|
||||
|
||||
var result = GetAllTenantConfigurationsFunction.ExistingManagedConfigs(reads);
|
||||
|
||||
Assert.Single(result);
|
||||
Assert.Equal(TenantB, result[0].TenantId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExistingManagedConfigs_AllExist_KeepsAll()
|
||||
{
|
||||
var reads = new (TenantConfiguration config, bool exists)[]
|
||||
{
|
||||
(new TenantConfiguration { TenantId = TenantB }, true),
|
||||
(new TenantConfiguration { TenantId = TenantC }, true),
|
||||
};
|
||||
|
||||
var result = GetAllTenantConfigurationsFunction.ExistingManagedConfigs(reads);
|
||||
|
||||
Assert.Equal(2, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExistingManagedConfigs_NoneExist_Empty()
|
||||
{
|
||||
var reads = new (TenantConfiguration config, bool exists)[]
|
||||
{
|
||||
(new TenantConfiguration { TenantId = TenantB }, false),
|
||||
};
|
||||
|
||||
Assert.Empty(GetAllTenantConfigurationsFunction.ExistingManagedConfigs(reads));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DelegatedBareArrayView_RedactsSecretsButKeepsNonSecrets()
|
||||
{
|
||||
var managed = new[]
|
||||
{
|
||||
new TenantConfiguration
|
||||
{
|
||||
TenantId = TenantB,
|
||||
DiagnosticsBlobSasUrl = "https://acct.blob.core.windows.net/c?sig=secret",
|
||||
WebhookUrl = "https://hooks.example/abc",
|
||||
DomainName = "contoso.com",
|
||||
},
|
||||
};
|
||||
|
||||
var view = GetAllTenantConfigurationsFunction.DelegatedBareArrayView(managed);
|
||||
|
||||
var v = Assert.Single(view);
|
||||
Assert.Equal(Constants.RedactedSecretPlaceholder, v.DiagnosticsBlobSasUrl);
|
||||
Assert.Equal(Constants.RedactedSecretPlaceholder, v.WebhookUrl);
|
||||
Assert.Equal("contoso.com", v.DomainName); // non-secret preserved
|
||||
// original instance is never mutated (redaction returns a copy)
|
||||
Assert.Equal("https://hooks.example/abc", managed[0].WebhookUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DelegatedBareArrayView_Empty_Empty()
|
||||
=> Assert.Empty(GetAllTenantConfigurationsFunction.DelegatedBareArrayView(new TenantConfiguration[0]));
|
||||
}
|
||||
+23
-3
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
@@ -6,6 +7,7 @@ using System.Web;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Pagination;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Pagination;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
@@ -58,15 +60,14 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
// never writes a default row). exists==false drops any id with no config row.
|
||||
var reads = await Task.WhenAll(requestCtx.AllowedTenantIds.Select(async tid =>
|
||||
await _configService.TryGetConfigurationAsync(tid)));
|
||||
var subset = reads.Where(r => r.exists).Select(r => r.config).ToList();
|
||||
var subset = ExistingManagedConfigs(reads);
|
||||
_logger.LogInformation("GetAllTenantConfigurations (delegated subset, {Count} tenants) by {User}",
|
||||
subset.Count, userIdentifier);
|
||||
|
||||
if (parsed.PageSize == null)
|
||||
{
|
||||
var redacted = subset.Select(c => c.RedactedCopyForReader()).ToList();
|
||||
var resp = req.CreateResponse(HttpStatusCode.OK);
|
||||
await resp.WriteAsJsonAsync(redacted);
|
||||
await resp.WriteAsJsonAsync(DelegatedBareArrayView(subset));
|
||||
return resp;
|
||||
}
|
||||
|
||||
@@ -149,5 +150,24 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// From the per-tenant point-read results, keep only the managed tenants that actually HAVE a config
|
||||
/// row (exists). A delegated caller's AllowedTenantIds is the authoritative bound; an id with no row
|
||||
/// (offboarded / never onboarded) is silently dropped rather than surfaced as an empty default.
|
||||
/// Pure + testable seam (handler HTTP entry is not unit-tested — see GetAllBlockedDevicesFunctionTests).
|
||||
/// </summary>
|
||||
internal static List<TenantConfiguration> ExistingManagedConfigs(
|
||||
IEnumerable<(TenantConfiguration config, bool exists)> reads)
|
||||
=> reads.Where(r => r.exists).Select(r => r.config).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// The delegated bare-array view of config/all: every managed-tenant config with its secrets
|
||||
/// (SAS / webhook URLs / custom headers) redacted. A delegated admin is never a Global Admin, so it
|
||||
/// must NEVER receive unredacted secrets for a tenant it merely manages. Pure + testable.
|
||||
/// </summary>
|
||||
internal static List<TenantConfiguration> DelegatedBareArrayView(
|
||||
IEnumerable<TenantConfiguration> existingManaged)
|
||||
=> existingManaged.Select(c => c.RedactedCopyForReader()).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user