stabilize tests
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
|
||||
using StellaOps.EvidenceLocker.Core.Domain;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Core.Builders;
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Text;
|
||||
|
||||
|
||||
using StellaOps.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Core.Builders;
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
|
||||
using StellaOps.Cryptography;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Core.Configuration;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
|
||||
using StellaOps.EvidenceLocker.Core.Domain;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Core.Storage;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
|
||||
|
||||
using System.Reflection;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Infrastructure.Db;
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Program> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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. |
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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. |
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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. |
|
||||
@@ -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. |
|
||||
Reference in New Issue
Block a user