mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
6d1ff8d7f0
- 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>
159 lines
6.2 KiB
C#
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()));
|
|
}
|