Add unit tests for SBOM ingestion and transformation
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
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:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace StellaOps.EvidenceLocker.Tests;
|
||||
|
||||
public class UnitTest1
|
||||
{
|
||||
[Fact]
|
||||
public void Test1()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user