Restructure solution layout by module
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Excititor.WebService.Endpoints;
|
||||
using StellaOps.Excititor.WebService.Services;
|
||||
|
||||
namespace StellaOps.Excititor.WebService.Tests;
|
||||
|
||||
public sealed class IngestEndpointsTests
|
||||
{
|
||||
private readonly FakeIngestOrchestrator _orchestrator = new();
|
||||
private readonly TimeProvider _timeProvider = TimeProvider.System;
|
||||
|
||||
[Fact]
|
||||
public async Task InitEndpoint_ReturnsUnauthorized_WhenMissingToken()
|
||||
{
|
||||
var httpContext = CreateHttpContext();
|
||||
var request = new IngestEndpoints.ExcititorInitRequest(null, false);
|
||||
|
||||
var result = await IngestEndpoints.HandleInitAsync(httpContext, request, _orchestrator, _timeProvider, CancellationToken.None);
|
||||
Assert.IsType<UnauthorizedHttpResult>(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InitEndpoint_ReturnsForbidden_WhenScopeMissing()
|
||||
{
|
||||
var httpContext = CreateHttpContext("vex.read");
|
||||
var request = new IngestEndpoints.ExcititorInitRequest(null, false);
|
||||
|
||||
var result = await IngestEndpoints.HandleInitAsync(httpContext, request, _orchestrator, _timeProvider, CancellationToken.None);
|
||||
Assert.IsType<ForbidHttpResult>(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InitEndpoint_NormalizesProviders_AndReturnsSummary()
|
||||
{
|
||||
var httpContext = CreateHttpContext("vex.admin");
|
||||
var request = new IngestEndpoints.ExcititorInitRequest(new[] { " suse ", "redhat", "REDHAT" }, true);
|
||||
var started = DateTimeOffset.Parse("2025-10-20T12:00:00Z");
|
||||
var completed = started.AddMinutes(2);
|
||||
_orchestrator.InitFactory = options => new InitSummary(
|
||||
Guid.Parse("9a5eb53c-3118-4f78-991e-7d2c1af92a14"),
|
||||
started,
|
||||
completed,
|
||||
ImmutableArray.Create(
|
||||
new InitProviderResult("redhat", "Red Hat", "succeeded", TimeSpan.FromSeconds(12), null),
|
||||
new InitProviderResult("suse", "SUSE", "failed", TimeSpan.FromSeconds(7), "unreachable")));
|
||||
|
||||
var result = await IngestEndpoints.HandleInitAsync(httpContext, request, _orchestrator, _timeProvider, CancellationToken.None);
|
||||
var ok = Assert.IsType<Ok<object>>(result);
|
||||
Assert.Equal(new[] { "redhat", "suse" }, _orchestrator.LastInitOptions?.Providers);
|
||||
Assert.True(_orchestrator.LastInitOptions?.Resume);
|
||||
|
||||
using var document = JsonDocument.Parse(JsonSerializer.Serialize(ok.Value));
|
||||
Assert.Equal("Initialized 2 provider(s); 1 succeeded, 1 failed.", document.RootElement.GetProperty("message").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunEndpoint_ReturnsBadRequest_WhenSinceInvalid()
|
||||
{
|
||||
var httpContext = CreateHttpContext("vex.admin");
|
||||
var request = new IngestEndpoints.ExcititorIngestRunRequest(new[] { "redhat" }, "not-a-date", null, false);
|
||||
|
||||
var result = await IngestEndpoints.HandleRunAsync(httpContext, request, _orchestrator, _timeProvider, CancellationToken.None);
|
||||
var bad = Assert.IsType<BadRequest<object>>(result);
|
||||
using var document = JsonDocument.Parse(JsonSerializer.Serialize(bad.Value));
|
||||
Assert.Contains("Invalid 'since'", document.RootElement.GetProperty("message").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunEndpoint_ReturnsBadRequest_WhenWindowInvalid()
|
||||
{
|
||||
var httpContext = CreateHttpContext("vex.admin");
|
||||
var request = new IngestEndpoints.ExcititorIngestRunRequest(Array.Empty<string>(), null, "-01:00:00", false);
|
||||
|
||||
var result = await IngestEndpoints.HandleRunAsync(httpContext, request, _orchestrator, _timeProvider, CancellationToken.None);
|
||||
var bad = Assert.IsType<BadRequest<object>>(result);
|
||||
using var document = JsonDocument.Parse(JsonSerializer.Serialize(bad.Value));
|
||||
Assert.Contains("Invalid duration", document.RootElement.GetProperty("message").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunEndpoint_PassesOptionsToOrchestrator()
|
||||
{
|
||||
var httpContext = CreateHttpContext("vex.admin");
|
||||
var started = DateTimeOffset.Parse("2025-10-20T14:00:00Z");
|
||||
var completed = started.AddMinutes(5);
|
||||
_orchestrator.RunFactory = options => new IngestRunSummary(
|
||||
Guid.Parse("65bbfa25-82fd-41da-8b6b-9d8bb1e2bb5f"),
|
||||
started,
|
||||
completed,
|
||||
ImmutableArray.Create(
|
||||
new ProviderRunResult(
|
||||
"redhat",
|
||||
"succeeded",
|
||||
12,
|
||||
42,
|
||||
started,
|
||||
completed,
|
||||
completed - started,
|
||||
"sha256:abc",
|
||||
completed.AddHours(-1),
|
||||
"cp1",
|
||||
null,
|
||||
options.Since)));
|
||||
|
||||
var request = new IngestEndpoints.ExcititorIngestRunRequest(new[] { "redhat" }, "2025-10-19T00:00:00Z", "1.00:00:00", true);
|
||||
var result = await IngestEndpoints.HandleRunAsync(httpContext, request, _orchestrator, _timeProvider, CancellationToken.None);
|
||||
var ok = Assert.IsType<Ok<object>>(result);
|
||||
|
||||
Assert.NotNull(_orchestrator.LastRunOptions);
|
||||
Assert.Equal(new[] { "redhat" }, _orchestrator.LastRunOptions!.Providers);
|
||||
Assert.True(_orchestrator.LastRunOptions.Force);
|
||||
Assert.Equal(TimeSpan.FromDays(1), _orchestrator.LastRunOptions.Window);
|
||||
|
||||
using var document = JsonDocument.Parse(JsonSerializer.Serialize(ok.Value));
|
||||
Assert.Equal("cp1", document.RootElement.GetProperty("providers")[0].GetProperty("checkpoint").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResumeEndpoint_PassesCheckpointToOrchestrator()
|
||||
{
|
||||
var httpContext = CreateHttpContext("vex.admin");
|
||||
var started = DateTimeOffset.Parse("2025-10-20T16:00:00Z");
|
||||
var completed = started.AddMinutes(2);
|
||||
_orchestrator.ResumeFactory = options => new IngestRunSummary(
|
||||
Guid.Parse("88407f25-4b3f-434d-8f8e-1c7f4925c37b"),
|
||||
started,
|
||||
completed,
|
||||
ImmutableArray.Create(
|
||||
new ProviderRunResult(
|
||||
"suse",
|
||||
"succeeded",
|
||||
5,
|
||||
10,
|
||||
started,
|
||||
completed,
|
||||
completed - started,
|
||||
null,
|
||||
null,
|
||||
options.Checkpoint,
|
||||
null,
|
||||
DateTimeOffset.UtcNow.AddDays(-1))));
|
||||
|
||||
var request = new IngestEndpoints.ExcititorIngestResumeRequest(new[] { "suse" }, "resume-token");
|
||||
var result = await IngestEndpoints.HandleResumeAsync(httpContext, request, _orchestrator, _timeProvider, CancellationToken.None);
|
||||
Assert.IsType<Ok<object>>(result);
|
||||
Assert.Equal("resume-token", _orchestrator.LastResumeOptions?.Checkpoint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReconcileEndpoint_ReturnsBadRequest_WhenMaxAgeInvalid()
|
||||
{
|
||||
var httpContext = CreateHttpContext("vex.admin");
|
||||
var request = new IngestEndpoints.ExcititorReconcileRequest(Array.Empty<string>(), "invalid");
|
||||
|
||||
var result = await IngestEndpoints.HandleReconcileAsync(httpContext, request, _orchestrator, _timeProvider, CancellationToken.None);
|
||||
var bad = Assert.IsType<BadRequest<object>>(result);
|
||||
using var document = JsonDocument.Parse(JsonSerializer.Serialize(bad.Value));
|
||||
Assert.Contains("Invalid duration", document.RootElement.GetProperty("message").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReconcileEndpoint_PassesOptionsAndReturnsSummary()
|
||||
{
|
||||
var httpContext = CreateHttpContext("vex.admin");
|
||||
var started = DateTimeOffset.Parse("2025-10-20T18:00:00Z");
|
||||
var completed = started.AddMinutes(4);
|
||||
_orchestrator.ReconcileFactory = options => new ReconcileSummary(
|
||||
Guid.Parse("a2c2cfe6-c21a-4a62-9db7-2ed2792f4e2d"),
|
||||
started,
|
||||
completed,
|
||||
ImmutableArray.Create(
|
||||
new ReconcileProviderResult(
|
||||
"ubuntu",
|
||||
"succeeded",
|
||||
"reconciled",
|
||||
started.AddDays(-2),
|
||||
started - TimeSpan.FromDays(3),
|
||||
20,
|
||||
18,
|
||||
null)));
|
||||
|
||||
var request = new IngestEndpoints.ExcititorReconcileRequest(new[] { "ubuntu" }, "2.00:00:00");
|
||||
var result = await IngestEndpoints.HandleReconcileAsync(httpContext, request, _orchestrator, _timeProvider, CancellationToken.None);
|
||||
var ok = Assert.IsType<Ok<object>>(result);
|
||||
|
||||
Assert.Equal(TimeSpan.FromDays(2), _orchestrator.LastReconcileOptions?.MaxAge);
|
||||
using var document = JsonDocument.Parse(JsonSerializer.Serialize(ok.Value));
|
||||
Assert.Equal("reconciled", document.RootElement.GetProperty("providers")[0].GetProperty("action").GetString());
|
||||
}
|
||||
|
||||
private static DefaultHttpContext CreateHttpContext(params string[] scopes)
|
||||
{
|
||||
var context = new DefaultHttpContext
|
||||
{
|
||||
RequestServices = new ServiceCollection().BuildServiceProvider(),
|
||||
Response = { Body = new MemoryStream() }
|
||||
};
|
||||
|
||||
if (scopes.Length > 0)
|
||||
{
|
||||
var claims = new List<Claim> { new Claim(ClaimTypes.NameIdentifier, "test-user") };
|
||||
claims.Add(new Claim("scope", string.Join(' ', scopes)));
|
||||
var identity = new ClaimsIdentity(claims, "Test");
|
||||
context.User = new ClaimsPrincipal(identity);
|
||||
}
|
||||
else
|
||||
{
|
||||
context.User = new ClaimsPrincipal(new ClaimsIdentity());
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
private sealed class FakeIngestOrchestrator : IVexIngestOrchestrator
|
||||
{
|
||||
public IngestInitOptions? LastInitOptions { get; private set; }
|
||||
public IngestRunOptions? LastRunOptions { get; private set; }
|
||||
public IngestResumeOptions? LastResumeOptions { get; private set; }
|
||||
public ReconcileOptions? LastReconcileOptions { get; private set; }
|
||||
|
||||
public Func<IngestInitOptions, InitSummary>? InitFactory { get; set; }
|
||||
public Func<IngestRunOptions, IngestRunSummary>? RunFactory { get; set; }
|
||||
public Func<IngestResumeOptions, IngestRunSummary>? ResumeFactory { get; set; }
|
||||
public Func<ReconcileOptions, ReconcileSummary>? ReconcileFactory { get; set; }
|
||||
|
||||
public Task<InitSummary> InitializeAsync(IngestInitOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
LastInitOptions = options;
|
||||
return Task.FromResult(InitFactory is null ? CreateDefaultInitSummary() : InitFactory(options));
|
||||
}
|
||||
|
||||
public Task<IngestRunSummary> RunAsync(IngestRunOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
LastRunOptions = options;
|
||||
return Task.FromResult(RunFactory is null ? CreateDefaultRunSummary() : RunFactory(options));
|
||||
}
|
||||
|
||||
public Task<IngestRunSummary> ResumeAsync(IngestResumeOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
LastResumeOptions = options;
|
||||
return Task.FromResult(ResumeFactory is null ? CreateDefaultRunSummary() : ResumeFactory(options));
|
||||
}
|
||||
|
||||
public Task<ReconcileSummary> ReconcileAsync(ReconcileOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
LastReconcileOptions = options;
|
||||
return Task.FromResult(ReconcileFactory is null ? CreateDefaultReconcileSummary() : ReconcileFactory(options));
|
||||
}
|
||||
|
||||
private static InitSummary CreateDefaultInitSummary()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
return new InitSummary(Guid.Empty, now, now, ImmutableArray<InitProviderResult>.Empty);
|
||||
}
|
||||
|
||||
private static IngestRunSummary CreateDefaultRunSummary()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
return new IngestRunSummary(Guid.Empty, now, now, ImmutableArray<ProviderRunResult>.Empty);
|
||||
}
|
||||
|
||||
private static ReconcileSummary CreateDefaultReconcileSummary()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
return new ReconcileSummary(Guid.Empty, now, now, ImmutableArray<ReconcileProviderResult>.Empty);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using EphemeralMongo;
|
||||
using MongoRunner = EphemeralMongo.MongoRunner;
|
||||
using MongoRunnerOptions = EphemeralMongo.MongoRunnerOptions;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Connectors.Abstractions;
|
||||
using StellaOps.Excititor.Export;
|
||||
using StellaOps.Excititor.Policy;
|
||||
using StellaOps.Excititor.Storage.Mongo;
|
||||
|
||||
namespace StellaOps.Excititor.WebService.Tests;
|
||||
|
||||
public sealed class MirrorEndpointsTests : IDisposable
|
||||
{
|
||||
private readonly TestWebApplicationFactory _factory;
|
||||
private readonly IMongoRunner _runner;
|
||||
|
||||
public MirrorEndpointsTests()
|
||||
{
|
||||
_runner = MongoRunner.Run(new MongoRunnerOptions { UseSingleNodeReplicaSet = true });
|
||||
_factory = new TestWebApplicationFactory(
|
||||
configureConfiguration: configuration =>
|
||||
{
|
||||
var data = new Dictionary<string, string?>
|
||||
{
|
||||
["Excititor:Storage:Mongo:ConnectionString"] = _runner.ConnectionString,
|
||||
["Excititor:Storage:Mongo:DatabaseName"] = "mirror-tests",
|
||||
[$"{MirrorDistributionOptions.SectionName}:Domains:0:Id"] = "primary",
|
||||
[$"{MirrorDistributionOptions.SectionName}:Domains:0:DisplayName"] = "Primary Mirror",
|
||||
[$"{MirrorDistributionOptions.SectionName}:Domains:0:MaxIndexRequestsPerHour"] = "1000",
|
||||
[$"{MirrorDistributionOptions.SectionName}:Domains:0:MaxDownloadRequestsPerHour"] = "1000",
|
||||
[$"{MirrorDistributionOptions.SectionName}:Domains:0:Exports:0:Key"] = "consensus",
|
||||
[$"{MirrorDistributionOptions.SectionName}:Domains:0:Exports:0:Format"] = "json",
|
||||
[$"{MirrorDistributionOptions.SectionName}:Domains:0:Exports:0:Filters:vulnId"] = "CVE-2025-0001",
|
||||
[$"{MirrorDistributionOptions.SectionName}:Domains:0:Exports:0:Filters:productKey"] = "pkg:test/demo",
|
||||
};
|
||||
|
||||
configuration.AddInMemoryCollection(data!);
|
||||
},
|
||||
configureServices: services =>
|
||||
{
|
||||
TestServiceOverrides.Apply(services);
|
||||
services.RemoveAll<IVexExportStore>();
|
||||
services.AddSingleton<IVexExportStore>(provider =>
|
||||
{
|
||||
var timeProvider = provider.GetRequiredService<TimeProvider>();
|
||||
return new FakeExportStore(timeProvider);
|
||||
});
|
||||
services.RemoveAll<IVexArtifactStore>();
|
||||
services.AddSingleton<IVexArtifactStore>(_ => new FakeArtifactStore());
|
||||
services.AddSingleton(new VexConnectorDescriptor("excititor:redhat", VexProviderKind.Distro, "Red Hat CSAF"));
|
||||
services.AddSingleton<StellaOps.Excititor.Attestation.Signing.IVexSigner, FakeSigner>();
|
||||
services.AddSingleton<StellaOps.Excititor.Policy.IVexPolicyEvaluator, FakePolicyEvaluator>();
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListDomains_ReturnsConfiguredDomain()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var response = await client.GetAsync("/excititor/mirror/domains");
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
var domains = document.RootElement.GetProperty("domains");
|
||||
Assert.Equal(1, domains.GetArrayLength());
|
||||
Assert.Equal("primary", domains[0].GetProperty("id").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DomainIndex_ReturnsManifestMetadata()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var response = await client.GetAsync("/excititor/mirror/domains/primary/index");
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
var exports = document.RootElement.GetProperty("exports");
|
||||
Assert.Equal(1, exports.GetArrayLength());
|
||||
var entry = exports[0];
|
||||
Assert.Equal("consensus", entry.GetProperty("exportKey").GetString());
|
||||
Assert.Equal("exports/20251019T000000000Z/abcdef", entry.GetProperty("exportId").GetString());
|
||||
var artifact = entry.GetProperty("artifact");
|
||||
Assert.Equal("sha256", artifact.GetProperty("algorithm").GetString());
|
||||
Assert.Equal("deadbeef", artifact.GetProperty("digest").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Download_ReturnsArtifactContent()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var response = await client.GetAsync("/excititor/mirror/domains/primary/exports/consensus/download");
|
||||
response.EnsureSuccessStatusCode();
|
||||
Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType);
|
||||
var payload = await response.Content.ReadAsStringAsync();
|
||||
Assert.Equal("{\"status\":\"ok\"}", payload);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_factory.Dispose();
|
||||
_runner.Dispose();
|
||||
}
|
||||
|
||||
private sealed class FakeExportStore : IVexExportStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<(string Signature, VexExportFormat Format), VexExportManifest> _manifests = new();
|
||||
|
||||
public FakeExportStore(TimeProvider timeProvider)
|
||||
{
|
||||
var filters = new[]
|
||||
{
|
||||
new VexQueryFilter("vulnId", "CVE-2025-0001"),
|
||||
new VexQueryFilter("productKey", "pkg:test/demo"),
|
||||
};
|
||||
|
||||
var query = VexQuery.Create(filters, Enumerable.Empty<VexQuerySort>());
|
||||
var signature = VexQuerySignature.FromQuery(query);
|
||||
var createdAt = new DateTimeOffset(2025, 10, 19, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
var manifest = new VexExportManifest(
|
||||
"exports/20251019T000000000Z/abcdef",
|
||||
signature,
|
||||
VexExportFormat.Json,
|
||||
createdAt,
|
||||
new VexContentAddress("sha256", "deadbeef"),
|
||||
1,
|
||||
new[] { "primary" },
|
||||
fromCache: false,
|
||||
consensusRevision: "rev-1",
|
||||
attestation: new VexAttestationMetadata("https://stella-ops.org/attestations/vex-export"),
|
||||
sizeBytes: 16);
|
||||
|
||||
_manifests.TryAdd((signature.Value, VexExportFormat.Json), manifest);
|
||||
|
||||
// Seed artifact content for download test.
|
||||
FakeArtifactStore.Seed(manifest.Artifact, "{\"status\":\"ok\"}");
|
||||
}
|
||||
|
||||
public ValueTask<VexExportManifest?> FindAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
_manifests.TryGetValue((signature.Value, format), out var manifest);
|
||||
return ValueTask.FromResult(manifest);
|
||||
}
|
||||
|
||||
public ValueTask SaveAsync(VexExportManifest manifest, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class FakeArtifactStore : IVexArtifactStore
|
||||
{
|
||||
private static readonly ConcurrentDictionary<VexContentAddress, byte[]> Content = new();
|
||||
|
||||
public static void Seed(VexContentAddress contentAddress, string payload)
|
||||
{
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(payload);
|
||||
Content[contentAddress] = bytes;
|
||||
}
|
||||
|
||||
public ValueTask<VexStoredArtifact> SaveAsync(VexExportArtifact artifact, CancellationToken cancellationToken)
|
||||
{
|
||||
Content[artifact.ContentAddress] = artifact.Content.ToArray();
|
||||
return ValueTask.FromResult(new VexStoredArtifact(artifact.ContentAddress, "memory://artifact", artifact.Content.Length, artifact.Metadata));
|
||||
}
|
||||
|
||||
public ValueTask DeleteAsync(VexContentAddress contentAddress, CancellationToken cancellationToken)
|
||||
{
|
||||
Content.TryRemove(contentAddress, out _);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask<Stream?> OpenReadAsync(VexContentAddress contentAddress, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Content.TryGetValue(contentAddress, out var bytes))
|
||||
{
|
||||
return ValueTask.FromResult<Stream?>(null);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult<Stream?>(new MemoryStream(bytes, writable: false));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeSigner : StellaOps.Excititor.Attestation.Signing.IVexSigner
|
||||
{
|
||||
public ValueTask<StellaOps.Excititor.Attestation.Signing.VexSignedPayload> SignAsync(ReadOnlyMemory<byte> payload, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(new StellaOps.Excititor.Attestation.Signing.VexSignedPayload("signature", "key"));
|
||||
}
|
||||
|
||||
private sealed class FakePolicyEvaluator : StellaOps.Excititor.Policy.IVexPolicyEvaluator
|
||||
{
|
||||
public string Version => "test";
|
||||
|
||||
public VexPolicySnapshot Snapshot => VexPolicySnapshot.Default;
|
||||
|
||||
public double GetProviderWeight(VexProvider provider) => 1.0;
|
||||
|
||||
public bool IsClaimEligible(VexClaim claim, VexProvider provider, out string? rejectionReason)
|
||||
{
|
||||
rejectionReason = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using EphemeralMongo;
|
||||
using MongoRunner = EphemeralMongo.MongoRunner;
|
||||
using MongoRunnerOptions = EphemeralMongo.MongoRunnerOptions;
|
||||
using StellaOps.Excititor.Attestation.Signing;
|
||||
using StellaOps.Excititor.Connectors.Abstractions;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Export;
|
||||
using StellaOps.Excititor.Policy;
|
||||
using StellaOps.Excititor.Storage.Mongo;
|
||||
|
||||
namespace StellaOps.Excititor.WebService.Tests;
|
||||
|
||||
public sealed class ResolveEndpointTests : IDisposable
|
||||
{
|
||||
private readonly TestWebApplicationFactory _factory;
|
||||
private readonly IMongoRunner _runner;
|
||||
|
||||
public ResolveEndpointTests()
|
||||
{
|
||||
_runner = MongoRunner.Run(new MongoRunnerOptions { UseSingleNodeReplicaSet = true });
|
||||
|
||||
_factory = new TestWebApplicationFactory(
|
||||
configureConfiguration: config =>
|
||||
{
|
||||
var rootPath = Path.Combine(Path.GetTempPath(), "excititor-resolve-tests");
|
||||
Directory.CreateDirectory(rootPath);
|
||||
var settings = new Dictionary<string, string?>
|
||||
{
|
||||
["Excititor:Storage:Mongo:ConnectionString"] = _runner.ConnectionString,
|
||||
["Excititor:Storage:Mongo:DatabaseName"] = "excititor-resolve-tests",
|
||||
["Excititor:Storage:Mongo:RawBucketName"] = "vex.raw",
|
||||
["Excititor:Storage:Mongo:GridFsInlineThresholdBytes"] = "256",
|
||||
["Excititor:Artifacts:FileSystem:RootPath"] = rootPath,
|
||||
};
|
||||
config.AddInMemoryCollection(settings!);
|
||||
},
|
||||
configureServices: services =>
|
||||
{
|
||||
services.AddTestAuthentication();
|
||||
TestServiceOverrides.Apply(services);
|
||||
services.AddSingleton<IVexSigner, FakeSigner>();
|
||||
services.AddSingleton<IVexPolicyEvaluator, FakePolicyEvaluator>();
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveEndpoint_ReturnsBadRequest_WhenInputsMissing()
|
||||
{
|
||||
var client = CreateClient("vex.read");
|
||||
var response = await client.PostAsJsonAsync("/excititor/resolve", new { vulnerabilityIds = new[] { "CVE-2025-0001" } });
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveEndpoint_ComputesConsensusAndAttestation()
|
||||
{
|
||||
const string vulnerabilityId = "CVE-2025-2222";
|
||||
const string productKey = "pkg:nuget/StellaOps.Demo@1.0.0";
|
||||
const string providerId = "redhat";
|
||||
|
||||
await SeedProviderAsync(providerId);
|
||||
await SeedClaimAsync(vulnerabilityId, productKey, providerId);
|
||||
|
||||
var client = CreateClient("vex.read");
|
||||
var request = new ResolveRequest(
|
||||
new[] { productKey },
|
||||
null,
|
||||
new[] { vulnerabilityId },
|
||||
null);
|
||||
|
||||
var response = await client.PostAsJsonAsync("/excititor/resolve", request);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<ResolveResponse>();
|
||||
Assert.NotNull(payload);
|
||||
Assert.NotNull(payload!.Policy);
|
||||
|
||||
var result = Assert.Single(payload.Results);
|
||||
Assert.Equal(vulnerabilityId, result.VulnerabilityId);
|
||||
Assert.Equal(productKey, result.ProductKey);
|
||||
Assert.Equal("not_affected", result.Status);
|
||||
Assert.NotNull(result.Envelope);
|
||||
Assert.Equal("signature", result.Envelope!.ContentSignature!.Value);
|
||||
Assert.Equal("key", result.Envelope.ContentSignature.KeyId);
|
||||
Assert.NotEqual(default, result.CalculatedAt);
|
||||
|
||||
Assert.NotNull(result.Signals);
|
||||
Assert.True(result.Signals!.Kev);
|
||||
Assert.NotNull(result.Envelope.AttestationSignature);
|
||||
Assert.False(string.IsNullOrWhiteSpace(result.Envelope.AttestationEnvelope));
|
||||
Assert.Equal(payload.Policy.ActiveRevisionId, result.PolicyRevisionId);
|
||||
Assert.Equal(payload.Policy.Version, result.PolicyVersion);
|
||||
Assert.Equal(payload.Policy.Digest, result.PolicyDigest);
|
||||
|
||||
var decision = Assert.Single(result.Decisions);
|
||||
Assert.True(decision.Included);
|
||||
Assert.Equal(providerId, decision.ProviderId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveEndpoint_ReturnsConflict_WhenPolicyRevisionMismatch()
|
||||
{
|
||||
const string vulnerabilityId = "CVE-2025-3333";
|
||||
const string productKey = "pkg:docker/demo@sha256:abcd";
|
||||
|
||||
var client = CreateClient("vex.read");
|
||||
var request = new ResolveRequest(
|
||||
new[] { productKey },
|
||||
null,
|
||||
new[] { vulnerabilityId },
|
||||
"rev-0");
|
||||
|
||||
var response = await client.PostAsJsonAsync("/excititor/resolve", request);
|
||||
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveEndpoint_ReturnsUnauthorized_WhenMissingToken()
|
||||
{
|
||||
var client = CreateClient();
|
||||
var request = new ResolveRequest(
|
||||
new[] { "pkg:test/demo" },
|
||||
null,
|
||||
new[] { "CVE-2025-0001" },
|
||||
null);
|
||||
|
||||
var response = await client.PostAsJsonAsync("/excititor/resolve", request);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveEndpoint_ReturnsForbidden_WhenScopeMissing()
|
||||
{
|
||||
var client = CreateClient("vex.admin");
|
||||
var request = new ResolveRequest(
|
||||
new[] { "pkg:test/demo" },
|
||||
null,
|
||||
new[] { "CVE-2025-0001" },
|
||||
null);
|
||||
|
||||
var response = await client.PostAsJsonAsync("/excititor/resolve", request);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
|
||||
private async Task SeedProviderAsync(string providerId)
|
||||
{
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var store = scope.ServiceProvider.GetRequiredService<IVexProviderStore>();
|
||||
var provider = new VexProvider(providerId, "Red Hat", VexProviderKind.Distro);
|
||||
await store.SaveAsync(provider, CancellationToken.None);
|
||||
}
|
||||
|
||||
private async Task SeedClaimAsync(string vulnerabilityId, string productKey, string providerId)
|
||||
{
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var store = scope.ServiceProvider.GetRequiredService<IVexClaimStore>();
|
||||
var timeProvider = scope.ServiceProvider.GetRequiredService<TimeProvider>();
|
||||
var observedAt = timeProvider.GetUtcNow();
|
||||
|
||||
var claim = new VexClaim(
|
||||
vulnerabilityId,
|
||||
providerId,
|
||||
new VexProduct(productKey, "Demo Component", version: "1.0.0", purl: productKey),
|
||||
VexClaimStatus.NotAffected,
|
||||
new VexClaimDocument(VexDocumentFormat.Csaf, "sha256:deadbeef", new Uri("https://example.org/vex/csaf.json")),
|
||||
observedAt.AddDays(-1),
|
||||
observedAt,
|
||||
VexJustification.ProtectedByMitigatingControl,
|
||||
detail: "Test justification",
|
||||
confidence: new VexConfidence("high", 0.9, "unit-test"),
|
||||
signals: new VexSignalSnapshot(
|
||||
new VexSeveritySignal("cvss:v3.1", 5.5, "medium"),
|
||||
kev: true,
|
||||
epss: 0.25));
|
||||
|
||||
await store.AppendAsync(new[] { claim }, observedAt, CancellationToken.None);
|
||||
}
|
||||
|
||||
private HttpClient CreateClient(params string[] scopes)
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
if (scopes.Length > 0)
|
||||
{
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", string.Join(' ', scopes));
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_factory.Dispose();
|
||||
_runner.Dispose();
|
||||
}
|
||||
|
||||
private sealed class ResolveRequest
|
||||
{
|
||||
public ResolveRequest(
|
||||
IReadOnlyList<string>? productKeys,
|
||||
IReadOnlyList<string>? purls,
|
||||
IReadOnlyList<string>? vulnerabilityIds,
|
||||
string? policyRevisionId)
|
||||
{
|
||||
ProductKeys = productKeys;
|
||||
Purls = purls;
|
||||
VulnerabilityIds = vulnerabilityIds;
|
||||
PolicyRevisionId = policyRevisionId;
|
||||
}
|
||||
|
||||
public IReadOnlyList<string>? ProductKeys { get; }
|
||||
|
||||
public IReadOnlyList<string>? Purls { get; }
|
||||
|
||||
public IReadOnlyList<string>? VulnerabilityIds { get; }
|
||||
|
||||
public string? PolicyRevisionId { get; }
|
||||
}
|
||||
|
||||
private sealed class ResolveResponse
|
||||
{
|
||||
public required DateTimeOffset ResolvedAt { get; init; }
|
||||
|
||||
public required ResolvePolicy Policy { get; init; }
|
||||
|
||||
public required List<ResolveResult> Results { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ResolvePolicy
|
||||
{
|
||||
public required string ActiveRevisionId { get; init; }
|
||||
|
||||
public required string Version { get; init; }
|
||||
|
||||
public required string Digest { get; init; }
|
||||
|
||||
public string? RequestedRevisionId { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ResolveResult
|
||||
{
|
||||
public required string VulnerabilityId { get; init; }
|
||||
|
||||
public required string ProductKey { get; init; }
|
||||
|
||||
public required string Status { get; init; }
|
||||
|
||||
public required DateTimeOffset CalculatedAt { get; init; }
|
||||
|
||||
public required List<ResolveSource> Sources { get; init; }
|
||||
|
||||
public required List<ResolveConflict> Conflicts { get; init; }
|
||||
|
||||
public ResolveSignals? Signals { get; init; }
|
||||
|
||||
public string? Summary { get; init; }
|
||||
|
||||
public required string PolicyRevisionId { get; init; }
|
||||
|
||||
public required string PolicyVersion { get; init; }
|
||||
|
||||
public required string PolicyDigest { get; init; }
|
||||
|
||||
public required List<ResolveDecision> Decisions { get; init; }
|
||||
|
||||
public ResolveEnvelope? Envelope { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ResolveSource
|
||||
{
|
||||
public required string ProviderId { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ResolveConflict
|
||||
{
|
||||
public string? ProviderId { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ResolveSignals
|
||||
{
|
||||
public ResolveSeverity? Severity { get; init; }
|
||||
|
||||
public bool? Kev { get; init; }
|
||||
|
||||
public double? Epss { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ResolveSeverity
|
||||
{
|
||||
public string? Scheme { get; init; }
|
||||
|
||||
public double? Score { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ResolveDecision
|
||||
{
|
||||
public required string ProviderId { get; init; }
|
||||
|
||||
public required bool Included { get; init; }
|
||||
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ResolveEnvelope
|
||||
{
|
||||
public required ResolveArtifact Artifact { get; init; }
|
||||
|
||||
public ResolveSignature? ContentSignature { get; init; }
|
||||
|
||||
public ResolveAttestationMetadata? Attestation { get; init; }
|
||||
|
||||
public string? AttestationEnvelope { get; init; }
|
||||
|
||||
public ResolveSignature? AttestationSignature { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ResolveArtifact
|
||||
{
|
||||
public required string Algorithm { get; init; }
|
||||
|
||||
public required string Digest { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ResolveSignature
|
||||
{
|
||||
public required string Value { get; init; }
|
||||
|
||||
public string? KeyId { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ResolveAttestationMetadata
|
||||
{
|
||||
public required string PredicateType { get; init; }
|
||||
|
||||
public ResolveRekorReference? Rekor { get; init; }
|
||||
|
||||
public string? EnvelopeDigest { get; init; }
|
||||
|
||||
public DateTimeOffset? SignedAt { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ResolveRekorReference
|
||||
{
|
||||
public string? Location { get; init; }
|
||||
}
|
||||
|
||||
private sealed class FakeSigner : IVexSigner
|
||||
{
|
||||
public ValueTask<VexSignedPayload> SignAsync(ReadOnlyMemory<byte> payload, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(new VexSignedPayload("signature", "key"));
|
||||
}
|
||||
|
||||
private sealed class FakePolicyEvaluator : IVexPolicyEvaluator
|
||||
{
|
||||
public string Version => "test";
|
||||
|
||||
public VexPolicySnapshot Snapshot => VexPolicySnapshot.Default;
|
||||
|
||||
public double GetProviderWeight(VexProvider provider) => 1.0;
|
||||
|
||||
public bool IsClaimEligible(VexClaim claim, VexProvider provider, out string? rejectionReason)
|
||||
{
|
||||
rejectionReason = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Net.Http.Json;
|
||||
using System.IO;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using EphemeralMongo;
|
||||
using MongoRunner = EphemeralMongo.MongoRunner;
|
||||
using MongoRunnerOptions = EphemeralMongo.MongoRunnerOptions;
|
||||
using StellaOps.Excititor.Attestation.Signing;
|
||||
using StellaOps.Excititor.Connectors.Abstractions;
|
||||
using StellaOps.Excititor.Policy;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Export;
|
||||
using StellaOps.Excititor.WebService;
|
||||
|
||||
namespace StellaOps.Excititor.WebService.Tests;
|
||||
|
||||
public sealed class StatusEndpointTests : IDisposable
|
||||
{
|
||||
private readonly TestWebApplicationFactory _factory;
|
||||
private readonly IMongoRunner _runner;
|
||||
|
||||
public StatusEndpointTests()
|
||||
{
|
||||
_runner = MongoRunner.Run(new MongoRunnerOptions { UseSingleNodeReplicaSet = true });
|
||||
_factory = new TestWebApplicationFactory(
|
||||
configureConfiguration: config =>
|
||||
{
|
||||
var rootPath = Path.Combine(Path.GetTempPath(), "excititor-offline-tests");
|
||||
Directory.CreateDirectory(rootPath);
|
||||
var settings = new Dictionary<string, string?>
|
||||
{
|
||||
["Excititor:Storage:Mongo:ConnectionString"] = _runner.ConnectionString,
|
||||
["Excititor:Storage:Mongo:DatabaseName"] = "excititor-web-tests",
|
||||
["Excititor:Storage:Mongo:RawBucketName"] = "vex.raw",
|
||||
["Excititor:Storage:Mongo:GridFsInlineThresholdBytes"] = "256",
|
||||
["Excititor:Artifacts:FileSystem:RootPath"] = rootPath,
|
||||
};
|
||||
config.AddInMemoryCollection(settings!);
|
||||
},
|
||||
configureServices: services =>
|
||||
{
|
||||
TestServiceOverrides.Apply(services);
|
||||
services.AddSingleton<IVexSigner, FakeSigner>();
|
||||
services.AddSingleton<IVexPolicyEvaluator, FakePolicyEvaluator>();
|
||||
services.AddSingleton(new VexConnectorDescriptor("excititor:redhat", VexProviderKind.Distro, "Red Hat CSAF"));
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StatusEndpoint_ReturnsArtifactStores()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var response = await client.GetAsync("/excititor/status");
|
||||
var raw = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, raw);
|
||||
|
||||
var payload = System.Text.Json.JsonSerializer.Deserialize<StatusResponse>(raw);
|
||||
Assert.NotNull(payload);
|
||||
Assert.NotEmpty(payload!.ArtifactStores);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_factory.Dispose();
|
||||
_runner.Dispose();
|
||||
}
|
||||
|
||||
private sealed class StatusResponse
|
||||
{
|
||||
public string[] ArtifactStores { get; set; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
private sealed class FakeSigner : IVexSigner
|
||||
{
|
||||
public ValueTask<VexSignedPayload> SignAsync(ReadOnlyMemory<byte> payload, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(new VexSignedPayload("signature", "key"));
|
||||
}
|
||||
|
||||
private sealed class FakePolicyEvaluator : IVexPolicyEvaluator
|
||||
{
|
||||
public string Version => "test";
|
||||
|
||||
public VexPolicySnapshot Snapshot => VexPolicySnapshot.Default;
|
||||
|
||||
public double GetProviderWeight(VexProvider provider) => 1.0;
|
||||
|
||||
public bool IsClaimEligible(VexClaim claim, VexProvider provider, out string? rejectionReason)
|
||||
{
|
||||
rejectionReason = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="EphemeralMongo" Version="3.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="9.10.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" PrivateAssets="all" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.Excititor.WebService/StellaOps.Excititor.WebService.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Excititor.WebService.Tests;
|
||||
|
||||
internal static class TestAuthenticationExtensions
|
||||
{
|
||||
public const string SchemeName = "TestBearer";
|
||||
|
||||
public static AuthenticationBuilder AddTestAuthentication(this IServiceCollection services)
|
||||
{
|
||||
return services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = SchemeName;
|
||||
options.DefaultChallengeScheme = SchemeName;
|
||||
}).AddScheme<AuthenticationSchemeOptions, TestAuthenticationHandler>(SchemeName, _ => { });
|
||||
}
|
||||
|
||||
private sealed class TestAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
public TestAuthenticationHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder)
|
||||
: base(options, logger, encoder)
|
||||
{
|
||||
}
|
||||
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
if (!Request.Headers.TryGetValue("Authorization", out var authorization) || authorization.Count == 0)
|
||||
{
|
||||
return Task.FromResult(AuthenticateResult.NoResult());
|
||||
}
|
||||
|
||||
var header = authorization[0];
|
||||
if (string.IsNullOrWhiteSpace(header) || !header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Task.FromResult(AuthenticateResult.Fail("Invalid authentication scheme."));
|
||||
}
|
||||
|
||||
var scopeSegment = header.Substring("Bearer ".Length);
|
||||
var scopes = scopeSegment.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
var claims = new List<Claim> { new Claim(ClaimTypes.NameIdentifier, "test-user") };
|
||||
if (scopes.Length > 0)
|
||||
{
|
||||
claims.Add(new Claim("scope", string.Join(' ', scopes)));
|
||||
}
|
||||
|
||||
var identity = new ClaimsIdentity(claims, SchemeName);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
var ticket = new AuthenticationTicket(principal, SchemeName);
|
||||
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Export;
|
||||
using StellaOps.Excititor.Storage.Mongo;
|
||||
using StellaOps.Excititor.WebService.Services;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Excititor.Attestation.Dsse;
|
||||
|
||||
namespace StellaOps.Excititor.WebService.Tests;
|
||||
|
||||
internal static class TestServiceOverrides
|
||||
{
|
||||
public static void Apply(IServiceCollection services)
|
||||
{
|
||||
services.RemoveAll<IVexConnector>();
|
||||
services.RemoveAll<IVexIngestOrchestrator>();
|
||||
services.RemoveAll<IVexConnectorStateRepository>();
|
||||
services.RemoveAll<IVexExportCacheService>();
|
||||
services.RemoveAll<IVexExportDataSource>();
|
||||
services.RemoveAll<IVexExportStore>();
|
||||
services.RemoveAll<IVexCacheIndex>();
|
||||
services.RemoveAll<IVexCacheMaintenance>();
|
||||
services.RemoveAll<IVexAttestationClient>();
|
||||
|
||||
services.AddSingleton<IVexIngestOrchestrator, StubIngestOrchestrator>();
|
||||
services.AddSingleton<IVexConnectorStateRepository, StubConnectorStateRepository>();
|
||||
services.AddSingleton<IVexExportCacheService, StubExportCacheService>();
|
||||
services.RemoveAll<IExportEngine>();
|
||||
services.AddSingleton<IExportEngine, StubExportEngine>();
|
||||
services.AddSingleton<IVexExportDataSource, StubExportDataSource>();
|
||||
services.AddSingleton<IVexExportStore, StubExportStore>();
|
||||
services.AddSingleton<IVexCacheIndex, StubCacheIndex>();
|
||||
services.AddSingleton<IVexCacheMaintenance, StubCacheMaintenance>();
|
||||
services.AddSingleton<IVexAttestationClient, StubAttestationClient>();
|
||||
|
||||
services.RemoveAll<IHostedService>();
|
||||
services.AddSingleton<IHostedService, NoopHostedService>();
|
||||
}
|
||||
|
||||
private sealed class StubExportCacheService : IVexExportCacheService
|
||||
{
|
||||
public ValueTask InvalidateAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken)
|
||||
=> ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask<int> PruneDanglingAsync(CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(0);
|
||||
|
||||
public ValueTask<int> PruneExpiredAsync(DateTimeOffset asOf, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(0);
|
||||
}
|
||||
|
||||
private sealed class StubExportEngine : IExportEngine
|
||||
{
|
||||
public ValueTask<VexExportManifest> ExportAsync(VexExportRequestContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var manifest = new VexExportManifest(
|
||||
exportId: "stub/export",
|
||||
querySignature: VexQuerySignature.FromQuery(context.Query),
|
||||
format: context.Format,
|
||||
createdAt: DateTimeOffset.UtcNow,
|
||||
artifact: new VexContentAddress("sha256", "stub"),
|
||||
claimCount: 0,
|
||||
sourceProviders: Array.Empty<string>());
|
||||
|
||||
return ValueTask.FromResult(manifest);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubExportDataSource : IVexExportDataSource
|
||||
{
|
||||
public ValueTask<VexExportDataSet> FetchAsync(VexQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
return ValueTask.FromResult(new VexExportDataSet(
|
||||
ImmutableArray<VexConsensus>.Empty,
|
||||
ImmutableArray<VexClaim>.Empty,
|
||||
ImmutableArray<string>.Empty));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubExportStore : IVexExportStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<(string Signature, VexExportFormat Format), VexExportManifest> _store = new();
|
||||
|
||||
public ValueTask<VexExportManifest?> FindAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
_store.TryGetValue((signature.Value, format), out var manifest);
|
||||
return ValueTask.FromResult(manifest);
|
||||
}
|
||||
|
||||
public ValueTask SaveAsync(VexExportManifest manifest, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
_store[(manifest.QuerySignature.Value, manifest.Format)] = manifest;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubCacheIndex : IVexCacheIndex
|
||||
{
|
||||
private readonly ConcurrentDictionary<(string Signature, VexExportFormat Format), VexCacheEntry> _entries = new();
|
||||
|
||||
public ValueTask<VexCacheEntry?> FindAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
_entries.TryGetValue((signature.Value, format), out var entry);
|
||||
return ValueTask.FromResult(entry);
|
||||
}
|
||||
|
||||
public ValueTask RemoveAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
_entries.TryRemove((signature.Value, format), out _);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask SaveAsync(VexCacheEntry entry, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
_entries[(entry.QuerySignature.Value, entry.Format)] = entry;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubCacheMaintenance : IVexCacheMaintenance
|
||||
{
|
||||
public ValueTask<int> RemoveExpiredAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.FromResult(0);
|
||||
|
||||
public ValueTask<int> RemoveMissingManifestReferencesAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.FromResult(0);
|
||||
}
|
||||
|
||||
private sealed class StubAttestationClient : IVexAttestationClient
|
||||
{
|
||||
public ValueTask<VexAttestationResponse> SignAsync(VexAttestationRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var envelope = new DsseEnvelope(
|
||||
Convert.ToBase64String(Encoding.UTF8.GetBytes("{\"stub\":\"payload\"}")),
|
||||
"application/vnd.in-toto+json",
|
||||
new[]
|
||||
{
|
||||
new DsseSignature("attestation-signature", "attestation-key"),
|
||||
});
|
||||
|
||||
var diagnostics = ImmutableDictionary<string, string>.Empty
|
||||
.Add("envelope", JsonSerializer.Serialize(envelope));
|
||||
|
||||
var metadata = new VexAttestationMetadata(
|
||||
"stub",
|
||||
envelopeDigest: VexDsseBuilder.ComputeEnvelopeDigest(envelope),
|
||||
signedAt: DateTimeOffset.UtcNow);
|
||||
|
||||
var response = new VexAttestationResponse(
|
||||
metadata,
|
||||
diagnostics);
|
||||
return ValueTask.FromResult(response);
|
||||
}
|
||||
|
||||
public ValueTask<VexAttestationVerification> VerifyAsync(VexAttestationVerificationRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var verification = new VexAttestationVerification(true, ImmutableDictionary<string, string>.Empty);
|
||||
return ValueTask.FromResult(verification);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubConnectorStateRepository : IVexConnectorStateRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, VexConnectorState> _states = new(StringComparer.Ordinal);
|
||||
|
||||
public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
_states.TryGetValue(connectorId, out var state);
|
||||
return ValueTask.FromResult(state);
|
||||
}
|
||||
|
||||
public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
_states[state.ConnectorId] = state;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubIngestOrchestrator : IVexIngestOrchestrator
|
||||
{
|
||||
public Task<InitSummary> InitializeAsync(IngestInitOptions options, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new InitSummary(Guid.Empty, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, ImmutableArray<InitProviderResult>.Empty));
|
||||
|
||||
public Task<IngestRunSummary> RunAsync(IngestRunOptions options, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new IngestRunSummary(Guid.Empty, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, ImmutableArray<ProviderRunResult>.Empty));
|
||||
|
||||
public Task<IngestRunSummary> ResumeAsync(IngestResumeOptions options, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new IngestRunSummary(Guid.Empty, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, ImmutableArray<ProviderRunResult>.Empty));
|
||||
|
||||
public Task<ReconcileSummary> ReconcileAsync(ReconcileOptions options, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new ReconcileSummary(Guid.Empty, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, ImmutableArray<ReconcileProviderResult>.Empty));
|
||||
}
|
||||
|
||||
private sealed class NoopHostedService : IHostedService
|
||||
{
|
||||
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace StellaOps.Excititor.WebService.Tests;
|
||||
|
||||
internal sealed class TestWebApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
private readonly Action<IConfigurationBuilder>? _configureConfiguration;
|
||||
private readonly Action<IServiceCollection>? _configureServices;
|
||||
|
||||
public TestWebApplicationFactory(
|
||||
Action<IConfigurationBuilder>? configureConfiguration,
|
||||
Action<IServiceCollection>? configureServices)
|
||||
{
|
||||
_configureConfiguration = configureConfiguration;
|
||||
_configureServices = configureServices;
|
||||
}
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Production");
|
||||
if (_configureConfiguration is not null)
|
||||
{
|
||||
builder.ConfigureAppConfiguration((_, config) => _configureConfiguration(config));
|
||||
}
|
||||
|
||||
if (_configureServices is not null)
|
||||
{
|
||||
builder.ConfigureServices(services => _configureServices(services));
|
||||
}
|
||||
}
|
||||
|
||||
protected override IHost CreateHost(IHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Production");
|
||||
builder.UseDefaultServiceProvider(options => options.ValidateScopes = false);
|
||||
return base.CreateHost(builder);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user