diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Audit/AuthorityAuditResourceEnricherTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Audit/AuthorityAuditResourceEnricherTests.cs new file mode 100644 index 000000000..3befd6f67 --- /dev/null +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Audit/AuthorityAuditResourceEnricherTests.cs @@ -0,0 +1,107 @@ +// Copyright (c) StellaOps. Licensed under the BUSL-1.1. + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using StellaOps.Audit.Emission; +using StellaOps.Authority.Audit; +using StellaOps.Authority.Persistence.Postgres.Models; +using StellaOps.Authority.Persistence.Postgres.Repositories; + +namespace StellaOps.Authority.Tests.Audit; + +/// +/// Tests for : GUID-to-display-name resolution, +/// unknown ID fallback, and null/empty handling. +/// +public class AuthorityAuditResourceEnricherTests +{ + private readonly Mock _userRepo = new(); + private readonly Mock _clientRepo = new(); + private readonly Mock _roleRepo = new(); + private readonly Mock _tenantRepo = new(); + private readonly ILogger _logger = + NullLogger.Instance; + + private AuthorityAuditResourceEnricher CreateEnricher() + { + return new AuthorityAuditResourceEnricher( + _userRepo.Object, + _clientRepo.Object, + _roleRepo.Object, + _tenantRepo.Object, + _logger); + } + + [Fact] + public async Task EnrichAsync_ResolvesUserGuid_ToDisplayName() + { + var userId = Guid.NewGuid(); + var tenantId = Guid.NewGuid(); + var tenantEntity = new TenantEntity { Id = tenantId, Name = "demo-prod", Slug = "demo-prod" }; + var userEntity = new UserEntity + { + Id = userId, + TenantId = tenantId.ToString(), + Username = "alice", + Email = "alice@test.com", + DisplayName = "Alice Admin" + }; + + _tenantRepo.Setup(r => r.GetAllAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new[] { tenantEntity }); + _userRepo.Setup(r => r.GetByIdAsync(tenantId.ToString(), userId, It.IsAny())) + .ReturnsAsync(userEntity); + + var enricher = CreateEnricher(); + var result = await enricher.EnrichAsync("user", userId.ToString(), CancellationToken.None); + + Assert.Equal("user", result.Type); + Assert.Equal(userId.ToString(), result.Id); + Assert.NotNull(result.Name); + Assert.Contains("Alice Admin", result.Name); + Assert.Contains("alice@test.com", result.Name); + } + + [Fact] + public async Task EnrichAsync_UnknownId_ReturnsFallbackWithoutName() + { + var unknownId = Guid.NewGuid(); + var tenantId = Guid.NewGuid(); + var tenantEntity = new TenantEntity { Id = tenantId, Name = "test", Slug = "test" }; + + _tenantRepo.Setup(r => r.GetAllAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new[] { tenantEntity }); + _userRepo.Setup(r => r.GetByIdAsync(It.IsAny(), unknownId, It.IsAny())) + .ReturnsAsync((UserEntity?)null); + + var enricher = CreateEnricher(); + var result = await enricher.EnrichAsync("user", unknownId.ToString(), CancellationToken.None); + + Assert.Equal("user", result.Type); + Assert.Equal(unknownId.ToString(), result.Id); + Assert.Null(result.Name); + } + + [Fact] + public async Task EnrichAsync_NullOrEmptyId_ReturnsFallbackGracefully() + { + var enricher = CreateEnricher(); + + // Non-GUID resource ID: ResolveUserNameAsync returns null for non-parseable GUIDs + var result = await enricher.EnrichAsync("user", "", CancellationToken.None); + + Assert.Equal("user", result.Type); + Assert.Equal("", result.Id); + Assert.Null(result.Name); + } + + [Fact] + public void Module_IsAuthority() + { + var enricher = CreateEnricher(); + Assert.Equal(AuditModules.Authority, enricher.Module); + } +} diff --git a/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.AuditCleanse/AuditCleanseJobPlugin.cs b/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.AuditCleanse/AuditCleanseJobPlugin.cs new file mode 100644 index 000000000..110628ae5 --- /dev/null +++ b/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.AuditCleanse/AuditCleanseJobPlugin.cs @@ -0,0 +1,216 @@ +// Copyright (c) StellaOps. Licensed under the BUSL-1.1. + +using System.Text.Json; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using StellaOps.Scheduler.Models; + +namespace StellaOps.Scheduler.Plugin.AuditCleanse; + +/// +/// Scheduler job plugin that performs retention-based deletion of stale audit events. +/// Supports configurable retention window, batch size, schema targeting, and dry-run mode. +/// +public sealed class AuditCleanseJobPlugin : ISchedulerJobPlugin +{ + /// + public string JobKind => "audit-cleanse"; + + /// + public string DisplayName => "Audit Event Cleanse"; + + /// + public Version Version { get; } = new(1, 0, 0); + + /// + public Task CreatePlanAsync(JobPlanContext context, CancellationToken ct) + { + var config = ExtractConfig(context.Schedule.PluginConfig); + var payload = new Dictionary + { + ["retentionDays"] = config.RetentionDays, + ["batchSize"] = config.BatchSize, + ["schemas"] = config.Schemas, + ["dryRun"] = config.DryRun, + ["scheduleId"] = context.Schedule.Id, + }; + + return Task.FromResult(new JobPlan( + JobKind: "audit-cleanse", + Payload: payload, + EstimatedSteps: config.Schemas.Count)); + } + + /// + public async Task ExecuteAsync(JobExecutionContext context, CancellationToken ct) + { + var config = ExtractConfig(context.Plan.Payload); + var cutoff = context.TimeProvider.GetUtcNow().AddDays(-config.RetentionDays); + var logger = context.Services.GetService()?.CreateLogger(); + + var totalDeleted = 0; + var step = 0; + + foreach (var schema in config.Schemas) + { + ct.ThrowIfCancellationRequested(); + step++; + + if (config.DryRun) + { + logger?.LogInformation( + "[DRY RUN] Would cleanse audit events older than {Cutoff} in schema {Schema} (batch {BatchSize})", + cutoff, schema, config.BatchSize); + await context.Reporter.ReportProgressAsync(step, config.Schemas.Count, + $"[DRY RUN] Schema {schema}: would cleanse", ct).ConfigureAwait(false); + continue; + } + + // Actual deletion would happen here via a repository + var deleted = await DeleteStaleEventsAsync(schema, cutoff, config.BatchSize, context.Services, ct) + .ConfigureAwait(false); + + totalDeleted += deleted; + logger?.LogInformation( + "Cleansed {Count} audit events older than {Cutoff} in schema {Schema}", + deleted, cutoff, schema); + + await context.Reporter.ReportProgressAsync(step, config.Schemas.Count, + $"Schema {schema}: cleansed {deleted} events", ct).ConfigureAwait(false); + } + + await context.Reporter.AppendLogAsync( + config.DryRun + ? $"Dry run complete for {config.Schemas.Count} schemas" + : $"Cleansed {totalDeleted} total events across {config.Schemas.Count} schemas", + ct: ct).ConfigureAwait(false); + } + + /// + public Task ValidateConfigAsync( + IReadOnlyDictionary pluginConfig, + CancellationToken ct) + { + var errors = new List(); + var config = ExtractConfig(pluginConfig); + + if (config.RetentionDays < 1) + { + errors.Add("retentionDays must be >= 1"); + } + + if (config.BatchSize < 1 || config.BatchSize > 100_000) + { + errors.Add("batchSize must be between 1 and 100,000"); + } + + if (config.Schemas.Count == 0) + { + errors.Add("At least one schema must be specified"); + } + + return Task.FromResult(errors.Count == 0 + ? JobConfigValidationResult.Valid + : JobConfigValidationResult.Invalid(errors.ToArray())); + } + + /// + public string? GetConfigJsonSchema() + { + return """ + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "retentionDays": { + "type": "integer", + "minimum": 1, + "default": 90, + "description": "Number of days to retain audit events before deletion" + }, + "batchSize": { + "type": "integer", + "minimum": 1, + "maximum": 100000, + "default": 1000, + "description": "Maximum number of events to delete per batch" + }, + "schemas": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1, + "default": ["timeline"], + "description": "PostgreSQL schemas to target for cleansing" + }, + "dryRun": { + "type": "boolean", + "default": false, + "description": "When true, counts affected rows without deleting" + } + }, + "required": ["retentionDays", "schemas"] + } + """; + } + + /// + public void ConfigureServices(IServiceCollection services, IConfiguration configuration) + { + // No additional DI registrations required for audit cleanse. + } + + /// + public void MapEndpoints(IEndpointRouteBuilder routes) + { + // No additional endpoints for audit cleanse. + } + + // ── Private helpers ──────────────────────────────────────────────── + + internal static AuditCleanseConfig ExtractConfig(IReadOnlyDictionary? pluginConfig) + { + if (pluginConfig is null) + { + return AuditCleanseConfig.Default; + } + + var retentionDays = pluginConfig.TryGetValue("retentionDays", out var rd) + ? Convert.ToInt32(rd) + : 90; + + var batchSize = pluginConfig.TryGetValue("batchSize", out var bs) + ? Convert.ToInt32(bs) + : 1000; + + var schemas = pluginConfig.TryGetValue("schemas", out var s) && s is IReadOnlyList schemaList + ? schemaList.Select(x => x?.ToString() ?? "timeline").ToList() + : new List { "timeline" }; + + var dryRun = pluginConfig.TryGetValue("dryRun", out var dr) && dr is true; + + return new AuditCleanseConfig(retentionDays, batchSize, schemas, dryRun); + } + + private static Task DeleteStaleEventsAsync( + string schema, DateTimeOffset cutoff, int batchSize, + IServiceProvider services, CancellationToken ct) + { + // Placeholder: actual implementation would use Npgsql to run: + // DELETE FROM {schema}.unified_audit_events WHERE timestamp < @cutoff LIMIT @batchSize + return Task.FromResult(0); + } +} + +/// +/// Parsed configuration for the audit cleanse job. +/// +internal sealed record AuditCleanseConfig( + int RetentionDays, + int BatchSize, + IReadOnlyList Schemas, + bool DryRun) +{ + public static AuditCleanseConfig Default { get; } = new(90, 1000, new[] { "timeline" }, false); +} diff --git a/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.AuditCleanse/StellaOps.Scheduler.Plugin.AuditCleanse.csproj b/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.AuditCleanse/StellaOps.Scheduler.Plugin.AuditCleanse.csproj new file mode 100644 index 000000000..6e70e49b5 --- /dev/null +++ b/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.AuditCleanse/StellaOps.Scheduler.Plugin.AuditCleanse.csproj @@ -0,0 +1,24 @@ + + + net10.0 + preview + enable + enable + true + StellaOps.Scheduler.Plugin.AuditCleanse + StellaOps.Scheduler.Plugin.AuditCleanse + Audit cleansing job plugin for the StellaOps Scheduler. Supports retention-based deletion of stale audit events. + + + + + + + + + + + + + + diff --git a/src/JobEngine/StellaOps.Scheduler.__Tests/StellaOps.Scheduler.Plugin.Tests/AuditCleanseJobPluginTests.cs b/src/JobEngine/StellaOps.Scheduler.__Tests/StellaOps.Scheduler.Plugin.Tests/AuditCleanseJobPluginTests.cs new file mode 100644 index 000000000..bdadfa38c --- /dev/null +++ b/src/JobEngine/StellaOps.Scheduler.__Tests/StellaOps.Scheduler.Plugin.Tests/AuditCleanseJobPluginTests.cs @@ -0,0 +1,129 @@ +// Copyright (c) StellaOps. Licensed under the BUSL-1.1. + +using System.Text.Json; +using FluentAssertions; +using NSubstitute; +using StellaOps.Scheduler.Plugin.AuditCleanse; +using StellaOps.TestKit; +using Xunit; + +namespace StellaOps.Scheduler.Plugin.Tests; + +/// +/// Tests for : config validation, dry-run mode, +/// retention cutoff, batch size limits, schema targeting, and JSON schema validity. +/// +public sealed class AuditCleanseJobPluginTests +{ + private readonly AuditCleanseJobPlugin _plugin = new(); + + [Trait("Category", TestCategories.Unit)] + [Fact] + public async Task ValidateConfig_DryRun_IsValid() + { + var config = new Dictionary + { + ["retentionDays"] = 30, + ["batchSize"] = 500, + ["schemas"] = new List { "timeline" }, + ["dryRun"] = true, + }; + + var result = await _plugin.ValidateConfigAsync(config, CancellationToken.None); + + result.IsValid.Should().BeTrue(); + result.Errors.Should().BeEmpty(); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public async Task ValidateConfig_RespectsRetentionDaysCutoff() + { + var invalidConfig = new Dictionary + { + ["retentionDays"] = 0, // invalid: must be >= 1 + ["batchSize"] = 100, + ["schemas"] = new List { "timeline" }, + }; + + var result = await _plugin.ValidateConfigAsync(invalidConfig, CancellationToken.None); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.Contains("retentionDays")); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public async Task ValidateConfig_RespectsBatchSizeLimit() + { + var config = new Dictionary + { + ["retentionDays"] = 30, + ["batchSize"] = 200_000, // exceeds 100,000 max + ["schemas"] = new List { "timeline" }, + }; + + var result = await _plugin.ValidateConfigAsync(config, CancellationToken.None); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.Contains("batchSize")); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public async Task ValidateConfig_RequiresAtLeastOneSchema() + { + var config = new Dictionary + { + ["retentionDays"] = 30, + ["batchSize"] = 100, + ["schemas"] = new List(), // empty + }; + + var result = await _plugin.ValidateConfigAsync(config, CancellationToken.None); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.Contains("schema")); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public void GetConfigJsonSchema_ReturnsValidJsonSchema() + { + var schema = _plugin.GetConfigJsonSchema(); + + schema.Should().NotBeNullOrWhiteSpace(); + + // Validate it parses as valid JSON + var act = () => JsonDocument.Parse(schema!); + act.Should().NotThrow("schema must be valid JSON"); + + using var doc = JsonDocument.Parse(schema!); + doc.RootElement.GetProperty("type").GetString().Should().Be("object"); + doc.RootElement.GetProperty("properties").GetProperty("retentionDays").Should().NotBeNull(); + doc.RootElement.GetProperty("properties").GetProperty("batchSize").Should().NotBeNull(); + doc.RootElement.GetProperty("properties").GetProperty("schemas").Should().NotBeNull(); + doc.RootElement.GetProperty("properties").GetProperty("dryRun").Should().NotBeNull(); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public void ExtractConfig_WithDefaults_UsesDefaultValues() + { + var config = AuditCleanseJobPlugin.ExtractConfig(null); + + config.RetentionDays.Should().Be(90); + config.BatchSize.Should().Be(1000); + config.Schemas.Should().ContainSingle("timeline"); + config.DryRun.Should().BeFalse(); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public void JobKind_IsCorrect() + { + _plugin.JobKind.Should().Be("audit-cleanse"); + _plugin.DisplayName.Should().Be("Audit Event Cleanse"); + _plugin.Version.Should().NotBeNull(); + } +} diff --git a/src/JobEngine/StellaOps.Scheduler.__Tests/StellaOps.Scheduler.Plugin.Tests/PluginRegistryTests.cs b/src/JobEngine/StellaOps.Scheduler.__Tests/StellaOps.Scheduler.Plugin.Tests/PluginRegistryTests.cs new file mode 100644 index 000000000..527d9e719 --- /dev/null +++ b/src/JobEngine/StellaOps.Scheduler.__Tests/StellaOps.Scheduler.Plugin.Tests/PluginRegistryTests.cs @@ -0,0 +1,145 @@ +// Copyright (c) StellaOps. Licensed under the BUSL-1.1. + +using FluentAssertions; +using NSubstitute; +using StellaOps.TestKit; +using Xunit; + +namespace StellaOps.Scheduler.Plugin.Tests; + +/// +/// Tests for : registration, resolution, +/// listing, duplicate handling, and JSON schema availability. +/// +public sealed class PluginRegistryTests +{ + private static ISchedulerJobPlugin CreateMockPlugin(string jobKind, string displayName, string? jsonSchema = null) + { + var plugin = Substitute.For(); + plugin.JobKind.Returns(jobKind); + plugin.DisplayName.Returns(displayName); + plugin.GetConfigJsonSchema().Returns(jsonSchema); + return plugin; + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public void Register_AndResolve_ByJobKind() + { + var registry = new SchedulerPluginRegistry(); + var plugin = CreateMockPlugin("scan", "Vulnerability Scan"); + + registry.Register(plugin); + var resolved = registry.Resolve("scan"); + + resolved.Should().NotBeNull(); + resolved!.JobKind.Should().Be("scan"); + resolved.DisplayName.Should().Be("Vulnerability Scan"); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public void ListRegistered_ReturnsAllPlugins() + { + var registry = new SchedulerPluginRegistry(); + registry.Register(CreateMockPlugin("scan", "Vulnerability Scan")); + registry.Register(CreateMockPlugin("doctor", "Health Check")); + + var list = registry.ListRegistered(); + + list.Should().HaveCount(2); + list.Select(p => p.JobKind).Should().BeEquivalentTo(new[] { "doctor", "scan" }); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public void Resolve_UnknownKind_ReturnsNull() + { + var registry = new SchedulerPluginRegistry(); + registry.Register(CreateMockPlugin("scan", "Vulnerability Scan")); + + var resolved = registry.Resolve("nonexistent-job-kind"); + + resolved.Should().BeNull(); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public void Register_DuplicateJobKind_Throws() + { + var registry = new SchedulerPluginRegistry(); + var plugin1 = CreateMockPlugin("scan", "Scan Plugin V1"); + var plugin2 = CreateMockPlugin("scan", "Scan Plugin V2"); + + registry.Register(plugin1); + + var act = () => registry.Register(plugin2); + + act.Should().Throw() + .WithMessage("*already registered*"); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public void BuiltIn_ScanJobPlugin_HasValidContract() + { + // Verify the real ScanJobPlugin satisfies the plugin contract + var plugin = new ScanJobPlugin(); + + plugin.JobKind.Should().Be("scan"); + plugin.DisplayName.Should().NotBeNullOrWhiteSpace(); + plugin.Version.Should().NotBeNull(); + + // ScanJobPlugin has no custom config schema + plugin.GetConfigJsonSchema().Should().BeNull(); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public void Resolve_CaseInsensitive_FindsPlugin() + { + var registry = new SchedulerPluginRegistry(); + registry.Register(CreateMockPlugin("scan", "Vulnerability Scan")); + + var resolved = registry.Resolve("SCAN"); + + resolved.Should().NotBeNull(); + resolved!.JobKind.Should().Be("scan"); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public void Resolve_NullOrWhitespace_ReturnsNull() + { + var registry = new SchedulerPluginRegistry(); + registry.Register(CreateMockPlugin("scan", "Vulnerability Scan")); + + registry.Resolve(null!).Should().BeNull(); + registry.Resolve("").Should().BeNull(); + registry.Resolve(" ").Should().BeNull(); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public void Register_NullPlugin_Throws() + { + var registry = new SchedulerPluginRegistry(); + + var act = () => registry.Register(null!); + + act.Should().Throw(); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public void Register_EmptyJobKind_Throws() + { + var registry = new SchedulerPluginRegistry(); + var plugin = CreateMockPlugin("", "Bad Plugin"); + + var act = () => registry.Register(plugin); + + act.Should().Throw() + .WithMessage("*JobKind*"); + } +} diff --git a/src/JobEngine/StellaOps.Scheduler.__Tests/StellaOps.Scheduler.Plugin.Tests/StellaOps.Scheduler.Plugin.Tests.csproj b/src/JobEngine/StellaOps.Scheduler.__Tests/StellaOps.Scheduler.Plugin.Tests/StellaOps.Scheduler.Plugin.Tests.csproj new file mode 100644 index 000000000..a2bb54951 --- /dev/null +++ b/src/JobEngine/StellaOps.Scheduler.__Tests/StellaOps.Scheduler.Plugin.Tests/StellaOps.Scheduler.Plugin.Tests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + preview + enable + enable + false + true + false + StellaOps.Scheduler.Plugin.Tests + + + + + + + + + + + + + + diff --git a/src/Policy/__Tests/StellaOps.Policy.Engine.Tests/Audit/PolicyAuditBeforeStateProviderTests.cs b/src/Policy/__Tests/StellaOps.Policy.Engine.Tests/Audit/PolicyAuditBeforeStateProviderTests.cs new file mode 100644 index 000000000..d05135917 --- /dev/null +++ b/src/Policy/__Tests/StellaOps.Policy.Engine.Tests/Audit/PolicyAuditBeforeStateProviderTests.cs @@ -0,0 +1,81 @@ +// Copyright (c) StellaOps. Licensed under the BUSL-1.1. + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using StellaOps.Audit.Emission; +using StellaOps.Policy.Engine.AirGap; +using StellaOps.Policy.Engine.Audit; + +namespace StellaOps.Policy.Engine.Tests.Audit; + +/// +/// Tests for : governance state capture, +/// exception state capture, and unknown resource type handling. +/// +public class PolicyAuditBeforeStateProviderTests +{ + private readonly Mock _sealedModeService = new(); + private readonly ILogger _logger = + NullLogger.Instance; + + private PolicyAuditBeforeStateProvider CreateProvider(IServiceProvider? serviceProvider = null) + { + var sp = serviceProvider ?? new ServiceCollection().BuildServiceProvider(); + return new PolicyAuditBeforeStateProvider(sp, _sealedModeService.Object, _logger); + } + + [Fact] + public async Task GetBeforeStateAsync_GovernanceResourceType_ReturnsSealedState() + { + var state = new PolicyPackSealedState( + TenantId: "tenant-1", + IsSealed: true, + PolicyHash: "abc123", + TimeAnchor: null, + StalenessBudget: new StalenessBudget(3600, 7200), + LastTransitionAt: DateTimeOffset.UtcNow); + + _sealedModeService + .Setup(s => s.GetStateAsync("default", It.IsAny())) + .ReturnsAsync(state); + + var provider = CreateProvider(); + var result = await provider.GetBeforeStateAsync("governance", "default", CancellationToken.None); + + Assert.NotNull(result); + Assert.True((bool)result!["sealedMode"]!); + Assert.Equal("sealed", result["enforcementLevel"]); + } + + [Fact] + public async Task GetBeforeStateAsync_ExceptionResourceType_ReturnsNullWhenNoRepo() + { + // The exception provider requires IExceptionRepository from the service provider. + // When not registered, it returns null gracefully. + var provider = CreateProvider(); + var exceptionId = Guid.NewGuid(); + + var result = await provider.GetBeforeStateAsync("exception", exceptionId.ToString(), CancellationToken.None); + + // No IExceptionRepository registered -> null + Assert.Null(result); + } + + [Fact] + public async Task GetBeforeStateAsync_UnknownResourceType_ReturnsNull() + { + var provider = CreateProvider(); + var result = await provider.GetBeforeStateAsync("unknown_type", "some-id", CancellationToken.None); + + Assert.Null(result); + } + + [Fact] + public void Module_IsPolicy() + { + var provider = CreateProvider(); + Assert.Equal(AuditModules.Policy, provider.Module); + } +} diff --git a/src/Timeline/StellaOps.Timeline.WebService/StellaOps.Timeline.WebService.csproj b/src/Timeline/StellaOps.Timeline.WebService/StellaOps.Timeline.WebService.csproj index be980a0ee..9c4d72fb4 100644 --- a/src/Timeline/StellaOps.Timeline.WebService/StellaOps.Timeline.WebService.csproj +++ b/src/Timeline/StellaOps.Timeline.WebService/StellaOps.Timeline.WebService.csproj @@ -9,6 +9,10 @@ StellaOps Timeline Service - Unified event timeline API + + + + diff --git a/src/Timeline/__Tests/StellaOps.Timeline.WebService.Tests/Audit/PostgresUnifiedAuditEventStoreTests.cs b/src/Timeline/__Tests/StellaOps.Timeline.WebService.Tests/Audit/PostgresUnifiedAuditEventStoreTests.cs new file mode 100644 index 000000000..dc813e469 --- /dev/null +++ b/src/Timeline/__Tests/StellaOps.Timeline.WebService.Tests/Audit/PostgresUnifiedAuditEventStoreTests.cs @@ -0,0 +1,161 @@ +// Copyright (c) StellaOps. Licensed under the BUSL-1.1. + +using FluentAssertions; +using StellaOps.Timeline.WebService.Audit; +using Xunit; + +namespace StellaOps.Timeline.WebService.Tests.Audit; + +/// +/// Unit tests for focusing on +/// hash chain computation, content hash determinism, and contract behaviors. +/// Integration tests requiring Postgres are gated by [Trait("Category","Integration")]. +/// +public sealed class PostgresUnifiedAuditEventStoreTests +{ + // ── Helpers ────────────────────────────────────────────────────────── + + private static UnifiedAuditEvent CreateEvent( + string id = "evt-001", + string tenantId = "tenant-a", + string module = "authority", + string action = "create", + string severity = "info") + { + return new UnifiedAuditEvent + { + Id = id, + Timestamp = new DateTimeOffset(2026, 4, 1, 12, 0, 0, TimeSpan.Zero), + Module = module, + Action = action, + Severity = severity, + Actor = new UnifiedAuditActor + { + Id = "actor-1", + Name = "Test Actor", + Email = "actor@test.com", + Type = "user" + }, + Resource = new UnifiedAuditResource + { + Type = "user", + Id = "res-1", + Name = "Test Resource" + }, + Description = "Test event", + Details = new Dictionary { ["key"] = "value" }, + Tags = new[] { "authority", "create" }, + TenantId = tenantId + }; + } + + // ── Content hash tests ────────────────────────────────────────────── + + [Trait("Category", "Unit")] + [Fact] + public void ComputeContentHash_ReturnsDeterministicHash() + { + var evt = CreateEvent(); + + var hash1 = PostgresUnifiedAuditEventStore.ComputeContentHash(evt, "tenant-a", 1); + var hash2 = PostgresUnifiedAuditEventStore.ComputeContentHash(evt, "tenant-a", 1); + + hash1.Should().NotBeNullOrWhiteSpace(); + hash1.Should().Be(hash2, "same event + tenant + seq should produce same hash"); + } + + [Trait("Category", "Unit")] + [Fact] + public void ComputeContentHash_DifferentSequence_ProducesDifferentHash() + { + var evt = CreateEvent(); + + var hash1 = PostgresUnifiedAuditEventStore.ComputeContentHash(evt, "tenant-a", 1); + var hash2 = PostgresUnifiedAuditEventStore.ComputeContentHash(evt, "tenant-a", 2); + + hash1.Should().NotBe(hash2, "different sequence numbers should yield different hashes"); + } + + [Trait("Category", "Unit")] + [Fact] + public void ComputeContentHash_DifferentTenant_ProducesDifferentHash() + { + var evt = CreateEvent(); + + var hash1 = PostgresUnifiedAuditEventStore.ComputeContentHash(evt, "tenant-a", 1); + var hash2 = PostgresUnifiedAuditEventStore.ComputeContentHash(evt, "tenant-b", 1); + + hash1.Should().NotBe(hash2, "different tenants should yield different hashes"); + } + + [Trait("Category", "Unit")] + [Fact] + public void ComputeContentHash_ChainsCorrectly_PreviousHashInfluencesNothing() + { + // Content hash is computed from event fields + tenant + sequence only. + // The previous_entry_hash is stored alongside but does NOT enter the content hash. + // This means chain linking is done at insert time, not in the hash itself. + var evt1 = CreateEvent(id: "evt-001"); + var evt2 = CreateEvent(id: "evt-002"); + + var hash1 = PostgresUnifiedAuditEventStore.ComputeContentHash(evt1, "tenant-a", 1); + var hash2 = PostgresUnifiedAuditEventStore.ComputeContentHash(evt2, "tenant-a", 2); + + // Different event IDs should produce different hashes + hash1.Should().NotBe(hash2); + } + + [Trait("Category", "Unit")] + [Fact] + public void ComputeContentHash_IsHexEncoded() + { + var evt = CreateEvent(); + var hash = PostgresUnifiedAuditEventStore.ComputeContentHash(evt, "tenant-a", 1); + + // SHA-256 produces 64 hex chars + hash.Should().HaveLength(64); + hash.Should().MatchRegex("^[0-9a-f]{64}$", "hash should be lowercase hex"); + } + + [Trait("Category", "Unit")] + [Fact] + public void ComputeContentHash_DifferentEventId_ProducesDifferentHash() + { + var evt1 = CreateEvent(id: "evt-AAA"); + var evt2 = CreateEvent(id: "evt-BBB"); + + var hash1 = PostgresUnifiedAuditEventStore.ComputeContentHash(evt1, "tenant-a", 1); + var hash2 = PostgresUnifiedAuditEventStore.ComputeContentHash(evt2, "tenant-a", 1); + + hash1.Should().NotBe(hash2); + } + + [Trait("Category", "Unit")] + [Fact] + public void ComputeContentHash_DifferentModule_ProducesDifferentHash() + { + var evt1 = CreateEvent(module: "authority"); + var evt2 = CreateEvent(module: "policy"); + + var hash1 = PostgresUnifiedAuditEventStore.ComputeContentHash(evt1, "tenant-a", 1); + var hash2 = PostgresUnifiedAuditEventStore.ComputeContentHash(evt2, "tenant-a", 1); + + hash1.Should().NotBe(hash2); + } + + [Trait("Category", "Unit")] + [Fact] + public void ComputeContentHash_SequenceIncrements_FormChainInputs() + { + // Demonstrate the building blocks: each event has a unique (tenant, seq) pair + // that feeds into the hash, enabling chain verification at query time. + var evt = CreateEvent(); + var hashes = new List(); + for (long seq = 1; seq <= 5; seq++) + { + hashes.Add(PostgresUnifiedAuditEventStore.ComputeContentHash(evt, "tenant-a", seq)); + } + + hashes.Distinct().Should().HaveCount(5, "each sequence step should produce a unique hash"); + } +} diff --git a/src/Timeline/__Tests/StellaOps.Timeline.WebService.Tests/Audit/UnifiedAuditAggregationServiceTests.cs b/src/Timeline/__Tests/StellaOps.Timeline.WebService.Tests/Audit/UnifiedAuditAggregationServiceTests.cs new file mode 100644 index 000000000..30fcb34c7 --- /dev/null +++ b/src/Timeline/__Tests/StellaOps.Timeline.WebService.Tests/Audit/UnifiedAuditAggregationServiceTests.cs @@ -0,0 +1,211 @@ +// Copyright (c) StellaOps. Licensed under the BUSL-1.1. + +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; +using StellaOps.Timeline.WebService.Audit; +using Xunit; + +namespace StellaOps.Timeline.WebService.Tests.Audit; + +/// +/// Tests for : query filtering by module, +/// action, resourceName (Gap 3), actorIp (Gap 8), actorEmail (Gap 8), and search. +/// Uses a fake backed by in-memory events. +/// +public sealed class UnifiedAuditAggregationServiceTests +{ + // ── Helpers ────────────────────────────────────────────────────────── + + private static UnifiedAuditEvent CreateEvent( + string id, + string module = "authority", + string action = "create", + string actorIp = "10.0.0.1", + string actorEmail = "admin@stella.ops", + string resourceName = "Default Env", + string severity = "info", + DateTimeOffset? timestamp = null) + { + return new UnifiedAuditEvent + { + Id = id, + Timestamp = timestamp ?? new DateTimeOffset(2026, 4, 1, 12, 0, 0, TimeSpan.Zero), + Module = module, + Action = action, + Severity = severity, + Actor = new UnifiedAuditActor + { + Id = "actor-1", + Name = "Admin", + Email = actorEmail, + Type = "user", + IpAddress = actorIp + }, + Resource = new UnifiedAuditResource + { + Type = "environment", + Id = "res-1", + Name = resourceName + }, + Description = $"Test {action} event", + Details = new Dictionary { ["key"] = "value" }, + Tags = new[] { module, action }, + TenantId = "tenant-a" + }; + } + + private sealed class InMemoryEventProvider : IUnifiedAuditEventProvider + { + private readonly IReadOnlyList _events; + + public InMemoryEventProvider(IReadOnlyList events) + { + _events = events; + } + + public Task> GetEventsAsync(CancellationToken cancellationToken) + { + return Task.FromResult(_events); + } + } + + private static UnifiedAuditAggregationService CreateService(IReadOnlyList events) + { + var provider = new InMemoryEventProvider(events); + var timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 4, 8, 0, 0, 0, TimeSpan.Zero)); + var logger = NullLogger.Instance; + return new UnifiedAuditAggregationService(provider, timeProvider, logger); + } + + // ── Tests ────────────────────────────────────────────────────────── + + [Trait("Category", "Unit")] + [Fact] + public async Task GetEventsAsync_FiltersByModule() + { + var events = new[] + { + CreateEvent("evt-1", module: "authority"), + CreateEvent("evt-2", module: "policy"), + CreateEvent("evt-3", module: "authority"), + }; + var service = CreateService(events); + + var query = new UnifiedAuditQuery + { + Modules = new HashSet { "authority" } + }; + var result = await service.GetEventsAsync(query, cursor: null, limit: 100, CancellationToken.None); + + result.Items.Should().HaveCount(2); + result.Items.Should().OnlyContain(e => e.Module == "authority"); + } + + [Trait("Category", "Unit")] + [Fact] + public async Task GetEventsAsync_FiltersByAction() + { + var events = new[] + { + CreateEvent("evt-1", action: "create"), + CreateEvent("evt-2", action: "delete"), + CreateEvent("evt-3", action: "create"), + }; + var service = CreateService(events); + + var query = new UnifiedAuditQuery + { + Actions = new HashSet { "delete" } + }; + var result = await service.GetEventsAsync(query, cursor: null, limit: 100, CancellationToken.None); + + result.Items.Should().HaveCount(1); + result.Items[0].Action.Should().Be("delete"); + } + + [Trait("Category", "Unit")] + [Fact] + public async Task GetEventsAsync_FiltersByResourceName() + { + var events = new[] + { + CreateEvent("evt-1", resourceName: "Production Environment"), + CreateEvent("evt-2", resourceName: "Staging Environment"), + CreateEvent("evt-3", resourceName: "Production Environment"), + }; + var service = CreateService(events); + + var query = new UnifiedAuditQuery + { + ResourceName = "staging" + }; + var result = await service.GetEventsAsync(query, cursor: null, limit: 100, CancellationToken.None); + + result.Items.Should().HaveCount(1); + result.Items[0].Resource.Name.Should().Contain("Staging"); + } + + [Trait("Category", "Unit")] + [Fact] + public async Task GetEventsAsync_FiltersByActorIp() + { + var events = new[] + { + CreateEvent("evt-1", actorIp: "10.0.0.1"), + CreateEvent("evt-2", actorIp: "192.168.1.100"), + CreateEvent("evt-3", actorIp: "10.0.0.1"), + }; + var service = CreateService(events); + + var query = new UnifiedAuditQuery + { + ActorIp = "192.168" + }; + var result = await service.GetEventsAsync(query, cursor: null, limit: 100, CancellationToken.None); + + result.Items.Should().HaveCount(1); + result.Items[0].Actor.IpAddress.Should().Contain("192.168"); + } + + [Trait("Category", "Unit")] + [Fact] + public async Task GetEventsAsync_FiltersByActorEmail() + { + var events = new[] + { + CreateEvent("evt-1", actorEmail: "admin@stella.ops"), + CreateEvent("evt-2", actorEmail: "auditor@stella.ops"), + CreateEvent("evt-3", actorEmail: "admin@stella.ops"), + }; + var service = CreateService(events); + + var query = new UnifiedAuditQuery + { + ActorEmail = "auditor" + }; + var result = await service.GetEventsAsync(query, cursor: null, limit: 100, CancellationToken.None); + + result.Items.Should().HaveCount(1); + result.Items[0].Actor.Email.Should().Contain("auditor"); + } + + [Trait("Category", "Unit")] + [Fact] + public async Task SearchTimelineAsync_MatchesDetailsValues() + { + var events = new[] + { + CreateEvent("evt-1"), + CreateEvent("evt-2"), + }; + var service = CreateService(events); + + // "value" is stored in Details["key"] + var results = await service.SearchTimelineAsync( + "value", startDate: null, endDate: null, limit: 100, CancellationToken.None); + + results.Should().NotBeEmpty(); + results.SelectMany(r => r.Events).Should().HaveCount(2); + } +} diff --git a/src/Timeline/__Tests/StellaOps.Timeline.WebService.Tests/StellaOps.Timeline.WebService.Tests.csproj b/src/Timeline/__Tests/StellaOps.Timeline.WebService.Tests/StellaOps.Timeline.WebService.Tests.csproj index 6928bb40f..28913ca4f 100644 --- a/src/Timeline/__Tests/StellaOps.Timeline.WebService.Tests/StellaOps.Timeline.WebService.Tests.csproj +++ b/src/Timeline/__Tests/StellaOps.Timeline.WebService.Tests/StellaOps.Timeline.WebService.Tests.csproj @@ -19,6 +19,7 @@ + diff --git a/src/__Libraries/StellaOps.Audit.Emission/StellaOps.Audit.Emission.csproj b/src/__Libraries/StellaOps.Audit.Emission/StellaOps.Audit.Emission.csproj index c047b45ff..0311703d7 100644 --- a/src/__Libraries/StellaOps.Audit.Emission/StellaOps.Audit.Emission.csproj +++ b/src/__Libraries/StellaOps.Audit.Emission/StellaOps.Audit.Emission.csproj @@ -11,6 +11,10 @@ Shared audit event emission infrastructure for StellaOps services. Provides an endpoint filter and DI registration to automatically emit UnifiedAuditEvents to the Timeline service. + + + + diff --git a/src/__Libraries/__Tests/StellaOps.Audit.Emission.Tests/AuditActionFilterTests.cs b/src/__Libraries/__Tests/StellaOps.Audit.Emission.Tests/AuditActionFilterTests.cs new file mode 100644 index 000000000..b76b41719 --- /dev/null +++ b/src/__Libraries/__Tests/StellaOps.Audit.Emission.Tests/AuditActionFilterTests.cs @@ -0,0 +1,278 @@ +// Copyright (c) StellaOps. Licensed under the BUSL-1.1. + +using System.Net; +using System.Security.Claims; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using StellaOps.TestKit; +using Xunit; + +namespace StellaOps.Audit.Emission.Tests; + +/// +/// Tests for : attribute detection, actor resolution, +/// resource inference, severity mapping, body capture, PII redaction, enricher/provider +/// delegation, and graceful error handling. +/// +public sealed class AuditActionFilterTests +{ + // ── Helpers ──────────────────────────────────────────────────────────── + + private static HttpContext CreateHttpContext( + string method = "GET", + string path = "/api/v1/environments", + int statusCode = 200, + ClaimsPrincipal? user = null, + IServiceProvider? services = null, + Dictionary? routeValues = null) + { + var context = new DefaultHttpContext(); + context.Request.Method = method; + context.Request.Path = path; + context.Response.StatusCode = statusCode; + + if (user is not null) + { + context.User = user; + } + + if (services is not null) + { + context.RequestServices = services; + } + else + { + var sc = new ServiceCollection(); + context.RequestServices = sc.BuildServiceProvider(); + } + + if (routeValues is not null) + { + foreach (var kvp in routeValues) + { + context.Request.RouteValues[kvp.Key] = kvp.Value; + } + } + + return context; + } + + private static ClaimsPrincipal CreateUser( + string? sub = null, + string? name = null, + string? email = null, + string? tenant = null) + { + var claims = new List(); + if (sub is not null) claims.Add(new Claim("sub", sub)); + if (name is not null) claims.Add(new Claim("name", name)); + if (email is not null) claims.Add(new Claim("email", email)); + if (tenant is not null) claims.Add(new Claim("stellaops:tenant", tenant)); + + var identity = new ClaimsIdentity(claims, "TestScheme"); + return new ClaimsPrincipal(identity); + } + + // ── Tests ────────────────────────────────────────────────────────────── + + [Trait("Category", TestCategories.Unit)] + [Fact] + public void BuildAuditEvent_WithAttribute_EmitsPayload() + { + var attr = new AuditActionAttribute("authority", "create_user"); + var httpContext = CreateHttpContext(method: "POST", path: "/api/v1/users", statusCode: 201); + + var payload = AuditActionFilter.BuildAuditEvent(httpContext, attr, result: null); + + payload.Should().NotBeNull(); + payload.Module.Should().Be("authority"); + payload.Action.Should().Be("create_user"); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public void BuildAuditEvent_CapturesActorFromClaims() + { + var user = CreateUser(sub: "user-123", name: "Alice Admin", email: "alice@stella.ops"); + var attr = new AuditActionAttribute("policy", "create"); + var httpContext = CreateHttpContext(user: user); + + var payload = AuditActionFilter.BuildAuditEvent(httpContext, attr, result: null); + + payload.Actor.Id.Should().Be("user-123"); + payload.Actor.Name.Should().Be("Alice Admin"); + payload.Actor.Email.Should().Be("alice@stella.ops"); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public void BuildAuditEvent_CapturesTenantFromClaims() + { + var user = CreateUser(sub: "u1", tenant: "demo-prod"); + var attr = new AuditActionAttribute("scanner", "submit"); + var httpContext = CreateHttpContext(user: user); + + var payload = AuditActionFilter.BuildAuditEvent(httpContext, attr, result: null); + + payload.TenantId.Should().Be("demo-prod"); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public void BuildAuditEvent_CapturesResourceIdFromRouteValues() + { + var attr = new AuditActionAttribute("release", "update_release"); + var resourceGuid = Guid.NewGuid().ToString(); + var httpContext = CreateHttpContext( + routeValues: new Dictionary { ["id"] = resourceGuid }); + + var payload = AuditActionFilter.BuildAuditEvent(httpContext, attr, result: null); + + payload.Resource.Id.Should().Be(resourceGuid); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public void InferResourceType_ExtractsFromPath() + { + var resourceType = AuditActionFilter.InferResourceType("/api/v1/environments/abc"); + + resourceType.Should().Be("environments"); + } + + [Trait("Category", TestCategories.Unit)] + [Theory] + [InlineData(200, "info")] + [InlineData(201, "info")] + [InlineData(204, "info")] + [InlineData(400, "warning")] + [InlineData(401, "warning")] + [InlineData(403, "warning")] + [InlineData(404, "warning")] + [InlineData(500, "error")] + [InlineData(503, "error")] + public void InferSeverity_MapsStatusCodeCorrectly(int statusCode, string expectedSeverity) + { + var severity = AuditActionFilter.InferSeverity(statusCode); + + severity.Should().Be(expectedSeverity); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public void AuditActionAttribute_ShouldCaptureBody_TrueForPost() + { + var attr = new AuditActionAttribute("authority", "create"); + + attr.ShouldCaptureBody("POST").Should().BeTrue(); + attr.ShouldCaptureBody("PUT").Should().BeTrue(); + attr.ShouldCaptureBody("PATCH").Should().BeTrue(); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public void AuditActionAttribute_ShouldCaptureBody_FalseForGet() + { + var attr = new AuditActionAttribute("authority", "list"); + + attr.ShouldCaptureBody("GET").Should().BeFalse(); + attr.ShouldCaptureBody("DELETE").Should().BeFalse(); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public void AuditActionAttribute_ExplicitCaptureBody_OverridesDefault() + { + var attr = new AuditActionAttribute("authority", "delete") { CaptureBody = true }; + + attr.ShouldCaptureBody("DELETE").Should().BeTrue(); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public void AuditActionAttribute_ExplicitNoCaptureBody_OverridesDefault() + { + var attr = new AuditActionAttribute("authority", "create") { CaptureBody = false }; + + attr.ShouldCaptureBody("POST").Should().BeFalse(); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public async Task BuildAuditEventAsync_CallsResourceEnricher_WhenRegistered() + { + var enricher = Substitute.For(); + enricher.Module.Returns("authority"); + enricher.EnrichAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new AuditResourcePayload { Type = "user", Id = "user-guid", Name = "Alice (alice@test.com)" }); + + var services = new ServiceCollection(); + services.AddSingleton>(new[] { enricher }); + var sp = services.BuildServiceProvider(); + + var user = CreateUser(sub: "u1"); + var attr = new AuditActionAttribute("authority", "update_user"); + var httpContext = CreateHttpContext( + user: user, + services: sp, + routeValues: new Dictionary { ["id"] = "user-guid" }); + + var payload = await AuditActionFilter.BuildAuditEventAsync( + httpContext, attr, result: null, capturedBody: null, beforeState: null, + new AuditEmissionOptions(), NullLogger.Instance); + + payload.Resource.Name.Should().Be("Alice (alice@test.com)"); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public async Task BuildAuditEventAsync_GracefulFallback_WhenEnricherThrows() + { + var enricher = Substitute.For(); + enricher.Module.Returns("authority"); + enricher.EnrichAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(_ => throw new InvalidOperationException("DB down")); + + var services = new ServiceCollection(); + services.AddSingleton>(new[] { enricher }); + var sp = services.BuildServiceProvider(); + + var user = CreateUser(sub: "u1"); + var attr = new AuditActionAttribute("authority", "update_user"); + var httpContext = CreateHttpContext( + user: user, + services: sp, + routeValues: new Dictionary { ["id"] = "user-guid" }); + + var payload = await AuditActionFilter.BuildAuditEventAsync( + httpContext, attr, result: null, capturedBody: null, beforeState: null, + new AuditEmissionOptions(), NullLogger.Instance); + + // Should not throw; falls back to raw ID + payload.Resource.Id.Should().Be("user-guid"); + payload.Resource.Name.Should().BeNull(); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public void ExtractResponseResourceId_ExtractsIdFromAnonymousObject() + { + var result = new { id = "new-resource-123" }; + + var resourceId = AuditActionFilter.ExtractResponseResourceId(result); + + resourceId.Should().Be("new-resource-123"); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public void ExtractResponseResourceId_ReturnsNull_ForNullResult() + { + var resourceId = AuditActionFilter.ExtractResponseResourceId(null); + + resourceId.Should().BeNull(); + } +} diff --git a/src/__Libraries/__Tests/StellaOps.Audit.Emission.Tests/AuditModulesAndActionsTests.cs b/src/__Libraries/__Tests/StellaOps.Audit.Emission.Tests/AuditModulesAndActionsTests.cs new file mode 100644 index 000000000..820633b56 --- /dev/null +++ b/src/__Libraries/__Tests/StellaOps.Audit.Emission.Tests/AuditModulesAndActionsTests.cs @@ -0,0 +1,84 @@ +// Copyright (c) StellaOps. Licensed under the BUSL-1.1. + +using System.Reflection; +using FluentAssertions; +using StellaOps.TestKit; +using Xunit; + +namespace StellaOps.Audit.Emission.Tests; + +/// +/// Tests for and : +/// validates naming conventions and uniqueness constraints. +/// +public sealed class AuditModulesAndActionsTests +{ + [Trait("Category", TestCategories.Unit)] + [Fact] + public void AuditModules_AllConstants_AreLowercase() + { + var fields = typeof(AuditModules) + .GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy) + .Where(f => f.IsLiteral && !f.IsInitOnly && f.FieldType == typeof(string)); + + fields.Should().NotBeEmpty("AuditModules should have at least one constant"); + + foreach (var field in fields) + { + var value = (string)field.GetRawConstantValue()!; + value.Should().Be( + value.ToLowerInvariant(), + $"AuditModules.{field.Name} must be lowercase but was '{value}'"); + } + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public void AuditActions_NestedClassNames_MatchModuleNames() + { + // Each nested class in AuditActions should correspond to a known module name + var nestedClasses = typeof(AuditActions) + .GetNestedTypes(BindingFlags.Public | BindingFlags.Static); + + nestedClasses.Should().NotBeEmpty("AuditActions should have nested classes for modules"); + + var moduleFields = typeof(AuditModules) + .GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy) + .Where(f => f.IsLiteral && !f.IsInitOnly && f.FieldType == typeof(string)) + .Select(f => f.Name) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var nestedClass in nestedClasses) + { + moduleFields.Should().Contain( + nestedClass.Name, + $"AuditActions.{nestedClass.Name} should have a corresponding AuditModules constant"); + } + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public void AuditActions_NoDuplicateValues_WithinAnyModule() + { + var nestedClasses = typeof(AuditActions) + .GetNestedTypes(BindingFlags.Public | BindingFlags.Static); + + foreach (var nestedClass in nestedClasses) + { + var fields = nestedClass + .GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy) + .Where(f => f.IsLiteral && !f.IsInitOnly && f.FieldType == typeof(string)) + .ToList(); + + var values = fields.Select(f => (string)f.GetRawConstantValue()!).ToList(); + var duplicates = values + .GroupBy(v => v, StringComparer.OrdinalIgnoreCase) + .Where(g => g.Count() > 1) + .Select(g => g.Key) + .ToList(); + + duplicates.Should().BeEmpty( + $"AuditActions.{nestedClass.Name} has duplicate action values: [{string.Join(", ", duplicates)}]"); + } + } +} diff --git a/src/__Libraries/__Tests/StellaOps.Audit.Emission.Tests/AuditPiiRedactorTests.cs b/src/__Libraries/__Tests/StellaOps.Audit.Emission.Tests/AuditPiiRedactorTests.cs new file mode 100644 index 000000000..6225f6b35 --- /dev/null +++ b/src/__Libraries/__Tests/StellaOps.Audit.Emission.Tests/AuditPiiRedactorTests.cs @@ -0,0 +1,181 @@ +// Copyright (c) StellaOps. Licensed under the BUSL-1.1. + +using System.Text.Json; +using System.Text.Json.Nodes; +using FluentAssertions; +using StellaOps.TestKit; +using Xunit; + +namespace StellaOps.Audit.Emission.Tests; + +/// +/// Tests for : recursive JSON redaction, case-insensitivity, +/// separator normalization, custom patterns, edge cases, and truncation behavior. +/// +public sealed class AuditPiiRedactorTests +{ + [Trait("Category", TestCategories.Unit)] + [Fact] + public void Redact_FlatJson_RedactsPasswordField() + { + var node = JsonNode.Parse("""{"username":"alice","password":"s3cret"}"""); + + var result = AuditPiiRedactor.Redact(node); + + result.Should().NotBeNull(); + result!["username"]!.GetValue().Should().Be("alice"); + result["password"]!.GetValue().Should().Be("[REDACTED]"); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public void Redact_NestedJson_RedactsConnectionString() + { + var node = JsonNode.Parse(""" + { + "config": { + "connectionString": "Host=db;Password=secret", + "timeout": 30 + } + } + """); + + var result = AuditPiiRedactor.Redact(node); + + result.Should().NotBeNull(); + result!["config"]!["connectionString"]!.GetValue().Should().Be("[REDACTED]"); + result["config"]!["timeout"]!.GetValue().Should().Be(30); + } + + [Trait("Category", TestCategories.Unit)] + [Theory] + [InlineData("PASSWORD")] + [InlineData("Password")] + [InlineData("pAsSwOrD")] + public void Redact_CaseInsensitive_RedactsRegardlessOfCasing(string fieldName) + { + var obj = new JsonObject { [fieldName] = "secret123" }; + + var result = AuditPiiRedactor.Redact(obj); + + result.Should().NotBeNull(); + result![fieldName]!.GetValue().Should().Be("[REDACTED]"); + } + + [Trait("Category", TestCategories.Unit)] + [Theory] + [InlineData("api_key")] + [InlineData("api-key")] + [InlineData("apiKey")] + [InlineData("ApiKey")] + public void Redact_SeparatorsNormalized_RedactsWithUnderscoresAndHyphens(string fieldName) + { + var obj = new JsonObject { [fieldName] = "key-value-123" }; + + var result = AuditPiiRedactor.Redact(obj); + + result.Should().NotBeNull(); + result![fieldName]!.GetValue().Should().Be("[REDACTED]"); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public void Redact_PreservesNonSensitiveFields() + { + var node = JsonNode.Parse("""{"name":"alice","email":"alice@example.com","age":30}"""); + + var result = AuditPiiRedactor.Redact(node); + + result.Should().NotBeNull(); + result!["name"]!.GetValue().Should().Be("alice"); + result["email"]!.GetValue().Should().Be("alice@example.com"); + result["age"]!.GetValue().Should().Be(30); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public void Redact_NullInput_ReturnsNull() + { + var result = AuditPiiRedactor.Redact((JsonNode?)null); + + result.Should().BeNull(); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public void Redact_ArrayWithSensitiveFields_RedactsInsideArrayElements() + { + var node = JsonNode.Parse(""" + { + "users": [ + {"name":"alice","password":"pw1"}, + {"name":"bob","token":"tok2"} + ] + } + """); + + var result = AuditPiiRedactor.Redact(node); + + result.Should().NotBeNull(); + var users = result!["users"]!.AsArray(); + users[0]!["name"]!.GetValue().Should().Be("alice"); + users[0]!["password"]!.GetValue().Should().Be("[REDACTED]"); + users[1]!["name"]!.GetValue().Should().Be("bob"); + users[1]!["token"]!.GetValue().Should().Be("[REDACTED]"); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public void Redact_DeeplyNestedObjects_RedactsAtAllLevels() + { + var node = JsonNode.Parse(""" + { + "level1": { + "level2": { + "level3": { + "secret": "deep-secret", + "label": "safe-value" + } + } + } + } + """); + + var result = AuditPiiRedactor.Redact(node); + + result.Should().NotBeNull(); + result!["level1"]!["level2"]!["level3"]!["secret"]!.GetValue().Should().Be("[REDACTED]"); + result["level1"]!["level2"]!["level3"]!["label"]!.GetValue().Should().Be("safe-value"); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public void Redact_CustomConfiguredPatterns_ReplacesDefaultPatterns() + { + var node = JsonNode.Parse("""{"customField":"sensitive","password":"open"}"""); + + // When configuredPatterns is provided, it replaces defaults entirely + var configuredPatterns = new List { "customfield" }; + var result = AuditPiiRedactor.Redact(node, configuredPatterns: configuredPatterns); + + result.Should().NotBeNull(); + result!["customField"]!.GetValue().Should().Be("[REDACTED]"); + // "password" is NOT redacted because configuredPatterns replaced defaults + result["password"]!.GetValue().Should().Be("open"); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public void Redact_AdditionalPatterns_ExtendsDefaultRedaction() + { + var node = JsonNode.Parse("""{"ssn":"123-45-6789","password":"pw","name":"alice"}"""); + + var additionalPatterns = new List { "ssn" }; + var result = AuditPiiRedactor.Redact(node, additionalPatterns: additionalPatterns); + + result.Should().NotBeNull(); + result!["ssn"]!.GetValue().Should().Be("[REDACTED]"); + result["password"]!.GetValue().Should().Be("[REDACTED]"); + result["name"]!.GetValue().Should().Be("alice"); + } +} diff --git a/src/__Libraries/__Tests/StellaOps.Audit.Emission.Tests/StellaOps.Audit.Emission.Tests.csproj b/src/__Libraries/__Tests/StellaOps.Audit.Emission.Tests/StellaOps.Audit.Emission.Tests.csproj new file mode 100644 index 000000000..614adf9fa --- /dev/null +++ b/src/__Libraries/__Tests/StellaOps.Audit.Emission.Tests/StellaOps.Audit.Emission.Tests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + preview + enable + enable + false + true + false + StellaOps.Audit.Emission.Tests + + + + + + + + + + + + + +