up the blokcing tasks
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Risk Bundle CI / risk-bundle-build (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Risk Bundle CI / risk-bundle-offline-kit (push) Has been cancelled
Risk Bundle CI / publish-checksums (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-11 02:32:18 +02:00
parent 92bc4d3a07
commit 49922dff5a
474 changed files with 76071 additions and 12411 deletions

View File

@@ -0,0 +1,530 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.TaskRunner.Core.Tenancy;
using StellaOps.TaskRunner.Infrastructure.Tenancy;
namespace StellaOps.TaskRunner.Tests;
/// <summary>
/// Tests for tenant enforcement per TASKRUN-TEN-48-001.
/// </summary>
public sealed class TenantEnforcementTests
{
#region TenantContext Tests
[Fact]
public void TenantContext_RequiresTenantId()
{
Assert.ThrowsAny<ArgumentException>(() =>
new TenantContext(null!, "project-1"));
Assert.ThrowsAny<ArgumentException>(() =>
new TenantContext("", "project-1"));
Assert.ThrowsAny<ArgumentException>(() =>
new TenantContext(" ", "project-1"));
}
[Fact]
public void TenantContext_RequiresProjectId()
{
Assert.ThrowsAny<ArgumentException>(() =>
new TenantContext("tenant-1", null!));
Assert.ThrowsAny<ArgumentException>(() =>
new TenantContext("tenant-1", ""));
Assert.ThrowsAny<ArgumentException>(() =>
new TenantContext("tenant-1", " "));
}
[Fact]
public void TenantContext_TrimsIds()
{
var context = new TenantContext(" tenant-1 ", " project-1 ");
Assert.Equal("tenant-1", context.TenantId);
Assert.Equal("project-1", context.ProjectId);
}
[Fact]
public void TenantContext_GeneratesStoragePrefix()
{
var context = new TenantContext("Tenant-1", "Project-1");
Assert.Equal("tenant-1/project-1", context.StoragePrefix);
}
[Fact]
public void TenantContext_GeneratesFlatPrefix()
{
var context = new TenantContext("Tenant-1", "Project-1");
Assert.Equal("tenant-1_project-1", context.FlatPrefix);
}
[Fact]
public void TenantContext_GeneratesLoggingScope()
{
var context = new TenantContext("tenant-1", "project-1");
var scope = context.ToLoggingScope();
Assert.Equal("tenant-1", scope["TenantId"]);
Assert.Equal("project-1", scope["ProjectId"]);
}
[Fact]
public void TenantContext_DefaultRestrictionsAreNone()
{
var context = new TenantContext("tenant-1", "project-1");
Assert.False(context.Restrictions.EgressBlocked);
Assert.False(context.Restrictions.ReadOnly);
Assert.False(context.Restrictions.Suspended);
Assert.Null(context.Restrictions.MaxConcurrentRuns);
}
#endregion
#region StoragePathResolver Tests
[Fact]
public void StoragePathResolver_HierarchicalPaths()
{
var options = new TenantStoragePathOptions
{
PathStrategy = TenantPathStrategy.Hierarchical,
StateBasePath = "state",
LogsBasePath = "logs"
};
var resolver = new TenantScopedStoragePathResolver(options, "/data");
var tenant = new TenantContext("tenant-1", "project-1");
var statePath = resolver.GetStatePath(tenant, "run-123");
var logsPath = resolver.GetLogsPath(tenant, "run-123");
Assert.Contains("state", statePath);
Assert.Contains("tenant-1", statePath);
Assert.Contains("project-1", statePath);
Assert.Contains("run-123", statePath);
Assert.Contains("logs", logsPath);
Assert.Contains("tenant-1", logsPath);
}
[Fact]
public void StoragePathResolver_FlatPaths()
{
var options = new TenantStoragePathOptions
{
PathStrategy = TenantPathStrategy.Flat,
StateBasePath = "state"
};
var resolver = new TenantScopedStoragePathResolver(options, "/data");
var tenant = new TenantContext("tenant-1", "project-1");
var statePath = resolver.GetStatePath(tenant, "run-123");
Assert.Contains("tenant-1_project-1_run-123", statePath);
}
[Fact]
public void StoragePathResolver_HashedPaths()
{
var options = new TenantStoragePathOptions
{
PathStrategy = TenantPathStrategy.Hashed
};
var resolver = new TenantScopedStoragePathResolver(options, "/data");
var tenant = new TenantContext("tenant-1", "project-1");
var basePath = resolver.GetTenantBasePath(tenant);
// Should contain a hash (hex characters)
Assert.DoesNotContain("tenant-1", basePath);
Assert.Contains("project-1", basePath);
}
[Fact]
public void StoragePathResolver_ValidatesPathOwnership()
{
var options = new TenantStoragePathOptions
{
PathStrategy = TenantPathStrategy.Hierarchical
};
// Use temp path for cross-platform compatibility
var basePath = Path.Combine(Path.GetTempPath(), "tenant-test-" + Guid.NewGuid().ToString("N")[..8]);
var resolver = new TenantScopedStoragePathResolver(options, basePath);
var tenant1 = new TenantContext("tenant-1", "project-1");
var tenant2 = new TenantContext("tenant-2", "project-1");
var tenant1Path = resolver.GetStatePath(tenant1, "run-123");
var tenant2Path = resolver.GetStatePath(tenant2, "run-123");
Assert.True(resolver.ValidatePathBelongsToTenant(tenant1, tenant1Path));
Assert.False(resolver.ValidatePathBelongsToTenant(tenant1, tenant2Path));
}
#endregion
#region EgressPolicy Tests
[Fact]
public async Task EgressPolicy_AllowsByDefault()
{
var options = new TenantEgressPolicyOptions { AllowByDefault = true };
var policy = CreateEgressPolicy(options);
var tenant = new TenantContext("tenant-1", "project-1");
var result = await policy.CheckEgressAsync(tenant, "example.com", 443);
Assert.True(result.IsAllowed);
}
[Fact]
public async Task EgressPolicy_BlocksGlobalBlocklist()
{
var options = new TenantEgressPolicyOptions
{
AllowByDefault = true,
GlobalBlocklist = ["blocked.com"]
};
var policy = CreateEgressPolicy(options);
var tenant = new TenantContext("tenant-1", "project-1");
var result = await policy.CheckEgressAsync(tenant, "blocked.com", 443);
Assert.False(result.IsAllowed);
Assert.Equal(EgressBlockReason.GlobalPolicy, result.BlockReason);
}
[Fact]
public async Task EgressPolicy_BlocksSuspendedTenants()
{
var options = new TenantEgressPolicyOptions { AllowByDefault = true };
var policy = CreateEgressPolicy(options);
var tenant = new TenantContext(
"tenant-1",
"project-1",
restrictions: new TenantRestrictions { Suspended = true });
var result = await policy.CheckEgressAsync(tenant, "example.com", 443);
Assert.False(result.IsAllowed);
Assert.Equal(EgressBlockReason.TenantSuspended, result.BlockReason);
}
[Fact]
public async Task EgressPolicy_BlocksRestrictedTenants()
{
var options = new TenantEgressPolicyOptions { AllowByDefault = true };
var policy = CreateEgressPolicy(options);
var tenant = new TenantContext(
"tenant-1",
"project-1",
restrictions: new TenantRestrictions { EgressBlocked = true });
var result = await policy.CheckEgressAsync(tenant, "example.com", 443);
Assert.False(result.IsAllowed);
Assert.Equal(EgressBlockReason.TenantRestriction, result.BlockReason);
}
[Fact]
public async Task EgressPolicy_AllowsRestrictedTenantAllowlist()
{
var options = new TenantEgressPolicyOptions { AllowByDefault = true };
var policy = CreateEgressPolicy(options);
var tenant = new TenantContext(
"tenant-1",
"project-1",
restrictions: new TenantRestrictions
{
EgressBlocked = true,
AllowedEgressDomains = ["allowed.com"]
});
var allowedResult = await policy.CheckEgressAsync(tenant, "allowed.com", 443);
var blockedResult = await policy.CheckEgressAsync(tenant, "other.com", 443);
Assert.True(allowedResult.IsAllowed);
Assert.False(blockedResult.IsAllowed);
}
[Fact]
public async Task EgressPolicy_SupportsWildcardDomains()
{
var options = new TenantEgressPolicyOptions
{
AllowByDefault = true,
GlobalBlocklist = ["*.blocked.com"]
};
var policy = CreateEgressPolicy(options);
var tenant = new TenantContext("tenant-1", "project-1");
var result = await policy.CheckEgressAsync(tenant, "sub.blocked.com", 443);
Assert.False(result.IsAllowed);
}
[Fact]
public async Task EgressPolicy_RecordsAttempts()
{
var auditLog = new InMemoryEgressAuditLog();
var options = new TenantEgressPolicyOptions
{
AllowByDefault = true,
LogBlockedAttempts = true
};
var policy = CreateEgressPolicy(options, auditLog);
var tenant = new TenantContext("tenant-1", "project-1");
var uri = new Uri("https://example.com/api");
var result = await policy.CheckEgressAsync(tenant, uri);
await policy.RecordEgressAttemptAsync(tenant, "run-123", uri, result);
var records = auditLog.GetAllRecords();
Assert.Single(records);
Assert.Equal("tenant-1", records[0].TenantId);
Assert.Equal("run-123", records[0].RunId);
Assert.True(records[0].WasAllowed);
}
#endregion
#region TenantEnforcer Tests
[Fact]
public async Task TenantEnforcer_RequiresTenantId()
{
var enforcer = CreateTenantEnforcer();
var request = new PackRunTenantRequest("", "project-1");
var result = await enforcer.ValidateRequestAsync(request);
Assert.False(result.IsValid);
Assert.Equal(TenantEnforcementFailureKind.MissingTenantId, result.FailureKind);
}
[Fact]
public async Task TenantEnforcer_RequiresProjectId()
{
var options = new TenancyEnforcementOptions { RequireProjectId = true };
var enforcer = CreateTenantEnforcer(options);
var request = new PackRunTenantRequest("tenant-1", "");
var result = await enforcer.ValidateRequestAsync(request);
Assert.False(result.IsValid);
Assert.Equal(TenantEnforcementFailureKind.MissingProjectId, result.FailureKind);
}
[Fact]
public async Task TenantEnforcer_BlocksSuspendedTenants()
{
var tenantProvider = new InMemoryTenantContextProvider();
var tenant = new TenantContext(
"tenant-1",
"project-1",
restrictions: new TenantRestrictions { Suspended = true });
tenantProvider.Register(tenant);
var options = new TenancyEnforcementOptions { BlockSuspendedTenants = true };
var enforcer = CreateTenantEnforcer(options, tenantProvider);
var request = new PackRunTenantRequest("tenant-1", "project-1");
var result = await enforcer.ValidateRequestAsync(request);
Assert.False(result.IsValid);
Assert.Equal(TenantEnforcementFailureKind.TenantSuspended, result.FailureKind);
}
[Fact]
public async Task TenantEnforcer_BlocksReadOnlyTenants()
{
var tenantProvider = new InMemoryTenantContextProvider();
var tenant = new TenantContext(
"tenant-1",
"project-1",
restrictions: new TenantRestrictions { ReadOnly = true });
tenantProvider.Register(tenant);
var enforcer = CreateTenantEnforcer(tenantProvider: tenantProvider);
var request = new PackRunTenantRequest("tenant-1", "project-1");
var result = await enforcer.ValidateRequestAsync(request);
Assert.False(result.IsValid);
Assert.Equal(TenantEnforcementFailureKind.TenantReadOnly, result.FailureKind);
}
[Fact]
public async Task TenantEnforcer_EnforcesConcurrentRunLimit()
{
var tenantProvider = new InMemoryTenantContextProvider();
var tenant = new TenantContext(
"tenant-1",
"project-1",
restrictions: new TenantRestrictions { MaxConcurrentRuns = 2 });
tenantProvider.Register(tenant);
var runTracker = new InMemoryConcurrentRunTracker();
await runTracker.IncrementAsync("tenant-1", "run-1");
await runTracker.IncrementAsync("tenant-1", "run-2");
var enforcer = CreateTenantEnforcer(tenantProvider: tenantProvider, runTracker: runTracker);
var request = new PackRunTenantRequest("tenant-1", "project-1");
var result = await enforcer.ValidateRequestAsync(request);
Assert.False(result.IsValid);
Assert.Equal(TenantEnforcementFailureKind.MaxConcurrentRunsReached, result.FailureKind);
}
[Fact]
public async Task TenantEnforcer_AllowsWithinConcurrentLimit()
{
var tenantProvider = new InMemoryTenantContextProvider();
var tenant = new TenantContext(
"tenant-1",
"project-1",
restrictions: new TenantRestrictions { MaxConcurrentRuns = 5 });
tenantProvider.Register(tenant);
var runTracker = new InMemoryConcurrentRunTracker();
await runTracker.IncrementAsync("tenant-1", "run-1");
var enforcer = CreateTenantEnforcer(tenantProvider: tenantProvider, runTracker: runTracker);
var request = new PackRunTenantRequest("tenant-1", "project-1");
var result = await enforcer.ValidateRequestAsync(request);
Assert.True(result.IsValid);
Assert.NotNull(result.Tenant);
}
[Fact]
public async Task TenantEnforcer_TracksRunStartCompletion()
{
var runTracker = new InMemoryConcurrentRunTracker();
var enforcer = CreateTenantEnforcer(runTracker: runTracker);
var tenant = new TenantContext("tenant-1", "project-1");
await enforcer.RecordRunStartAsync(tenant, "run-1");
Assert.Equal(1, await enforcer.GetConcurrentRunCountAsync(tenant));
await enforcer.RecordRunStartAsync(tenant, "run-2");
Assert.Equal(2, await enforcer.GetConcurrentRunCountAsync(tenant));
await enforcer.RecordRunCompletionAsync(tenant, "run-1");
Assert.Equal(1, await enforcer.GetConcurrentRunCountAsync(tenant));
await enforcer.RecordRunCompletionAsync(tenant, "run-2");
Assert.Equal(0, await enforcer.GetConcurrentRunCountAsync(tenant));
}
[Fact]
public async Task TenantEnforcer_CreatesExecutionContext()
{
var tenantProvider = new InMemoryTenantContextProvider();
var tenant = new TenantContext("tenant-1", "project-1");
tenantProvider.Register(tenant);
var enforcer = CreateTenantEnforcer(tenantProvider: tenantProvider);
var request = new PackRunTenantRequest("tenant-1", "project-1");
var context = await enforcer.CreateExecutionContextAsync(request, "run-123");
Assert.NotNull(context);
Assert.Equal("tenant-1", context.Tenant.TenantId);
Assert.Equal("project-1", context.Tenant.ProjectId);
Assert.NotNull(context.StoragePaths);
Assert.Contains("tenant-1", context.LoggingScope["TenantId"].ToString());
}
[Fact]
public async Task TenantEnforcer_ThrowsOnInvalidRequest()
{
var enforcer = CreateTenantEnforcer();
var request = new PackRunTenantRequest("", "project-1");
await Assert.ThrowsAsync<TenantEnforcementException>(() =>
enforcer.CreateExecutionContextAsync(request, "run-123").AsTask());
}
#endregion
#region ConcurrentRunTracker Tests
[Fact]
public async Task ConcurrentRunTracker_TracksMultipleTenants()
{
var tracker = new InMemoryConcurrentRunTracker();
await tracker.IncrementAsync("tenant-1", "run-1");
await tracker.IncrementAsync("tenant-1", "run-2");
await tracker.IncrementAsync("tenant-2", "run-3");
Assert.Equal(2, await tracker.GetCountAsync("tenant-1"));
Assert.Equal(1, await tracker.GetCountAsync("tenant-2"));
Assert.Equal(0, await tracker.GetCountAsync("tenant-3"));
}
[Fact]
public async Task ConcurrentRunTracker_PreventsDoubleIncrement()
{
var tracker = new InMemoryConcurrentRunTracker();
await tracker.IncrementAsync("tenant-1", "run-1");
await tracker.IncrementAsync("tenant-1", "run-1"); // Same run ID
Assert.Equal(1, await tracker.GetCountAsync("tenant-1"));
}
[Fact]
public async Task ConcurrentRunTracker_HandlesNonExistentDecrement()
{
var tracker = new InMemoryConcurrentRunTracker();
// Should not throw
await tracker.DecrementAsync("tenant-1", "non-existent");
Assert.Equal(0, await tracker.GetCountAsync("tenant-1"));
}
#endregion
#region Helper Methods
private static TenantEgressPolicy CreateEgressPolicy(
TenantEgressPolicyOptions? options = null,
IEgressAuditLog? auditLog = null)
{
return new TenantEgressPolicy(
options ?? new TenantEgressPolicyOptions(),
auditLog ?? NullEgressAuditLog.Instance,
NullLogger<TenantEgressPolicy>.Instance);
}
private static PackRunTenantEnforcer CreateTenantEnforcer(
TenancyEnforcementOptions? options = null,
ITenantContextProvider? tenantProvider = null,
IConcurrentRunTracker? runTracker = null)
{
var storageOptions = new TenantStoragePathOptions();
var pathResolver = new TenantScopedStoragePathResolver(storageOptions, Path.GetTempPath());
return new PackRunTenantEnforcer(
tenantProvider ?? new InMemoryTenantContextProvider(),
pathResolver,
options ?? new TenancyEnforcementOptions { ValidateTenantExists = false },
runTracker ?? new InMemoryConcurrentRunTracker(),
NullLogger<PackRunTenantEnforcer>.Instance);
}
#endregion
}