Files
git.stella-ops.org/src/EvidenceLocker/StellaOps.EvidenceLocker/StellaOps.EvidenceLocker.Tests/EvidenceSnapshotServiceTests.cs
2026-02-01 21:37:40 +02:00

522 lines
20 KiB
C#

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.Incident;
using StellaOps.EvidenceLocker.Core.Repositories;
using StellaOps.EvidenceLocker.Core.Signing;
using StellaOps.EvidenceLocker.Core.Storage;
using StellaOps.EvidenceLocker.Core.Timeline;
using StellaOps.EvidenceLocker.Infrastructure.Services;
using StellaOps.TestKit;
using System.Linq;
using System.Reflection;
using System.Runtime.Serialization;
using System.Security.Cryptography;
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);
}
[Trait("Category", TestCategories.Unit)]
[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);
}
[Trait("Category", TestCategories.Unit)]
[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);
}
[Trait("Category", TestCategories.Unit)]
[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));
}
[Trait("Category", TestCategories.Unit)]
[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);
}
[Trait("Category", TestCategories.Unit)]
[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));
}
[Trait("Category", TestCategories.Unit)]
[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));
}
[Trait("Category", TestCategories.Unit)]
[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<IReadOnlyList<EvidenceBundleDetails>> GetBundlesForReindexAsync(
TenantId tenantId,
DateTimeOffset? since,
DateTimeOffset? cursorUpdatedAt,
EvidenceBundleId? cursorBundleId,
int limit,
CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyList<EvidenceBundleDetails>>(Array.Empty<EvidenceBundleDetails>());
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));
}
}