Add unit tests for SBOM ingestion and transformation
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Implement `SbomIngestServiceCollectionExtensionsTests` to verify the SBOM ingestion pipeline exports snapshots correctly.
- Create `SbomIngestTransformerTests` to ensure the transformation produces expected nodes and edges, including deduplication of license nodes and normalization of timestamps.
- Add `SbomSnapshotExporterTests` to test the export functionality for manifest, adjacency, nodes, and edges.
- Introduce `VexOverlayTransformerTests` to validate the transformation of VEX nodes and edges.
- Set up project file for the test project with necessary dependencies and configurations.
- Include JSON fixture files for testing purposes.
This commit is contained in:
master
2025-11-04 07:49:39 +02:00
parent f72c5c513a
commit 2eb6852d34
491 changed files with 39445 additions and 3917 deletions

View File

@@ -0,0 +1,156 @@
using System;
using System.Net.Http;
using Docker.DotNet;
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Configurations;
using DotNet.Testcontainers.Containers;
using Microsoft.Extensions.Logging.Abstractions;
using Npgsql;
using StellaOps.EvidenceLocker.Core.Configuration;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Infrastructure.Db;
using Xunit;
namespace StellaOps.EvidenceLocker.Tests;
public sealed class DatabaseMigrationTests : IAsyncLifetime
{
private readonly PostgreSqlTestcontainer _postgres;
private EvidenceLockerDataSource? _dataSource;
private IEvidenceLockerMigrationRunner? _migrationRunner;
private string? _skipReason;
public DatabaseMigrationTests()
{
_postgres = new TestcontainersBuilder<PostgreSqlTestcontainer>()
.WithDatabase(new PostgreSqlTestcontainerConfiguration
{
Database = "evidence_locker_tests",
Username = "postgres",
Password = "postgres"
})
.WithCleanUp(true)
.Build();
}
[Fact]
public async Task ApplyAsync_CreatesExpectedSchemaAndPolicies()
{
if (_skipReason is not null)
{
Assert.Skip(_skipReason);
}
var cancellationToken = TestContext.Current.CancellationToken;
await _migrationRunner!.ApplyAsync(cancellationToken);
await using var connection = await _dataSource!.OpenConnectionAsync(cancellationToken);
await using var tablesCommand = new NpgsqlCommand(
"SELECT table_name FROM information_schema.tables WHERE table_schema = 'evidence_locker' ORDER BY table_name;",
connection);
var tables = new List<string>();
await using (var reader = await tablesCommand.ExecuteReaderAsync(cancellationToken))
{
while (await reader.ReadAsync(cancellationToken))
{
tables.Add(reader.GetString(0));
}
}
Assert.Contains("evidence_artifacts", tables);
Assert.Contains("evidence_bundles", tables);
Assert.Contains("evidence_holds", tables);
Assert.Contains("evidence_schema_version", tables);
await using var versionCommand = new NpgsqlCommand(
"SELECT COUNT(*) FROM evidence_locker.evidence_schema_version WHERE version = 1;",
connection);
var applied = Convert.ToInt64(await versionCommand.ExecuteScalarAsync(cancellationToken) ?? 0L);
Assert.Equal(1, applied);
var tenant = TenantId.FromGuid(Guid.NewGuid());
await using var tenantConnection = await _dataSource.OpenConnectionAsync(tenant, cancellationToken);
await using var insertCommand = new NpgsqlCommand(@"
INSERT INTO evidence_locker.evidence_bundles
(bundle_id, tenant_id, kind, status, root_hash, storage_key)
VALUES
(@bundle, @tenant, 1, 3, @hash, @key);",
tenantConnection);
insertCommand.Parameters.AddWithValue("bundle", Guid.NewGuid());
insertCommand.Parameters.AddWithValue("tenant", tenant.Value);
insertCommand.Parameters.AddWithValue("hash", new string('a', 64));
insertCommand.Parameters.AddWithValue("key", $"tenants/{tenant.Value:N}/bundles/test/resource");
await insertCommand.ExecuteNonQueryAsync(cancellationToken);
await using var isolationConnection = await _dataSource.OpenConnectionAsync(tenant, cancellationToken);
await using var selectCommand = new NpgsqlCommand(
"SELECT COUNT(*) FROM evidence_locker.evidence_bundles;",
isolationConnection);
var visibleCount = Convert.ToInt64(await selectCommand.ExecuteScalarAsync(cancellationToken) ?? 0L);
Assert.Equal(1, visibleCount);
await using var otherTenantConnection = await _dataSource.OpenConnectionAsync(TenantId.FromGuid(Guid.NewGuid()), cancellationToken);
await using var otherSelectCommand = new NpgsqlCommand(
"SELECT COUNT(*) FROM evidence_locker.evidence_bundles;",
otherTenantConnection);
var otherVisible = Convert.ToInt64(await otherSelectCommand.ExecuteScalarAsync(cancellationToken) ?? 0L);
Assert.Equal(0, otherVisible);
await using var violationConnection = await _dataSource.OpenConnectionAsync(tenant, cancellationToken);
await using var violationCommand = new NpgsqlCommand(@"
INSERT INTO evidence_locker.evidence_bundles
(bundle_id, tenant_id, kind, status, root_hash, storage_key)
VALUES
(@bundle, @tenant, 1, 3, @hash, @key);",
violationConnection);
violationCommand.Parameters.AddWithValue("bundle", Guid.NewGuid());
violationCommand.Parameters.AddWithValue("tenant", Guid.NewGuid());
violationCommand.Parameters.AddWithValue("hash", new string('b', 64));
violationCommand.Parameters.AddWithValue("key", "tenants/other/bundles/resource");
await Assert.ThrowsAsync<PostgresException>(() => violationCommand.ExecuteNonQueryAsync(cancellationToken));
}
public async ValueTask InitializeAsync()
{
try
{
await _postgres.StartAsync();
}
catch (HttpRequestException ex)
{
_skipReason = $"Docker endpoint unavailable: {ex.Message}";
return;
}
catch (Docker.DotNet.DockerApiException ex)
{
_skipReason = $"Docker API error: {ex.Message}";
return;
}
var databaseOptions = new DatabaseOptions
{
ConnectionString = _postgres.ConnectionString,
ApplyMigrationsAtStartup = false
};
_dataSource = new EvidenceLockerDataSource(databaseOptions, NullLogger<EvidenceLockerDataSource>.Instance);
_migrationRunner = new EvidenceLockerMigrationRunner(_dataSource, NullLogger<EvidenceLockerMigrationRunner>.Instance);
}
public async ValueTask DisposeAsync()
{
if (_skipReason is not null)
{
return;
}
if (_dataSource is not null)
{
await _dataSource.DisposeAsync();
}
await _postgres.DisposeAsync();
}
}

View File

@@ -0,0 +1,127 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.EvidenceLocker.Core.Builders;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Core.Repositories;
using StellaOps.EvidenceLocker.Infrastructure.Builders;
using Xunit;
namespace StellaOps.EvidenceLocker.Tests;
public sealed class EvidenceBundleBuilderTests
{
private readonly FakeRepository _repository = new();
private readonly IEvidenceBundleBuilder _builder;
public EvidenceBundleBuilderTests()
{
_builder = new EvidenceBundleBuilder(_repository, new MerkleTreeCalculator());
}
[Fact]
public async Task BuildAsync_ComputesDeterministicRootAndPersists()
{
var bundleId = EvidenceBundleId.FromGuid(Guid.NewGuid());
var tenantId = TenantId.FromGuid(Guid.NewGuid());
var request = new EvidenceBundleBuildRequest(
bundleId,
tenantId,
EvidenceBundleKind.Job,
DateTimeOffset.Parse("2025-11-03T15:04:05Z"),
new Dictionary<string, string> { ["run-id"] = "job-42" },
new List<EvidenceBundleMaterial>
{
new("inputs", "config/env.json", "5a6b7c", 1024, "application/json"),
new("outputs", "reports/result.txt", "7f8e9d", 2048, "text/plain")
});
var result = await _builder.BuildAsync(request, CancellationToken.None);
Assert.Equal(EvidenceBundleStatus.Sealed, _repository.LastStatus);
Assert.Equal(bundleId, _repository.LastBundleId);
Assert.Equal(tenantId, _repository.LastTenantId);
Assert.Equal(DateTimeOffset.Parse("2025-11-03T15:04:05Z"), _repository.LastUpdatedAt);
Assert.Equal(result.RootHash, _repository.LastRootHash);
Assert.Equal(2, result.Manifest.Entries.Count);
Assert.True(result.Manifest.Entries.SequenceEqual(
result.Manifest.Entries.OrderBy(entry => entry.CanonicalPath, StringComparer.Ordinal)));
}
[Fact]
public async Task BuildAsync_NormalizesSectionAndPath()
{
var request = new EvidenceBundleBuildRequest(
EvidenceBundleId.FromGuid(Guid.NewGuid()),
TenantId.FromGuid(Guid.NewGuid()),
EvidenceBundleKind.Evaluation,
DateTimeOffset.UtcNow,
new Dictionary<string, string>(),
new List<EvidenceBundleMaterial>
{
new(" Inputs ", "./Config/Env.JSON ", "abc123", 10, "application/json"),
new("OUTPUTS", "\\Logs\\app.log", "def456", 20, "text/plain")
});
var result = await _builder.BuildAsync(request, CancellationToken.None);
Assert.Collection(result.Manifest.Entries,
entry => Assert.Equal("inputs/config/env.json", entry.CanonicalPath),
entry => Assert.Equal("outputs/logs/app.log", entry.CanonicalPath));
}
private sealed class FakeRepository : IEvidenceBundleRepository
{
public EvidenceBundleId LastBundleId { get; private set; }
public TenantId LastTenantId { get; private set; }
public EvidenceBundleStatus LastStatus { get; private set; }
public string? LastRootHash { get; private set; }
public DateTimeOffset LastUpdatedAt { get; private set; }
public Task CreateBundleAsync(EvidenceBundle bundle, CancellationToken cancellationToken)
=> Task.CompletedTask;
public Task SetBundleAssemblyAsync(
EvidenceBundleId bundleId,
TenantId tenantId,
EvidenceBundleStatus status,
string rootHash,
DateTimeOffset updatedAt,
CancellationToken cancellationToken)
{
LastBundleId = bundleId;
LastTenantId = tenantId;
LastStatus = status;
LastRootHash = rootHash;
LastUpdatedAt = updatedAt;
return Task.CompletedTask;
}
public Task MarkBundleSealedAsync(EvidenceBundleId bundleId, TenantId tenantId, EvidenceBundleStatus status, DateTimeOffset sealedAt, CancellationToken cancellationToken)
=> Task.CompletedTask;
public Task UpsertSignatureAsync(EvidenceBundleSignature signature, CancellationToken cancellationToken)
=> Task.CompletedTask;
public Task<EvidenceBundleDetails?> GetBundleAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken)
=> Task.FromResult<EvidenceBundleDetails?>(null);
public Task<bool> ExistsAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken)
=> Task.FromResult(true);
public Task<EvidenceHold> CreateHoldAsync(EvidenceHold hold, CancellationToken cancellationToken)
=> Task.FromResult(hold);
public Task ExtendBundleRetentionAsync(EvidenceBundleId bundleId, TenantId tenantId, DateTimeOffset? holdExpiresAt, DateTimeOffset processedAt, CancellationToken cancellationToken)
=> Task.CompletedTask;
public Task UpdateStorageKeyAsync(EvidenceBundleId bundleId, TenantId tenantId, string storageKey, CancellationToken cancellationToken)
=> Task.CompletedTask;
public Task UpdatePortableStorageKeyAsync(EvidenceBundleId bundleId, TenantId tenantId, string storageKey, DateTimeOffset generatedAt, CancellationToken cancellationToken)
=> Task.CompletedTask;
}
}

View File

@@ -0,0 +1,316 @@
using System.Buffers.Binary;
using System.Formats.Tar;
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Core.Repositories;
using StellaOps.EvidenceLocker.Core.Storage;
using StellaOps.EvidenceLocker.Infrastructure.Services;
namespace StellaOps.EvidenceLocker.Tests;
public sealed class EvidenceBundlePackagingServiceTests
{
private static readonly TenantId TenantId = TenantId.FromGuid(Guid.NewGuid());
private static readonly EvidenceBundleId BundleId = EvidenceBundleId.FromGuid(Guid.NewGuid());
private static readonly DateTimeOffset CreatedAt = new(2025, 11, 3, 12, 30, 0, TimeSpan.Zero);
[Fact]
public async Task EnsurePackageAsync_ReturnsCached_WhenPackageExists()
{
var repository = new FakeRepository(CreateSealedBundle(), CreateSignature());
var objectStore = new FakeObjectStore(exists: true);
var service = new EvidenceBundlePackagingService(repository, objectStore, NullLogger<EvidenceBundlePackagingService>.Instance);
var result = await service.EnsurePackageAsync(TenantId, BundleId, CancellationToken.None);
Assert.False(result.Created);
Assert.Equal(repository.Bundle.StorageKey, result.StorageKey);
Assert.Equal(repository.Bundle.RootHash, result.RootHash);
Assert.False(objectStore.Stored);
}
[Fact]
public async Task EnsurePackageAsync_Throws_WhenSignatureMissing()
{
var repository = new FakeRepository(CreateSealedBundle(), signature: null);
var objectStore = new FakeObjectStore(exists: false);
var service = new EvidenceBundlePackagingService(repository, objectStore, NullLogger<EvidenceBundlePackagingService>.Instance);
await Assert.ThrowsAsync<InvalidOperationException>(() => service.EnsurePackageAsync(TenantId, BundleId, CancellationToken.None));
}
[Fact]
public async Task EnsurePackageAsync_CreatesPackageWithExpectedEntries()
{
var repository = new FakeRepository(
CreateSealedBundle(storageKey: $"tenants/{TenantId.Value:N}/bundles/{BundleId.Value:N}/bundle-old.tgz"),
CreateSignature(includeTimestamp: true));
var objectStore = new FakeObjectStore(exists: false);
var service = new EvidenceBundlePackagingService(repository, objectStore, NullLogger<EvidenceBundlePackagingService>.Instance);
var result = await service.EnsurePackageAsync(TenantId, BundleId, CancellationToken.None);
Assert.True(result.Created);
Assert.True(objectStore.Stored);
var entries = ReadArchiveEntries(objectStore.StoredBytes!);
Assert.Contains(entries.Keys, key => key == "manifest.json");
Assert.Contains(entries.Keys, key => key == "signature.json");
Assert.Contains(entries.Keys, key => key == "bundle.json");
Assert.Contains(entries.Keys, key => key == "checksums.txt");
Assert.Contains(entries.Keys, key => key == "instructions.txt");
var manifestJson = entries["manifest.json"];
using var manifestDoc = JsonDocument.Parse(manifestJson);
Assert.Equal(BundleId.Value.ToString("D"), manifestDoc.RootElement.GetProperty("bundleId").GetString());
var signatureJson = entries["signature.json"];
using var signatureDoc = JsonDocument.Parse(signatureJson);
Assert.Equal("application/vnd.stella.evidence.manifest+json", signatureDoc.RootElement.GetProperty("payloadType").GetString());
Assert.Equal("tsa.default", signatureDoc.RootElement.GetProperty("timestampAuthority").GetString());
Assert.False(string.IsNullOrEmpty(signatureDoc.RootElement.GetProperty("timestampToken").GetString()));
var checksums = entries["checksums.txt"].Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
Assert.Contains(checksums, line => line.Contains(repository.Bundle.RootHash, StringComparison.Ordinal));
var instructions = entries["instructions.txt"];
Assert.Contains("Timestamped At:", instructions, StringComparison.Ordinal);
Assert.Contains("Validate the RFC3161 timestamp token", instructions, StringComparison.Ordinal);
Assert.True(repository.StorageKeyUpdated);
}
[Fact]
public async Task EnsurePackageAsync_ProducesDeterministicGzipHeader()
{
var repository = new FakeRepository(CreateSealedBundle(), CreateSignature());
var objectStore = new FakeObjectStore(exists: false);
var service = new EvidenceBundlePackagingService(repository, objectStore, NullLogger<EvidenceBundlePackagingService>.Instance);
await service.EnsurePackageAsync(TenantId, BundleId, CancellationToken.None);
Assert.True(objectStore.Stored);
var archiveBytes = objectStore.StoredBytes!;
Assert.True(archiveBytes.Length > 10);
Assert.Equal(0x1f, archiveBytes[0]);
Assert.Equal(0x8b, archiveBytes[1]);
var mtime = BinaryPrimitives.ReadInt32LittleEndian(archiveBytes.AsSpan(4, 4));
var expectedSeconds = (int)(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero) - DateTimeOffset.UnixEpoch).TotalSeconds;
Assert.Equal(expectedSeconds, mtime);
}
[Fact]
public async Task EnsurePackageAsync_Throws_WhenManifestPayloadInvalid()
{
var signature = CreateSignature() with { Payload = "not-base64" };
var repository = new FakeRepository(CreateSealedBundle(), signature);
var objectStore = new FakeObjectStore(exists: false);
var service = new EvidenceBundlePackagingService(repository, objectStore, NullLogger<EvidenceBundlePackagingService>.Instance);
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => service.EnsurePackageAsync(TenantId, BundleId, CancellationToken.None));
Assert.Contains("manifest payload", exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task EnsurePackageAsync_Throws_WhenManifestPayloadNotJson()
{
var rawPayload = Convert.ToBase64String(Encoding.UTF8.GetBytes("not-json"));
var signature = CreateSignature() with { Payload = rawPayload };
var repository = new FakeRepository(CreateSealedBundle(), signature);
var objectStore = new FakeObjectStore(exists: false);
var service = new EvidenceBundlePackagingService(repository, objectStore, NullLogger<EvidenceBundlePackagingService>.Instance);
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => service.EnsurePackageAsync(TenantId, BundleId, CancellationToken.None));
Assert.Contains("manifest payload", exception.Message, StringComparison.OrdinalIgnoreCase);
}
private static EvidenceBundle CreateSealedBundle(string? storageKey = null)
=> new(
BundleId,
TenantId,
EvidenceBundleKind.Job,
EvidenceBundleStatus.Sealed,
new string('a', 64),
storageKey ?? $"tenants/{TenantId.Value:N}/bundles/{BundleId.Value:N}/bundle.tgz",
CreatedAt,
CreatedAt,
Description: "test bundle",
SealedAt: CreatedAt.AddMinutes(1),
ExpiresAt: null);
private static EvidenceBundleSignature CreateSignature(bool includeTimestamp = false)
{
var manifest = new
{
bundleId = BundleId.Value.ToString("D"),
tenantId = TenantId.Value.ToString("D"),
kind = (int)EvidenceBundleKind.Job,
createdAt = CreatedAt.ToString("O"),
metadata = new Dictionary<string, string> { ["run"] = "nightly" },
entries = new[]
{
new
{
section = "inputs",
canonicalPath = "inputs/config.json",
sha256 = new string('b', 64),
sizeBytes = 128,
mediaType = "application/json",
attributes = new Dictionary<string, string>()
}
}
};
var manifestJson = JsonSerializer.Serialize(manifest, new JsonSerializerOptions(JsonSerializerDefaults.Web));
var payload = Convert.ToBase64String(Encoding.UTF8.GetBytes(manifestJson));
return new EvidenceBundleSignature(
BundleId,
TenantId,
"application/vnd.stella.evidence.manifest+json",
payload,
Convert.ToBase64String(Encoding.UTF8.GetBytes("signature")),
"key-1",
"ES256",
"default",
CreatedAt.AddMinutes(1),
TimestampedAt: includeTimestamp ? CreatedAt.AddMinutes(2) : null,
TimestampAuthority: includeTimestamp ? "tsa.default" : null,
TimestampToken: includeTimestamp ? Encoding.UTF8.GetBytes("tsa-token") : null);
}
private static Dictionary<string, string> ReadArchiveEntries(byte[] archiveBytes)
{
using var memory = new MemoryStream(archiveBytes);
using var gzip = new GZipStream(memory, CompressionMode.Decompress, leaveOpen: true);
using var reader = new TarReader(gzip);
var entries = new Dictionary<string, string>(StringComparer.Ordinal);
TarEntry? entry;
while ((entry = reader.GetNextEntry()) is not null)
{
if (entry.EntryType != TarEntryType.RegularFile)
{
continue;
}
using var entryStream = new MemoryStream();
entry.DataStream!.CopyTo(entryStream);
var content = Encoding.UTF8.GetString(entryStream.ToArray());
entries[entry.Name] = content;
}
return entries;
}
private sealed class FakeRepository : IEvidenceBundleRepository
{
private EvidenceBundle _bundle;
public FakeRepository(EvidenceBundle bundle, EvidenceBundleSignature? signature)
{
_bundle = bundle;
Signature = signature;
}
public EvidenceBundle Bundle => _bundle;
public EvidenceBundleSignature? Signature { get; }
public bool StorageKeyUpdated { get; private set; }
public bool PortableStorageKeyUpdated { get; private set; }
public Task CreateBundleAsync(EvidenceBundle bundle, CancellationToken cancellationToken)
=> Task.CompletedTask;
public Task SetBundleAssemblyAsync(EvidenceBundleId bundleId, TenantId tenantId, EvidenceBundleStatus status, string rootHash, DateTimeOffset updatedAt, CancellationToken cancellationToken)
=> Task.CompletedTask;
public Task MarkBundleSealedAsync(EvidenceBundleId bundleId, TenantId tenantId, EvidenceBundleStatus status, DateTimeOffset sealedAt, CancellationToken cancellationToken)
=> Task.CompletedTask;
public Task UpsertSignatureAsync(EvidenceBundleSignature signature, CancellationToken cancellationToken)
=> Task.CompletedTask;
public Task<EvidenceBundleDetails?> GetBundleAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken)
=> Task.FromResult<EvidenceBundleDetails?>(new EvidenceBundleDetails(_bundle, Signature));
public Task<bool> ExistsAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken)
=> Task.FromResult(true);
public Task<EvidenceHold> CreateHoldAsync(EvidenceHold hold, CancellationToken cancellationToken)
=> Task.FromResult(hold);
public Task ExtendBundleRetentionAsync(EvidenceBundleId bundleId, TenantId tenantId, DateTimeOffset? holdExpiresAt, DateTimeOffset processedAt, CancellationToken cancellationToken)
=> Task.CompletedTask;
public Task UpdateStorageKeyAsync(EvidenceBundleId bundleId, TenantId tenantId, string storageKey, CancellationToken cancellationToken)
{
StorageKeyUpdated = true;
return Task.CompletedTask;
}
public Task UpdatePortableStorageKeyAsync(EvidenceBundleId bundleId, TenantId tenantId, string storageKey, DateTimeOffset generatedAt, CancellationToken cancellationToken)
{
PortableStorageKeyUpdated = true;
_bundle = _bundle with
{
PortableStorageKey = storageKey,
PortableGeneratedAt = generatedAt
};
return Task.CompletedTask;
}
}
private sealed class FakeObjectStore : IEvidenceObjectStore
{
private readonly bool _exists;
private readonly string? _fixedStorageKey;
public FakeObjectStore(bool exists, string? fixedStorageKey = null)
{
_exists = exists;
_fixedStorageKey = fixedStorageKey;
}
public bool Stored { get; private set; }
public byte[]? StoredBytes { get; private set; }
public Task<EvidenceObjectMetadata> StoreAsync(Stream content, EvidenceObjectWriteOptions options, CancellationToken cancellationToken)
{
Stored = true;
using var memory = new MemoryStream();
content.CopyTo(memory);
StoredBytes = memory.ToArray();
var storageKey = _fixedStorageKey ?? $"tenants/{options.TenantId.Value:N}/bundles/{options.BundleId.Value:N}/bundle.tgz";
return Task.FromResult(new EvidenceObjectMetadata(
storageKey,
options.ContentType,
StoredBytes.Length,
Convert.ToHexString(SHA256.HashData(StoredBytes)).ToLowerInvariant(),
ETag: null,
CreatedAt: DateTimeOffset.UtcNow));
}
public Task<Stream> OpenReadAsync(string storageKey, CancellationToken cancellationToken)
{
if (StoredBytes is null)
{
throw new FileNotFoundException("Package not created.");
}
return Task.FromResult<Stream>(new MemoryStream(StoredBytes, writable: false));
}
public Task<bool> ExistsAsync(string storageKey, CancellationToken cancellationToken)
=> Task.FromResult(_exists);
}
}

View File

@@ -0,0 +1,408 @@
using System.Collections.Generic;
using System.IO;
using System.Security.Cryptography;
using System.Net.Http.Headers;
using System.Reflection;
using System.Runtime.Serialization;
using System.Security.Claims;
using System.Text.Encodings.Web;
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.Configuration;
using StellaOps.EvidenceLocker.Core.Builders;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Core.Repositories;
using StellaOps.EvidenceLocker.Core.Signing;
using StellaOps.EvidenceLocker.Core.Incident;
using StellaOps.EvidenceLocker.Core.Timeline;
using StellaOps.EvidenceLocker.Core.Storage;
namespace StellaOps.EvidenceLocker.Tests;
internal sealed class EvidenceLockerWebApplicationFactory : WebApplicationFactory<Program>
{
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 TestEvidenceObjectStore ObjectStore => Services.GetRequiredService<TestEvidenceObjectStore>();
public TestTimelinePublisher TimelinePublisher => Services.GetRequiredService<TestTimelinePublisher>();
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<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<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);
});
});
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (disposing && Directory.Exists(_contentRoot))
{
Directory.Delete(_contentRoot, recursive: true);
}
}
}
internal 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);
}
}
internal sealed class TestTimelinePublisher : IEvidenceTimelinePublisher
{
public List<string> PublishedEvents { get; } = new();
public List<string> IncidentEvents { get; } = new();
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;
}
}
internal 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 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));
}
internal 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 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<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);
}
}
internal 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) ||
!string.Equals(header.Scheme, SchemeName, StringComparison.Ordinal))
{
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()!));
}
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")));
}
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));
}
}
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));
}
}

View File

@@ -0,0 +1,355 @@
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Formats.Tar;
using System.IO;
using System.IO.Compression;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Linq;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using StellaOps.Auth.Abstractions;
using StellaOps.EvidenceLocker.Core.Configuration;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.WebService.Contracts;
namespace StellaOps.EvidenceLocker.Tests;
public sealed class EvidenceLockerWebServiceTests
{
[Fact]
public async Task Snapshot_ReturnsSignatureAndEmitsTimeline()
{
using var factory = new EvidenceLockerWebApplicationFactory();
using var client = factory.CreateClient();
var tenantId = Guid.NewGuid().ToString("D");
ConfigureAuthHeaders(client, tenantId, scopes: $"{StellaOpsScopes.EvidenceCreate} {StellaOpsScopes.EvidenceRead}");
var payload = new
{
kind = (int)EvidenceBundleKind.Evaluation,
metadata = new Dictionary<string, string>
{
["run"] = "daily",
["orchestratorJobId"] = "job-123"
},
materials = new[]
{
new { section = "inputs", path = "config.json", sha256 = new string('a', 64), sizeBytes = 256L, mediaType = "application/json" }
}
};
var snapshotResponse = await client.PostAsJsonAsync("/evidence/snapshot", payload, TestContext.Current.CancellationToken);
snapshotResponse.EnsureSuccessStatusCode();
var snapshot = await snapshotResponse.Content.ReadFromJsonAsync<EvidenceSnapshotResponseDto>(TestContext.Current.CancellationToken);
Assert.NotNull(snapshot);
Assert.NotEqual(Guid.Empty, snapshot!.BundleId);
Assert.False(string.IsNullOrEmpty(snapshot.RootHash));
Assert.NotNull(snapshot.Signature);
Assert.False(string.IsNullOrEmpty(snapshot.Signature!.Signature));
Assert.NotNull(snapshot.Signature.TimestampToken);
var timelineEvent = Assert.Single(factory.TimelinePublisher.PublishedEvents);
Assert.Contains(snapshot.BundleId.ToString("D"), timelineEvent);
Assert.Contains(snapshot.RootHash, timelineEvent);
var bundle = await client.GetFromJsonAsync<EvidenceBundleResponseDto>($"/evidence/{snapshot.BundleId}", TestContext.Current.CancellationToken);
Assert.NotNull(bundle);
Assert.Equal(snapshot.RootHash, bundle!.RootHash);
Assert.NotNull(bundle.Signature);
Assert.Equal(snapshot.Signature.Signature, bundle.Signature!.Signature);
Assert.Equal(snapshot.Signature.TimestampToken, bundle.Signature.TimestampToken);
}
[Fact]
public async Task Snapshot_WithIncidentModeActive_ExtendsRetentionAndCapturesDebugArtifact()
{
using var baseFactory = new EvidenceLockerWebApplicationFactory();
using var factory = baseFactory.WithWebHostBuilder(
builder => builder.ConfigureAppConfiguration((_, configurationBuilder) =>
configurationBuilder.AddInMemoryCollection(new Dictionary<string, string?>
{
["EvidenceLocker:Incident:Enabled"] = "true",
["EvidenceLocker:Incident:RetentionExtensionDays"] = "60",
["EvidenceLocker:Incident:CaptureRequestSnapshot"] = "true"
})));
using var client = factory.CreateClient();
var optionsMonitor = factory.Services.GetRequiredService<IOptionsMonitor<EvidenceLockerOptions>>();
Assert.True(optionsMonitor.CurrentValue.Incident.Enabled);
Assert.Equal(60, optionsMonitor.CurrentValue.Incident.RetentionExtensionDays);
var tenantId = Guid.NewGuid().ToString("D");
ConfigureAuthHeaders(client, tenantId, scopes: $"{StellaOpsScopes.EvidenceCreate} {StellaOpsScopes.EvidenceRead}");
var payload = new
{
kind = (int)EvidenceBundleKind.Job,
metadata = new Dictionary<string, string> { ["run"] = "incident" },
materials = new[]
{
new { section = "inputs", path = "config.json", sha256 = new string('b', 64), sizeBytes = 64L, mediaType = "application/json" }
}
};
var snapshotResponse = await client.PostAsJsonAsync("/evidence/snapshot", payload, TestContext.Current.CancellationToken);
snapshotResponse.EnsureSuccessStatusCode();
var snapshot = await snapshotResponse.Content.ReadFromJsonAsync<EvidenceSnapshotResponseDto>(TestContext.Current.CancellationToken);
Assert.NotNull(snapshot);
var bundle = await client.GetFromJsonAsync<EvidenceBundleResponseDto>($"/evidence/{snapshot!.BundleId}", TestContext.Current.CancellationToken);
Assert.NotNull(bundle);
Assert.NotNull(bundle!.ExpiresAt);
Assert.True(bundle.ExpiresAt > bundle.CreatedAt);
var objectStore = factory.Services.GetRequiredService<TestEvidenceObjectStore>();
var timeline = factory.Services.GetRequiredService<TestTimelinePublisher>();
Assert.Contains(objectStore.StoredObjects.Keys, key => key.Contains("/incident/request-", StringComparison.Ordinal));
Assert.Contains("enabled", timeline.IncidentEvents);
}
[Fact]
public async Task Download_ReturnsPackageStream()
{
using var factory = new EvidenceLockerWebApplicationFactory();
using var client = factory.CreateClient();
var tenantId = Guid.NewGuid().ToString("D");
ConfigureAuthHeaders(client, tenantId, scopes: $"{StellaOpsScopes.EvidenceCreate} {StellaOpsScopes.EvidenceRead}");
var payload = new
{
kind = (int)EvidenceBundleKind.Evaluation,
metadata = new Dictionary<string, string> { ["run"] = "nightly" },
materials = new[]
{
new { section = "inputs", path = "config.json", sha256 = new string('a', 64), sizeBytes = 128L, mediaType = "application/json" }
}
};
var snapshotResponse = await client.PostAsJsonAsync("/evidence/snapshot", payload, TestContext.Current.CancellationToken);
snapshotResponse.EnsureSuccessStatusCode();
var snapshot = await snapshotResponse.Content.ReadFromJsonAsync<EvidenceSnapshotResponseDto>(TestContext.Current.CancellationToken);
Assert.NotNull(snapshot);
var downloadResponse = await client.GetAsync($"/evidence/{snapshot!.BundleId}/download", TestContext.Current.CancellationToken);
downloadResponse.EnsureSuccessStatusCode();
Assert.Equal("application/gzip", downloadResponse.Content.Headers.ContentType?.MediaType);
var archiveBytes = await downloadResponse.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken);
var mtime = BinaryPrimitives.ReadInt32LittleEndian(archiveBytes.AsSpan(4, 4));
var expectedSeconds = (int)(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero) - DateTimeOffset.UnixEpoch).TotalSeconds;
Assert.Equal(expectedSeconds, mtime);
var entries = ReadArchiveEntries(archiveBytes);
Assert.Contains("manifest.json", entries.Keys);
Assert.Contains("signature.json", entries.Keys);
Assert.Contains("instructions.txt", entries.Keys);
using var manifestDoc = JsonDocument.Parse(entries["manifest.json"]);
Assert.Equal(snapshot.BundleId.ToString(), manifestDoc.RootElement.GetProperty("bundleId").GetString());
var instructions = entries["instructions.txt"];
Assert.Contains("Evidence Bundle Instructions", instructions, StringComparison.Ordinal);
Assert.Contains("Validate `signature.json`", instructions, StringComparison.Ordinal);
Assert.Contains("Review `checksums.txt`", instructions, StringComparison.Ordinal);
if (instructions.Contains("Timestamped At:", StringComparison.Ordinal))
{
Assert.Contains("Validate the RFC3161 timestamp token", instructions, StringComparison.Ordinal);
}
Assert.NotEmpty(factory.ObjectStore.StoredObjects);
}
[Fact]
public async Task PortableDownload_ReturnsSanitizedBundle()
{
using var factory = new EvidenceLockerWebApplicationFactory();
using var client = factory.CreateClient();
var tenantId = Guid.NewGuid().ToString("D");
ConfigureAuthHeaders(client, tenantId, scopes: $"{StellaOpsScopes.EvidenceCreate} {StellaOpsScopes.EvidenceRead}");
var payload = new
{
kind = (int)EvidenceBundleKind.Export,
metadata = new Dictionary<string, string> { ["pipeline"] = "sealed" },
materials = new[]
{
new { section = "inputs", path = "artifact.txt", sha256 = new string('d', 64), sizeBytes = 256L, mediaType = "text/plain" }
}
};
var snapshotResponse = await client.PostAsJsonAsync("/evidence/snapshot", payload, TestContext.Current.CancellationToken);
snapshotResponse.EnsureSuccessStatusCode();
var snapshot = await snapshotResponse.Content.ReadFromJsonAsync<EvidenceSnapshotResponseDto>(TestContext.Current.CancellationToken);
Assert.NotNull(snapshot);
var portableResponse = await client.GetAsync($"/evidence/{snapshot!.BundleId}/portable", TestContext.Current.CancellationToken);
portableResponse.EnsureSuccessStatusCode();
Assert.Equal("application/gzip", portableResponse.Content.Headers.ContentType?.MediaType);
var archiveBytes = await portableResponse.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken);
var entries = ReadArchiveEntries(archiveBytes);
Assert.Contains("bundle.json", entries.Keys);
Assert.Contains("instructions-portable.txt", entries.Keys);
Assert.Contains("verify-offline.sh", entries.Keys);
using var bundleDoc = JsonDocument.Parse(entries["bundle.json"]);
var bundleRoot = bundleDoc.RootElement;
Assert.False(bundleRoot.TryGetProperty("tenantId", out _));
Assert.False(bundleRoot.TryGetProperty("storageKey", out _));
Assert.True(bundleRoot.TryGetProperty("portableGeneratedAt", out _));
var script = entries["verify-offline.sh"];
Assert.StartsWith("#!/usr/bin/env sh", script, StringComparison.Ordinal);
Assert.Contains("sha256sum", script, StringComparison.Ordinal);
Assert.Contains("stella evidence verify", script, StringComparison.Ordinal);
}
[Fact]
public async Task Snapshot_ReturnsValidationError_WhenQuotaExceeded()
{
using var factory = new EvidenceLockerWebApplicationFactory();
using var client = factory.CreateClient();
var tenantId = Guid.NewGuid().ToString("D");
ConfigureAuthHeaders(client, tenantId, scopes: $"{StellaOpsScopes.EvidenceCreate} {StellaOpsScopes.EvidenceRead}");
var payload = new
{
kind = (int)EvidenceBundleKind.Job,
materials = new[]
{
new { section = "inputs", path = "layer0.tar", sha256 = new string('a', 64), sizeBytes = 900L },
new { section = "inputs", path = "layer1.tar", sha256 = new string('b', 64), sizeBytes = 300L }
}
};
var response = await client.PostAsJsonAsync("/evidence/snapshot", payload, TestContext.Current.CancellationToken);
var responseContent = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.BadRequest, $"Expected 400 but received {(int)response.StatusCode}: {responseContent}");
var problem = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>(TestContext.Current.CancellationToken);
Assert.NotNull(problem);
Assert.True(problem!.Errors.TryGetValue("message", out var messages));
Assert.Contains(messages, m => m.Contains("exceeds", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task Snapshot_ReturnsForbidden_WhenTenantMissing()
{
using var factory = new EvidenceLockerWebApplicationFactory();
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(EvidenceLockerTestAuthHandler.SchemeName);
client.DefaultRequestHeaders.Add("X-Test-Scopes", $"{StellaOpsScopes.EvidenceCreate} {StellaOpsScopes.EvidenceRead}");
var payload = new
{
kind = (int)EvidenceBundleKind.Evaluation,
materials = new[]
{
new { section = "inputs", path = "input.txt", sha256 = "abc123", sizeBytes = 1L }
}
};
var response = await client.PostAsJsonAsync("/evidence/snapshot", payload, TestContext.Current.CancellationToken);
var responseContent = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.Forbidden, $"Expected 403 but received {(int)response.StatusCode}: {responseContent}");
}
[Fact]
public async Task Hold_ReturnsConflict_WhenCaseAlreadyExists()
{
using var factory = new EvidenceLockerWebApplicationFactory();
using var client = factory.CreateClient();
var repository = factory.Repository;
repository.HoldConflict = true;
var tenantId = Guid.NewGuid().ToString("D");
ConfigureAuthHeaders(client, tenantId, scopes: $"{StellaOpsScopes.EvidenceHold} {StellaOpsScopes.EvidenceRead}");
var response = await client.PostAsJsonAsync(
"/evidence/hold/case-123",
new
{
reason = "legal-hold"
},
TestContext.Current.CancellationToken);
var responseContent = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.BadRequest, $"Expected 400 but received {(int)response.StatusCode}: {responseContent}");
var problem = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>(TestContext.Current.CancellationToken);
Assert.NotNull(problem);
Assert.True(problem!.Errors.TryGetValue("message", out var messages));
Assert.Contains(messages, m => m.IndexOf("already exists", StringComparison.OrdinalIgnoreCase) >= 0);
}
[Fact]
public async Task Hold_CreatesTimelineEvent()
{
using var factory = new EvidenceLockerWebApplicationFactory();
using var client = factory.CreateClient();
var tenantId = Guid.NewGuid().ToString("D");
ConfigureAuthHeaders(client, tenantId, scopes: $"{StellaOpsScopes.EvidenceHold} {StellaOpsScopes.EvidenceRead}");
var response = await client.PostAsJsonAsync(
"/evidence/hold/case-789",
new
{
reason = "retention",
notes = "retain for investigation"
},
TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
var hold = await response.Content.ReadFromJsonAsync<EvidenceHoldResponseDto>(TestContext.Current.CancellationToken);
Assert.NotNull(hold);
Assert.Contains($"hold:{hold!.CaseId}", factory.TimelinePublisher.PublishedEvents);
}
private static Dictionary<string, string> ReadArchiveEntries(byte[] archiveBytes)
{
using var memory = new MemoryStream(archiveBytes);
using var gzip = new GZipStream(memory, CompressionMode.Decompress, leaveOpen: true);
using var reader = new TarReader(gzip);
var entries = new Dictionary<string, string>(StringComparer.Ordinal);
TarEntry? entry;
while ((entry = reader.GetNextEntry()) is not null)
{
if (entry.EntryType != TarEntryType.RegularFile)
{
continue;
}
using var entryStream = new MemoryStream();
entry.DataStream!.CopyTo(entryStream);
var content = Encoding.UTF8.GetString(entryStream.ToArray());
entries[entry.Name] = content;
}
return entries;
}
private static void ConfigureAuthHeaders(HttpClient client, string tenantId, string scopes)
{
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(EvidenceLockerTestAuthHandler.SchemeName);
client.DefaultRequestHeaders.Add("X-Test-Tenant", tenantId);
client.DefaultRequestHeaders.Add("X-Test-Scopes", scopes);
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", tenantId);
}
}

View File

@@ -0,0 +1,294 @@
using System.Formats.Tar;
using System.IO.Compression;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.EvidenceLocker.Core.Configuration;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Core.Repositories;
using StellaOps.EvidenceLocker.Core.Storage;
using StellaOps.EvidenceLocker.Infrastructure.Services;
namespace StellaOps.EvidenceLocker.Tests;
public sealed class EvidencePortableBundleServiceTests
{
private static readonly TenantId TenantId = TenantId.FromGuid(Guid.NewGuid());
private static readonly EvidenceBundleId BundleId = EvidenceBundleId.FromGuid(Guid.NewGuid());
private static readonly DateTimeOffset CreatedAt = new(2025, 11, 4, 10, 30, 0, TimeSpan.Zero);
[Fact]
public async Task EnsurePortablePackageAsync_ReturnsCached_WhenObjectExists()
{
var bundle = CreateSealedBundle(
portableStorageKey: "tenants/foo/bundles/bar/portable-bundle-v1.tgz",
portableGeneratedAt: CreatedAt);
var repository = new FakeRepository(bundle, CreateSignature());
var objectStore = new FakeObjectStore(exists: true);
var service = CreateService(repository, objectStore);
var result = await service.EnsurePortablePackageAsync(TenantId, BundleId, CancellationToken.None);
Assert.False(result.Created);
Assert.Equal(bundle.RootHash, result.RootHash);
Assert.Equal(bundle.PortableStorageKey, result.StorageKey);
Assert.False(objectStore.Stored);
Assert.False(repository.PortableStorageKeyUpdated);
}
[Fact]
public async Task EnsurePortablePackageAsync_CreatesPortableArchiveWithRedactedMetadata()
{
var repository = new FakeRepository(CreateSealedBundle(), CreateSignature(includeTimestamp: true));
var objectStore = new FakeObjectStore(exists: false, fixedStorageKey: "tenants/foo/bundles/bar/portable-bundle-v1.tgz");
var service = CreateService(repository, objectStore);
var result = await service.EnsurePortablePackageAsync(TenantId, BundleId, CancellationToken.None);
Assert.True(result.Created);
Assert.True(objectStore.Stored);
Assert.NotNull(objectStore.StoredBytes);
Assert.True(repository.PortableStorageKeyUpdated);
Assert.NotNull(repository.Bundle.PortableStorageKey);
Assert.NotNull(repository.Bundle.PortableGeneratedAt);
var entries = ReadArchiveEntries(objectStore.StoredBytes!);
Assert.Contains("manifest.json", entries.Keys);
Assert.Contains("signature.json", entries.Keys);
Assert.Contains("bundle.json", entries.Keys);
Assert.Contains("instructions-portable.txt", entries.Keys);
Assert.Contains("verify-offline.sh", entries.Keys);
using var bundleJson = JsonDocument.Parse(entries["bundle.json"]);
var root = bundleJson.RootElement;
Assert.False(root.TryGetProperty("tenantId", out _));
Assert.False(root.TryGetProperty("storageKey", out _));
Assert.False(root.TryGetProperty("description", out _));
Assert.Equal(repository.Bundle.Id.Value.ToString("D"), root.GetProperty("bundleId").GetString());
Assert.Equal(repository.Bundle.RootHash, root.GetProperty("rootHash").GetString());
Assert.True(root.TryGetProperty("portableGeneratedAt", out var generatedAtProperty));
Assert.True(DateTimeOffset.TryParse(generatedAtProperty.GetString(), out _));
var incidentMetadata = root.GetProperty("incidentMetadata");
Assert.Equal(JsonValueKind.Object, incidentMetadata.ValueKind);
Assert.True(incidentMetadata.EnumerateObject().Any(p => p.Name.StartsWith("incident.", StringComparison.Ordinal)));
var instructions = entries["instructions-portable.txt"];
Assert.Contains("Portable Evidence Bundle Instructions", instructions, StringComparison.Ordinal);
Assert.Contains("verify-offline.sh", instructions, StringComparison.Ordinal);
var script = entries["verify-offline.sh"];
Assert.StartsWith("#!/usr/bin/env sh", script, StringComparison.Ordinal);
Assert.Contains("sha256sum", script, StringComparison.Ordinal);
Assert.Contains("stella evidence verify", script, StringComparison.Ordinal);
}
[Fact]
public async Task EnsurePortablePackageAsync_Throws_WhenSignatureMissing()
{
var repository = new FakeRepository(CreateSealedBundle(), signature: null);
var objectStore = new FakeObjectStore(exists: false);
var service = CreateService(repository, objectStore);
await Assert.ThrowsAsync<InvalidOperationException>(() => service.EnsurePortablePackageAsync(TenantId, BundleId, CancellationToken.None));
}
private static EvidencePortableBundleService CreateService(FakeRepository repository, IEvidenceObjectStore objectStore)
{
var options = Options.Create(new EvidenceLockerOptions
{
Database = new DatabaseOptions { ConnectionString = "Host=localhost" },
ObjectStore = new ObjectStoreOptions { Kind = ObjectStoreKind.FileSystem, FileSystem = new FileSystemStoreOptions { RootPath = "." } },
Quotas = new QuotaOptions(),
Signing = new SigningOptions(),
Portable = new PortableOptions()
});
return new EvidencePortableBundleService(
repository,
objectStore,
options,
TimeProvider.System,
NullLogger<EvidencePortableBundleService>.Instance);
}
private static EvidenceBundle CreateSealedBundle(
string? portableStorageKey = null,
DateTimeOffset? portableGeneratedAt = null)
=> new EvidenceBundle(
BundleId,
TenantId,
EvidenceBundleKind.Evaluation,
EvidenceBundleStatus.Sealed,
new string('f', 64),
"tenants/foo/bundles/bar/bundle.tgz",
CreatedAt,
CreatedAt,
Description: "sensitive",
SealedAt: CreatedAt.AddMinutes(5),
ExpiresAt: CreatedAt.AddDays(30),
PortableStorageKey: portableStorageKey,
PortableGeneratedAt: portableGeneratedAt);
private static EvidenceBundleSignature CreateSignature(bool includeTimestamp = false)
{
var manifest = new
{
bundleId = BundleId.Value,
tenantId = TenantId.Value,
kind = (int)EvidenceBundleKind.Evaluation,
createdAt = CreatedAt,
metadata = new Dictionary<string, string>
{
["pipeline"] = "ops",
["incident.mode"] = "enabled",
["incident.changedAt"] = CreatedAt.ToString("O"),
["incident.retentionExtensionDays"] = "60"
},
entries = new[]
{
new
{
section = "inputs",
canonicalPath = "inputs/config.json",
sha256 = new string('a', 64),
sizeBytes = 128L,
mediaType = "application/json",
attributes = new Dictionary<string, string>()
}
}
};
var payload = JsonSerializer.Serialize(manifest);
return new EvidenceBundleSignature(
BundleId,
TenantId,
"application/vnd.stella.evidence.manifest+json",
Convert.ToBase64String(Encoding.UTF8.GetBytes(payload)),
"sig-payload",
"key-id",
"ES256",
"provider",
CreatedAt,
includeTimestamp ? CreatedAt.AddMinutes(1) : null,
includeTimestamp ? "tsa.default" : null,
includeTimestamp ? Encoding.UTF8.GetBytes("tsa-token") : null);
}
private static IReadOnlyDictionary<string, string> ReadArchiveEntries(byte[] archive)
{
using var memory = new MemoryStream(archive);
using var gzip = new GZipStream(memory, CompressionMode.Decompress);
using var tarReader = new TarReader(gzip);
var entries = new Dictionary<string, string>(StringComparer.Ordinal);
TarEntry? entry;
while ((entry = tarReader.GetNextEntry()) is not null)
{
if (entry.EntryType != TarEntryType.RegularFile)
{
continue;
}
using var entryStream = new MemoryStream();
entry.DataStream!.CopyTo(entryStream);
entries[entry.Name] = Encoding.UTF8.GetString(entryStream.ToArray());
}
return entries;
}
private sealed class FakeRepository : IEvidenceBundleRepository
{
private EvidenceBundle _bundle;
public FakeRepository(EvidenceBundle bundle, EvidenceBundleSignature? signature)
{
_bundle = bundle;
Signature = signature;
}
public EvidenceBundle Bundle => _bundle;
public EvidenceBundleSignature? Signature { get; }
public bool PortableStorageKeyUpdated { get; private set; }
public Task CreateBundleAsync(EvidenceBundle bundle, CancellationToken cancellationToken)
=> Task.CompletedTask;
public Task SetBundleAssemblyAsync(EvidenceBundleId bundleId, TenantId tenantId, EvidenceBundleStatus status, string rootHash, DateTimeOffset updatedAt, CancellationToken cancellationToken)
=> Task.CompletedTask;
public Task MarkBundleSealedAsync(EvidenceBundleId bundleId, TenantId tenantId, EvidenceBundleStatus status, DateTimeOffset sealedAt, CancellationToken cancellationToken)
=> Task.CompletedTask;
public Task UpsertSignatureAsync(EvidenceBundleSignature signature, CancellationToken cancellationToken)
=> Task.CompletedTask;
public Task<EvidenceBundleDetails?> GetBundleAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken)
=> Task.FromResult<EvidenceBundleDetails?>(new EvidenceBundleDetails(_bundle, Signature));
public Task<bool> ExistsAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken)
=> Task.FromResult(true);
public Task<EvidenceHold> CreateHoldAsync(EvidenceHold hold, CancellationToken cancellationToken)
=> Task.FromResult(hold);
public Task ExtendBundleRetentionAsync(EvidenceBundleId bundleId, TenantId tenantId, DateTimeOffset? holdExpiresAt, DateTimeOffset processedAt, CancellationToken cancellationToken)
=> Task.CompletedTask;
public Task UpdateStorageKeyAsync(EvidenceBundleId bundleId, TenantId tenantId, string storageKey, CancellationToken cancellationToken)
=> Task.CompletedTask;
public Task UpdatePortableStorageKeyAsync(EvidenceBundleId bundleId, TenantId tenantId, string storageKey, DateTimeOffset generatedAt, CancellationToken cancellationToken)
{
PortableStorageKeyUpdated = true;
_bundle = _bundle with
{
PortableStorageKey = storageKey,
PortableGeneratedAt = generatedAt
};
return Task.CompletedTask;
}
}
private sealed class FakeObjectStore : IEvidenceObjectStore
{
private readonly bool _exists;
private readonly string? _fixedStorageKey;
public FakeObjectStore(bool exists, string? fixedStorageKey = null)
{
_exists = exists;
_fixedStorageKey = fixedStorageKey;
}
public bool Stored { get; private set; }
public byte[]? StoredBytes { get; private set; }
public Task<EvidenceObjectMetadata> StoreAsync(Stream content, EvidenceObjectWriteOptions options, CancellationToken cancellationToken)
{
Stored = true;
using var memory = new MemoryStream();
content.CopyTo(memory);
StoredBytes = memory.ToArray();
var storageKey = _fixedStorageKey ?? $"tenants/{options.TenantId.Value:N}/bundles/{options.BundleId.Value:N}/portable-bundle-v1.tgz";
return Task.FromResult(new EvidenceObjectMetadata(
storageKey,
options.ContentType,
StoredBytes.Length,
Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(StoredBytes)).ToLowerInvariant(),
ETag: null,
CreatedAt: DateTimeOffset.UtcNow));
}
public Task<Stream> OpenReadAsync(string storageKey, CancellationToken cancellationToken)
=> Task.FromResult<Stream>(new MemoryStream(StoredBytes ?? Array.Empty<byte>()));
public Task<bool> ExistsAsync(string storageKey, CancellationToken cancellationToken)
=> Task.FromResult(_exists);
}
}

View File

@@ -0,0 +1,269 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography;
using StellaOps.EvidenceLocker.Core.Builders;
using StellaOps.EvidenceLocker.Core.Configuration;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Core.Signing;
using StellaOps.EvidenceLocker.Infrastructure.Signing;
using Xunit;
namespace StellaOps.EvidenceLocker.Tests;
public sealed class EvidenceSignatureServiceTests
{
private static readonly SigningKeyMaterialOptions TestKeyMaterial = CreateKeyMaterial();
[Fact]
public async Task SignManifestAsync_SignsManifestWithoutTimestamp_WhenTimestampingDisabled()
{
var timestampClient = new FakeTimestampAuthorityClient();
var timeProvider = new TestTimeProvider(new DateTimeOffset(2025, 11, 3, 10, 0, 0, TimeSpan.Zero));
var service = CreateService(timestampClient, timeProvider);
var manifest = CreateManifest();
var signature = await service.SignManifestAsync(
manifest.BundleId,
manifest.TenantId,
manifest,
CancellationToken.None);
Assert.NotNull(signature);
Assert.Equal("application/vnd.stella.test+json", signature.PayloadType);
Assert.NotNull(signature.Payload);
Assert.NotEmpty(signature.Signature);
Assert.Null(signature.TimestampedAt);
Assert.Null(signature.TimestampAuthority);
Assert.Null(signature.TimestampToken);
Assert.Equal(0, timestampClient.CallCount);
}
[Fact]
public async Task SignManifestAsync_AttachesTimestamp_WhenAuthorityClientSucceeds()
{
var timestampClient = new FakeTimestampAuthorityClient
{
Result = new TimestampResult(
new DateTimeOffset(2025, 11, 3, 10, 0, 5, TimeSpan.Zero),
"CN=Test TSA",
new byte[] { 1, 2, 3 })
};
var timeProvider = new TestTimeProvider(new DateTimeOffset(2025, 11, 3, 10, 0, 0, TimeSpan.Zero));
var signingOptions = CreateSigningOptions(timestamping: new TimestampingOptions
{
Enabled = true,
Endpoint = "https://tsa.example",
HashAlgorithm = "SHA256"
});
var service = CreateService(timestampClient, timeProvider, signingOptions);
var manifest = CreateManifest();
var signature = await service.SignManifestAsync(
manifest.BundleId,
manifest.TenantId,
manifest,
CancellationToken.None);
Assert.NotNull(signature);
Assert.Equal(timestampClient.Result!.Timestamp, signature.TimestampedAt);
Assert.Equal(timestampClient.Result.Authority, signature.TimestampAuthority);
Assert.Equal(timestampClient.Result.Token, signature.TimestampToken);
Assert.Equal(1, timestampClient.CallCount);
}
[Fact]
public async Task SignManifestAsync_Throws_WhenTimestampRequiredAndClientFails()
{
var timestampClient = new FakeTimestampAuthorityClient
{
Exception = new InvalidOperationException("TSA offline")
};
var signingOptions = CreateSigningOptions(timestamping: new TimestampingOptions
{
Enabled = true,
Endpoint = "https://tsa.example",
HashAlgorithm = "SHA256",
RequireTimestamp = true
});
var service = CreateService(timestampClient, new TestTimeProvider(DateTimeOffset.UtcNow), signingOptions);
var manifest = CreateManifest();
await Assert.ThrowsAsync<InvalidOperationException>(() => service.SignManifestAsync(
manifest.BundleId,
manifest.TenantId,
manifest,
CancellationToken.None));
}
[Fact]
public async Task SignManifestAsync_ProducesDeterministicPayload()
{
var timestampClient = new FakeTimestampAuthorityClient();
var service = CreateService(timestampClient, new TestTimeProvider(DateTimeOffset.UtcNow));
var sharedBundleId = EvidenceBundleId.FromGuid(Guid.NewGuid());
var sharedTenantId = TenantId.FromGuid(Guid.NewGuid());
var manifestA = CreateManifest(
metadataOrder: new[] { ("zeta", "1"), ("alpha", "2") },
bundleId: sharedBundleId,
tenantId: sharedTenantId);
var manifestB = CreateManifest(
metadataOrder: new[] { ("alpha", "2"), ("zeta", "1") },
bundleId: sharedBundleId,
tenantId: sharedTenantId);
var signatureA = await service.SignManifestAsync(
manifestA.BundleId,
manifestA.TenantId,
manifestA,
CancellationToken.None);
var signatureB = await service.SignManifestAsync(
manifestB.BundleId,
manifestB.TenantId,
manifestB,
CancellationToken.None);
Assert.NotNull(signatureA);
Assert.NotNull(signatureB);
var payloadA = Encoding.UTF8.GetString(Convert.FromBase64String(signatureA!.Payload));
var payloadB = Encoding.UTF8.GetString(Convert.FromBase64String(signatureB!.Payload));
Assert.Equal(payloadA, payloadB);
using var document = JsonDocument.Parse(payloadA);
var metadataElement = document.RootElement.GetProperty("metadata");
using var enumerator = metadataElement.EnumerateObject();
Assert.True(enumerator.MoveNext());
Assert.Equal("alpha", enumerator.Current.Name);
Assert.True(enumerator.MoveNext());
Assert.Equal("zeta", enumerator.Current.Name);
}
private static EvidenceSignatureService CreateService(
ITimestampAuthorityClient timestampAuthorityClient,
TimeProvider timeProvider,
SigningOptions? signingOptions = null)
{
var registry = new CryptoProviderRegistry(new ICryptoProvider[] { new DefaultCryptoProvider() });
var options = Options.Create(new EvidenceLockerOptions
{
Database = new DatabaseOptions { ConnectionString = "Host=localhost" },
ObjectStore = new ObjectStoreOptions
{
Kind = ObjectStoreKind.FileSystem,
FileSystem = new FileSystemStoreOptions { RootPath = "." }
},
Quotas = new QuotaOptions(),
Signing = signingOptions ?? CreateSigningOptions()
});
return new EvidenceSignatureService(
registry,
timestampAuthorityClient,
options,
timeProvider,
NullLogger<EvidenceSignatureService>.Instance);
}
private static SigningOptions CreateSigningOptions(TimestampingOptions? timestamping = null)
=> new()
{
Enabled = true,
Algorithm = SignatureAlgorithms.Es256,
KeyId = "test-key",
PayloadType = "application/vnd.stella.test+json",
KeyMaterial = TestKeyMaterial,
Timestamping = timestamping
};
private static SigningKeyMaterialOptions CreateKeyMaterial()
{
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var privatePem = ecdsa.ExportECPrivateKeyPem();
var publicPem = ecdsa.ExportSubjectPublicKeyInfoPem();
return new SigningKeyMaterialOptions
{
EcPrivateKeyPem = privatePem,
EcPublicKeyPem = publicPem
};
}
private static EvidenceBundleManifest CreateManifest(
(string key, string value)[]? metadataOrder = null,
EvidenceBundleId? bundleId = null,
TenantId? tenantId = null)
{
metadataOrder ??= new[] { ("alpha", "1"), ("beta", "2") };
var metadataDictionary = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var (key, value) in metadataOrder)
{
metadataDictionary[key] = value;
}
var metadata = new ReadOnlyDictionary<string, string>(metadataDictionary);
var attributesDictionary = new Dictionary<string, string>(StringComparer.Ordinal)
{
["scope"] = "inputs",
["priority"] = "high"
};
var attributes = new ReadOnlyDictionary<string, string>(attributesDictionary);
var manifestEntry = new EvidenceManifestEntry(
"inputs",
"inputs/config.json",
new string('a', 64),
128,
"application/json",
attributes);
return new EvidenceBundleManifest(
bundleId ?? EvidenceBundleId.FromGuid(Guid.NewGuid()),
tenantId ?? TenantId.FromGuid(Guid.NewGuid()),
EvidenceBundleKind.Evaluation,
new DateTimeOffset(2025, 11, 3, 9, 30, 0, TimeSpan.Zero),
metadata,
new List<EvidenceManifestEntry> { manifestEntry });
}
private sealed class FakeTimestampAuthorityClient : ITimestampAuthorityClient
{
public TimestampResult? Result { get; set; }
public Exception? Exception { get; set; }
public int CallCount { get; private set; }
public Task<TimestampResult?> RequestTimestampAsync(
ReadOnlyMemory<byte> signature,
string hashAlgorithm,
CancellationToken cancellationToken)
{
CallCount++;
if (Exception is not null)
{
throw Exception;
}
return Task.FromResult(Result);
}
}
private sealed class TestTimeProvider(DateTimeOffset fixedUtcNow) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => fixedUtcNow;
}
}

View File

@@ -0,0 +1,502 @@
using System.Reflection;
using System.Runtime.Serialization;
using System.Security.Cryptography;
using System.Linq;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Npgsql;
using StellaOps.Cryptography;
using StellaOps.EvidenceLocker.Core.Builders;
using StellaOps.EvidenceLocker.Core.Configuration;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Core.Repositories;
using StellaOps.EvidenceLocker.Core.Signing;
using StellaOps.EvidenceLocker.Core.Incident;
using StellaOps.EvidenceLocker.Core.Storage;
using StellaOps.EvidenceLocker.Core.Timeline;
using StellaOps.EvidenceLocker.Infrastructure.Services;
namespace StellaOps.EvidenceLocker.Tests;
public sealed class EvidenceSnapshotServiceTests
{
private static readonly string ValidSha256 = Sha('a');
private static readonly string DefaultRootHash = Sha('f');
private readonly FakeRepository _repository = new();
private readonly FakeBuilder _builder = new();
private readonly FakeSignatureService _signatureService = new();
private readonly FakeTimelinePublisher _timelinePublisher = new();
private readonly TestIncidentState _incidentState = new();
private readonly TestObjectStore _objectStore = new();
private readonly EvidenceSnapshotService _service;
public EvidenceSnapshotServiceTests()
{
var options = Options.Create(new EvidenceLockerOptions
{
Database = new DatabaseOptions { ConnectionString = "Host=localhost" },
ObjectStore = new ObjectStoreOptions
{
Kind = ObjectStoreKind.FileSystem,
FileSystem = new FileSystemStoreOptions { RootPath = "." }
},
Quotas = new QuotaOptions
{
MaxMaterialCount = 4,
MaxTotalMaterialSizeBytes = 1_024,
MaxMetadataEntries = 4,
MaxMetadataKeyLength = 32,
MaxMetadataValueLength = 64
},
Signing = new SigningOptions
{
Enabled = false,
Algorithm = SignatureAlgorithms.Es256,
KeyId = "test-key"
},
Incident = new IncidentModeOptions
{
Enabled = false,
RetentionExtensionDays = 30,
CaptureRequestSnapshot = true
}
});
_service = new EvidenceSnapshotService(
_repository,
_builder,
_signatureService,
_timelinePublisher,
_incidentState,
_objectStore,
TimeProvider.System,
options,
NullLogger<EvidenceSnapshotService>.Instance);
}
[Fact]
public async Task CreateSnapshotAsync_PersistsBundleAndBuildsManifest()
{
var request = new EvidenceSnapshotRequest
{
Kind = EvidenceBundleKind.Evaluation,
Metadata = new Dictionary<string, string> { ["run"] = "alpha" },
Materials = new List<EvidenceSnapshotMaterial>
{
new()
{
Section = "inputs",
Path = "config.json",
Sha256 = ValidSha256,
SizeBytes = 128,
MediaType = "application/json"
}
}
};
var tenantId = TenantId.FromGuid(Guid.NewGuid());
var result = await _service.CreateSnapshotAsync(tenantId, request, CancellationToken.None);
Assert.NotEqual(Guid.Empty, result.BundleId);
Assert.Equal(_builder.RootHash, result.RootHash);
Assert.Equal(tenantId, _repository.LastCreateTenant);
Assert.Equal(EvidenceBundleStatus.Pending, _repository.LastCreatedStatus);
Assert.Equal(EvidenceBundleStatus.Assembling, _repository.AssemblyStatus);
Assert.Equal(_builder.RootHash, _repository.AssemblyRootHash);
Assert.True(_builder.Invoked);
Assert.Null(result.Signature);
Assert.False(_timelinePublisher.BundleSealedPublished);
}
[Fact]
public async Task CreateSnapshotAsync_StoresSignature_WhenSignerReturnsEnvelope()
{
var tenantId = TenantId.FromGuid(Guid.NewGuid());
var bundleId = EvidenceBundleId.FromGuid(Guid.NewGuid());
_signatureService.NextSignature = new EvidenceBundleSignature(
bundleId,
tenantId,
"application/vnd.test",
"payload",
Convert.ToBase64String(new byte[] { 1, 2, 3 }),
"key-1",
SignatureAlgorithms.Es256,
"default",
DateTimeOffset.UtcNow,
null,
null,
null);
_builder.OverrideBundleId = bundleId;
var request = new EvidenceSnapshotRequest
{
Kind = EvidenceBundleKind.Evaluation,
Materials = new List<EvidenceSnapshotMaterial>
{
new() { Section = "inputs", Path = "config.json", Sha256 = ValidSha256, SizeBytes = 128 }
}
};
var result = await _service.CreateSnapshotAsync(tenantId, request, CancellationToken.None);
Assert.NotNull(result.Signature);
Assert.True(_repository.SignatureUpserted);
Assert.True(_timelinePublisher.BundleSealedPublished);
Assert.Equal(_repository.LastCreatedBundleId?.Value ?? Guid.Empty, result.BundleId);
}
[Fact]
public async Task CreateSnapshotAsync_ThrowsWhenMaterialQuotaExceeded()
{
var request = new EvidenceSnapshotRequest
{
Kind = EvidenceBundleKind.Job,
Materials = new List<EvidenceSnapshotMaterial>
{
new() { Section = "a", Path = "1", Sha256 = Sha('a'), SizeBytes = 900 },
new() { Section = "b", Path = "2", Sha256 = Sha('b'), SizeBytes = 300 }
}
};
await Assert.ThrowsAsync<InvalidOperationException>(() =>
_service.CreateSnapshotAsync(TenantId.FromGuid(Guid.NewGuid()), request, CancellationToken.None));
}
[Fact]
public async Task CreateHoldAsync_ReturnsHoldWhenValid()
{
var tenantId = TenantId.FromGuid(Guid.NewGuid());
_repository.NextExistsResult = true;
var holdRequest = new EvidenceHoldRequest
{
BundleId = Guid.NewGuid(),
Reason = "legal",
Notes = "note"
};
var hold = await _service.CreateHoldAsync(tenantId, "case-123", holdRequest, CancellationToken.None);
Assert.Equal("case-123", hold.CaseId);
Assert.True(_repository.RetentionExtended);
Assert.Equal(holdRequest.BundleId, _repository.RetentionBundleId?.Value);
Assert.Null(_repository.RetentionExpiresAt);
Assert.NotNull(_repository.RetentionProcessedAt);
Assert.Equal(tenantId, _repository.RetentionTenant);
Assert.True(_timelinePublisher.HoldPublished);
}
[Fact]
public async Task CreateHoldAsyncThrowsWhenBundleMissing()
{
var tenantId = TenantId.FromGuid(Guid.NewGuid());
_repository.NextExistsResult = false;
await Assert.ThrowsAsync<InvalidOperationException>(() =>
_service.CreateHoldAsync(tenantId, "case-999", new EvidenceHoldRequest
{
BundleId = Guid.NewGuid(),
Reason = "legal"
}, CancellationToken.None));
}
[Fact]
public async Task CreateHoldAsyncThrowsWhenCaseAlreadyExists()
{
_repository.ThrowUniqueViolationForHolds = true;
await Assert.ThrowsAsync<InvalidOperationException>(() =>
_service.CreateHoldAsync(
TenantId.FromGuid(Guid.NewGuid()),
"case-dup",
new EvidenceHoldRequest { Reason = "legal" },
CancellationToken.None));
}
[Fact]
public async Task CreateSnapshotAsync_ExtendsRetentionAndCapturesIncidentArtifacts_WhenIncidentModeActive()
{
_incidentState.SetState(true, retentionExtensionDays: 45, captureSnapshot: true);
Assert.True(_incidentState.Current.IsActive);
Assert.Equal(45, _incidentState.Current.RetentionExtensionDays);
var request = new EvidenceSnapshotRequest
{
Kind = EvidenceBundleKind.Job,
Metadata = new Dictionary<string, string> { ["run"] = "diagnostic" },
Materials = new List<EvidenceSnapshotMaterial>
{
new() { Section = "inputs", Path = "input.txt", Sha256 = ValidSha256, SizeBytes = 32 }
}
};
var tenantId = TenantId.FromGuid(Guid.NewGuid());
var result = await _service.CreateSnapshotAsync(tenantId, request, CancellationToken.None);
Assert.NotNull(_repository.CreatedBundle);
Assert.True(_repository.CreatedBundle!.ExpiresAt.HasValue);
Assert.NotNull(_repository.LastCreatedExpiresAt);
Assert.NotNull(_repository.LastCreatedAt);
Assert.Equal(
_repository.LastCreatedAt!.Value.AddDays(45),
_repository.LastCreatedExpiresAt!.Value,
TimeSpan.FromSeconds(1));
Assert.NotEmpty(_objectStore.StoredArtifacts);
var incidentEntry = result.Manifest.Entries.Single(e => e.Section == "incident");
Assert.True(result.Manifest.Metadata.ContainsKey("incident.mode"));
Assert.Equal("enabled", result.Manifest.Metadata["incident.mode"]);
Assert.StartsWith("incident/request-", incidentEntry.CanonicalPath, StringComparison.Ordinal);
Assert.Equal("application/json", incidentEntry.MediaType);
}
private static string Sha(char fill) => new string(fill, 64);
private sealed class FakeRepository : IEvidenceBundleRepository
{
public TenantId? LastCreateTenant { get; private set; }
public EvidenceBundleStatus? LastCreatedStatus { get; private set; }
public EvidenceBundleId? LastCreatedBundleId { get; private set; }
public bool NextExistsResult { get; set; } = true;
public bool ThrowUniqueViolationForHolds { get; set; }
public bool SignatureUpserted { get; private set; }
public bool RetentionExtended { get; private set; }
public EvidenceBundleId? RetentionBundleId { get; private set; }
public TenantId? RetentionTenant { get; private set; }
public DateTimeOffset? RetentionExpiresAt { get; private set; }
public DateTimeOffset? RetentionProcessedAt { get; private set; }
public string? AssemblyRootHash { get; private set; }
public EvidenceBundleStatus? AssemblyStatus { get; private set; }
public DateTimeOffset? LastCreatedExpiresAt { get; private set; }
public DateTimeOffset? LastCreatedAt { get; private set; }
public EvidenceBundle? CreatedBundle { get; private set; }
public Task CreateBundleAsync(EvidenceBundle bundle, CancellationToken cancellationToken)
{
LastCreateTenant = bundle.TenantId;
LastCreatedStatus = bundle.Status;
LastCreatedBundleId = bundle.Id;
LastCreatedExpiresAt = bundle.ExpiresAt;
LastCreatedAt = bundle.CreatedAt;
CreatedBundle = bundle;
return Task.CompletedTask;
}
public Task SetBundleAssemblyAsync(EvidenceBundleId bundleId, TenantId tenantId, EvidenceBundleStatus status, string rootHash, DateTimeOffset updatedAt, CancellationToken cancellationToken)
{
AssemblyStatus = status;
AssemblyRootHash = rootHash;
return Task.CompletedTask;
}
public Task MarkBundleSealedAsync(EvidenceBundleId bundleId, TenantId tenantId, EvidenceBundleStatus status, DateTimeOffset sealedAt, CancellationToken cancellationToken)
=> Task.CompletedTask;
public Task UpsertSignatureAsync(EvidenceBundleSignature signature, CancellationToken cancellationToken)
{
SignatureUpserted = true;
return Task.CompletedTask;
}
public Task<EvidenceBundleDetails?> GetBundleAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken)
=> Task.FromResult<EvidenceBundleDetails?>(null);
public Task<bool> ExistsAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken)
=> Task.FromResult(NextExistsResult);
public Task<EvidenceHold> CreateHoldAsync(EvidenceHold hold, CancellationToken cancellationToken)
{
if (ThrowUniqueViolationForHolds)
{
throw CreateUniqueViolationException();
}
return Task.FromResult(hold);
}
public Task ExtendBundleRetentionAsync(
EvidenceBundleId bundleId,
TenantId tenantId,
DateTimeOffset? holdExpiresAt,
DateTimeOffset processedAt,
CancellationToken cancellationToken)
{
RetentionExtended = true;
RetentionBundleId = bundleId;
RetentionTenant = tenantId;
RetentionExpiresAt = holdExpiresAt;
RetentionProcessedAt = processedAt;
return Task.CompletedTask;
}
public Task UpdateStorageKeyAsync(
EvidenceBundleId bundleId,
TenantId tenantId,
string storageKey,
CancellationToken cancellationToken)
=> Task.CompletedTask;
public Task UpdatePortableStorageKeyAsync(
EvidenceBundleId bundleId,
TenantId tenantId,
string storageKey,
DateTimeOffset generatedAt,
CancellationToken cancellationToken)
=> Task.CompletedTask;
#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);
}
}
private sealed class FakeBuilder : IEvidenceBundleBuilder
{
public string RootHash { get; } = DefaultRootHash;
public bool Invoked { get; private set; }
public EvidenceBundleId? OverrideBundleId { get; set; }
public Task<EvidenceBundleBuildResult> BuildAsync(EvidenceBundleBuildRequest request, CancellationToken cancellationToken)
{
Invoked = true;
var effectiveBundleId = OverrideBundleId ?? request.BundleId;
var manifest = new EvidenceBundleManifest(
effectiveBundleId,
request.TenantId,
request.Kind,
request.CreatedAt,
request.Metadata,
request.Materials.Select(m => new EvidenceManifestEntry(
m.Section,
$"{m.Section}/{m.Path}",
m.Sha256,
m.SizeBytes,
m.MediaType,
m.Attributes ?? new Dictionary<string, string>())).ToList());
return Task.FromResult(new EvidenceBundleBuildResult(RootHash, manifest));
}
}
private sealed class FakeSignatureService : IEvidenceSignatureService
{
public EvidenceBundleSignature? NextSignature { get; set; }
public Task<EvidenceBundleSignature?> SignManifestAsync(EvidenceBundleId bundleId, TenantId tenantId, EvidenceBundleManifest manifest, CancellationToken cancellationToken)
{
if (NextSignature is null)
{
return Task.FromResult<EvidenceBundleSignature?>(null);
}
return Task.FromResult<EvidenceBundleSignature?>(NextSignature with { BundleId = bundleId, TenantId = tenantId });
}
}
private sealed class FakeTimelinePublisher : IEvidenceTimelinePublisher
{
public bool BundleSealedPublished { get; private set; }
public bool HoldPublished { get; private set; }
public string? LastBundleRoot { get; private set; }
public List<string> IncidentEvents { get; } = new();
public Task PublishBundleSealedAsync(
EvidenceBundleSignature signature,
EvidenceBundleManifest manifest,
string rootHash,
CancellationToken cancellationToken)
{
BundleSealedPublished = true;
LastBundleRoot = rootHash;
return Task.CompletedTask;
}
public Task PublishHoldCreatedAsync(EvidenceHold hold, CancellationToken cancellationToken)
{
HoldPublished = true;
return Task.CompletedTask;
}
public Task PublishIncidentModeChangedAsync(IncidentModeChange change, CancellationToken cancellationToken)
{
IncidentEvents.Add(change.IsActive ? "enabled" : "disabled");
return Task.CompletedTask;
}
}
private sealed class TestIncidentState : IIncidentModeState
{
private IncidentModeSnapshot _snapshot = new(false, DateTimeOffset.UtcNow, 0, false);
public IncidentModeSnapshot Current => _snapshot;
public bool IsActive => _snapshot.IsActive;
public void SetState(bool isActive, int retentionExtensionDays, bool captureSnapshot)
{
_snapshot = new IncidentModeSnapshot(
isActive,
DateTimeOffset.UtcNow,
retentionExtensionDays,
captureSnapshot);
}
}
private sealed class TestObjectStore : IEvidenceObjectStore
{
private readonly Dictionary<string, byte[]> _objects = new(StringComparer.Ordinal);
public List<EvidenceObjectMetadata> StoredArtifacts { get; } = new();
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}";
var sha = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
var metadata = new EvidenceObjectMetadata(
storageKey,
options.ContentType,
bytes.LongLength,
sha,
ETag: null,
CreatedAt: DateTimeOffset.UtcNow);
_objects[storageKey] = bytes;
StoredArtifacts.Add(metadata);
return Task.FromResult(metadata);
}
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(_objects.ContainsKey(storageKey));
}
}

View File

@@ -0,0 +1,84 @@
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.EvidenceLocker.Core.Configuration;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Core.Storage;
using StellaOps.EvidenceLocker.Infrastructure.Storage;
namespace StellaOps.EvidenceLocker.Tests;
public sealed class FileSystemEvidenceObjectStoreTests : IDisposable
{
private readonly string _rootPath;
public FileSystemEvidenceObjectStoreTests()
{
_rootPath = Path.Combine(Path.GetTempPath(), $"evidence-locker-tests-{Guid.NewGuid():N}");
}
[Fact]
public async Task StoreAsync_EnforcesWriteOnceWhenConfigured()
{
var cancellationToken = TestContext.Current.CancellationToken;
var store = CreateStore(enforceWriteOnce: true);
var options = CreateWriteOptions();
using var first = CreateStream("payload-1");
await store.StoreAsync(first, options, cancellationToken);
using var second = CreateStream("payload-1");
await Assert.ThrowsAsync<InvalidOperationException>(() => store.StoreAsync(second, options, cancellationToken));
}
[Fact]
public async Task StoreAsync_AllowsOverwriteWhenWriteOnceDisabled()
{
var cancellationToken = TestContext.Current.CancellationToken;
var store = CreateStore(enforceWriteOnce: false);
var options = CreateWriteOptions() with { EnforceWriteOnce = false };
using var first = CreateStream("payload-1");
var firstMetadata = await store.StoreAsync(first, options, cancellationToken);
using var second = CreateStream("payload-1");
var secondMetadata = await store.StoreAsync(second, options, cancellationToken);
Assert.Equal(firstMetadata.Sha256, secondMetadata.Sha256);
Assert.True(File.Exists(Path.Combine(_rootPath, secondMetadata.StorageKey.Replace('/', Path.DirectorySeparatorChar))));
}
private FileSystemEvidenceObjectStore CreateStore(bool enforceWriteOnce)
{
var fileSystemOptions = new FileSystemStoreOptions
{
RootPath = _rootPath
};
return new FileSystemEvidenceObjectStore(
fileSystemOptions,
enforceWriteOnce,
NullLogger<FileSystemEvidenceObjectStore>.Instance);
}
private static EvidenceObjectWriteOptions CreateWriteOptions()
{
var tenant = TenantId.FromGuid(Guid.NewGuid());
var bundle = EvidenceBundleId.FromGuid(Guid.NewGuid());
return new EvidenceObjectWriteOptions(
tenant,
bundle,
"artifact.txt",
"text/plain");
}
private static MemoryStream CreateStream(string content)
=> new(Encoding.UTF8.GetBytes(content));
public void Dispose()
{
if (Directory.Exists(_rootPath))
{
Directory.Delete(_rootPath, recursive: true);
}
}
}

View File

@@ -0,0 +1,81 @@
using System;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography;
using StellaOps.EvidenceLocker.Core.Configuration;
using StellaOps.EvidenceLocker.Core.Signing;
using StellaOps.EvidenceLocker.Infrastructure.Signing;
using Xunit;
namespace StellaOps.EvidenceLocker.Tests;
public sealed class Rfc3161TimestampAuthorityClientTests
{
[Fact]
public async Task RequestTimestampAsync_ReturnsNull_WhenAuthorityFailsAndTimestampOptional()
{
var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.InternalServerError));
var client = CreateClient(handler, new TimestampingOptions
{
Enabled = true,
Endpoint = "https://tsa.example",
HashAlgorithm = "SHA256",
RequireTimestamp = false
});
var result = await client.RequestTimestampAsync(new byte[] { 4, 5, 6 }, "SHA256", CancellationToken.None);
Assert.Null(result);
}
[Fact]
public async Task RequestTimestampAsync_Throws_WhenAuthorityFailsAndTimestampRequired()
{
var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.InternalServerError));
var client = CreateClient(handler, new TimestampingOptions
{
Enabled = true,
Endpoint = "https://tsa.example",
HashAlgorithm = "SHA256",
RequireTimestamp = true
});
await Assert.ThrowsAsync<InvalidOperationException>(() => client.RequestTimestampAsync(new byte[] { 7, 8 }, "SHA256", CancellationToken.None));
}
private static Rfc3161TimestampAuthorityClient CreateClient(HttpMessageHandler handler, TimestampingOptions timestampingOptions)
{
var httpClient = new HttpClient(handler, disposeHandler: false);
var options = Options.Create(new EvidenceLockerOptions
{
Database = new DatabaseOptions { ConnectionString = "Host=localhost" },
ObjectStore = new ObjectStoreOptions
{
Kind = ObjectStoreKind.FileSystem,
FileSystem = new FileSystemStoreOptions { RootPath = "." }
},
Quotas = new QuotaOptions(),
Signing = new SigningOptions
{
Algorithm = SignatureAlgorithms.Es256,
KeyId = "test-key",
Timestamping = timestampingOptions
}
});
return new Rfc3161TimestampAuthorityClient(httpClient, options, NullLogger<Rfc3161TimestampAuthorityClient>.Instance);
}
private sealed class StubHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responder) : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responder = responder;
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(_responder(request));
}
}

View File

@@ -0,0 +1,139 @@
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Amazon;
using Amazon.Runtime;
using Amazon.S3;
using Amazon.S3.Model;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.EvidenceLocker.Core.Configuration;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Core.Storage;
using StellaOps.EvidenceLocker.Infrastructure.Storage;
namespace StellaOps.EvidenceLocker.Tests;
public sealed class S3EvidenceObjectStoreTests
{
[Fact]
public async Task StoreAsync_SetsIfNoneMatchAndMetadataWhenEnforcingWriteOnce()
{
var fakeClient = new FakeAmazonS3Client();
using var store = new S3EvidenceObjectStore(
fakeClient,
new AmazonS3StoreOptions
{
BucketName = "evidence",
Region = RegionEndpoint.USEast1.SystemName,
Prefix = "locker"
},
enforceWriteOnce: true,
NullLogger<S3EvidenceObjectStore>.Instance);
var options = new EvidenceObjectWriteOptions(
TenantId.FromGuid(Guid.NewGuid()),
EvidenceBundleId.FromGuid(Guid.NewGuid()),
"bundle-manifest.json",
"application/json",
EnforceWriteOnce: true,
Tags: new Dictionary<string, string>
{
{ "case", "incident-123" }
});
var metadata = await store.StoreAsync(new MemoryStream(Encoding.UTF8.GetBytes("{\"value\":1}")), options, CancellationToken.None);
var request = fakeClient.PutRequests.Single();
Assert.Equal("evidence", request.Bucket);
Assert.StartsWith("locker/tenants/", request.Key, StringComparison.Ordinal);
Assert.Equal("*", request.IfNoneMatch);
var shaEntry = request.Metadata.FirstOrDefault(kvp => kvp.Key.EndsWith("sha256", StringComparison.OrdinalIgnoreCase));
Assert.NotEqual(default(KeyValuePair<string, string>), shaEntry);
Assert.Equal(metadata.Sha256, shaEntry.Value);
var tenantEntry = request.Metadata.FirstOrDefault(kvp => kvp.Key.EndsWith("tenant-id", StringComparison.OrdinalIgnoreCase));
Assert.NotEqual(default(KeyValuePair<string, string>), tenantEntry);
Assert.Equal(options.TenantId.Value.ToString("D"), tenantEntry.Value);
Assert.Contains(request.Tags, tag => tag.Key == "case" && tag.Value == "incident-123");
Assert.Equal("\"etag\"", metadata.ETag);
}
[Fact]
public async Task StoreAsync_DoesNotSetIfNoneMatchWhenWriteOnceDisabled()
{
var fakeClient = new FakeAmazonS3Client();
using var store = new S3EvidenceObjectStore(
fakeClient,
new AmazonS3StoreOptions
{
BucketName = "evidence",
Region = RegionEndpoint.USEast1.SystemName
},
enforceWriteOnce: false,
NullLogger<S3EvidenceObjectStore>.Instance);
var options = new EvidenceObjectWriteOptions(
TenantId.FromGuid(Guid.NewGuid()),
EvidenceBundleId.FromGuid(Guid.NewGuid()),
"artifact.bin",
"application/octet-stream",
EnforceWriteOnce: false);
await store.StoreAsync(new MemoryStream(Encoding.UTF8.GetBytes("payload")), options, CancellationToken.None);
var request = fakeClient.PutRequests.Single();
Assert.Null(request.IfNoneMatch);
}
private sealed class FakeAmazonS3Client : AmazonS3Client
{
public FakeAmazonS3Client()
: base(new AnonymousAWSCredentials(), new AmazonS3Config
{
RegionEndpoint = RegionEndpoint.USEast1,
ForcePathStyle = true,
UseHttp = true,
ServiceURL = "http://localhost"
})
{
}
public List<CapturedPutObjectRequest> PutRequests { get; } = new();
public override Task<PutObjectResponse> PutObjectAsync(PutObjectRequest request, CancellationToken cancellationToken = default)
{
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var key in request.Metadata.Keys)
{
metadata[key] = request.Metadata[key];
}
var tags = request.TagSet?.Select(tag => new KeyValuePair<string, string>(tag.Key, tag.Value)).ToList()
?? new List<KeyValuePair<string, string>>();
var ifNoneMatch = request.Headers?["If-None-Match"];
using var memory = new MemoryStream();
request.InputStream.CopyTo(memory);
PutRequests.Add(new CapturedPutObjectRequest(
request.BucketName,
request.Key,
metadata,
tags,
ifNoneMatch,
memory.ToArray()));
return Task.FromResult(new PutObjectResponse
{
ETag = "\"etag\""
});
}
public sealed record CapturedPutObjectRequest(
string Bucket,
string Key,
IDictionary<string, string> Metadata,
IList<KeyValuePair<string, string>> Tags,
string? IfNoneMatch,
byte[] Payload);
}
}

View File

@@ -1,135 +1,35 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
<PackageReference Include="xunit.v3" Version="3.0.0"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.3"/>
</ItemGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest"/>
</ItemGroup>
<ItemGroup>
<Using Include="Xunit"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.EvidenceLocker.Core\StellaOps.EvidenceLocker.Core.csproj"/>
<ProjectReference Include="..\StellaOps.EvidenceLocker.Infrastructure\StellaOps.EvidenceLocker.Infrastructure.csproj"/>
</ItemGroup>
</Project>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DotNet.Testcontainers" Version="1.6.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0-preview.7.25380.108" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="Npgsql" Version="8.0.3" />
<PackageReference Include="xunit.v3" Version="3.0.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.3" />
</ItemGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.EvidenceLocker.Core\StellaOps.EvidenceLocker.Core.csproj" />
<ProjectReference Include="..\StellaOps.EvidenceLocker.Infrastructure\StellaOps.EvidenceLocker.Infrastructure.csproj" />
<ProjectReference Include="..\StellaOps.EvidenceLocker.WebService\StellaOps.EvidenceLocker.WebService.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,180 @@
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography;
using StellaOps.EvidenceLocker.Core.Builders;
using StellaOps.EvidenceLocker.Core.Configuration;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Infrastructure.Timeline;
namespace StellaOps.EvidenceLocker.Tests;
public sealed class TimelineIndexerEvidenceTimelinePublisherTests
{
[Fact]
public async Task PublishBundleSealedAsync_SendsExpectedPayload()
{
var tenantId = TenantId.FromGuid(Guid.NewGuid());
var bundleId = EvidenceBundleId.FromGuid(Guid.NewGuid());
var manifest = new EvidenceBundleManifest(
bundleId,
tenantId,
EvidenceBundleKind.Job,
DateTimeOffset.Parse("2025-11-03T12:00:00Z"),
new Dictionary<string, string>
{
["project"] = "atlas",
["environment"] = "prod"
},
new List<EvidenceManifestEntry>
{
new(
"inputs",
"inputs/config.json",
new string('a', 64),
128,
"application/json",
new Dictionary<string, string> { ["role"] = "primary" })
});
var signature = new EvidenceBundleSignature(
bundleId,
tenantId,
"application/vnd.stella.manifest+json",
Convert.ToBase64String(new byte[] { 1, 2, 3 }),
Convert.ToBase64String(new byte[] { 4, 5, 6 }),
"key-1",
SignatureAlgorithms.Es256,
"default",
DateTimeOffset.Parse("2025-11-03T12:05:00Z"),
DateTimeOffset.Parse("2025-11-03T12:06:00Z"),
"tsa://test",
new byte[] { 9, 8, 7 });
var handler = new RecordingHttpMessageHandler(HttpStatusCode.Accepted);
var client = new HttpClient(handler);
var publisher = new TimelineIndexerEvidenceTimelinePublisher(
client,
CreateOptions(),
TimeProvider.System,
NullLogger<TimelineIndexerEvidenceTimelinePublisher>.Instance);
await publisher.PublishBundleSealedAsync(signature, manifest, new string('f', 64), CancellationToken.None);
var request = Assert.Single(handler.Requests);
Assert.Equal(HttpMethod.Post, request.Method);
Assert.Equal("https://timeline.test/events", request.Uri?.ToString());
Assert.NotNull(request.Content);
using var json = JsonDocument.Parse(request.Content!);
var root = json.RootElement;
Assert.Equal("evidence.bundle.sealed", root.GetProperty("kind").GetString());
Assert.Equal(signature.BundleId.Value.ToString("D"), root.GetProperty("attributes").GetProperty("bundleId").GetString());
var bundle = root.GetProperty("bundle");
Assert.Equal(signature.BundleId.Value.ToString("D"), bundle.GetProperty("bundleId").GetString());
Assert.Equal(new string('f', 64), bundle.GetProperty("rootHash").GetString());
var signatureJson = bundle.GetProperty("signature");
Assert.Equal(Convert.ToBase64String(new byte[] { 4, 5, 6 }), signatureJson.GetProperty("signature").GetString());
Assert.Equal(Convert.ToBase64String(new byte[] { 9, 8, 7 }), signatureJson.GetProperty("timestampToken").GetString());
var metadata = bundle.GetProperty("metadata");
Assert.Equal("atlas", metadata.GetProperty("project").GetString());
Assert.Equal("prod", metadata.GetProperty("environment").GetString());
var entry = Assert.Single(bundle.GetProperty("entries").EnumerateArray());
Assert.Equal("inputs", entry.GetProperty("section").GetString());
Assert.Equal("inputs/config.json", entry.GetProperty("canonicalPath").GetString());
Assert.Equal("primary", entry.GetProperty("attributes").GetProperty("role").GetString());
}
[Fact]
public async Task PublishHoldCreatedAsync_ProducesHoldPayload()
{
var tenantId = TenantId.FromGuid(Guid.NewGuid());
var hold = new EvidenceHold(
EvidenceHoldId.FromGuid(Guid.NewGuid()),
tenantId,
EvidenceBundleId.FromGuid(Guid.NewGuid()),
"case-001",
"legal",
DateTimeOffset.Parse("2025-10-31T09:00:00Z"),
DateTimeOffset.Parse("2025-12-31T00:00:00Z"),
ReleasedAt: null,
Notes: "retain until close");
var handler = new RecordingHttpMessageHandler(HttpStatusCode.BadGateway);
var client = new HttpClient(handler);
var publisher = new TimelineIndexerEvidenceTimelinePublisher(
client,
CreateOptions(),
TimeProvider.System,
NullLogger<TimelineIndexerEvidenceTimelinePublisher>.Instance);
await publisher.PublishHoldCreatedAsync(hold, CancellationToken.None);
var request = Assert.Single(handler.Requests);
Assert.Equal(HttpMethod.Post, request.Method);
using var json = JsonDocument.Parse(request.Content!);
var root = json.RootElement;
Assert.Equal("evidence.hold.created", root.GetProperty("kind").GetString());
Assert.Equal(hold.CaseId, root.GetProperty("attributes").GetProperty("caseId").GetString());
var holdJson = root.GetProperty("hold");
Assert.Equal(hold.Id.Value.ToString("D"), holdJson.GetProperty("holdId").GetString());
Assert.Equal(hold.BundleId?.Value.ToString("D"), holdJson.GetProperty("bundleId").GetString());
}
private static IOptions<EvidenceLockerOptions> CreateOptions()
=> Options.Create(new EvidenceLockerOptions
{
Database = new DatabaseOptions { ConnectionString = "Host=localhost" },
ObjectStore = new ObjectStoreOptions
{
Kind = ObjectStoreKind.FileSystem,
FileSystem = new FileSystemStoreOptions { RootPath = "." }
},
Quotas = new QuotaOptions(),
Signing = new SigningOptions
{
Enabled = true,
Algorithm = SignatureAlgorithms.Es256,
KeyId = "test-key"
},
Timeline = new TimelineOptions
{
Enabled = true,
Endpoint = "https://timeline.test/events",
Source = "test-source"
}
});
private sealed class RecordingHttpMessageHandler : HttpMessageHandler
{
private readonly HttpStatusCode _statusCode;
public RecordingHttpMessageHandler(HttpStatusCode statusCode)
{
_statusCode = statusCode;
}
public List<RecordedRequest> Requests { get; } = new();
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var content = request.Content is null
? null
: await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
Requests.Add(new RecordedRequest(request.Method, request.RequestUri, content));
return new HttpResponseMessage(_statusCode);
}
}
private sealed record RecordedRequest(HttpMethod Method, Uri? Uri, string? Content);
}

View File

@@ -1,10 +0,0 @@
namespace StellaOps.EvidenceLocker.Tests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}