522 lines
20 KiB
C#
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));
|
|
}
|
|
}
|