feat: add PolicyPackSelectorComponent with tests and integration
- Implemented PolicyPackSelectorComponent for selecting policy packs. - Added unit tests for component behavior, including API success and error handling. - Introduced monaco-workers type declarations for editor workers. - Created acceptance tests for guardrails with stubs for AT1–AT10. - Established SCA Failure Catalogue Fixtures for regression testing. - Developed plugin determinism harness with stubs for PL1–PL10. - Added scripts for evidence upload and verification processes.
This commit is contained in:
@@ -6,4 +6,5 @@ public interface ITimelineQueryService
|
||||
{
|
||||
Task<IReadOnlyList<TimelineEventView>> QueryAsync(string tenantId, TimelineQueryOptions options, CancellationToken cancellationToken = default);
|
||||
Task<TimelineEventView?> GetAsync(string tenantId, string eventId, CancellationToken cancellationToken = default);
|
||||
Task<TimelineEvidenceView?> GetEvidenceAsync(string tenantId, string eventId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -6,4 +6,5 @@ public interface ITimelineQueryStore
|
||||
{
|
||||
Task<IReadOnlyList<TimelineEventView>> QueryAsync(string tenantId, TimelineQueryOptions options, CancellationToken cancellationToken);
|
||||
Task<TimelineEventView?> GetAsync(string tenantId, string eventId, CancellationToken cancellationToken);
|
||||
Task<TimelineEvidenceView?> GetEvidenceAsync(string tenantId, string eventId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace StellaOps.TimelineIndexer.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Evidence linkage for a timeline event, pointing to sealed bundle/attestation artifacts.
|
||||
/// </summary>
|
||||
public sealed class TimelineEvidenceView
|
||||
{
|
||||
public required string EventId { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public Guid? BundleId { get; init; }
|
||||
public string? BundleDigest { get; init; }
|
||||
public string? AttestationSubject { get; init; }
|
||||
public string? AttestationDigest { get; init; }
|
||||
public string? ManifestUri { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
@@ -19,6 +19,37 @@ public sealed class TimelineQueryService(ITimelineQueryStore store) : ITimelineQ
|
||||
return store.GetAsync(tenantId, eventId, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<TimelineEvidenceView?> GetEvidenceAsync(string tenantId, string eventId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(eventId);
|
||||
var evidence = await store.GetEvidenceAsync(tenantId, eventId, cancellationToken).ConfigureAwait(false);
|
||||
if (evidence is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var manifest = evidence.ManifestUri;
|
||||
if (manifest is null && evidence.BundleId is not null)
|
||||
{
|
||||
manifest = $"bundles/{evidence.BundleId:N}/manifest.dsse.json";
|
||||
}
|
||||
|
||||
var subject = evidence.AttestationSubject ?? evidence.BundleDigest;
|
||||
|
||||
return new TimelineEvidenceView
|
||||
{
|
||||
EventId = evidence.EventId,
|
||||
TenantId = evidence.TenantId,
|
||||
BundleId = evidence.BundleId,
|
||||
BundleDigest = evidence.BundleDigest,
|
||||
AttestationSubject = subject,
|
||||
AttestationDigest = evidence.AttestationDigest,
|
||||
ManifestUri = manifest,
|
||||
CreatedAt = evidence.CreatedAt
|
||||
};
|
||||
}
|
||||
|
||||
private static TimelineQueryOptions Normalize(TimelineQueryOptions options)
|
||||
{
|
||||
var limit = options.Limit;
|
||||
|
||||
@@ -75,6 +75,27 @@ public sealed class TimelineQueryStore(TimelineIndexerDataSource dataSource, ILo
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<TimelineEvidenceView?> GetEvidenceAsync(string tenantId, string eventId, CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT d.event_id, d.tenant_id, d.bundle_id, d.bundle_digest, d.attestation_subject, d.attestation_digest, d.manifest_uri, d.created_at
|
||||
FROM timeline.timeline_event_digests d
|
||||
WHERE d.tenant_id = @tenant_id AND d.event_id = @event_id
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "event_id", eventId);
|
||||
},
|
||||
MapEvidence,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static TimelineEventView MapEvent(NpgsqlDataReader reader) => new()
|
||||
{
|
||||
EventSeq = reader.GetInt64(0),
|
||||
@@ -118,6 +139,37 @@ public sealed class TimelineQueryStore(TimelineIndexerDataSource dataSource, ILo
|
||||
};
|
||||
}
|
||||
|
||||
private static TimelineEvidenceView MapEvidence(NpgsqlDataReader reader)
|
||||
{
|
||||
var bundleDigest = GetNullableString(reader, 3);
|
||||
var attestationSubject = GetNullableString(reader, 4);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(attestationSubject))
|
||||
{
|
||||
attestationSubject = bundleDigest;
|
||||
}
|
||||
|
||||
var bundleId = GetNullableGuid(reader, 2);
|
||||
var manifestUri = GetNullableString(reader, 6);
|
||||
|
||||
if (manifestUri is null && bundleId is not null)
|
||||
{
|
||||
manifestUri = $"bundles/{bundleId:N}/manifest.dsse.json";
|
||||
}
|
||||
|
||||
return new TimelineEvidenceView
|
||||
{
|
||||
EventId = reader.GetString(0),
|
||||
TenantId = reader.GetString(1),
|
||||
BundleId = bundleId,
|
||||
BundleDigest = bundleDigest,
|
||||
AttestationSubject = attestationSubject,
|
||||
AttestationDigest = GetNullableString(reader, 5),
|
||||
ManifestUri = manifestUri,
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(7)
|
||||
};
|
||||
}
|
||||
|
||||
private static IDictionary<string, string>? DeserializeAttributes(NpgsqlDataReader reader, int ordinal)
|
||||
{
|
||||
if (reader.IsDBNull(ordinal)) return null;
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.TimelineIndexer.Core.Models;
|
||||
using StellaOps.TimelineIndexer.Infrastructure.Subscriptions;
|
||||
using StellaOps.TimelineIndexer.Core.Abstractions;
|
||||
|
||||
namespace StellaOps.TimelineIndexer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Offline integration test that wires the real parser + query store against the golden EB1 sealed bundle fixtures.
|
||||
/// </summary>
|
||||
public class EvidenceLinkageIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ParsesAndReturnsEvidenceFromSealedBundle()
|
||||
{
|
||||
var bundleId = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||
var tenantId = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
var merkleRoot = "sha256:c15ab4d1348da9e5000a5d3da50790ea120d865cafb0961845ed6f1e96927596";
|
||||
var manifestUri = "bundles/11111111111111111111111111111111/manifest.dsse.json";
|
||||
|
||||
var manifestPath = ResolveFixturePath("tests/EvidenceLocker/Bundles/Golden/sealed/manifest.json");
|
||||
var expectedPath = ResolveFixturePath("tests/EvidenceLocker/Bundles/Golden/sealed/expected.json");
|
||||
|
||||
var manifestJson = await File.ReadAllTextAsync(manifestPath, TestContext.Current.CancellationToken);
|
||||
var expectedJson = await File.ReadAllTextAsync(expectedPath, TestContext.Current.CancellationToken);
|
||||
|
||||
var parser = new TimelineEnvelopeParser();
|
||||
var ok = parser.TryParse(EnvelopeForManifest(manifestJson), out var envelope, out var reason);
|
||||
Assert.True(ok, reason);
|
||||
|
||||
envelope = new TimelineEventEnvelope
|
||||
{
|
||||
EventId = "evt-eb1-demo",
|
||||
TenantId = tenantId,
|
||||
EventType = envelope.EventType,
|
||||
Source = envelope.Source,
|
||||
OccurredAt = envelope.OccurredAt,
|
||||
CorrelationId = envelope.CorrelationId,
|
||||
TraceId = envelope.TraceId,
|
||||
Actor = envelope.Actor,
|
||||
Severity = envelope.Severity,
|
||||
PayloadHash = envelope.PayloadHash,
|
||||
RawPayloadJson = envelope.RawPayloadJson,
|
||||
NormalizedPayloadJson = envelope.NormalizedPayloadJson,
|
||||
Attributes = envelope.Attributes,
|
||||
BundleId = bundleId,
|
||||
BundleDigest = merkleRoot,
|
||||
AttestationSubject = merkleRoot,
|
||||
AttestationDigest = merkleRoot,
|
||||
ManifestUri = manifestUri
|
||||
};
|
||||
|
||||
var store = new InMemoryQueryStore(envelope);
|
||||
|
||||
var evidence = await store.GetEvidenceAsync(tenantId, envelope.EventId, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(evidence);
|
||||
Assert.Equal(bundleId, evidence!.BundleId);
|
||||
Assert.Equal(merkleRoot, evidence.BundleDigest);
|
||||
Assert.Equal(manifestUri, evidence.ManifestUri);
|
||||
|
||||
using var doc = JsonDocument.Parse(expectedJson);
|
||||
var subject = doc.RootElement.GetProperty("subject").GetString();
|
||||
Assert.Equal(subject, evidence.AttestationSubject);
|
||||
}
|
||||
|
||||
private static string EnvelopeForManifest(string manifestJson)
|
||||
{
|
||||
return $@"{{
|
||||
""eventId"": ""evt-eb1-demo"",
|
||||
""tenant"": ""aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"",
|
||||
""kind"": ""export.bundle.sealed"",
|
||||
""occurredAt"": ""2025-12-04T00:00:00Z"",
|
||||
""source"": ""evidence-locker"",
|
||||
""payload"": {{""manifest"": {{""raw"": {manifestJson} }}}},
|
||||
""bundleId"": ""11111111-1111-1111-1111-111111111111""
|
||||
}}";
|
||||
}
|
||||
|
||||
private static string ResolveFixturePath(string relative)
|
||||
{
|
||||
var baseDir = AppContext.BaseDirectory;
|
||||
// bin/Debug/net10.0/ -> StellaOps.TimelineIndexer.Tests -> TimelineIndexer -> src -> repo root
|
||||
var root = Path.GetFullPath(Path.Combine(baseDir, "..", "..", "..", "..", "..", "..", ".."));
|
||||
return Path.GetFullPath(Path.Combine(root, relative));
|
||||
}
|
||||
|
||||
private sealed class InMemoryQueryStore : ITimelineQueryStore
|
||||
{
|
||||
private readonly TimelineEventEnvelope _envelope;
|
||||
|
||||
public InMemoryQueryStore(TimelineEventEnvelope envelope)
|
||||
{
|
||||
_envelope = envelope;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<TimelineEventView>> QueryAsync(string tenantId, TimelineQueryOptions options, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<IReadOnlyList<TimelineEventView>>(Array.Empty<TimelineEventView>());
|
||||
|
||||
public Task<TimelineEventView?> GetAsync(string tenantId, string eventId, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<TimelineEventView?>(null);
|
||||
|
||||
public Task<TimelineEvidenceView?> GetEvidenceAsync(string tenantId, string eventId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!string.Equals(tenantId, _envelope.TenantId, StringComparison.OrdinalIgnoreCase) ||
|
||||
!string.Equals(eventId, _envelope.EventId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Task.FromResult<TimelineEvidenceView?>(null);
|
||||
}
|
||||
|
||||
return Task.FromResult<TimelineEvidenceView?>(new TimelineEvidenceView
|
||||
{
|
||||
EventId = _envelope.EventId,
|
||||
TenantId = _envelope.TenantId,
|
||||
BundleId = _envelope.BundleId,
|
||||
BundleDigest = _envelope.BundleDigest,
|
||||
AttestationSubject = _envelope.AttestationSubject ?? _envelope.BundleDigest,
|
||||
AttestationDigest = _envelope.AttestationDigest,
|
||||
ManifestUri = _envelope.ManifestUri,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,4 +35,33 @@ public class TimelineEnvelopeParserTests
|
||||
Assert.NotNull(envelope.RawPayloadJson);
|
||||
Assert.NotNull(envelope.NormalizedPayloadJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parser_Maps_Evidence_Metadata()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"eventId": "22222222-2222-2222-2222-222222222222",
|
||||
"tenantId": "tenant-b",
|
||||
"kind": "export.bundle.sealed",
|
||||
"occurredAt": "2025-12-02T01:02:03Z",
|
||||
"bundleId": "9f34f8c6-7a7c-4d63-9c70-2ae6e8f8c6aa",
|
||||
"bundleDigest": "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd",
|
||||
"attestationSubject": "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd",
|
||||
"attestationDigest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcd",
|
||||
"manifestUri": "bundles/9f34f8c6/manifest.dsse.json"
|
||||
}
|
||||
""";
|
||||
|
||||
var parser = new TimelineEnvelopeParser();
|
||||
|
||||
var parsed = parser.TryParse(json, out var envelope, out var reason);
|
||||
|
||||
Assert.True(parsed, reason);
|
||||
Assert.Equal(Guid.Parse("9f34f8c6-7a7c-4d63-9c70-2ae6e8f8c6aa"), envelope.BundleId);
|
||||
Assert.Equal("sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd", envelope.BundleDigest);
|
||||
Assert.Equal("sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd", envelope.AttestationSubject);
|
||||
Assert.Equal("sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcd", envelope.AttestationDigest);
|
||||
Assert.Equal("bundles/9f34f8c6/manifest.dsse.json", envelope.ManifestUri);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,36 @@ public class TimelineIngestionServiceTests
|
||||
Assert.False(second.Inserted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ingest_PersistsEvidenceMetadata_WhenPresent()
|
||||
{
|
||||
var store = new FakeStore();
|
||||
var service = new TimelineIngestionService(store);
|
||||
var envelope = new TimelineEventEnvelope
|
||||
{
|
||||
EventId = "evt-evidence",
|
||||
TenantId = "tenant-e",
|
||||
EventType = "export.bundle.sealed",
|
||||
Source = "exporter",
|
||||
OccurredAt = DateTimeOffset.Parse("2025-12-02T01:02:03Z"),
|
||||
RawPayloadJson = "{}",
|
||||
BundleId = Guid.Parse("9f34f8c6-7a7c-4d63-9c70-2ae6e8f8c6aa"),
|
||||
BundleDigest = "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd",
|
||||
AttestationSubject = "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd",
|
||||
AttestationDigest = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcd",
|
||||
ManifestUri = "bundles/9f34f8c6/manifest.dsse.json"
|
||||
};
|
||||
|
||||
var result = await service.IngestAsync(envelope, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(result.Inserted);
|
||||
Assert.Equal(envelope.BundleId, store.LastEnvelope?.BundleId);
|
||||
Assert.Equal(envelope.BundleDigest, store.LastEnvelope?.BundleDigest);
|
||||
Assert.Equal(envelope.AttestationSubject, store.LastEnvelope?.AttestationSubject);
|
||||
Assert.Equal(envelope.AttestationDigest, store.LastEnvelope?.AttestationDigest);
|
||||
Assert.Equal(envelope.ManifestUri, store.LastEnvelope?.ManifestUri);
|
||||
}
|
||||
|
||||
private sealed class FakeStore : ITimelineEventStore
|
||||
{
|
||||
private readonly HashSet<(string tenant, string id)> _seen = new();
|
||||
|
||||
@@ -49,16 +49,71 @@ public sealed class TimelineIngestionWorkerTests
|
||||
Assert.Equal("sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", store.LastHash); // hash of "{}"
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Worker_Passes_Evidence_Metadata()
|
||||
{
|
||||
var subscriber = new InMemoryTimelineEventSubscriber();
|
||||
var store = new RecordingStore();
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<ITimelineEventSubscriber>(subscriber);
|
||||
services.AddSingleton<ITimelineEventStore>(store);
|
||||
services.AddSingleton<ITimelineIngestionService, TimelineIngestionService>();
|
||||
services.AddSingleton<IHostedService, TimelineIngestionWorker>();
|
||||
services.AddLogging();
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var hosted = provider.GetRequiredService<IHostedService>();
|
||||
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
|
||||
await hosted.StartAsync(cts.Token);
|
||||
|
||||
var evt = new TimelineEventEnvelope
|
||||
{
|
||||
EventId = "evt-evidence-worker",
|
||||
TenantId = "tenant-e",
|
||||
EventType = "export.bundle.sealed",
|
||||
Source = "exporter",
|
||||
OccurredAt = DateTimeOffset.UtcNow,
|
||||
RawPayloadJson = "{}",
|
||||
BundleId = Guid.Parse("9f34f8c6-7a7c-4d63-9c70-2ae6e8f8c6aa"),
|
||||
BundleDigest = "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd",
|
||||
AttestationSubject = "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd",
|
||||
AttestationDigest = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcd",
|
||||
ManifestUri = "bundles/9f34f8c6/manifest.dsse.json"
|
||||
};
|
||||
|
||||
subscriber.Enqueue(evt);
|
||||
subscriber.Complete();
|
||||
|
||||
await Task.Delay(200, cts.Token);
|
||||
await hosted.StopAsync(cts.Token);
|
||||
|
||||
Assert.Equal(evt.BundleId, store.LastBundleId);
|
||||
Assert.Equal(evt.BundleDigest, store.LastBundleDigest);
|
||||
Assert.Equal(evt.AttestationSubject, store.LastAttestationSubject);
|
||||
Assert.Equal(evt.AttestationDigest, store.LastAttestationDigest);
|
||||
Assert.Equal(evt.ManifestUri, store.LastManifestUri);
|
||||
}
|
||||
|
||||
private sealed class RecordingStore : ITimelineEventStore
|
||||
{
|
||||
private readonly HashSet<(string tenant, string id)> _seen = new();
|
||||
public int InsertCalls { get; private set; }
|
||||
public string? LastHash { get; private set; }
|
||||
public Guid? LastBundleId { get; private set; }
|
||||
public string? LastBundleDigest { get; private set; }
|
||||
public string? LastAttestationSubject { get; private set; }
|
||||
public string? LastAttestationDigest { get; private set; }
|
||||
public string? LastManifestUri { get; private set; }
|
||||
|
||||
public Task<bool> InsertAsync(TimelineEventEnvelope envelope, CancellationToken cancellationToken = default)
|
||||
{
|
||||
InsertCalls++;
|
||||
LastHash = envelope.PayloadHash;
|
||||
LastBundleId = envelope.BundleId;
|
||||
LastBundleDigest = envelope.BundleDigest;
|
||||
LastAttestationSubject = envelope.AttestationSubject;
|
||||
LastAttestationDigest = envelope.AttestationDigest;
|
||||
LastManifestUri = envelope.ManifestUri;
|
||||
return Task.FromResult(_seen.Add((envelope.TenantId, envelope.EventId)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,10 +29,49 @@ public class TimelineQueryServiceTests
|
||||
Assert.Equal(("tenant-1", "evt-1"), store.LastGet);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvidenceAsync_PassesTenantAndId()
|
||||
{
|
||||
var store = new FakeStore();
|
||||
var service = new TimelineQueryService(store);
|
||||
|
||||
await service.GetEvidenceAsync("tenant-x", "evt-evidence", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(("tenant-x", "evt-evidence"), store.LastEvidenceGet);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvidenceAsync_FillsManifestUriFromBundleId_WhenMissing()
|
||||
{
|
||||
var bundleId = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||
var store = new FakeStore
|
||||
{
|
||||
Evidence = new TimelineEvidenceView
|
||||
{
|
||||
EventId = "evt",
|
||||
TenantId = "tenant",
|
||||
BundleId = bundleId,
|
||||
BundleDigest = "sha256:deadbeef",
|
||||
AttestationSubject = "sha256:deadbeef",
|
||||
AttestationDigest = "sha256:feedface",
|
||||
ManifestUri = null,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
}
|
||||
};
|
||||
var service = new TimelineQueryService(store);
|
||||
|
||||
var evidence = await service.GetEvidenceAsync("tenant", "evt", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(evidence);
|
||||
Assert.Equal($"bundles/{bundleId:N}/manifest.dsse.json", evidence!.ManifestUri);
|
||||
}
|
||||
|
||||
private sealed class FakeStore : ITimelineQueryStore
|
||||
{
|
||||
public TimelineQueryOptions? LastOptions { get; private set; }
|
||||
public (string tenant, string id)? LastGet { get; private set; }
|
||||
public (string tenant, string id)? LastEvidenceGet { get; private set; }
|
||||
public TimelineEvidenceView? Evidence { get; set; }
|
||||
|
||||
public Task<IReadOnlyList<TimelineEventView>> QueryAsync(string tenantId, TimelineQueryOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -45,5 +84,11 @@ public class TimelineQueryServiceTests
|
||||
LastGet = (tenantId, eventId);
|
||||
return Task.FromResult<TimelineEventView?>(null);
|
||||
}
|
||||
|
||||
public Task<TimelineEvidenceView?> GetEvidenceAsync(string tenantId, string eventId, CancellationToken cancellationToken)
|
||||
{
|
||||
LastEvidenceGet = (tenantId, eventId);
|
||||
return Task.FromResult(Evidence);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,18 @@ app.MapGet("/timeline/{eventId}", async (
|
||||
})
|
||||
.RequireAuthorization(StellaOpsResourceServerPolicies.TimelineRead);
|
||||
|
||||
app.MapGet("/timeline/{eventId}/evidence", async (
|
||||
HttpContext ctx,
|
||||
ITimelineQueryService service,
|
||||
string eventId,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = GetTenantId(ctx);
|
||||
var evidence = await service.GetEvidenceAsync(tenantId, eventId, cancellationToken).ConfigureAwait(false);
|
||||
return evidence is null ? Results.NotFound() : Results.Ok(evidence);
|
||||
})
|
||||
.RequireAuthorization(StellaOpsResourceServerPolicies.TimelineRead);
|
||||
|
||||
app.MapPost("/timeline/events", () => Results.Accepted("/timeline/events", new { status = "indexed" }))
|
||||
.RequireAuthorization(StellaOpsResourceServerPolicies.TimelineWrite);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user