Files
git.stella-ops.org/src/EvidenceLocker/StellaOps.EvidenceLocker/StellaOps.EvidenceLocker.Tests/EvidenceLockerWebApplicationFactory.cs

528 lines
22 KiB
C#

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<EvidenceLockerProgram>
{
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<TestEvidenceBundleRepository>();
public TestEvidenceGateArtifactRepository GateArtifactRepository => Services.GetRequiredService<TestEvidenceGateArtifactRepository>();
public TestEvidenceObjectStore ObjectStore => Services.GetRequiredService<TestEvidenceObjectStore>();
public TestTimelinePublisher TimelinePublisher => Services.GetRequiredService<TestTimelinePublisher>();
/// <summary>
/// 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.
/// </summary>
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<string, string?>
{
["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<IEvidenceBundleRepository>();
services.RemoveAll<IEvidenceGateArtifactRepository>();
services.RemoveAll<IEvidenceTimelinePublisher>();
services.RemoveAll<ITimestampAuthorityClient>();
services.RemoveAll<IEvidenceObjectStore>();
services.RemoveAll<IConfigureOptions<AuthenticationOptions>>();
services.RemoveAll<IPostConfigureOptions<AuthenticationOptions>>();
services.RemoveAll<IConfigureOptions<JwtBearerOptions>>();
services.RemoveAll<IPostConfigureOptions<JwtBearerOptions>>();
services.AddSingleton<TestEvidenceBundleRepository>();
services.AddSingleton<IEvidenceBundleRepository>(sp => sp.GetRequiredService<TestEvidenceBundleRepository>());
services.AddSingleton<TestEvidenceGateArtifactRepository>();
services.AddSingleton<IEvidenceGateArtifactRepository>(sp => sp.GetRequiredService<TestEvidenceGateArtifactRepository>());
services.AddSingleton<TestTimelinePublisher>();
services.AddSingleton<IEvidenceTimelinePublisher>(sp => sp.GetRequiredService<TestTimelinePublisher>());
services.AddSingleton<ITimestampAuthorityClient, TestTimestampAuthorityClient>();
services.AddSingleton<TestEvidenceObjectStore>();
services.AddSingleton<IEvidenceObjectStore>(sp => sp.GetRequiredService<TestEvidenceObjectStore>());
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = EvidenceLockerTestAuthHandler.SchemeName;
options.DefaultChallengeScheme = EvidenceLockerTestAuthHandler.SchemeName;
})
.AddScheme<AuthenticationSchemeOptions, EvidenceLockerTestAuthHandler>(EvidenceLockerTestAuthHandler.SchemeName, _ => { })
.AddScheme<AuthenticationSchemeOptions, EvidenceLockerTestAuthHandler>(StellaOpsAuthenticationDefaults.AuthenticationScheme, _ => { });
services.PostConfigure<AuthorizationOptions>(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<TimestampResult?> RequestTimestampAsync(ReadOnlyMemory<byte> signature, string hashAlgorithm, CancellationToken cancellationToken)
{
var token = signature.ToArray();
var result = new TimestampResult(DateTimeOffset.UtcNow, "test-tsa", token);
return Task.FromResult<TimestampResult?>(result);
}
}
public sealed class TestTimelinePublisher : IEvidenceTimelinePublisher
{
public List<string> PublishedEvents { get; } = new();
public List<string> 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<string, byte[]> _objects = new(StringComparer.Ordinal);
private readonly HashSet<string> _preExisting = new(StringComparer.Ordinal);
public IReadOnlyDictionary<string, byte[]> StoredObjects => _objects;
public void SeedExisting(string storageKey) => _preExisting.Add(storageKey);
public void Reset()
{
_objects.Clear();
_preExisting.Clear();
}
public Task<EvidenceObjectMetadata> 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<Stream> OpenReadAsync(string storageKey, CancellationToken cancellationToken)
{
if (!_objects.TryGetValue(storageKey, out var bytes))
{
throw new FileNotFoundException(storageKey);
}
return Task.FromResult<Stream>(new MemoryStream(bytes, writable: false));
}
public Task<bool> ExistsAsync(string storageKey, CancellationToken cancellationToken)
=> Task.FromResult(_preExisting.Contains(storageKey));
}
public sealed class TestEvidenceBundleRepository : IEvidenceBundleRepository
{
private readonly List<EvidenceBundleSignature> _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<EvidenceBundleDetails?> 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<EvidenceBundleDetails?>(bundle is null ? null : new EvidenceBundleDetails(bundle, signature));
}
public Task<IReadOnlyList<EvidenceBundleDetails>> 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<EvidenceBundle> 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<IReadOnlyList<EvidenceBundleDetails>>(results);
}
public Task<bool> ExistsAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken)
=> Task.FromResult(_bundles.ContainsKey((bundleId.Value, tenantId.Value)));
public Task<EvidenceHold> 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<EvidenceBundle, EvidenceBundle> 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, "<SqlState>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<EvidenceGateArtifactRecord> 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<EvidenceGateArtifactRecord?> GetByArtifactIdAsync(TenantId tenantId, string artifactId, CancellationToken cancellationToken)
{
_records.TryGetValue((tenantId.Value, artifactId), out var record);
return Task.FromResult(record);
}
}
public sealed class EvidenceLockerTestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
internal const string SchemeName = "EvidenceLockerTest";
public EvidenceLockerTestAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder)
{
}
protected override Task<AuthenticateResult> 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<Claim>();
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));
}
}