Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions.Tests/GetAllTenantConfigurationsFunctionTests.cs
T
Oliver Kieselbach 6d1ff8d7f0 fix: code-review follow-ups (watcher dispose race, revoke-enforcement tests, scan guard)
- ConsoleBypassWatcher: re-check _disposed after watcher.Start() — a Dispose()
  racing the background arm could leave an armed WMI subscription no code path
  could ever stop (leaked until process exit).
- SignalRContext: collapse rapid accessRevoked pushes into one restart via an
  in-flight guard — a second start() on a non-Disconnected connection throws
  and burns the retry budget.
- config/all: negative-cache managed ids a rescue scan proved missing (5 min
  TTL) — one stale grant (offboarded tenant) no longer makes every delegated
  config/all call pay a full table scan.
- DelegatedAdmin/TenantGroup functions: extract testable revoke-enforcement
  cores (RevokeCoreAsync, SetEnabledCoreAsync, UnassignCoreAsync,
  DeleteGroupCoreAsync, ValidateGrantRequest) and cover the security flows:
  404-on-missing-row writes no audit row and fires no disconnect; real
  revoke/disable/unassign/group-delete always cuts live streams; grant
  validation is fail-closed (GUID tenantId, recognized roles only).
- dashboard/page.tsx: drop unused next/link import left by the banner removal.

Tests: backend 3099 passed, web 557 passed, agent ConsoleBypassWatcher suite
passed, tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 13:13:26 +02:00

159 lines
6.2 KiB
C#

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?[]
{
new TenantConfiguration { TenantId = TenantB },
null, // no config row (404) → 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?[]
{
new TenantConfiguration { TenantId = TenantB },
new TenantConfiguration { TenantId = TenantC },
};
var result = GetAllTenantConfigurationsFunction.ExistingManagedConfigs(reads);
Assert.Equal(2, result.Count);
}
[Fact]
public void ExistingManagedConfigs_NoneExist_Empty()
=> Assert.Empty(GetAllTenantConfigurationsFunction.ExistingManagedConfigs(new TenantConfiguration?[] { null }));
[Fact]
public void MergeRescuedConfigs_AddsCaseVariantRow_MissedByExactCasePointRead()
{
// AllowedTenantIds is lowercased; the stored config row has an upper-case PartitionKey,
// so the exact-case point-read missed it. The scan rescue must restore it.
var managedIds = new[] { TenantB, TenantC };
var hits = new List<TenantConfiguration> { new() { TenantId = TenantB } };
var scanned = new[]
{
new TenantConfiguration { TenantId = TenantB },
new TenantConfiguration { TenantId = TenantC.ToUpperInvariant() }, // case-variant row
};
var merged = GetAllTenantConfigurationsFunction.MergeRescuedConfigs(managedIds, hits, scanned);
Assert.Equal(2, merged.Count);
Assert.Contains(merged, c => c.TenantId == TenantC.ToUpperInvariant());
}
[Fact]
public void MergeRescuedConfigs_NeverAddsUnmanagedTenant()
{
var managedIds = new[] { TenantB, TenantC };
var hits = new List<TenantConfiguration> { new() { TenantId = TenantB } };
var scanned = new[]
{
new TenantConfiguration { TenantId = TenantB },
new TenantConfiguration { TenantId = "44444444-4444-4444-4444-444444444444" }, // NOT managed
};
var merged = GetAllTenantConfigurationsFunction.MergeRescuedConfigs(managedIds, hits, scanned);
var only = Assert.Single(merged);
Assert.Equal(TenantB, only.TenantId);
}
[Fact]
public void MergeRescuedConfigs_DoesNotDuplicatePointReadHits()
{
var managedIds = new[] { TenantB };
var hits = new List<TenantConfiguration> { new() { TenantId = TenantB } };
var scanned = new[] { new TenantConfiguration { TenantId = TenantB.ToUpperInvariant() } };
var merged = GetAllTenantConfigurationsFunction.MergeRescuedConfigs(managedIds, hits, scanned);
Assert.Single(merged); // scan row is the same tenant (case-insensitive) → not added twice
}
[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]));
[Fact]
public void MissingManagedIds_ReturnsOnlyIdsWithoutConfig_CaseInsensitive()
{
var configs = new List<TenantConfiguration>
{
new() { TenantId = TenantB.ToUpperInvariant() }, // case variant still counts as present
};
var missing = GetAllTenantConfigurationsFunction.MissingManagedIds(new[] { TenantB, TenantC }, configs);
var only = Assert.Single(missing);
Assert.Equal(TenantC, only);
}
[Fact]
public void MissingManagedIds_AllPresent_Empty()
=> Assert.Empty(GetAllTenantConfigurationsFunction.MissingManagedIds(
new[] { TenantB },
new List<TenantConfiguration> { new() { TenantId = TenantB } }));
[Fact]
public void MissingConfigCacheKey_LowercasesId_SoCaseVariantsShareOneVerdict()
=> Assert.Equal(
GetAllTenantConfigurationsFunction.MissingConfigCacheKey(TenantB),
GetAllTenantConfigurationsFunction.MissingConfigCacheKey(TenantB.ToUpperInvariant()));
}