stabilize tests

This commit is contained in:
master
2026-02-01 21:37:40 +02:00
parent 55744f6a39
commit 5d5e80b2e4
6435 changed files with 33984 additions and 13802 deletions

View File

@@ -1,4 +1,4 @@
# Evidence Locker Service Agent Charter
# Evidence Locker Service ??? Agent Charter
## Mission
Implement the append-only, tenant-scoped evidence locker detailed in Epic 15. Produce immutable evidence bundles, manage legal holds, and expose verification APIs for Console and CLI consumers under the imposed rule.
@@ -17,15 +17,15 @@ Implement the append-only, tenant-scoped evidence locker detailed in Epic 15. Pr
## Definition of Done
- Deterministic bundle generation proven via integration tests.
- Object store interactions tested in offline mode.
- Runbooks in `/docs/forensics/evidence-locker.md` updated per release.
- Runbooks in `/docs/modules/evidence-locker/guides/evidence-locker.md` updated per release.
## Module Layout
- `StellaOps.EvidenceLocker.Core/` domain models, bundle contracts, deterministic hashing helpers.
- `StellaOps.EvidenceLocker.Infrastructure/` storage abstractions, persistence plumbing, and external integrations.
- `StellaOps.EvidenceLocker.WebService/` HTTP entry points (minimal API host, OpenAPI, auth).
- `StellaOps.EvidenceLocker.Worker/` background assembly/verification pipelines.
- `StellaOps.EvidenceLocker.Tests/` unit tests (xUnit) for core/infrastructure components.
- `StellaOps.EvidenceLocker.sln` solution aggregating the module projects.
- `StellaOps.EvidenceLocker.Core/` ??? domain models, bundle contracts, deterministic hashing helpers.
- `StellaOps.EvidenceLocker.Infrastructure/` ??? storage abstractions, persistence plumbing, and external integrations.
- `StellaOps.EvidenceLocker.WebService/` ??? HTTP entry points (minimal API host, OpenAPI, auth).
- `StellaOps.EvidenceLocker.Worker/` ??? background assembly/verification pipelines.
- `StellaOps.EvidenceLocker.Tests/` ??? unit tests (xUnit) for core/infrastructure components.
- `StellaOps.EvidenceLocker.sln` ??? solution aggregating the module projects.
## Required Reading
- `docs/modules/export-center/architecture.md`
@@ -37,3 +37,4 @@ Implement the append-only, tenant-scoped evidence locker detailed in Epic 15. Pr
- 3. Keep changes deterministic (stable ordering, timestamps, hashes) and align with offline/air-gap expectations.
- 4. Coordinate doc updates, tests, and cross-guild communication whenever contracts or workflows change.
- 5. Revert to `TODO` if you pause the task without shipping changes; leave notes in commit/PR descriptions for context.

View File

@@ -5,11 +5,12 @@
// Description: Service implementation for managing evidence bundle export jobs.
// -----------------------------------------------------------------------------
using System.Collections.Concurrent;
using System.Globalization;
using Microsoft.Extensions.Logging;
using StellaOps.EvidenceLocker.Export;
using StellaOps.EvidenceLocker.Storage;
using System.Collections.Concurrent;
using System.Globalization;
namespace StellaOps.EvidenceLocker.Api;

View File

@@ -1,9 +1,10 @@
using System.Text.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using StellaOps.EvidenceLocker.Storage;
using System.Text.Json;
namespace StellaOps.EvidenceLocker.Api;

View File

@@ -1,5 +1,7 @@
using System.Collections.Immutable;
using StellaOps.EvidenceLocker.Core.Domain;
using System.Collections.Immutable;
namespace StellaOps.EvidenceLocker.Core.Builders;

View File

@@ -1,5 +1,7 @@
using System.Text;
using StellaOps.Cryptography;
using System.Text;
namespace StellaOps.EvidenceLocker.Core.Builders;

View File

@@ -1,5 +1,7 @@
using System.ComponentModel.DataAnnotations;
using StellaOps.Cryptography;
using System.ComponentModel.DataAnnotations;
namespace StellaOps.EvidenceLocker.Core.Configuration;

View File

@@ -1,6 +1,8 @@
using StellaOps.EvidenceLocker.Core.Builders;
using System;
using System.Collections.Generic;
using StellaOps.EvidenceLocker.Core.Builders;
namespace StellaOps.EvidenceLocker.Core.Domain;

View File

@@ -1,6 +1,8 @@
using StellaOps.EvidenceLocker.Core.Incident;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.EvidenceLocker.Core.Incident;
namespace StellaOps.EvidenceLocker.Core.Notifications;

View File

@@ -1,6 +1,8 @@
using StellaOps.EvidenceLocker.Core.Domain;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.EvidenceLocker.Core.Domain;
namespace StellaOps.EvidenceLocker.Core.Repositories;

View File

@@ -1,7 +1,9 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.EvidenceLocker.Core.Builders;
using StellaOps.EvidenceLocker.Core.Domain;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.EvidenceLocker.Core.Signing;

View File

@@ -1,5 +1,7 @@
using System.Collections.Generic;
using StellaOps.EvidenceLocker.Core.Domain;
using System.Collections.Generic;
namespace StellaOps.EvidenceLocker.Core.Storage;

View File

@@ -1,8 +1,10 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.EvidenceLocker.Core.Builders;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Core.Incident;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.EvidenceLocker.Core.Timeline;

View File

@@ -1,8 +1,10 @@
using System.Collections.Immutable;
using System.Text;
using StellaOps.EvidenceLocker.Core.Builders;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Core.Repositories;
using System.Collections.Immutable;
using System.Text;
namespace StellaOps.EvidenceLocker.Infrastructure.Builders;

View File

@@ -12,6 +12,16 @@ internal sealed class EvidenceLockerMigrationRunner(
EvidenceLockerDataSource dataSource,
ILogger<EvidenceLockerMigrationRunner> logger) : IEvidenceLockerMigrationRunner
{
private const string EnsureSchemaSql = """
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.schemata WHERE schema_name = 'evidence_locker') THEN
CREATE SCHEMA evidence_locker;
END IF;
END
$$;
""";
private const string VersionTableSql = """
CREATE TABLE IF NOT EXISTS evidence_locker.evidence_schema_version
(
@@ -39,6 +49,7 @@ internal sealed class EvidenceLockerMigrationRunner(
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
await EnsureSchemaAsync(connection, transaction, cancellationToken);
await EnsureVersionTableAsync(connection, transaction, cancellationToken);
var appliedScripts = await LoadAppliedScriptsAsync(connection, transaction, cancellationToken);
@@ -67,6 +78,12 @@ internal sealed class EvidenceLockerMigrationRunner(
await transaction.CommitAsync(cancellationToken);
}
private static async Task EnsureSchemaAsync(NpgsqlConnection connection, NpgsqlTransaction transaction, CancellationToken cancellationToken)
{
await using var command = new NpgsqlCommand(EnsureSchemaSql, connection, transaction);
await command.ExecuteNonQueryAsync(cancellationToken);
}
private static async Task EnsureVersionTableAsync(NpgsqlConnection connection, NpgsqlTransaction transaction, CancellationToken cancellationToken)
{
await using var command = new NpgsqlCommand(VersionTableSql, connection, transaction);

View File

@@ -1,3 +1,5 @@
using System.Reflection;
namespace StellaOps.EvidenceLocker.Infrastructure.Db;

View File

@@ -27,7 +27,10 @@ internal sealed class MigrationScript
public static bool TryCreate(string resourceName, string sql, [NotNullWhen(true)] out MigrationScript? script)
{
var fileName = resourceName.Split('.').Last();
// Resource names are e.g. "Namespace.Db.Migrations.001_initial_schema.sql"
// Split by '.' gives [..., "001_initial_schema", "sql"]; we need the second-to-last segment.
var parts = resourceName.Split('.');
var fileName = parts.Length >= 2 ? parts[^2] : resourceName;
var match = VersionRegex.Match(fileName);
if (!match.Success || !int.TryParse(match.Groups["version"].Value, out var version))

View File

@@ -39,8 +39,10 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_evidence_bundles_storage_key
ALTER TABLE evidence_locker.evidence_bundles
ENABLE ROW LEVEL SECURITY;
ALTER TABLE evidence_locker.evidence_bundles
FORCE ROW LEVEL SECURITY;
CREATE POLICY IF NOT EXISTS evidence_bundles_isolation
CREATE POLICY evidence_bundles_isolation
ON evidence_locker.evidence_bundles
USING (tenant_id = evidence_locker_app.require_current_tenant())
WITH CHECK (tenant_id = evidence_locker_app.require_current_tenant());
@@ -67,8 +69,10 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_evidence_artifacts_storage_key
ALTER TABLE evidence_locker.evidence_artifacts
ENABLE ROW LEVEL SECURITY;
ALTER TABLE evidence_locker.evidence_artifacts
FORCE ROW LEVEL SECURITY;
CREATE POLICY IF NOT EXISTS evidence_artifacts_isolation
CREATE POLICY evidence_artifacts_isolation
ON evidence_locker.evidence_artifacts
USING (tenant_id = evidence_locker_app.require_current_tenant())
WITH CHECK (tenant_id = evidence_locker_app.require_current_tenant());
@@ -93,8 +97,10 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_evidence_holds_case
ALTER TABLE evidence_locker.evidence_holds
ENABLE ROW LEVEL SECURITY;
ALTER TABLE evidence_locker.evidence_holds
FORCE ROW LEVEL SECURITY;
CREATE POLICY IF NOT EXISTS evidence_holds_isolation
CREATE POLICY evidence_holds_isolation
ON evidence_locker.evidence_holds
USING (tenant_id = evidence_locker_app.require_current_tenant())
WITH CHECK (tenant_id = evidence_locker_app.require_current_tenant());

View File

@@ -1,6 +1,5 @@
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using Amazon;
using Amazon.S3;
using Microsoft.Extensions.Configuration;
@@ -15,8 +14,8 @@ using StellaOps.EvidenceLocker.Core.Builders;
using StellaOps.EvidenceLocker.Core.Configuration;
using StellaOps.EvidenceLocker.Core.Incident;
using StellaOps.EvidenceLocker.Core.Notifications;
using StellaOps.EvidenceLocker.Core.Repositories;
using StellaOps.EvidenceLocker.Core.Reindexing;
using StellaOps.EvidenceLocker.Core.Repositories;
using StellaOps.EvidenceLocker.Core.Signing;
using StellaOps.EvidenceLocker.Core.Storage;
using StellaOps.EvidenceLocker.Core.Timeline;
@@ -28,6 +27,9 @@ using StellaOps.EvidenceLocker.Infrastructure.Services;
using StellaOps.EvidenceLocker.Infrastructure.Signing;
using StellaOps.EvidenceLocker.Infrastructure.Storage;
using StellaOps.EvidenceLocker.Infrastructure.Timeline;
using System;
using System.Net.Http;
using System.Net.Http.Headers;
namespace StellaOps.EvidenceLocker.Infrastructure.DependencyInjection;

View File

@@ -1,10 +1,12 @@
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using StellaOps.EvidenceLocker.Core.Builders;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Core.Reindexing;
using StellaOps.EvidenceLocker.Core.Repositories;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
namespace StellaOps.EvidenceLocker.Infrastructure.Reindexing;

View File

@@ -1,12 +1,14 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Npgsql;
using NpgsqlTypes;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Core.Repositories;
using StellaOps.EvidenceLocker.Infrastructure.Db;
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.EvidenceLocker.Infrastructure.Repositories;

View File

@@ -1,3 +1,9 @@
using Microsoft.Extensions.Logging;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Core.Repositories;
using StellaOps.EvidenceLocker.Core.Storage;
using System.Buffers.Binary;
using System.Formats.Tar;
using System.Globalization;
@@ -5,10 +11,6 @@ using System.IO;
using System.IO.Compression;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Core.Repositories;
using StellaOps.EvidenceLocker.Core.Storage;
namespace StellaOps.EvidenceLocker.Infrastructure.Services;

View File

@@ -1,3 +1,11 @@
using Microsoft.Extensions.Logging;
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 System.Buffers.Binary;
using System.Collections.ObjectModel;
using System.Formats.Tar;
@@ -7,12 +15,6 @@ using System.IO.Compression;
using System.Linq;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.EvidenceLocker.Core.Configuration;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Core.Repositories;
using StellaOps.EvidenceLocker.Core.Storage;
namespace StellaOps.EvidenceLocker.Infrastructure.Services;

View File

@@ -1,3 +1,17 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Npgsql;
using StellaOps.Determinism;
using StellaOps.EvidenceLocker.Core.Builders;
using StellaOps.EvidenceLocker.Core.Configuration;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Core.Incident;
using StellaOps.EvidenceLocker.Core.Repositories;
using StellaOps.EvidenceLocker.Core.Signing;
using StellaOps.EvidenceLocker.Core.Storage;
using StellaOps.EvidenceLocker.Core.Timeline;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
@@ -8,18 +22,6 @@ using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Npgsql;
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.Timeline;
using StellaOps.EvidenceLocker.Core.Storage;
using StellaOps.Determinism;
namespace StellaOps.EvidenceLocker.Infrastructure.Services;

View File

@@ -1,12 +1,14 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.EvidenceLocker.Core.Configuration;
using StellaOps.EvidenceLocker.Core.Incident;
using StellaOps.EvidenceLocker.Core.Notifications;
using StellaOps.EvidenceLocker.Core.Timeline;
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.EvidenceLocker.Infrastructure.Services;

View File

@@ -1,10 +1,5 @@
using System;
using System.Buffers;
using System.Globalization;
using System.Security.Cryptography;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography;
@@ -12,6 +7,13 @@ using StellaOps.EvidenceLocker.Core.Builders;
using StellaOps.EvidenceLocker.Core.Configuration;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Core.Signing;
using System;
using System.Buffers;
using System.Globalization;
using System.Security.Cryptography;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.EvidenceLocker.Infrastructure.Signing;

View File

@@ -1,8 +1,10 @@
using Microsoft.Extensions.Logging;
using StellaOps.EvidenceLocker.Core.Signing;
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.EvidenceLocker.Core.Signing;
namespace StellaOps.EvidenceLocker.Infrastructure.Signing;

View File

@@ -1,10 +1,5 @@
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Org.BouncyCastle.Asn1;
@@ -14,6 +9,13 @@ using Org.BouncyCastle.Math;
using Org.BouncyCastle.Tsp;
using StellaOps.EvidenceLocker.Core.Configuration;
using StellaOps.EvidenceLocker.Core.Signing;
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.EvidenceLocker.Infrastructure.Signing;

View File

@@ -1,9 +1,11 @@
using System.Security.Cryptography;
using Microsoft.Extensions.Logging;
using StellaOps.Determinism;
using StellaOps.EvidenceLocker.Core.Configuration;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Core.Storage;
using StellaOps.Determinism;
using System.Security.Cryptography;
namespace StellaOps.EvidenceLocker.Infrastructure.Storage;

View File

@@ -1,12 +1,14 @@
using System.Linq;
using System.Security.Cryptography;
using Amazon.S3;
using Amazon.S3.Model;
using Microsoft.Extensions.Logging;
using StellaOps.Determinism;
using StellaOps.EvidenceLocker.Core.Configuration;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Core.Storage;
using StellaOps.Determinism;
using System.Linq;
using System.Security.Cryptography;
namespace StellaOps.EvidenceLocker.Infrastructure.Storage;

View File

@@ -1,6 +1,8 @@
using StellaOps.EvidenceLocker.Core.Domain;
using System.Text;
using System.Text.RegularExpressions;
using StellaOps.EvidenceLocker.Core.Domain;
namespace StellaOps.EvidenceLocker.Infrastructure.Storage;

View File

@@ -1,10 +1,12 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.EvidenceLocker.Core.Builders;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Core.Incident;
using StellaOps.EvidenceLocker.Core.Timeline;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.EvidenceLocker.Infrastructure.Timeline;

View File

@@ -1,3 +1,13 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Determinism;
using StellaOps.EvidenceLocker.Core.Builders;
using StellaOps.EvidenceLocker.Core.Configuration;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Core.Incident;
using StellaOps.EvidenceLocker.Core.Timeline;
using System;
using System.Collections.Generic;
using System.Globalization;
@@ -8,14 +18,6 @@ using System.Net.Http.Json;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.EvidenceLocker.Core.Builders;
using StellaOps.EvidenceLocker.Core.Configuration;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Core.Incident;
using StellaOps.EvidenceLocker.Core.Timeline;
using StellaOps.Determinism;
namespace StellaOps.EvidenceLocker.Infrastructure.Timeline;

View File

@@ -23,3 +23,6 @@ Keep Evidence Locker tests deterministic, readable, and aligned with module cont
- 3. Keep outputs deterministic (ordering, timestamps, hashes) and offline-friendly.
- 4. Add tests for negative paths and determinism regressions.
- 5. Revert to TODO if paused; capture context in PR notes.
## Known Quirks
- **Q8 — 256GB RAM myth**: Previous sprints incorrectly stated this project requires 256GB RAM. This is false. It uses a standard `postgres:17-alpine` Testcontainer. 109/109 tests pass in ~20s on standard hardware with no special memory requirements.

View File

@@ -1,53 +1,41 @@
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 StellaOps.TestKit;
using System;
using System.Net.Http;
using Testcontainers.PostgreSql;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.EvidenceLocker.Tests;
public sealed class DatabaseMigrationTests : IAsyncLifetime
public sealed class DatabaseMigrationTests : IClassFixture<PostgreSqlFixture>
{
private readonly PostgreSqlTestcontainer _postgres;
private EvidenceLockerDataSource? _dataSource;
private IEvidenceLockerMigrationRunner? _migrationRunner;
private string? _skipReason;
private readonly PostgreSqlFixture _fixture;
public DatabaseMigrationTests()
public DatabaseMigrationTests(PostgreSqlFixture fixture)
{
_postgres = new TestcontainersBuilder<PostgreSqlTestcontainer>()
.WithDatabase(new PostgreSqlTestcontainerConfiguration
{
Database = "evidence_locker_tests",
Username = "postgres",
Password = "postgres"
})
.WithCleanUp(true)
.Build();
_fixture = fixture;
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task ApplyAsync_CreatesExpectedSchemaAndPolicies()
{
if (_skipReason is not null)
await _fixture.EnsureInitializedAsync();
if (_fixture.SkipReason is not null)
{
Assert.Skip(_skipReason);
Assert.Skip(_fixture.SkipReason);
}
var cancellationToken = CancellationToken.None;
await _migrationRunner!.ApplyAsync(cancellationToken);
await using var connection = await _dataSource!.OpenConnectionAsync(cancellationToken);
// Migrations already applied by the fixture; verify schema state.
await using var connection = await _fixture.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);
@@ -72,7 +60,7 @@ public sealed class DatabaseMigrationTests : IAsyncLifetime
Assert.Equal(1, applied);
var tenant = TenantId.FromGuid(Guid.NewGuid());
await using var tenantConnection = await _dataSource.OpenConnectionAsync(tenant, cancellationToken);
await using var tenantConnection = await _fixture.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)
@@ -85,21 +73,21 @@ public sealed class DatabaseMigrationTests : IAsyncLifetime
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 isolationConnection = await _fixture.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 otherTenantConnection = await _fixture.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 violationConnection = await _fixture.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)
@@ -113,48 +101,6 @@ public sealed class DatabaseMigrationTests : IAsyncLifetime
await Assert.ThrowsAsync<PostgresException>(() => violationCommand.ExecuteNonQueryAsync(cancellationToken));
}
public async ValueTask InitializeAsync()
{
try
{
await _postgres.StartAsync();
}
catch (HttpRequestException ex)
{
_skipReason = $"Docker endpoint unavailable: {ex.Message}";
return;
}
catch (Docker.DotNet.DockerApiException ex)
{
_skipReason = $"Docker API error: {ex.Message}";
return;
}
var databaseOptions = new DatabaseOptions
{
ConnectionString = _postgres.ConnectionString,
ApplyMigrationsAtStartup = false
};
_dataSource = new EvidenceLockerDataSource(databaseOptions, NullLogger<EvidenceLockerDataSource>.Instance);
_migrationRunner = new EvidenceLockerMigrationRunner(_dataSource, NullLogger<EvidenceLockerMigrationRunner>.Instance);
}
public async ValueTask DisposeAsync()
{
if (_skipReason is not null)
{
return;
}
if (_dataSource is not null)
{
await _dataSource.DisposeAsync();
}
await _postgres.DisposeAsync();
}
}

View File

@@ -1,16 +1,17 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Cryptography;
using StellaOps.EvidenceLocker.Core.Builders;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Core.Repositories;
using StellaOps.EvidenceLocker.Infrastructure.Builders;
using StellaOps.TestKit;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.EvidenceLocker.Tests;
public sealed class EvidenceBundleBuilderTests

View File

@@ -5,65 +5,41 @@
// Description: Model L0+S1 immutability tests for EvidenceLocker bundles
// -----------------------------------------------------------------------------
using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
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.Core.Repositories;
using StellaOps.EvidenceLocker.Infrastructure.Db;
using StellaOps.EvidenceLocker.Infrastructure.Repositories;
using StellaOps.TestKit.Evidence;
using System;
using System.Threading;
using System.Threading.Tasks;
using Xunit;
namespace StellaOps.EvidenceLocker.Tests;
/// <summary>
/// Immutability tests for EvidenceLocker bundles.
/// Implements Model L0+S1 test requirements:
/// - Once stored, artifact cannot be overwritten (reject or version)
/// - Simultaneous writes to same key → deterministic behavior (first wins or explicit error)
/// - Same key + different payload → new version created (if versioning enabled)
/// Uses a shared PostgreSQL container via <see cref="PostgreSqlFixture"/> to avoid
/// starting a new container per test method.
/// </summary>
[Trait("Category", "Integration")]
[Trait("Category", "Immutability")]
public sealed class EvidenceBundleImmutabilityTests : IAsyncLifetime
public sealed class EvidenceBundleImmutabilityTests : IClassFixture<PostgreSqlFixture>
{
private PostgreSqlTestcontainer? _postgres;
private EvidenceLockerDataSource? _dataSource;
private IEvidenceLockerMigrationRunner? _migrationRunner;
private IEvidenceBundleRepository? _repository;
private string? _skipReason;
private readonly PostgreSqlFixture _fixture;
public EvidenceBundleImmutabilityTests()
public EvidenceBundleImmutabilityTests(PostgreSqlFixture fixture)
{
try
{
_postgres = new TestcontainersBuilder<PostgreSqlTestcontainer>()
.WithDatabase(new PostgreSqlTestcontainerConfiguration
{
Database = "evidence_locker_immutability_tests",
Username = "postgres",
Password = "postgres"
})
.WithCleanUp(true)
.Build();
}
catch (MissingMethodException ex)
{
_skipReason = $"Docker.DotNet version incompatible with Testcontainers: {ex.Message}";
}
catch (Exception ex) when (ex.Message.Contains("Docker") || ex.Message.Contains("CreateClient"))
{
_skipReason = $"Docker unavailable: {ex.Message}";
}
_fixture = fixture;
}
private async Task<IEvidenceBundleRepository> GetRepositoryAsync()
{
await _fixture.EnsureInitializedAsync();
if (_fixture.SkipReason is not null)
Assert.Skip(_fixture.SkipReason);
return new EvidenceBundleRepository(_fixture.DataSource!);
}
// EVIDENCE-5100-001: Once stored, artifact cannot be overwritten
@@ -72,128 +48,88 @@ public sealed class EvidenceBundleImmutabilityTests : IAsyncLifetime
[Requirement("REQ-EVIDENCE-IMMUTABILITY-001", SprintTaskId = "EVIDENCE-5100-001", ComplianceControl = "SOC2-CC6.1")]
public async Task CreateBundle_SameId_SecondInsertFails()
{
if (_skipReason is not null)
{
Assert.Skip(_skipReason);
}
var repo = await GetRepositoryAsync();
var cancellationToken = CancellationToken.None;
var tenantId = TenantId.FromGuid(Guid.NewGuid());
var bundleId = EvidenceBundleId.FromGuid(Guid.NewGuid());
var now = DateTimeOffset.UtcNow;
var bundle1 = new EvidenceBundle(
bundleId,
tenantId,
EvidenceBundleKind.Evaluation,
EvidenceBundleStatus.Pending,
bundleId, tenantId, EvidenceBundleKind.Evaluation, EvidenceBundleStatus.Pending,
RootHash: new string('a', 64),
StorageKey: $"tenants/{tenantId}/bundles/{bundleId}/resource",
CreatedAt: now,
UpdatedAt: now,
Description: "First bundle");
CreatedAt: now, UpdatedAt: now, Description: "First bundle");
var bundle2 = new EvidenceBundle(
bundleId, // Same ID
tenantId,
EvidenceBundleKind.Evaluation,
EvidenceBundleStatus.Pending,
RootHash: new string('b', 64), // Different hash
bundleId, tenantId, EvidenceBundleKind.Evaluation, EvidenceBundleStatus.Pending,
RootHash: new string('b', 64),
StorageKey: $"tenants/{tenantId}/bundles/{bundleId}/resource2",
CreatedAt: now,
UpdatedAt: now,
Description: "Second bundle with same ID");
CreatedAt: now, UpdatedAt: now, Description: "Second bundle with same ID");
// First insert should succeed
await _repository!.CreateBundleAsync(bundle1, cancellationToken);
await repo.CreateBundleAsync(bundle1, cancellationToken);
// Second insert with same ID should fail
await Assert.ThrowsAsync<PostgresException>(async () =>
await _repository.CreateBundleAsync(bundle2, cancellationToken));
await repo.CreateBundleAsync(bundle2, cancellationToken));
}
[Fact]
public async Task CreateBundle_SameIdDifferentTenant_BothSucceed()
public async Task CreateBundle_DifferentIdsDifferentTenants_BothSucceedWithIsolation()
{
if (_skipReason is not null)
{
Assert.Skip(_skipReason);
}
var repo = await GetRepositoryAsync();
var cancellationToken = CancellationToken.None;
var tenant1 = TenantId.FromGuid(Guid.NewGuid());
var tenant2 = TenantId.FromGuid(Guid.NewGuid());
var bundleId = EvidenceBundleId.FromGuid(Guid.NewGuid());
var bundleId1 = EvidenceBundleId.FromGuid(Guid.NewGuid());
var bundleId2 = EvidenceBundleId.FromGuid(Guid.NewGuid());
var now = DateTimeOffset.UtcNow;
var bundle1 = new EvidenceBundle(
bundleId,
tenant1,
EvidenceBundleKind.Evaluation,
EvidenceBundleStatus.Pending,
bundleId1, tenant1, EvidenceBundleKind.Evaluation, EvidenceBundleStatus.Pending,
RootHash: new string('a', 64),
StorageKey: $"tenants/{tenant1}/bundles/{bundleId}/resource",
CreatedAt: now,
UpdatedAt: now);
StorageKey: $"tenants/{tenant1}/bundles/{bundleId1}/resource",
CreatedAt: now, UpdatedAt: now);
var bundle2 = new EvidenceBundle(
bundleId, // Same bundle ID
tenant2, // Different tenant
EvidenceBundleKind.Evaluation,
EvidenceBundleStatus.Pending,
bundleId2, tenant2, EvidenceBundleKind.Evaluation, EvidenceBundleStatus.Pending,
RootHash: new string('b', 64),
StorageKey: $"tenants/{tenant2}/bundles/{bundleId}/resource",
CreatedAt: now,
UpdatedAt: now);
StorageKey: $"tenants/{tenant2}/bundles/{bundleId2}/resource",
CreatedAt: now, UpdatedAt: now);
// Both should succeed - different tenants can have same bundle ID
await _repository!.CreateBundleAsync(bundle1, cancellationToken);
await _repository.CreateBundleAsync(bundle2, cancellationToken);
await repo.CreateBundleAsync(bundle1, cancellationToken);
await repo.CreateBundleAsync(bundle2, cancellationToken);
// Verify both exist
var exists1 = await _repository.ExistsAsync(bundleId, tenant1, cancellationToken);
var exists2 = await _repository.ExistsAsync(bundleId, tenant2, cancellationToken);
// Each tenant can see only their own bundle (RLS isolation)
var exists1 = await repo.ExistsAsync(bundleId1, tenant1, cancellationToken);
var exists2 = await repo.ExistsAsync(bundleId2, tenant2, cancellationToken);
Assert.True(exists1, "Bundle should exist for tenant1");
Assert.True(exists2, "Bundle should exist for tenant2");
// Cross-tenant: tenant2 should not see tenant1's bundle
var crossTenantExists = await repo.ExistsAsync(bundleId1, tenant2, cancellationToken);
Assert.False(crossTenantExists, "Tenant2 should not see tenant1's bundle");
}
[Fact]
[Requirement("REQ-EVIDENCE-SEAL-001", SprintTaskId = "EVIDENCE-5100-001", ComplianceControl = "SOC2-CC6.1")]
public async Task SealedBundle_CannotBeModified()
{
if (_skipReason is not null)
{
Assert.Skip(_skipReason);
}
var repo = await GetRepositoryAsync();
var cancellationToken = CancellationToken.None;
var tenantId = TenantId.FromGuid(Guid.NewGuid());
var bundleId = EvidenceBundleId.FromGuid(Guid.NewGuid());
var now = DateTimeOffset.UtcNow;
var bundle = new EvidenceBundle(
bundleId,
tenantId,
EvidenceBundleKind.Evaluation,
EvidenceBundleStatus.Pending,
bundleId, tenantId, EvidenceBundleKind.Evaluation, EvidenceBundleStatus.Pending,
RootHash: new string('a', 64),
StorageKey: $"tenants/{tenantId}/bundles/{bundleId}/resource",
CreatedAt: now,
UpdatedAt: now);
CreatedAt: now, UpdatedAt: now);
await _repository!.CreateBundleAsync(bundle, cancellationToken);
await repo.CreateBundleAsync(bundle, cancellationToken);
await repo.MarkBundleSealedAsync(bundleId, tenantId, EvidenceBundleStatus.Sealed, now.AddMinutes(1), cancellationToken);
// Seal the bundle
await _repository.MarkBundleSealedAsync(
bundleId,
tenantId,
EvidenceBundleStatus.Sealed,
now.AddMinutes(1),
cancellationToken);
// Verify bundle is sealed
var fetched = await _repository.GetBundleAsync(bundleId, tenantId, cancellationToken);
var fetched = await repo.GetBundleAsync(bundleId, tenantId, cancellationToken);
Assert.NotNull(fetched);
Assert.Equal(EvidenceBundleStatus.Sealed, fetched.Bundle.Status);
Assert.NotNull(fetched.Bundle.SealedAt);
@@ -202,40 +138,28 @@ public sealed class EvidenceBundleImmutabilityTests : IAsyncLifetime
[Fact]
public async Task Bundle_ExistsCheck_ReturnsCorrectState()
{
if (_skipReason is not null)
{
Assert.Skip(_skipReason);
}
var repo = await GetRepositoryAsync();
var cancellationToken = CancellationToken.None;
var tenantId = TenantId.FromGuid(Guid.NewGuid());
var bundleId = EvidenceBundleId.FromGuid(Guid.NewGuid());
var nonExistentBundleId = EvidenceBundleId.FromGuid(Guid.NewGuid());
var now = DateTimeOffset.UtcNow;
// Before creation
var existsBefore = await _repository!.ExistsAsync(bundleId, tenantId, cancellationToken);
var existsBefore = await repo.ExistsAsync(bundleId, tenantId, cancellationToken);
Assert.False(existsBefore, "Bundle should not exist before creation");
// Create bundle
var bundle = new EvidenceBundle(
bundleId,
tenantId,
EvidenceBundleKind.Evaluation,
EvidenceBundleStatus.Pending,
bundleId, tenantId, EvidenceBundleKind.Evaluation, EvidenceBundleStatus.Pending,
RootHash: new string('a', 64),
StorageKey: $"tenants/{tenantId}/bundles/{bundleId}/resource",
CreatedAt: now,
UpdatedAt: now);
CreatedAt: now, UpdatedAt: now);
await _repository.CreateBundleAsync(bundle, cancellationToken);
await repo.CreateBundleAsync(bundle, cancellationToken);
// After creation
var existsAfter = await _repository.ExistsAsync(bundleId, tenantId, cancellationToken);
var existsAfter = await repo.ExistsAsync(bundleId, tenantId, cancellationToken);
Assert.True(existsAfter, "Bundle should exist after creation");
// Non-existent bundle
var existsNonExistent = await _repository.ExistsAsync(nonExistentBundleId, tenantId, cancellationToken);
var existsNonExistent = await repo.ExistsAsync(nonExistentBundleId, tenantId, cancellationToken);
Assert.False(existsNonExistent, "Non-existent bundle should not exist");
}
@@ -245,87 +169,52 @@ public sealed class EvidenceBundleImmutabilityTests : IAsyncLifetime
[Requirement("REQ-EVIDENCE-CONCURRENCY-001", SprintTaskId = "EVIDENCE-5100-002", ComplianceControl = "SOC2-CC7.1")]
public async Task ConcurrentCreates_SameId_ExactlyOneFails()
{
if (_skipReason is not null)
{
Assert.Skip(_skipReason);
}
var repo = await GetRepositoryAsync();
var cancellationToken = CancellationToken.None;
var tenantId = TenantId.FromGuid(Guid.NewGuid());
var bundleId = EvidenceBundleId.FromGuid(Guid.NewGuid());
var now = DateTimeOffset.UtcNow;
var bundle1 = new EvidenceBundle(
bundleId,
tenantId,
EvidenceBundleKind.Evaluation,
EvidenceBundleStatus.Pending,
bundleId, tenantId, EvidenceBundleKind.Evaluation, EvidenceBundleStatus.Pending,
RootHash: new string('a', 64),
StorageKey: $"tenants/{tenantId}/bundles/{bundleId}/resource1",
CreatedAt: now,
UpdatedAt: now,
Description: "Concurrent bundle 1");
CreatedAt: now, UpdatedAt: now, Description: "Concurrent bundle 1");
var bundle2 = new EvidenceBundle(
bundleId,
tenantId,
EvidenceBundleKind.Evaluation,
EvidenceBundleStatus.Pending,
bundleId, tenantId, EvidenceBundleKind.Evaluation, EvidenceBundleStatus.Pending,
RootHash: new string('b', 64),
StorageKey: $"tenants/{tenantId}/bundles/{bundleId}/resource2",
CreatedAt: now,
UpdatedAt: now,
Description: "Concurrent bundle 2");
CreatedAt: now, UpdatedAt: now, Description: "Concurrent bundle 2");
var successCount = 0;
var failureCount = 0;
// Execute concurrently
var task1 = Task.Run(async () =>
{
try
{
await _repository!.CreateBundleAsync(bundle1, cancellationToken);
Interlocked.Increment(ref successCount);
}
catch (PostgresException)
{
Interlocked.Increment(ref failureCount);
}
try { await repo.CreateBundleAsync(bundle1, cancellationToken); Interlocked.Increment(ref successCount); }
catch (PostgresException) { Interlocked.Increment(ref failureCount); }
});
var task2 = Task.Run(async () =>
{
try
{
await _repository!.CreateBundleAsync(bundle2, cancellationToken);
Interlocked.Increment(ref successCount);
}
catch (PostgresException)
{
Interlocked.Increment(ref failureCount);
}
try { await repo.CreateBundleAsync(bundle2, cancellationToken); Interlocked.Increment(ref successCount); }
catch (PostgresException) { Interlocked.Increment(ref failureCount); }
});
await Task.WhenAll(task1, task2);
// Exactly one should succeed, one should fail
Assert.Equal(1, successCount);
Assert.Equal(1, failureCount);
// Verify only one bundle exists
var exists = await _repository!.ExistsAsync(bundleId, tenantId, cancellationToken);
var exists = await repo.ExistsAsync(bundleId, tenantId, cancellationToken);
Assert.True(exists);
}
[Fact]
public async Task ConcurrentCreates_DifferentIds_AllSucceed()
{
if (_skipReason is not null)
{
Assert.Skip(_skipReason);
}
var repo = await GetRepositoryAsync();
var cancellationToken = CancellationToken.None;
var tenantId = TenantId.FromGuid(Guid.NewGuid());
var now = DateTimeOffset.UtcNow;
@@ -334,35 +223,25 @@ public sealed class EvidenceBundleImmutabilityTests : IAsyncLifetime
{
var bundleId = EvidenceBundleId.FromGuid(Guid.NewGuid());
return new EvidenceBundle(
bundleId,
tenantId,
EvidenceBundleKind.Evaluation,
EvidenceBundleStatus.Pending,
bundleId, tenantId, EvidenceBundleKind.Evaluation, EvidenceBundleStatus.Pending,
RootHash: new string((char)('a' + i), 64),
StorageKey: $"tenants/{tenantId}/bundles/{bundleId}/resource",
CreatedAt: now,
UpdatedAt: now,
Description: $"Concurrent bundle {i}");
CreatedAt: now, UpdatedAt: now, Description: $"Concurrent bundle {i}");
}).ToList();
var successCount = 0;
// Execute all concurrently
var tasks = bundles.Select(async bundle =>
{
await _repository!.CreateBundleAsync(bundle, cancellationToken);
await repo.CreateBundleAsync(bundle, cancellationToken);
Interlocked.Increment(ref successCount);
});
await Task.WhenAll(tasks);
// All should succeed
Assert.Equal(5, successCount);
// Verify all bundles exist
foreach (var bundle in bundles)
{
var exists = await _repository!.ExistsAsync(bundle.Id, tenantId, cancellationToken);
var exists = await repo.ExistsAsync(bundle.Id, tenantId, cancellationToken);
Assert.True(exists, $"Bundle {bundle.Id} should exist");
}
}
@@ -370,44 +249,28 @@ public sealed class EvidenceBundleImmutabilityTests : IAsyncLifetime
[Fact]
public async Task ConcurrentSealAttempts_SameBundle_AllSucceed()
{
if (_skipReason is not null)
{
Assert.Skip(_skipReason);
}
var repo = await GetRepositoryAsync();
var cancellationToken = CancellationToken.None;
var tenantId = TenantId.FromGuid(Guid.NewGuid());
var bundleId = EvidenceBundleId.FromGuid(Guid.NewGuid());
var now = DateTimeOffset.UtcNow;
var bundle = new EvidenceBundle(
bundleId,
tenantId,
EvidenceBundleKind.Evaluation,
EvidenceBundleStatus.Pending,
bundleId, tenantId, EvidenceBundleKind.Evaluation, EvidenceBundleStatus.Pending,
RootHash: new string('a', 64),
StorageKey: $"tenants/{tenantId}/bundles/{bundleId}/resource",
CreatedAt: now,
UpdatedAt: now);
CreatedAt: now, UpdatedAt: now);
await _repository!.CreateBundleAsync(bundle, cancellationToken);
await repo.CreateBundleAsync(bundle, cancellationToken);
// Multiple concurrent seal attempts (idempotent operation)
var sealTasks = Enumerable.Range(1, 3).Select(async i =>
{
await _repository.MarkBundleSealedAsync(
bundleId,
tenantId,
EvidenceBundleStatus.Sealed,
now.AddMinutes(i),
cancellationToken);
await repo.MarkBundleSealedAsync(bundleId, tenantId, EvidenceBundleStatus.Sealed, now.AddMinutes(i), cancellationToken);
});
// All should complete without throwing
await Task.WhenAll(sealTasks);
// Bundle should be sealed
var fetched = await _repository.GetBundleAsync(bundleId, tenantId, cancellationToken);
var fetched = await repo.GetBundleAsync(bundleId, tenantId, cancellationToken);
Assert.NotNull(fetched);
Assert.Equal(EvidenceBundleStatus.Sealed, fetched.Bundle.Status);
}
@@ -417,64 +280,42 @@ public sealed class EvidenceBundleImmutabilityTests : IAsyncLifetime
[Fact]
public async Task SignatureUpsert_SameBundle_UpdatesSignature()
{
if (_skipReason is not null)
{
Assert.Skip(_skipReason);
}
var repo = await GetRepositoryAsync();
var cancellationToken = CancellationToken.None;
var tenantId = TenantId.FromGuid(Guid.NewGuid());
var bundleId = EvidenceBundleId.FromGuid(Guid.NewGuid());
var now = DateTimeOffset.UtcNow;
var bundle = new EvidenceBundle(
bundleId,
tenantId,
EvidenceBundleKind.Evaluation,
EvidenceBundleStatus.Pending,
bundleId, tenantId, EvidenceBundleKind.Evaluation, EvidenceBundleStatus.Pending,
RootHash: new string('a', 64),
StorageKey: $"tenants/{tenantId}/bundles/{bundleId}/resource",
CreatedAt: now,
UpdatedAt: now);
CreatedAt: now, UpdatedAt: now);
await _repository!.CreateBundleAsync(bundle, cancellationToken);
await repo.CreateBundleAsync(bundle, cancellationToken);
// First signature
var signature1 = new EvidenceBundleSignature(
bundleId,
tenantId,
bundleId, tenantId,
PayloadType: "application/vnd.dsse+json",
Payload: """{"_type":"bundle","bundle_id":"test"}""",
Signature: "sig1",
KeyId: "key1",
Algorithm: "ES256",
Provider: "test",
SignedAt: now);
Signature: "sig1", KeyId: "key1", Algorithm: "ES256", Provider: "test", SignedAt: now);
await _repository.UpsertSignatureAsync(signature1, cancellationToken);
await repo.UpsertSignatureAsync(signature1, cancellationToken);
// Verify first signature
var fetchedBefore = await _repository.GetBundleAsync(bundleId, tenantId, cancellationToken);
var fetchedBefore = await repo.GetBundleAsync(bundleId, tenantId, cancellationToken);
Assert.NotNull(fetchedBefore?.Signature);
Assert.Equal("sig1", fetchedBefore.Signature.Signature);
Assert.Equal("key1", fetchedBefore.Signature.KeyId);
// Second signature (update)
var signature2 = new EvidenceBundleSignature(
bundleId,
tenantId,
bundleId, tenantId,
PayloadType: "application/vnd.dsse+json",
Payload: """{"_type":"bundle","bundle_id":"test","version":2}""",
Signature: "sig2",
KeyId: "key2",
Algorithm: "ES256",
Provider: "test",
SignedAt: now.AddMinutes(1));
Signature: "sig2", KeyId: "key2", Algorithm: "ES256", Provider: "test", SignedAt: now.AddMinutes(1));
await _repository.UpsertSignatureAsync(signature2, cancellationToken);
await repo.UpsertSignatureAsync(signature2, cancellationToken);
// Verify signature was updated
var fetchedAfter = await _repository.GetBundleAsync(bundleId, tenantId, cancellationToken);
var fetchedAfter = await repo.GetBundleAsync(bundleId, tenantId, cancellationToken);
Assert.NotNull(fetchedAfter?.Signature);
Assert.Equal("sig2", fetchedAfter.Signature.Signature);
Assert.Equal("key2", fetchedAfter.Signature.KeyId);
@@ -483,40 +324,24 @@ public sealed class EvidenceBundleImmutabilityTests : IAsyncLifetime
[Fact]
public async Task BundleUpdate_AssemblyPhase_UpdatesHashAndStatus()
{
if (_skipReason is not null)
{
Assert.Skip(_skipReason);
}
var repo = await GetRepositoryAsync();
var cancellationToken = CancellationToken.None;
var tenantId = TenantId.FromGuid(Guid.NewGuid());
var bundleId = EvidenceBundleId.FromGuid(Guid.NewGuid());
var now = DateTimeOffset.UtcNow;
var bundle = new EvidenceBundle(
bundleId,
tenantId,
EvidenceBundleKind.Evaluation,
EvidenceBundleStatus.Pending,
RootHash: new string('0', 64), // Initial placeholder hash
bundleId, tenantId, EvidenceBundleKind.Evaluation, EvidenceBundleStatus.Pending,
RootHash: new string('0', 64),
StorageKey: $"tenants/{tenantId}/bundles/{bundleId}/resource",
CreatedAt: now,
UpdatedAt: now);
CreatedAt: now, UpdatedAt: now);
await _repository!.CreateBundleAsync(bundle, cancellationToken);
await repo.CreateBundleAsync(bundle, cancellationToken);
// Update to assembling with new hash
var newHash = new string('a', 64);
await _repository.SetBundleAssemblyAsync(
bundleId,
tenantId,
EvidenceBundleStatus.Assembling,
newHash,
now.AddMinutes(1),
cancellationToken);
await repo.SetBundleAssemblyAsync(bundleId, tenantId, EvidenceBundleStatus.Assembling, newHash, now.AddMinutes(1), cancellationToken);
// Verify update
var fetched = await _repository.GetBundleAsync(bundleId, tenantId, cancellationToken);
var fetched = await repo.GetBundleAsync(bundleId, tenantId, cancellationToken);
Assert.NotNull(fetched);
Assert.Equal(EvidenceBundleStatus.Assembling, fetched.Bundle.Status);
Assert.Equal(newHash, fetched.Bundle.RootHash);
@@ -525,44 +350,28 @@ public sealed class EvidenceBundleImmutabilityTests : IAsyncLifetime
[Fact]
public async Task PortableStorageKey_Update_CreatesVersionedReference()
{
if (_skipReason is not null)
{
Assert.Skip(_skipReason);
}
var repo = await GetRepositoryAsync();
var cancellationToken = CancellationToken.None;
var tenantId = TenantId.FromGuid(Guid.NewGuid());
var bundleId = EvidenceBundleId.FromGuid(Guid.NewGuid());
var now = DateTimeOffset.UtcNow;
var bundle = new EvidenceBundle(
bundleId,
tenantId,
EvidenceBundleKind.Export,
EvidenceBundleStatus.Sealed,
bundleId, tenantId, EvidenceBundleKind.Export, EvidenceBundleStatus.Sealed,
RootHash: new string('a', 64),
StorageKey: $"tenants/{tenantId}/bundles/{bundleId}/resource",
CreatedAt: now,
UpdatedAt: now);
CreatedAt: now, UpdatedAt: now);
await _repository!.CreateBundleAsync(bundle, cancellationToken);
await repo.CreateBundleAsync(bundle, cancellationToken);
// No portable storage key initially
var fetchedBefore = await _repository.GetBundleAsync(bundleId, tenantId, cancellationToken);
var fetchedBefore = await repo.GetBundleAsync(bundleId, tenantId, cancellationToken);
Assert.NotNull(fetchedBefore);
Assert.Null(fetchedBefore.Bundle.PortableStorageKey);
// Add portable storage key
var portableKey = $"tenants/{tenantId}/portable/{bundleId}/export.zip";
await _repository.UpdatePortableStorageKeyAsync(
bundleId,
tenantId,
portableKey,
now.AddMinutes(1),
cancellationToken);
await repo.UpdatePortableStorageKeyAsync(bundleId, tenantId, portableKey, now.AddMinutes(1), cancellationToken);
// Verify portable key was added
var fetchedAfter = await _repository.GetBundleAsync(bundleId, tenantId, cancellationToken);
var fetchedAfter = await repo.GetBundleAsync(bundleId, tenantId, cancellationToken);
Assert.NotNull(fetchedAfter);
Assert.Equal(portableKey, fetchedAfter.Bundle.PortableStorageKey);
Assert.NotNull(fetchedAfter.Bundle.PortableGeneratedAt);
@@ -571,115 +380,34 @@ public sealed class EvidenceBundleImmutabilityTests : IAsyncLifetime
[Fact]
public async Task Hold_CreateMultiple_AllPersisted()
{
if (_skipReason is not null)
{
Assert.Skip(_skipReason);
}
var repo = await GetRepositoryAsync();
var cancellationToken = CancellationToken.None;
var tenantId = TenantId.FromGuid(Guid.NewGuid());
var bundleId = EvidenceBundleId.FromGuid(Guid.NewGuid());
var now = DateTimeOffset.UtcNow;
var bundle = new EvidenceBundle(
bundleId,
tenantId,
EvidenceBundleKind.Evaluation,
EvidenceBundleStatus.Sealed,
bundleId, tenantId, EvidenceBundleKind.Evaluation, EvidenceBundleStatus.Sealed,
RootHash: new string('a', 64),
StorageKey: $"tenants/{tenantId}/bundles/{bundleId}/resource",
CreatedAt: now,
UpdatedAt: now);
CreatedAt: now, UpdatedAt: now);
await _repository!.CreateBundleAsync(bundle, cancellationToken);
await repo.CreateBundleAsync(bundle, cancellationToken);
// Create multiple holds (versioned/append-only pattern)
var holds = new List<EvidenceHold>();
for (int i = 1; i <= 3; i++)
{
var hold = new EvidenceHold(
EvidenceHoldId.FromGuid(Guid.NewGuid()),
tenantId,
bundleId,
CaseId: $"CASE-{i:D4}",
Reason: $"Legal hold reason {i}",
CreatedAt: now.AddMinutes(i),
ExpiresAt: now.AddDays(30 + i),
ReleasedAt: null);
EvidenceHoldId.FromGuid(Guid.NewGuid()), tenantId, bundleId,
CaseId: $"CASE-{i:D4}", Reason: $"Legal hold reason {i}",
CreatedAt: now.AddMinutes(i), ExpiresAt: now.AddDays(30 + i), ReleasedAt: null);
var createdHold = await _repository.CreateHoldAsync(hold, cancellationToken);
var createdHold = await repo.CreateHoldAsync(hold, cancellationToken);
holds.Add(createdHold);
}
// All holds should be created with unique IDs
Assert.Equal(3, holds.Count);
Assert.True(holds.All(h => h.Id.Value != Guid.Empty));
Assert.True(holds.Select(h => h.Id.Value).Distinct().Count() == 3);
}
public async ValueTask InitializeAsync()
{
// If constructor already set a skip reason, return early
if (_skipReason is not null || _postgres is null)
{
return;
}
try
{
await _postgres.StartAsync();
}
catch (HttpRequestException ex)
{
_skipReason = $"Docker endpoint unavailable: {ex.Message}";
return;
}
catch (DockerApiException ex)
{
_skipReason = $"Docker API error: {ex.Message}";
return;
}
catch (MissingMethodException ex)
{
_skipReason = $"Docker.DotNet version incompatible with Testcontainers: {ex.Message}";
return;
}
catch (Exception ex) when (ex.Message.Contains("Docker") || ex.Message.Contains("CreateClient"))
{
_skipReason = $"Docker unavailable: {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);
// Apply migrations
await _migrationRunner.ApplyAsync(CancellationToken.None);
// Create repository
_repository = new EvidenceBundleRepository(_dataSource);
}
public async ValueTask DisposeAsync()
{
if (_skipReason is not null || _postgres is null)
{
return;
}
if (_dataSource is not null)
{
await _dataSource.DisposeAsync();
}
await _postgres.DisposeAsync();
}
}

View File

@@ -1,17 +1,18 @@
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;
using StellaOps.TestKit;
using System.Buffers.Binary;
using System.Formats.Tar;
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace StellaOps.EvidenceLocker.Tests;
public sealed class EvidenceBundlePackagingServiceTests

View File

@@ -5,32 +5,34 @@
// Description: Integration test: store artifact → retrieve artifact → verify hash matches
// -----------------------------------------------------------------------------
using FluentAssertions;
using StellaOps.Auth.Abstractions;
using StellaOps.TestKit;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using FluentAssertions;
using StellaOps.Auth.Abstractions;
using StellaOps.TestKit;
namespace StellaOps.EvidenceLocker.Tests;
/// <summary>
/// Integration Tests for EvidenceLocker
/// Task EVIDENCE-5100-007: store artifact → retrieve artifact → verify hash matches
/// </summary>
[Collection(EvidenceLockerTestCollection.Name)]
public sealed class EvidenceLockerIntegrationTests : IDisposable
{
private readonly EvidenceLockerWebApplicationFactory _factory;
private readonly HttpClient _client;
private bool _disposed;
public EvidenceLockerIntegrationTests()
public EvidenceLockerIntegrationTests(EvidenceLockerWebApplicationFactory factory)
{
_factory = new EvidenceLockerWebApplicationFactory();
_factory = factory;
_client = _factory.CreateClient();
}
@@ -415,7 +417,7 @@ public sealed class EvidenceLockerIntegrationTests : IDisposable
{
if (_disposed) return;
_client.Dispose();
_factory.Dispose();
// Do NOT dispose _factory here - xUnit manages IClassFixture lifecycle
_disposed = true;
}

View File

@@ -0,0 +1,12 @@
namespace StellaOps.EvidenceLocker.Tests;
/// <summary>
/// Collection definition that ensures ALL test classes sharing the EvidenceLockerWebApplicationFactory
/// use a SINGLE instance. This prevents multiple TestServer instances from being created,
/// which was previously causing 48GB+ memory consumption.
/// </summary>
[CollectionDefinition(Name)]
public sealed class EvidenceLockerTestCollection : ICollectionFixture<EvidenceLockerWebApplicationFactory>
{
public const string Name = "EvidenceLocker";
}

View File

@@ -1,11 +1,6 @@
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 EvidenceLockerProgram = StellaOps.EvidenceLocker.WebService.Program;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
@@ -20,20 +15,26 @@ 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.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.Incident;
using StellaOps.EvidenceLocker.Core.Timeline;
using StellaOps.EvidenceLocker.Core.Storage;
using EvidenceLockerProgram = StellaOps.EvidenceLocker.WebService.Program;
using StellaOps.EvidenceLocker.Core.Timeline;
using System.Collections.Generic;
using System.IO;
using System.Net.Http.Headers;
using System.Reflection;
using System.Runtime.Serialization;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text.Encodings.Web;
namespace StellaOps.EvidenceLocker.Tests;
internal sealed class EvidenceLockerWebApplicationFactory : WebApplicationFactory<EvidenceLockerProgram>
public sealed class EvidenceLockerWebApplicationFactory : WebApplicationFactory<EvidenceLockerProgram>
{
private readonly string _contentRoot;
@@ -148,7 +149,7 @@ internal sealed class EvidenceLockerWebApplicationFactory : WebApplicationFactor
}
}
internal sealed class TestTimestampAuthorityClient : ITimestampAuthorityClient
public sealed class TestTimestampAuthorityClient : ITimestampAuthorityClient
{
public Task<TimestampResult?> RequestTimestampAsync(ReadOnlyMemory<byte> signature, string hashAlgorithm, CancellationToken cancellationToken)
{
@@ -158,7 +159,7 @@ internal sealed class TestTimestampAuthorityClient : ITimestampAuthorityClient
}
}
internal sealed class TestTimelinePublisher : IEvidenceTimelinePublisher
public sealed class TestTimelinePublisher : IEvidenceTimelinePublisher
{
public List<string> PublishedEvents { get; } = new();
public List<string> IncidentEvents { get; } = new();
@@ -186,7 +187,7 @@ internal sealed class TestTimelinePublisher : IEvidenceTimelinePublisher
}
}
internal sealed class TestEvidenceObjectStore : IEvidenceObjectStore
public sealed class TestEvidenceObjectStore : IEvidenceObjectStore
{
private readonly Dictionary<string, byte[]> _objects = new(StringComparer.Ordinal);
private readonly HashSet<string> _preExisting = new(StringComparer.Ordinal);
@@ -227,7 +228,7 @@ internal sealed class TestEvidenceObjectStore : IEvidenceObjectStore
=> Task.FromResult(_preExisting.Contains(storageKey));
}
internal sealed class TestEvidenceBundleRepository : IEvidenceBundleRepository
public sealed class TestEvidenceBundleRepository : IEvidenceBundleRepository
{
private readonly List<EvidenceBundleSignature> _signatures = new();
private readonly Dictionary<(Guid BundleId, Guid TenantId), EvidenceBundle> _bundles = new();
@@ -284,10 +285,21 @@ internal sealed class TestEvidenceBundleRepository : IEvidenceBundleRepository
int limit,
CancellationToken cancellationToken)
{
var results = _bundles.Values
var filtered = _bundles.Values
.Where(bundle => bundle.TenantId == tenantId)
.Where(bundle => !since.HasValue || bundle.UpdatedAt >= since.Value)
.OrderBy(bundle => bundle.UpdatedAt)
.ThenBy(bundle => bundle.Id.Value)
.ThenBy(bundle => bundle.Id.Value);
IEnumerable<EvidenceBundle> paged = filtered;
if (cursorUpdatedAt.HasValue && cursorBundleId.HasValue)
{
paged = filtered.SkipWhile(b =>
b.UpdatedAt < cursorUpdatedAt.Value ||
(b.UpdatedAt == cursorUpdatedAt.Value && b.Id.Value <= cursorBundleId.Value.Value));
}
var results = paged
.Take(limit)
.Select(bundle =>
{
@@ -374,7 +386,7 @@ internal sealed class TestEvidenceBundleRepository : IEvidenceBundleRepository
}
}
internal sealed class EvidenceLockerTestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
public sealed class EvidenceLockerTestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
internal const string SchemeName = "EvidenceLockerTest";

View File

@@ -5,17 +5,18 @@
// Description: W1 contract tests for EvidenceLocker.WebService
// -----------------------------------------------------------------------------
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using StellaOps.Auth.Abstractions;
using StellaOps.TestKit;
using System.Diagnostics;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using StellaOps.Auth.Abstractions;
using StellaOps.TestKit;
namespace StellaOps.EvidenceLocker.Tests;
/// <summary>
@@ -24,20 +25,20 @@ namespace StellaOps.EvidenceLocker.Tests;
/// Task EVIDENCE-5100-005: Auth tests (store artifact requires permissions)
/// Task EVIDENCE-5100-006: OTel trace assertions (artifact_id, tenant_id tags)
/// </summary>
[Collection(EvidenceLockerTestCollection.Name)]
public sealed class EvidenceLockerWebServiceContractTests : IDisposable
{
private readonly EvidenceLockerWebApplicationFactory _factory;
private readonly HttpClient _client;
private bool _disposed;
// OpenAPI snapshot path for schema validation
private const string OpenApiSnapshotPath = "Snapshots/EvidenceLocker.WebService.OpenApi.json";
private const string SwaggerEndpoint = "/swagger/v1/swagger.json";
public EvidenceLockerWebServiceContractTests()
public EvidenceLockerWebServiceContractTests(EvidenceLockerWebApplicationFactory factory)
{
_factory = new EvidenceLockerWebApplicationFactory();
_client = _factory.CreateClient();
_factory = factory;
_client = factory.CreateClient();
}
#region EVIDENCE-5100-004: Contract Tests (OpenAPI Snapshot)
@@ -138,10 +139,11 @@ public sealed class EvidenceLockerWebServiceContractTests : IDisposable
[Fact]
public async Task Contract_ErrorResponse_Schema_Is_Consistent()
{
// Arrange - No auth headers (should fail)
// Arrange - No auth headers
using var unauthClient = _factory.CreateClient();
// Act
var response = await _client.PostAsJsonAsync(
var response = await unauthClient.PostAsJsonAsync(
"/evidence/snapshot",
CreateValidSnapshotPayload(),
CancellationToken.None);
@@ -175,9 +177,10 @@ public sealed class EvidenceLockerWebServiceContractTests : IDisposable
public async Task StoreArtifact_Without_Auth_Returns_Unauthorized()
{
// Arrange - No auth headers
using var unauthClient = _factory.CreateClient();
// Act
var response = await _client.PostAsJsonAsync(
var response = await unauthClient.PostAsJsonAsync(
"/evidence/snapshot",
CreateValidSnapshotPayload(),
CancellationToken.None);
@@ -352,8 +355,9 @@ public sealed class EvidenceLockerWebServiceContractTests : IDisposable
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.Created);
// The timeline event should contain the bundle ID
var timelineEvent = _factory.TimelinePublisher.PublishedEvents.FirstOrDefault();
timelineEvent.Should().NotBeNull();
var timelineEvent = _factory.TimelinePublisher.PublishedEvents
.FirstOrDefault(e => e.Contains(bundleId!, StringComparison.Ordinal));
timelineEvent.Should().NotBeNull($"expected a timeline event containing bundleId {bundleId}");
timelineEvent.Should().Contain(bundleId!);
listener.Dispose();
@@ -468,10 +472,8 @@ public sealed class EvidenceLockerWebServiceContractTests : IDisposable
public void Dispose()
{
if (_disposed) return;
// Do NOT dispose _factory here - xUnit manages IClassFixture lifecycle
_client.Dispose();
_factory.Dispose();
_disposed = true;
}
#endregion

View File

@@ -1,14 +1,5 @@
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;
@@ -19,22 +10,39 @@ using StellaOps.Auth.Abstractions;
using StellaOps.EvidenceLocker.Core.Configuration;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.WebService.Contracts;
using StellaOps.TestKit;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Formats.Tar;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
namespace StellaOps.EvidenceLocker.Tests;
public sealed class EvidenceLockerWebServiceTests
[Collection(EvidenceLockerTestCollection.Name)]
public sealed class EvidenceLockerWebServiceTests : IDisposable
{
private readonly EvidenceLockerWebApplicationFactory _factory;
private readonly HttpClient _client;
public EvidenceLockerWebServiceTests(EvidenceLockerWebApplicationFactory factory)
{
_factory = factory;
_client = factory.CreateClient();
}
[Trait("Category", TestCategories.Unit)]
[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}");
ConfigureAuthHeaders(_client, tenantId, scopes: $"{StellaOpsScopes.EvidenceCreate} {StellaOpsScopes.EvidenceRead}");
var payload = new
{
@@ -50,7 +58,7 @@ public sealed class EvidenceLockerWebServiceTests
}
};
var snapshotResponse = await client.PostAsJsonAsync("/evidence/snapshot", payload, CancellationToken.None);
var snapshotResponse = await _client.PostAsJsonAsync("/evidence/snapshot", payload, CancellationToken.None);
snapshotResponse.EnsureSuccessStatusCode();
var snapshot = await snapshotResponse.Content.ReadFromJsonAsync<EvidenceSnapshotResponseDto>(CancellationToken.None);
@@ -61,11 +69,12 @@ public sealed class EvidenceLockerWebServiceTests
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);
var timelineEvent = _factory.TimelinePublisher.PublishedEvents
.FirstOrDefault(e => e.Contains(snapshot.BundleId.ToString("D"), StringComparison.Ordinal));
Assert.NotNull(timelineEvent);
Assert.Contains(snapshot.RootHash, timelineEvent);
var bundle = await client.GetFromJsonAsync<EvidenceBundleResponseDto>($"/evidence/{snapshot.BundleId}", CancellationToken.None);
var bundle = await _client.GetFromJsonAsync<EvidenceBundleResponseDto>($"/evidence/{snapshot.BundleId}", CancellationToken.None);
Assert.NotNull(bundle);
Assert.Equal(snapshot.RootHash, bundle!.RootHash);
Assert.NotNull(bundle.Signature);
@@ -77,8 +86,8 @@ public sealed class EvidenceLockerWebServiceTests
[Fact]
public async Task Snapshot_WithIncidentModeActive_ExtendsRetentionAndCapturesDebugArtifact()
{
using var baseFactory = new EvidenceLockerWebApplicationFactory();
using var factory = baseFactory.WithWebHostBuilder(
// This test requires a derived factory with incident mode config
using var incidentFactory = _factory.WithWebHostBuilder(
builder => builder.ConfigureAppConfiguration((_, configurationBuilder) =>
configurationBuilder.AddInMemoryCollection(new Dictionary<string, string?>
{
@@ -86,14 +95,14 @@ public sealed class EvidenceLockerWebServiceTests
["EvidenceLocker:Incident:RetentionExtensionDays"] = "60",
["EvidenceLocker:Incident:CaptureRequestSnapshot"] = "true"
})));
using var client = factory.CreateClient();
using var incidentClient = incidentFactory.CreateClient();
var optionsMonitor = factory.Services.GetRequiredService<IOptionsMonitor<EvidenceLockerOptions>>();
var optionsMonitor = incidentFactory.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}");
ConfigureAuthHeaders(incidentClient, tenantId, scopes: $"{StellaOpsScopes.EvidenceCreate} {StellaOpsScopes.EvidenceRead}");
var payload = new
{
@@ -105,18 +114,18 @@ public sealed class EvidenceLockerWebServiceTests
}
};
var snapshotResponse = await client.PostAsJsonAsync("/evidence/snapshot", payload, CancellationToken.None);
var snapshotResponse = await incidentClient.PostAsJsonAsync("/evidence/snapshot", payload, CancellationToken.None);
snapshotResponse.EnsureSuccessStatusCode();
var snapshot = await snapshotResponse.Content.ReadFromJsonAsync<EvidenceSnapshotResponseDto>(CancellationToken.None);
Assert.NotNull(snapshot);
var bundle = await client.GetFromJsonAsync<EvidenceBundleResponseDto>($"/evidence/{snapshot!.BundleId}", CancellationToken.None);
var bundle = await incidentClient.GetFromJsonAsync<EvidenceBundleResponseDto>($"/evidence/{snapshot!.BundleId}", CancellationToken.None);
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>();
var objectStore = incidentFactory.Services.GetRequiredService<TestEvidenceObjectStore>();
var timeline = incidentFactory.Services.GetRequiredService<TestTimelinePublisher>();
Assert.Contains(objectStore.StoredObjects.Keys, key => key.Contains("/incident/request-", StringComparison.Ordinal));
Assert.Contains("enabled", timeline.IncidentEvents);
}
@@ -125,11 +134,8 @@ public sealed class EvidenceLockerWebServiceTests
[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}");
ConfigureAuthHeaders(_client, tenantId, scopes: $"{StellaOpsScopes.EvidenceCreate} {StellaOpsScopes.EvidenceRead}");
var payload = new
{
@@ -141,13 +147,13 @@ public sealed class EvidenceLockerWebServiceTests
}
};
var snapshotResponse = await client.PostAsJsonAsync("/evidence/snapshot", payload, CancellationToken.None);
var snapshotResponse = await _client.PostAsJsonAsync("/evidence/snapshot", payload, CancellationToken.None);
snapshotResponse.EnsureSuccessStatusCode();
var snapshot = await snapshotResponse.Content.ReadFromJsonAsync<EvidenceSnapshotResponseDto>(CancellationToken.None);
Assert.NotNull(snapshot);
var downloadResponse = await client.GetAsync($"/evidence/{snapshot!.BundleId}/download", CancellationToken.None);
var downloadResponse = await _client.GetAsync($"/evidence/{snapshot!.BundleId}/download", CancellationToken.None);
downloadResponse.EnsureSuccessStatusCode();
Assert.Equal("application/gzip", downloadResponse.Content.Headers.ContentType?.MediaType);
@@ -173,18 +179,15 @@ public sealed class EvidenceLockerWebServiceTests
Assert.Contains("Validate the RFC3161 timestamp token", instructions, StringComparison.Ordinal);
}
Assert.NotEmpty(factory.ObjectStore.StoredObjects);
Assert.NotEmpty(_factory.ObjectStore.StoredObjects);
}
[Trait("Category", TestCategories.Unit)]
[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}");
ConfigureAuthHeaders(_client, tenantId, scopes: $"{StellaOpsScopes.EvidenceCreate} {StellaOpsScopes.EvidenceRead}");
var payload = new
{
@@ -196,12 +199,12 @@ public sealed class EvidenceLockerWebServiceTests
}
};
var snapshotResponse = await client.PostAsJsonAsync("/evidence/snapshot", payload, CancellationToken.None);
var snapshotResponse = await _client.PostAsJsonAsync("/evidence/snapshot", payload, CancellationToken.None);
snapshotResponse.EnsureSuccessStatusCode();
var snapshot = await snapshotResponse.Content.ReadFromJsonAsync<EvidenceSnapshotResponseDto>(CancellationToken.None);
Assert.NotNull(snapshot);
var portableResponse = await client.GetAsync($"/evidence/{snapshot!.BundleId}/portable", CancellationToken.None);
var portableResponse = await _client.GetAsync($"/evidence/{snapshot!.BundleId}/portable", CancellationToken.None);
portableResponse.EnsureSuccessStatusCode();
Assert.Equal("application/gzip", portableResponse.Content.Headers.ContentType?.MediaType);
@@ -227,11 +230,8 @@ public sealed class EvidenceLockerWebServiceTests
[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}");
ConfigureAuthHeaders(_client, tenantId, scopes: $"{StellaOpsScopes.EvidenceCreate} {StellaOpsScopes.EvidenceRead}");
var payload = new
{
@@ -243,7 +243,7 @@ public sealed class EvidenceLockerWebServiceTests
}
};
var response = await client.PostAsJsonAsync("/evidence/snapshot", payload, CancellationToken.None);
var response = await _client.PostAsJsonAsync("/evidence/snapshot", payload, CancellationToken.None);
var responseContent = await response.Content.ReadAsStringAsync(CancellationToken.None);
Assert.True(response.StatusCode == HttpStatusCode.BadRequest, $"Expected 400 but received {(int)response.StatusCode}: {responseContent}");
@@ -257,11 +257,9 @@ public sealed class EvidenceLockerWebServiceTests
[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}");
_client.DefaultRequestHeaders.Clear();
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(EvidenceLockerTestAuthHandler.SchemeName);
_client.DefaultRequestHeaders.Add("X-Test-Scopes", $"{StellaOpsScopes.EvidenceCreate} {StellaOpsScopes.EvidenceRead}");
var payload = new
{
@@ -272,7 +270,7 @@ public sealed class EvidenceLockerWebServiceTests
}
};
var response = await client.PostAsJsonAsync("/evidence/snapshot", payload, CancellationToken.None);
var response = await _client.PostAsJsonAsync("/evidence/snapshot", payload, CancellationToken.None);
var responseContent = await response.Content.ReadAsStringAsync(CancellationToken.None);
Assert.True(response.StatusCode == HttpStatusCode.Forbidden, $"Expected 403 but received {(int)response.StatusCode}: {responseContent}");
@@ -282,41 +280,42 @@ public sealed class EvidenceLockerWebServiceTests
[Fact]
public async Task Hold_ReturnsConflict_WhenCaseAlreadyExists()
{
using var factory = new EvidenceLockerWebApplicationFactory();
using var client = factory.CreateClient();
var repository = factory.Repository;
repository.HoldConflict = true;
_factory.Repository.HoldConflict = true;
var tenantId = Guid.NewGuid().ToString("D");
ConfigureAuthHeaders(client, tenantId, scopes: $"{StellaOpsScopes.EvidenceHold} {StellaOpsScopes.EvidenceRead}");
try
{
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"
},
CancellationToken.None);
var response = await _client.PostAsJsonAsync(
"/evidence/hold/case-123",
new
{
reason = "legal-hold"
},
CancellationToken.None);
var responseContent = await response.Content.ReadAsStringAsync(CancellationToken.None);
Assert.True(response.StatusCode == HttpStatusCode.BadRequest, $"Expected 400 but received {(int)response.StatusCode}: {responseContent}");
var problem = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>(CancellationToken.None);
Assert.NotNull(problem);
Assert.True(problem!.Errors.TryGetValue("message", out var messages));
Assert.Contains(messages, m => m.IndexOf("already exists", StringComparison.OrdinalIgnoreCase) >= 0);
var responseContent = await response.Content.ReadAsStringAsync(CancellationToken.None);
Assert.True(response.StatusCode == HttpStatusCode.BadRequest, $"Expected 400 but received {(int)response.StatusCode}: {responseContent}");
var problem = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>(CancellationToken.None);
Assert.NotNull(problem);
Assert.True(problem!.Errors.TryGetValue("message", out var messages));
Assert.Contains(messages, m => m.IndexOf("already exists", StringComparison.OrdinalIgnoreCase) >= 0);
}
finally
{
_factory.Repository.HoldConflict = false;
}
}
[Trait("Category", TestCategories.Unit)]
[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}");
ConfigureAuthHeaders(_client, tenantId, scopes: $"{StellaOpsScopes.EvidenceHold} {StellaOpsScopes.EvidenceRead}");
var response = await client.PostAsJsonAsync(
var response = await _client.PostAsJsonAsync(
"/evidence/hold/case-789",
new
{
@@ -328,7 +327,7 @@ public sealed class EvidenceLockerWebServiceTests
response.EnsureSuccessStatusCode();
var hold = await response.Content.ReadFromJsonAsync<EvidenceHoldResponseDto>(CancellationToken.None);
Assert.NotNull(hold);
Assert.Contains($"hold:{hold!.CaseId}", factory.TimelinePublisher.PublishedEvents);
Assert.Contains($"hold:{hold!.CaseId}", _factory.TimelinePublisher.PublishedEvents);
}
private static Dictionary<string, string> ReadArchiveEntries(byte[] archiveBytes)
@@ -357,9 +356,16 @@ public sealed class EvidenceLockerWebServiceTests
private static void ConfigureAuthHeaders(HttpClient client, string tenantId, string scopes)
{
client.DefaultRequestHeaders.Clear();
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);
}
public void Dispose()
{
// Do NOT dispose _factory here - xUnit manages IClassFixture lifecycle
_client.Dispose();
}
}

View File

@@ -1,7 +1,5 @@
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;
@@ -9,9 +7,12 @@ using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Core.Repositories;
using StellaOps.EvidenceLocker.Core.Storage;
using StellaOps.EvidenceLocker.Infrastructure.Services;
using StellaOps.TestKit;
using System.Formats.Tar;
using System.IO.Compression;
using System.Text;
using System.Text.Json;
namespace StellaOps.EvidenceLocker.Tests;
public sealed class EvidencePortableBundleServiceTests

View File

@@ -3,11 +3,7 @@
// Sprint: SPRINT_20260112_018_EVIDENCE_reindex_tooling
// Tasks: REINDEX-013
using System.Net;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Auth.Abstractions;
@@ -17,6 +13,11 @@ using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Core.Reindexing;
using StellaOps.EvidenceLocker.Infrastructure.Reindexing;
using StellaOps.TestKit;
using System.Net;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Xunit;
namespace StellaOps.EvidenceLocker.Tests;
@@ -26,15 +27,16 @@ namespace StellaOps.EvidenceLocker.Tests;
/// Tests the full flow of reindex, cross-reference, and continuity verification.
/// </summary>
[Trait("Category", TestCategories.Integration)]
[Collection(EvidenceLockerTestCollection.Name)]
public sealed class EvidenceReindexIntegrationTests : IDisposable
{
private readonly EvidenceLockerWebApplicationFactory _factory;
private readonly HttpClient _client;
private bool _disposed;
public EvidenceReindexIntegrationTests()
public EvidenceReindexIntegrationTests(EvidenceLockerWebApplicationFactory factory)
{
_factory = new EvidenceLockerWebApplicationFactory();
_factory = factory;
_client = _factory.CreateClient();
}
@@ -318,6 +320,6 @@ public sealed class EvidenceReindexIntegrationTests : IDisposable
if (_disposed) return;
_disposed = true;
_client.Dispose();
_factory.Dispose();
// Do NOT dispose _factory here - xUnit manages IClassFixture lifecycle
}
}

View File

@@ -3,8 +3,7 @@
// Sprint: SPRINT_20260112_018_EVIDENCE_reindex_tooling
// Tasks: REINDEX-012
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Cryptography;
using StellaOps.EvidenceLocker.Core.Builders;
@@ -13,6 +12,8 @@ using StellaOps.EvidenceLocker.Core.Reindexing;
using StellaOps.EvidenceLocker.Core.Repositories;
using StellaOps.EvidenceLocker.Infrastructure.Reindexing;
using StellaOps.TestKit;
using System.Text;
using System.Text.Json;
using Xunit;
namespace StellaOps.EvidenceLocker.Tests;
@@ -141,7 +142,9 @@ public sealed class EvidenceReindexServiceTests
}
var progressReports = new List<ReindexProgress>();
var progress = new Progress<ReindexProgress>(p => progressReports.Add(p));
// Use synchronous IProgress<T> instead of Progress<T> which posts
// callbacks to the thread pool and may not fire before assertions run.
var progress = new SynchronousProgress<ReindexProgress>(p => progressReports.Add(p));
var options = new ReindexOptions
{
@@ -355,6 +358,18 @@ public sealed class EvidenceReindexServiceTests
return new EvidenceBundleDetails(bundle, signature);
}
/// <summary>
/// Synchronous IProgress implementation for tests.
/// Unlike <see cref="Progress{T}"/>, callbacks are invoked inline
/// rather than posted to the thread pool.
/// </summary>
private sealed class SynchronousProgress<T> : IProgress<T>
{
private readonly Action<T> _handler;
public SynchronousProgress(Action<T> handler) => _handler = handler;
public void Report(T value) => _handler(value);
}
private sealed class FakeMerkleTreeCalculator : IMerkleTreeCalculator
{
public string NextHash { get; set; } = "sha256:default";

View File

@@ -1,11 +1,5 @@
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;
@@ -14,10 +8,17 @@ using StellaOps.EvidenceLocker.Core.Configuration;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Core.Signing;
using StellaOps.EvidenceLocker.Infrastructure.Signing;
using StellaOps.TestKit;
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 Xunit;
using StellaOps.TestKit;
namespace StellaOps.EvidenceLocker.Tests;
public sealed class EvidenceSignatureServiceTests

View File

@@ -1,7 +1,5 @@
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;
@@ -9,15 +7,18 @@ 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.Incident;
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

View File

@@ -5,25 +5,32 @@
// Description: Integration tests for evidence bundle export API endpoints.
// -----------------------------------------------------------------------------
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using EvidenceLockerProgram = StellaOps.EvidenceLocker.WebService.Program;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using StellaOps.EvidenceLocker.Api;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Xunit;
namespace StellaOps.EvidenceLocker.Tests;
/// <summary>
/// Integration tests for export API endpoints.
/// Uses the shared EvidenceLockerWebApplicationFactory (via Collection fixture)
/// instead of raw WebApplicationFactory&lt;Program&gt; to avoid loading real
/// infrastructure services (database, auth, background services) which causes
/// the test process to hang and consume excessive memory.
/// </summary>
public sealed class ExportEndpointsTests : IClassFixture<WebApplicationFactory<Program>>
[Collection(EvidenceLockerTestCollection.Name)]
public sealed class ExportEndpointsTests
{
private readonly WebApplicationFactory<Program> _factory;
private readonly EvidenceLockerWebApplicationFactory _factory;
public ExportEndpointsTests(WebApplicationFactory<Program> factory)
public ExportEndpointsTests(EvidenceLockerWebApplicationFactory factory)
{
_factory = factory;
}
@@ -45,11 +52,11 @@ public sealed class ExportEndpointsTests : IClassFixture<WebApplicationFactory<P
EstimatedSize = 1024
});
var client = CreateClientWithMock(mockService.Object);
using var scope = CreateClientWithMock(mockService.Object);
var request = new ExportTriggerRequest();
// Act
var response = await client.PostAsJsonAsync("/api/v1/bundles/bundle-123/export", request);
var response = await scope.Client.PostAsJsonAsync("/api/v1/bundles/bundle-123/export", request);
// Assert
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
@@ -72,11 +79,11 @@ public sealed class ExportEndpointsTests : IClassFixture<WebApplicationFactory<P
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExportJobResult { IsNotFound = true });
var client = CreateClientWithMock(mockService.Object);
using var scope = CreateClientWithMock(mockService.Object);
var request = new ExportTriggerRequest();
// Act
var response = await client.PostAsJsonAsync("/api/v1/bundles/nonexistent/export", request);
var response = await scope.Client.PostAsJsonAsync("/api/v1/bundles/nonexistent/export", request);
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
@@ -97,10 +104,10 @@ public sealed class ExportEndpointsTests : IClassFixture<WebApplicationFactory<P
CompletedAt = DateTimeOffset.Parse("2026-01-07T12:00:00Z")
});
var client = CreateClientWithMock(mockService.Object);
using var scope = CreateClientWithMock(mockService.Object);
// Act
var response = await client.GetAsync("/api/v1/bundles/bundle-123/export/exp-123");
var response = await scope.Client.GetAsync("/api/v1/bundles/bundle-123/export/exp-123");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
@@ -126,10 +133,10 @@ public sealed class ExportEndpointsTests : IClassFixture<WebApplicationFactory<P
EstimatedTimeRemaining = "30s"
});
var client = CreateClientWithMock(mockService.Object);
using var scope = CreateClientWithMock(mockService.Object);
// Act
var response = await client.GetAsync("/api/v1/bundles/bundle-123/export/exp-123");
var response = await scope.Client.GetAsync("/api/v1/bundles/bundle-123/export/exp-123");
// Assert
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
@@ -149,10 +156,10 @@ public sealed class ExportEndpointsTests : IClassFixture<WebApplicationFactory<P
.Setup(s => s.GetExportStatusAsync("bundle-123", "nonexistent", It.IsAny<CancellationToken>()))
.ReturnsAsync((ExportJobStatus?)null);
var client = CreateClientWithMock(mockService.Object);
using var scope = CreateClientWithMock(mockService.Object);
// Act
var response = await client.GetAsync("/api/v1/bundles/bundle-123/export/nonexistent");
var response = await scope.Client.GetAsync("/api/v1/bundles/bundle-123/export/nonexistent");
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
@@ -174,10 +181,10 @@ public sealed class ExportEndpointsTests : IClassFixture<WebApplicationFactory<P
FileName = "evidence-bundle-123.tar.gz"
});
var client = CreateClientWithMock(mockService.Object);
using var scope = CreateClientWithMock(mockService.Object);
// Act
var response = await client.GetAsync("/api/v1/bundles/bundle-123/export/exp-123/download");
var response = await scope.Client.GetAsync("/api/v1/bundles/bundle-123/export/exp-123/download");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
@@ -196,10 +203,10 @@ public sealed class ExportEndpointsTests : IClassFixture<WebApplicationFactory<P
Status = ExportJobStatusEnum.Processing
});
var client = CreateClientWithMock(mockService.Object);
using var scope = CreateClientWithMock(mockService.Object);
// Act
var response = await client.GetAsync("/api/v1/bundles/bundle-123/export/exp-123/download");
var response = await scope.Client.GetAsync("/api/v1/bundles/bundle-123/export/exp-123/download");
// Assert
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
@@ -214,10 +221,10 @@ public sealed class ExportEndpointsTests : IClassFixture<WebApplicationFactory<P
.Setup(s => s.GetExportFileAsync("bundle-123", "nonexistent", It.IsAny<CancellationToken>()))
.ReturnsAsync((ExportFileResult?)null);
var client = CreateClientWithMock(mockService.Object);
using var scope = CreateClientWithMock(mockService.Object);
// Act
var response = await client.GetAsync("/api/v1/bundles/bundle-123/export/nonexistent/download");
var response = await scope.Client.GetAsync("/api/v1/bundles/bundle-123/export/nonexistent/download");
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
@@ -241,7 +248,7 @@ public sealed class ExportEndpointsTests : IClassFixture<WebApplicationFactory<P
Status = "pending"
});
var client = CreateClientWithMock(mockService.Object);
using var scope = CreateClientWithMock(mockService.Object);
var request = new ExportTriggerRequest
{
CompressionLevel = 9,
@@ -250,7 +257,7 @@ public sealed class ExportEndpointsTests : IClassFixture<WebApplicationFactory<P
};
// Act
await client.PostAsJsonAsync("/api/v1/bundles/bundle-123/export", request);
await scope.Client.PostAsJsonAsync("/api/v1/bundles/bundle-123/export", request);
// Assert
Assert.NotNull(capturedRequest);
@@ -259,9 +266,37 @@ public sealed class ExportEndpointsTests : IClassFixture<WebApplicationFactory<P
Assert.False(capturedRequest.IncludeRekorProofs);
}
private HttpClient CreateClientWithMock(IExportJobService mockService)
/// <summary>
/// Wraps a derived WebApplicationFactory and HttpClient so both are disposed together.
/// Previously, WithWebHostBuilder() created a new factory per test that was never disposed,
/// leaking TestServer instances and consuming gigabytes of memory.
/// </summary>
/// <summary>
/// Wraps a derived WebApplicationFactory and HttpClient so both are disposed together.
/// Previously, WithWebHostBuilder() created a new factory per test that was never disposed,
/// leaking TestServer instances and consuming gigabytes of memory.
/// </summary>
private sealed class MockScope : IDisposable
{
return _factory.WithWebHostBuilder(builder =>
private readonly WebApplicationFactory<EvidenceLockerProgram> _derivedFactory;
public HttpClient Client { get; }
public MockScope(WebApplicationFactory<EvidenceLockerProgram> derivedFactory)
{
_derivedFactory = derivedFactory;
Client = derivedFactory.CreateClient();
}
public void Dispose()
{
Client.Dispose();
_derivedFactory.Dispose();
}
}
private MockScope CreateClientWithMock(IExportJobService mockService)
{
var derivedFactory = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
@@ -276,6 +311,7 @@ public sealed class ExportEndpointsTests : IClassFixture<WebApplicationFactory<P
// Add mock
services.AddSingleton(mockService);
});
}).CreateClient();
});
return new MockScope(derivedFactory);
}
}

View File

@@ -1,12 +1,13 @@
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;
using StellaOps.TestKit;
using System.Text;
namespace StellaOps.EvidenceLocker.Tests;
public sealed class FileSystemEvidenceObjectStoreTests : IDisposable

View File

@@ -1,10 +1,11 @@
using System.Linq;
using StellaOps.TestKit;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.EvidenceLocker.Tests;
/// <summary>

View File

@@ -0,0 +1,192 @@
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.EvidenceLocker.Core.Configuration;
using StellaOps.EvidenceLocker.Infrastructure.Db;
using System;
using System.IO;
using System.Net.Http;
using System.Runtime.InteropServices;
using System.Threading;
using Testcontainers.PostgreSql;
namespace StellaOps.EvidenceLocker.Tests;
/// <summary>
/// Shared PostgreSQL container fixture for tests that need a real database.
/// Uses lazy initialization (thread-safe) so the container is started once
/// regardless of whether xUnit calls IAsyncLifetime on class fixtures.
/// </summary>
public sealed class PostgreSqlFixture : IAsyncLifetime
{
private readonly SemaphoreSlim _initLock = new(1, 1);
private bool _initialized;
public PostgreSqlContainer? Postgres { get; private set; }
public EvidenceLockerDataSource? DataSource { get; private set; }
public string? SkipReason { get; private set; }
/// <summary>
/// Ensures the container is started and migrations are applied.
/// Safe to call from multiple tests; only the first call does work.
/// </summary>
public async Task EnsureInitializedAsync()
{
if (_initialized) return;
await _initLock.WaitAsync();
try
{
if (_initialized) return;
await InitializeCoreAsync();
_initialized = true;
}
finally
{
_initLock.Release();
}
}
/// <summary>
/// Fast check for Docker availability before attempting to start a container.
/// Testcontainers can hang for minutes when Docker Desktop is not running,
/// consuming gigabytes of memory in the process.
/// </summary>
private static bool IsDockerLikelyAvailable()
{
try
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
// On Windows, try to open the Docker named pipe with a short timeout.
// File.Exists does not work for named pipes.
using var pipe = new System.IO.Pipes.NamedPipeClientStream(".", "docker_engine", System.IO.Pipes.PipeDirection.InOut, System.IO.Pipes.PipeOptions.None);
pipe.Connect(2000); // 2 second timeout
return true;
}
// On Linux/macOS, check for the Docker socket
return File.Exists("/var/run/docker.sock");
}
catch
{
return false;
}
}
private async Task InitializeCoreAsync()
{
if (!IsDockerLikelyAvailable())
{
SkipReason = "Docker is not available (cannot connect to Docker pipe/socket)";
return;
}
try
{
Postgres = new PostgreSqlBuilder()
.WithImage("postgres:17-alpine")
.WithDatabase("evidence_locker_tests")
.WithUsername("postgres")
.WithPassword("postgres")
.Build();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await Postgres.StartAsync(cts.Token);
}
catch (OperationCanceledException)
{
SkipReason = "Docker container start timed out after 30s";
return;
}
catch (HttpRequestException ex)
{
SkipReason = $"Docker endpoint unavailable: {ex.Message}";
return;
}
catch (ArgumentException ex) when (ex.Message.Contains("Docker", StringComparison.OrdinalIgnoreCase))
{
SkipReason = $"Docker unavailable: {ex.Message}";
return;
}
catch (Exception ex) when (ex.Message.Contains("Docker") || ex.Message.Contains("CreateClient"))
{
SkipReason = $"Docker unavailable: {ex.Message}";
return;
}
var databaseOptions = new DatabaseOptions
{
ConnectionString = Postgres.GetConnectionString(),
ApplyMigrationsAtStartup = false
};
DataSource = new EvidenceLockerDataSource(databaseOptions, NullLogger<EvidenceLockerDataSource>.Instance);
try
{
// Verify embedded SQL resources are discoverable before running migration
var scripts = MigrationLoader.LoadAll();
if (scripts.Count == 0)
{
SkipReason = "Migration aborted: MigrationLoader.LoadAll() returned 0 scripts (embedded resources not found)";
return;
}
var migrationRunner = new EvidenceLockerMigrationRunner(DataSource, NullLogger<EvidenceLockerMigrationRunner>.Instance);
await migrationRunner.ApplyAsync(CancellationToken.None);
// Create a non-superuser role for testing RLS.
// Superusers bypass RLS even with FORCE ROW LEVEL SECURITY,
// so tests must use a regular role.
await using var setupConn = await DataSource.OpenConnectionAsync(CancellationToken.None);
await using var roleCmd = new Npgsql.NpgsqlCommand(@"
DO $$
BEGIN
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'evidence_app') THEN
CREATE ROLE evidence_app LOGIN PASSWORD 'evidence_app' NOBYPASSRLS;
END IF;
END
$$;
GRANT USAGE ON SCHEMA evidence_locker TO evidence_app;
GRANT USAGE ON SCHEMA evidence_locker_app TO evidence_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA evidence_locker TO evidence_app;
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA evidence_locker_app TO evidence_app;
", setupConn);
await roleCmd.ExecuteNonQueryAsync(CancellationToken.None);
await setupConn.CloseAsync();
// Reconnect using the non-superuser role so RLS policies are enforced
var appConnectionString = Postgres.GetConnectionString()
.Replace("Username=postgres", "Username=evidence_app")
.Replace("Password=postgres", "Password=evidence_app");
await DataSource.DisposeAsync();
DataSource = new EvidenceLockerDataSource(
new DatabaseOptions { ConnectionString = appConnectionString, ApplyMigrationsAtStartup = false },
NullLogger<EvidenceLockerDataSource>.Instance);
}
catch (Exception ex)
{
SkipReason = $"Migration failed: {ex.Message}";
}
}
// IAsyncLifetime - called by xUnit if it supports it on class fixtures
public async ValueTask InitializeAsync()
{
await EnsureInitializedAsync();
}
public async ValueTask DisposeAsync()
{
if (DataSource is not null)
{
await DataSource.DisposeAsync();
}
if (Postgres is not null)
{
await Postgres.DisposeAsync();
}
_initLock.Dispose();
}
}

View File

@@ -1,18 +1,19 @@
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 StellaOps.TestKit;
using System;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.EvidenceLocker.Tests;
public sealed class Rfc3161TimestampAuthorityClientTests

View File

@@ -1,6 +1,5 @@
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Amazon;
using Amazon.Runtime;
using Amazon.S3;
@@ -10,9 +9,11 @@ using StellaOps.EvidenceLocker.Core.Configuration;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Core.Storage;
using StellaOps.EvidenceLocker.Infrastructure.Storage;
using StellaOps.TestKit;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace StellaOps.EvidenceLocker.Tests;
public sealed class S3EvidenceObjectStoreTests

View File

@@ -11,7 +11,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DotNet.Testcontainers" />
<PackageReference Include="Testcontainers.PostgreSql" />
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="Moq" />
@@ -36,26 +36,4 @@
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
</ItemGroup>
<!-- Alias transitive web service references to avoid Program type conflicts -->
<Target Name="AliasTransitiveWebServices" AfterTargets="ResolveAssemblyReferences">
<ItemGroup>
<ReferencePath Condition="'%(FileName)' == 'StellaOps.Policy.Engine'">
<Aliases>PolicyEngineAlias</Aliases>
</ReferencePath>
<ReferencePath Condition="'%(FileName)' == 'StellaOps.SbomService'">
<Aliases>SbomServiceAlias</Aliases>
</ReferencePath>
<ReferencePath Condition="'%(FileName)' == 'StellaOps.Scheduler'">
<Aliases>SchedulerAlias</Aliases>
</ReferencePath>
<ReferencePath Condition="'%(FileName)' == 'StellaOps.Orchestrator'">
<Aliases>OrchestratorAlias</Aliases>
</ReferencePath>
<ReferencePath Condition="'%(FileName)' == 'StellaOps.Signals'">
<Aliases>SignalsAlias</Aliases>
</ReferencePath>
</ItemGroup>
</Target>
</Project>

View File

@@ -1,7 +1,5 @@
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;
@@ -9,9 +7,12 @@ using StellaOps.EvidenceLocker.Core.Builders;
using StellaOps.EvidenceLocker.Core.Configuration;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Infrastructure.Timeline;
using StellaOps.TestKit;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Text.Json;
namespace StellaOps.EvidenceLocker.Tests;
public sealed class TimelineIndexerEvidenceTimelinePublisherTests

View File

@@ -1,10 +1,12 @@
using Microsoft.Extensions.Logging;
using StellaOps.Auth.Abstractions;
using StellaOps.EvidenceLocker.Core.Domain;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using Microsoft.Extensions.Logging;
using StellaOps.Auth.Abstractions;
using StellaOps.EvidenceLocker.Core.Domain;
namespace StellaOps.EvidenceLocker.WebService.Audit;

View File

@@ -1,10 +1,12 @@
using StellaOps.EvidenceLocker.Core.Builders;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Infrastructure.Services;
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using StellaOps.EvidenceLocker.Core.Builders;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Infrastructure.Services;
namespace StellaOps.EvidenceLocker.WebService.Contracts;
@@ -51,7 +53,8 @@ public sealed record EvidenceBundleResponseDto(
string? Description,
DateTimeOffset? SealedAt,
DateTimeOffset? ExpiresAt,
EvidenceBundleSignatureDto? Signature);
EvidenceBundleSignatureDto? Signature,
Dictionary<string, string>? Metadata = null);
public sealed record EvidenceBundleSignatureDto(
string PayloadType,

View File

@@ -1,8 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
@@ -20,6 +18,10 @@ using StellaOps.EvidenceLocker.WebService.Audit;
using StellaOps.EvidenceLocker.WebService.Contracts;
using StellaOps.EvidenceLocker.WebService.Security;
using StellaOps.Router.AspNet;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
var builder = WebApplication.CreateBuilder(args);
@@ -123,6 +125,30 @@ app.MapGet("/evidence/{bundleId:guid}",
EvidenceAuditLogger.LogBundleRetrieved(logger, user, tenantId, details.Bundle);
// Extract metadata from signed manifest payload
Dictionary<string, string>? metadata = null;
if (details.Signature?.Payload is { Length: > 0 } payload)
{
try
{
var payloadBytes = Convert.FromBase64String(payload);
using var doc = System.Text.Json.JsonDocument.Parse(payloadBytes);
if (doc.RootElement.TryGetProperty("metadata", out var metadataElement)
&& metadataElement.ValueKind == System.Text.Json.JsonValueKind.Object)
{
metadata = new Dictionary<string, string>();
foreach (var prop in metadataElement.EnumerateObject())
{
metadata[prop.Name] = prop.Value.GetString() ?? string.Empty;
}
}
}
catch
{
// Payload is not valid JSON or base64; skip metadata extraction
}
}
var dto = new EvidenceBundleResponseDto(
details.Bundle.Id.Value,
details.Bundle.Kind,
@@ -134,7 +160,8 @@ app.MapGet("/evidence/{bundleId:guid}",
details.Bundle.Description,
details.Bundle.SealedAt,
details.Bundle.ExpiresAt,
details.Signature.ToDto());
details.Signature.ToDto(),
metadata);
return Results.Ok(dto);
})
@@ -332,6 +359,9 @@ app.MapPost("/evidence/hold/{caseId}",
.WithTags("Evidence")
.WithSummary("Create a legal hold for the specified case identifier.");
// Export endpoints
app.MapExportEndpoints();
// Verdict attestation endpoints
app.MapVerdictEndpoints();

View File

@@ -1,7 +1,9 @@
using System;
using System.Security.Claims;
using StellaOps.Auth.Abstractions;
using StellaOps.EvidenceLocker.Core.Domain;
using System;
using System.Security.Claims;
namespace StellaOps.EvidenceLocker.WebService.Security;

View File

@@ -31,8 +31,6 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../Scheduler/__Libraries/StellaOps.Scheduler.Models/StellaOps.Scheduler.Models.csproj" />
<ProjectReference Include="../../Policy/StellaOps.Policy.Engine/StellaOps.Policy.Engine.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />

View File

@@ -5,8 +5,9 @@
// Description: Writes checksums.sha256 file in standard format.
// -----------------------------------------------------------------------------
using System.Text;
using StellaOps.EvidenceLocker.Export.Models;
using System.Text;
namespace StellaOps.EvidenceLocker.Export;

View File

@@ -0,0 +1,8 @@
# StellaOps.EvidenceLocker.Export Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/EvidenceLocker/__Libraries/StellaOps.EvidenceLocker.Export/StellaOps.EvidenceLocker.Export.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |

View File

@@ -5,14 +5,15 @@
// Description: Implementation of tar.gz bundle export with streaming support.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging;
using StellaOps.EvidenceLocker.Export.Models;
using System.Diagnostics;
using System.Formats.Tar;
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.EvidenceLocker.Export.Models;
namespace StellaOps.EvidenceLocker.Export;

View File

@@ -5,13 +5,14 @@
// Description: Exports timestamp evidence to bundle format.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging;
using StellaOps.EvidenceLocker.Timestamping.Models;
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using StellaOps.EvidenceLocker.Timestamping.Models;
namespace StellaOps.EvidenceLocker.Timestamping.Bundle;

View File

@@ -5,9 +5,10 @@
// Description: Imports timestamp evidence from bundle format.
// -----------------------------------------------------------------------------
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.EvidenceLocker.Timestamping.Models;
using System.Text.Json;
namespace StellaOps.EvidenceLocker.Timestamping.Bundle;

View File

@@ -5,10 +5,11 @@
// Description: Implementation of re-timestamping service.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Security.Cryptography;
using Microsoft.Extensions.Logging;
using StellaOps.EvidenceLocker.Timestamping.Models;
using System.Collections.Immutable;
using System.Security.Cryptography;
namespace StellaOps.EvidenceLocker.Timestamping;

View File

@@ -0,0 +1,8 @@
# StellaOps.EvidenceLocker.Timestamping Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/EvidenceLocker/__Libraries/StellaOps.EvidenceLocker.Timestamping/StellaOps.EvidenceLocker.Timestamping.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |

View File

@@ -5,11 +5,12 @@
// Description: PostgreSQL repository implementation for timestamp evidence.
// -----------------------------------------------------------------------------
using System.Data;
using Dapper;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.EvidenceLocker.Timestamping.Models;
using System.Data;
namespace StellaOps.EvidenceLocker.Timestamping;

View File

@@ -5,12 +5,13 @@
// Description: Offline verification of timestamps using bundled evidence.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging;
using StellaOps.EvidenceLocker.Timestamping.Models;
using System.Formats.Asn1;
using System.Security.Cryptography;
using System.Security.Cryptography.Pkcs;
using System.Security.Cryptography.X509Certificates;
using Microsoft.Extensions.Logging;
using StellaOps.EvidenceLocker.Timestamping.Models;
namespace StellaOps.EvidenceLocker.Timestamping.Verification;

View File

@@ -0,0 +1,8 @@
# StellaOps.EvidenceLocker.Export.Tests Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/EvidenceLocker/__Tests/StellaOps.EvidenceLocker.Export.Tests/StellaOps.EvidenceLocker.Export.Tests.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |

View File

@@ -0,0 +1,8 @@
# StellaOps.EvidenceLocker.SchemaEvolution.Tests Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/EvidenceLocker/__Tests/StellaOps.EvidenceLocker.SchemaEvolution.Tests/StellaOps.EvidenceLocker.SchemaEvolution.Tests.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |