using EvidenceLockerProgram = StellaOps.EvidenceLocker.WebService.Program; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Npgsql; using StellaOps.Auth.Abstractions; using StellaOps.Auth.ServerIntegration; using StellaOps.EvidenceLocker.Core.Builders; using StellaOps.EvidenceLocker.Core.Configuration; using StellaOps.EvidenceLocker.Core.Domain; using StellaOps.EvidenceLocker.Core.Incident; using StellaOps.EvidenceLocker.Core.Repositories; using StellaOps.EvidenceLocker.Core.Signing; using StellaOps.EvidenceLocker.Core.Storage; using StellaOps.EvidenceLocker.Core.Timeline; using System.Collections.Generic; using System.IO; using System.Net.Http.Headers; using System.Reflection; using System.Runtime.Serialization; using System.Security.Claims; using System.Security.Cryptography; using System.Text.Encodings.Web; namespace StellaOps.EvidenceLocker.Tests; public sealed class EvidenceLockerWebApplicationFactory : WebApplicationFactory { private readonly string _contentRoot; public EvidenceLockerWebApplicationFactory() { _contentRoot = Path.Combine(Path.GetTempPath(), "evidence-locker-tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_contentRoot); File.WriteAllText(Path.Combine(_contentRoot, "appsettings.json"), "{}"); } public TestEvidenceBundleRepository Repository => Services.GetRequiredService(); public TestEvidenceGateArtifactRepository GateArtifactRepository => Services.GetRequiredService(); public TestEvidenceObjectStore ObjectStore => Services.GetRequiredService(); public TestTimelinePublisher TimelinePublisher => Services.GetRequiredService(); /// /// Resets all singleton test doubles to prevent accumulated state from /// leaking memory across test classes sharing this factory instance. /// Call from each test class constructor. /// public void ResetTestState() { Repository.Reset(); GateArtifactRepository.Reset(); ObjectStore.Reset(); TimelinePublisher.Reset(); } private static SigningKeyMaterialOptions GenerateKeyMaterial() { using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256); return new SigningKeyMaterialOptions { EcPrivateKeyPem = ecdsa.ExportECPrivateKeyPem(), EcPublicKeyPem = ecdsa.ExportSubjectPublicKeyInfoPem() }; } protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.UseSetting(WebHostDefaults.ContentRootKey, _contentRoot); builder.ConfigureAppConfiguration((context, configurationBuilder) => { configurationBuilder.Sources.Clear(); var keyMaterial = GenerateKeyMaterial(); configurationBuilder.AddInMemoryCollection(new Dictionary { ["EvidenceLocker:Database:ConnectionString"] = "Host=localhost", ["EvidenceLocker:Database:ApplyMigrationsAtStartup"] = "false", ["EvidenceLocker:ObjectStore:Kind"] = "FileSystem", ["EvidenceLocker:ObjectStore:FileSystem:RootPath"] = ".", ["EvidenceLocker:Quotas:MaxMaterialCount"] = "4", ["EvidenceLocker:Quotas:MaxTotalMaterialSizeBytes"] = "1024", ["EvidenceLocker:Quotas:MaxMetadataEntries"] = "4", ["EvidenceLocker:Quotas:MaxMetadataKeyLength"] = "32", ["EvidenceLocker:Quotas:MaxMetadataValueLength"] = "64", ["EvidenceLocker:Signing:Enabled"] = "true", ["EvidenceLocker:Signing:Algorithm"] = "ES256", ["EvidenceLocker:Signing:KeyId"] = "test-key", ["EvidenceLocker:Signing:PayloadType"] = "application/vnd.stella.test-manifest+json", ["EvidenceLocker:Signing:KeyMaterial:EcPrivateKeyPem"] = keyMaterial.EcPrivateKeyPem, ["EvidenceLocker:Signing:KeyMaterial:EcPublicKeyPem"] = keyMaterial.EcPublicKeyPem, ["EvidenceLocker:Signing:Timestamping:Enabled"] = "true", ["EvidenceLocker:Signing:Timestamping:Endpoint"] = "https://tsa.example", ["EvidenceLocker:Signing:Timestamping:HashAlgorithm"] = "SHA256", ["EvidenceLocker:Incident:Enabled"] = "false", ["EvidenceLocker:Incident:RetentionExtensionDays"] = "30", ["EvidenceLocker:Incident:CaptureRequestSnapshot"] = "true", ["Authority:ResourceServer:Authority"] = "https://authority.localtest.me", ["Authority:ResourceServer:Audiences:0"] = "api://evidence-locker", ["Authority:ResourceServer:RequiredTenants:0"] = "tenant-default" }); }); builder.ConfigureTestServices(services => { services.RemoveAll(); services.RemoveAll(); services.RemoveAll(); services.RemoveAll(); services.RemoveAll(); services.RemoveAll>(); services.RemoveAll>(); services.RemoveAll>(); services.RemoveAll>(); services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); services.AddAuthentication(options => { options.DefaultAuthenticateScheme = EvidenceLockerTestAuthHandler.SchemeName; options.DefaultChallengeScheme = EvidenceLockerTestAuthHandler.SchemeName; }) .AddScheme(EvidenceLockerTestAuthHandler.SchemeName, _ => { }) .AddScheme(StellaOpsAuthenticationDefaults.AuthenticationScheme, _ => { }); services.PostConfigure(options => { var allowAllPolicy = new AuthorizationPolicyBuilder() .AddAuthenticationSchemes(EvidenceLockerTestAuthHandler.SchemeName) .RequireAssertion(_ => true) .Build(); options.DefaultPolicy = allowAllPolicy; options.FallbackPolicy = allowAllPolicy; options.AddPolicy(StellaOpsResourceServerPolicies.EvidenceCreate, allowAllPolicy); options.AddPolicy(StellaOpsResourceServerPolicies.EvidenceRead, allowAllPolicy); options.AddPolicy(StellaOpsResourceServerPolicies.EvidenceHold, allowAllPolicy); options.AddPolicy(StellaOpsResourceServerPolicies.ExportViewer, allowAllPolicy); options.AddPolicy(StellaOpsResourceServerPolicies.ExportOperator, allowAllPolicy); options.AddPolicy(StellaOpsResourceServerPolicies.ExportAdmin, allowAllPolicy); }); }); } protected override void Dispose(bool disposing) { base.Dispose(disposing); if (disposing && Directory.Exists(_contentRoot)) { Directory.Delete(_contentRoot, recursive: true); } } } public sealed class TestTimestampAuthorityClient : ITimestampAuthorityClient { public Task RequestTimestampAsync(ReadOnlyMemory signature, string hashAlgorithm, CancellationToken cancellationToken) { var token = signature.ToArray(); var result = new TimestampResult(DateTimeOffset.UtcNow, "test-tsa", token); return Task.FromResult(result); } } public sealed class TestTimelinePublisher : IEvidenceTimelinePublisher { public List PublishedEvents { get; } = new(); public List IncidentEvents { get; } = new(); public void Reset() { PublishedEvents.Clear(); IncidentEvents.Clear(); } public Task PublishBundleSealedAsync( EvidenceBundleSignature signature, EvidenceBundleManifest manifest, string rootHash, CancellationToken cancellationToken) { PublishedEvents.Add($"bundle:{signature.BundleId.Value:D}:{rootHash}"); return Task.CompletedTask; } public Task PublishHoldCreatedAsync(EvidenceHold hold, CancellationToken cancellationToken) { PublishedEvents.Add($"hold:{hold.CaseId}"); return Task.CompletedTask; } public Task PublishIncidentModeChangedAsync(IncidentModeChange change, CancellationToken cancellationToken) { IncidentEvents.Add(change.IsActive ? "enabled" : "disabled"); return Task.CompletedTask; } } public sealed class TestEvidenceObjectStore : IEvidenceObjectStore { private readonly Dictionary _objects = new(StringComparer.Ordinal); private readonly HashSet _preExisting = new(StringComparer.Ordinal); public IReadOnlyDictionary StoredObjects => _objects; public void SeedExisting(string storageKey) => _preExisting.Add(storageKey); public void Reset() { _objects.Clear(); _preExisting.Clear(); } public Task StoreAsync(Stream content, EvidenceObjectWriteOptions options, CancellationToken cancellationToken) { using var memory = new MemoryStream(); content.CopyTo(memory); var bytes = memory.ToArray(); var storageKey = $"tenants/{options.TenantId.Value:N}/bundles/{options.BundleId.Value:N}/{options.ArtifactName}"; _objects[storageKey] = bytes; _preExisting.Add(storageKey); return Task.FromResult(new EvidenceObjectMetadata( storageKey, options.ContentType, bytes.Length, Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(), null, DateTimeOffset.UtcNow)); } public Task OpenReadAsync(string storageKey, CancellationToken cancellationToken) { if (!_objects.TryGetValue(storageKey, out var bytes)) { throw new FileNotFoundException(storageKey); } return Task.FromResult(new MemoryStream(bytes, writable: false)); } public Task ExistsAsync(string storageKey, CancellationToken cancellationToken) => Task.FromResult(_preExisting.Contains(storageKey)); } public sealed class TestEvidenceBundleRepository : IEvidenceBundleRepository { private readonly List _signatures = new(); private readonly Dictionary<(Guid BundleId, Guid TenantId), EvidenceBundle> _bundles = new(); public bool HoldConflict { get; set; } public void Reset() { _signatures.Clear(); _bundles.Clear(); HoldConflict = false; } public Task CreateBundleAsync(EvidenceBundle bundle, CancellationToken cancellationToken) { _bundles[(bundle.Id.Value, bundle.TenantId.Value)] = bundle; return Task.CompletedTask; } public Task SetBundleAssemblyAsync(EvidenceBundleId bundleId, TenantId tenantId, EvidenceBundleStatus status, string rootHash, DateTimeOffset updatedAt, CancellationToken cancellationToken) { UpdateBundle(bundleId, tenantId, bundle => bundle with { Status = status, RootHash = rootHash, UpdatedAt = updatedAt }); return Task.CompletedTask; } public Task MarkBundleSealedAsync(EvidenceBundleId bundleId, TenantId tenantId, EvidenceBundleStatus status, DateTimeOffset sealedAt, CancellationToken cancellationToken) { UpdateBundle(bundleId, tenantId, bundle => bundle with { Status = status, SealedAt = sealedAt, UpdatedAt = sealedAt }); return Task.CompletedTask; } public Task UpsertSignatureAsync(EvidenceBundleSignature signature, CancellationToken cancellationToken) { _signatures.RemoveAll(sig => sig.BundleId == signature.BundleId && sig.TenantId == signature.TenantId); _signatures.Add(signature); return Task.CompletedTask; } public Task GetBundleAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken) { _bundles.TryGetValue((bundleId.Value, tenantId.Value), out var bundle); var signature = _signatures.FirstOrDefault(sig => sig.BundleId == bundleId && sig.TenantId == tenantId); return Task.FromResult(bundle is null ? null : new EvidenceBundleDetails(bundle, signature)); } public Task> GetBundlesForReindexAsync( TenantId tenantId, DateTimeOffset? since, DateTimeOffset? cursorUpdatedAt, EvidenceBundleId? cursorBundleId, int limit, CancellationToken cancellationToken) { var filtered = _bundles.Values .Where(bundle => bundle.TenantId == tenantId) .Where(bundle => !since.HasValue || bundle.UpdatedAt >= since.Value) .OrderBy(bundle => bundle.UpdatedAt) .ThenBy(bundle => bundle.Id.Value); IEnumerable paged = filtered; if (cursorUpdatedAt.HasValue && cursorBundleId.HasValue) { paged = filtered.SkipWhile(b => b.UpdatedAt < cursorUpdatedAt.Value || (b.UpdatedAt == cursorUpdatedAt.Value && b.Id.Value <= cursorBundleId.Value.Value)); } var results = paged .Take(limit) .Select(bundle => { var signature = _signatures.FirstOrDefault(sig => sig.BundleId == bundle.Id && sig.TenantId == tenantId); return new EvidenceBundleDetails(bundle, signature); }) .ToList(); return Task.FromResult>(results); } public Task ExistsAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken) => Task.FromResult(_bundles.ContainsKey((bundleId.Value, tenantId.Value))); public Task CreateHoldAsync(EvidenceHold hold, CancellationToken cancellationToken) { if (HoldConflict) { throw CreateUniqueViolationException(); } return Task.FromResult(hold); } public Task ExtendBundleRetentionAsync(EvidenceBundleId bundleId, TenantId tenantId, DateTimeOffset? holdExpiresAt, DateTimeOffset processedAt, CancellationToken cancellationToken) { UpdateBundle(bundleId, tenantId, bundle => bundle with { ExpiresAt = holdExpiresAt, UpdatedAt = processedAt > bundle.UpdatedAt ? processedAt : bundle.UpdatedAt }); return Task.CompletedTask; } public Task UpdateStorageKeyAsync(EvidenceBundleId bundleId, TenantId tenantId, string storageKey, CancellationToken cancellationToken) { UpdateBundle(bundleId, tenantId, bundle => bundle with { StorageKey = storageKey, UpdatedAt = DateTimeOffset.UtcNow }); return Task.CompletedTask; } public Task UpdatePortableStorageKeyAsync( EvidenceBundleId bundleId, TenantId tenantId, string storageKey, DateTimeOffset generatedAt, CancellationToken cancellationToken) { UpdateBundle(bundleId, tenantId, bundle => bundle with { PortableStorageKey = storageKey, PortableGeneratedAt = generatedAt, UpdatedAt = generatedAt > bundle.UpdatedAt ? generatedAt : bundle.UpdatedAt }); return Task.CompletedTask; } private void UpdateBundle(EvidenceBundleId bundleId, TenantId tenantId, Func updater) { var key = (bundleId.Value, tenantId.Value); if (_bundles.TryGetValue(key, out var existing)) { _bundles[key] = updater(existing); } } #pragma warning disable SYSLIB0050 private static PostgresException CreateUniqueViolationException() { var exception = (PostgresException)FormatterServices.GetUninitializedObject(typeof(PostgresException)); SetStringField(exception, "k__BackingField", PostgresErrorCodes.UniqueViolation); SetStringField(exception, "_sqlState", PostgresErrorCodes.UniqueViolation); return exception; } #pragma warning restore SYSLIB0050 private static void SetStringField(object target, string fieldName, string value) { var field = target.GetType().GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic); field?.SetValue(target, value); } } public sealed class TestEvidenceGateArtifactRepository : IEvidenceGateArtifactRepository { private readonly Dictionary<(Guid TenantId, string ArtifactId), EvidenceGateArtifactRecord> _records = new(); public void Reset() => _records.Clear(); public Task UpsertAsync(EvidenceGateArtifactRecord record, CancellationToken cancellationToken) { var stored = record with { UpdatedAt = DateTimeOffset.UtcNow }; _records[(record.TenantId.Value, record.ArtifactId)] = stored; return Task.FromResult(stored); } public Task GetByArtifactIdAsync(TenantId tenantId, string artifactId, CancellationToken cancellationToken) { _records.TryGetValue((tenantId.Value, artifactId), out var record); return Task.FromResult(record); } } public sealed class EvidenceLockerTestAuthHandler : AuthenticationHandler { internal const string SchemeName = "EvidenceLockerTest"; public EvidenceLockerTestAuthHandler( IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder) : base(options, logger, encoder) { } protected override Task HandleAuthenticateAsync() { if (!Request.Headers.TryGetValue("Authorization", out var rawHeader) || !AuthenticationHeaderValue.TryParse(rawHeader, out var header)) { return Task.FromResult(AuthenticateResult.NoResult()); } // Accept both "EvidenceLockerTest" and "Bearer" schemes for test flexibility if (!string.Equals(header.Scheme, SchemeName, StringComparison.OrdinalIgnoreCase) && !string.Equals(header.Scheme, "Bearer", StringComparison.OrdinalIgnoreCase)) { return Task.FromResult(AuthenticateResult.NoResult()); } var claims = new List(); var subject = Request.Headers.TryGetValue("X-Test-Subject", out var subjectValue) ? subjectValue.ToString() : "subject-test"; claims.Add(new Claim(StellaOpsClaimTypes.Subject, subject)); if (Request.Headers.TryGetValue("X-Test-Client", out var clientValue) && !string.IsNullOrWhiteSpace(clientValue)) { claims.Add(new Claim(StellaOpsClaimTypes.ClientId, clientValue.ToString()!)); } // Support both X-Test-Tenant and X-Tenant-Id headers if (Request.Headers.TryGetValue("X-Test-Tenant", out var tenantValue) && Guid.TryParse(tenantValue, out var tenantId)) { claims.Add(new Claim(StellaOpsClaimTypes.Tenant, tenantId.ToString("D"))); } else if (Request.Headers.TryGetValue("X-Tenant-Id", out var tenantIdValue) && Guid.TryParse(tenantIdValue, out var tenantIdFromHeader)) { claims.Add(new Claim(StellaOpsClaimTypes.Tenant, tenantIdFromHeader.ToString("D"))); } // Support both X-Test-Scopes and X-Scopes headers if (Request.Headers.TryGetValue("X-Test-Scopes", out var scopesValue)) { var scopes = scopesValue .ToString() .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); foreach (var scope in scopes) { claims.Add(new Claim(StellaOpsClaimTypes.Scope, scope)); } } else if (Request.Headers.TryGetValue("X-Scopes", out var xScopesValue)) { var scopes = xScopesValue .ToString() .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); foreach (var scope in scopes) { claims.Add(new Claim(StellaOpsClaimTypes.Scope, scope)); } } var identity = new ClaimsIdentity(claims, Scheme.Name); var principal = new ClaimsPrincipal(identity); var ticket = new AuthenticationTicket(principal, Scheme.Name); return Task.FromResult(AuthenticateResult.Success(ticket)); } }