save progress

This commit is contained in:
StellaOps Bot
2025-12-18 09:53:46 +02:00
parent 28823a8960
commit 7d5250238c
87 changed files with 9750 additions and 2026 deletions

View File

@@ -7,7 +7,7 @@ public sealed class AuthorizationTests
[Fact]
public async Task ApiRoutesRequireAuthenticationWhenAuthorityEnabled()
{
using var factory = new ScannerApplicationFactory(configuration =>
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
{
configuration["scanner:authority:enabled"] = "true";
configuration["scanner:authority:allowAnonymousFallback"] = "false";

View File

@@ -11,7 +11,7 @@ public sealed class CallGraphEndpointsTests
public async Task SubmitCallGraphRequiresContentDigestHeader()
{
using var secrets = new TestSurfaceSecretsScope();
using var factory = new ScannerApplicationFactory(configuration =>
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
{
configuration["scanner:authority:enabled"] = "false";
});
@@ -30,7 +30,7 @@ public sealed class CallGraphEndpointsTests
public async Task SubmitCallGraphReturnsAcceptedAndDetectsDuplicates()
{
using var secrets = new TestSurfaceSecretsScope();
using var factory = new ScannerApplicationFactory(configuration =>
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
{
configuration["scanner:authority:enabled"] = "false";
});
@@ -101,4 +101,3 @@ public sealed class CallGraphEndpointsTests
});
}
}

View File

@@ -15,7 +15,7 @@ namespace StellaOps.Scanner.WebService.Tests.Integration;
/// End-to-end integration tests for the Triage workflow.
/// Tests the complete flow from alert list to decision recording.
/// </summary>
public sealed class TriageWorkflowIntegrationTests : IClassFixture<ScannerApplicationFactory>
public sealed class TriageWorkflowIntegrationTests : IClassFixture<ScannerApplicationFixture>
{
private readonly HttpClient _client;
private static readonly JsonSerializerOptions JsonOptions = new()
@@ -23,9 +23,9 @@ public sealed class TriageWorkflowIntegrationTests : IClassFixture<ScannerApplic
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public TriageWorkflowIntegrationTests(ScannerApplicationFactory factory)
public TriageWorkflowIntegrationTests(ScannerApplicationFixture fixture)
{
_client = factory.CreateClient();
_client = fixture.Factory.CreateClient();
}
#region Alert List Tests

View File

@@ -4,6 +4,10 @@ using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Authority.Storage.Postgres.Models;
using StellaOps.Authority.Storage.Postgres.Repositories;
using Xunit;
namespace StellaOps.Scanner.WebService.Tests;
@@ -22,7 +26,7 @@ public sealed class OfflineKitEndpointsTests
var (keyId, keyPem, dsseJson) = CreateSignedDsse(bundleBytes);
File.WriteAllText(Path.Combine(trustRoots.Path, $"{keyId}.pem"), keyPem, Encoding.UTF8);
using var factory = new ScannerApplicationFactory(config =>
using var factory = new ScannerApplicationFactory().WithOverrides(config =>
{
config["Scanner:OfflineKit:Enabled"] = "true";
config["Scanner:OfflineKit:RequireDsse"] = "true";
@@ -89,7 +93,7 @@ public sealed class OfflineKitEndpointsTests
signatures = new[] { new { keyid = keyId, sig = Convert.ToBase64String(new byte[] { 1, 2, 3 }) } }
}, new JsonSerializerOptions(JsonSerializerDefaults.Web));
using var factory = new ScannerApplicationFactory(config =>
using var factory = new ScannerApplicationFactory().WithOverrides(config =>
{
config["Scanner:OfflineKit:Enabled"] = "true";
config["Scanner:OfflineKit:RequireDsse"] = "true";
@@ -142,7 +146,7 @@ public sealed class OfflineKitEndpointsTests
signatures = new[] { new { keyid = "unknown", sig = Convert.ToBase64String(new byte[] { 1, 2, 3 }) } }
}, new JsonSerializerOptions(JsonSerializerDefaults.Web));
using var factory = new ScannerApplicationFactory(config =>
using var factory = new ScannerApplicationFactory().WithOverrides(config =>
{
config["Scanner:OfflineKit:Enabled"] = "true";
config["Scanner:OfflineKit:RequireDsse"] = "false";
@@ -172,6 +176,57 @@ public sealed class OfflineKitEndpointsTests
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
}
[Fact]
public async Task OfflineKitImport_EmitsAuditEvent_WithTenantHeader()
{
using var contentRoot = new TempDirectory();
var bundleBytes = Encoding.UTF8.GetBytes("deterministic-offline-kit-bundle");
var bundleSha = ComputeSha256Hex(bundleBytes);
var auditEmitter = new CapturingAuditEmitter();
using var factory = new ScannerApplicationFactory().WithOverrides(config =>
{
config["Scanner:OfflineKit:Enabled"] = "true";
config["Scanner:OfflineKit:RequireDsse"] = "false";
config["Scanner:OfflineKit:RekorOfflineMode"] = "false";
}, configureServices: services =>
{
services.RemoveAll<IOfflineKitAuditEmitter>();
services.AddSingleton<IOfflineKitAuditEmitter>(auditEmitter);
});
using var configured = factory.WithWebHostBuilder(builder => builder.UseContentRoot(contentRoot.Path));
using var client = configured.CreateClient();
var metadataJson = JsonSerializer.Serialize(new
{
bundleId = "test-bundle",
bundleSha256 = $"sha256:{bundleSha}",
bundleSize = bundleBytes.Length
}, new JsonSerializerOptions(JsonSerializerDefaults.Web));
using var content = new MultipartFormDataContent();
content.Add(new StringContent(metadataJson, Encoding.UTF8, "application/json"), "metadata");
var bundleContent = new ByteArrayContent(bundleBytes);
bundleContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
content.Add(bundleContent, "bundle", "bundle.tgz");
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/offline-kit/import") { Content = content };
request.Headers.Add("X-Stella-Tenant", "tenant-a");
using var response = await client.SendAsync(request).ConfigureAwait(false);
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
var entity = auditEmitter.LastRecorded;
Assert.NotNull(entity);
Assert.Equal("tenant-a", entity!.TenantId);
Assert.Equal("offlinekit.import", entity.EventType);
Assert.Equal("accepted", entity.Result);
}
private static string ComputeSha256Hex(byte[] bytes)
=> Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
@@ -247,4 +302,21 @@ public sealed class OfflineKitEndpointsTests
}
}
}
private sealed class CapturingAuditEmitter : IOfflineKitAuditEmitter
{
private readonly object gate = new();
public OfflineKitAuditEntity? LastRecorded { get; private set; }
public Task RecordAsync(OfflineKitAuditEntity entity, CancellationToken cancellationToken = default)
{
lock (gate)
{
LastRecorded = entity;
}
return Task.CompletedTask;
}
}
}

View File

@@ -10,7 +10,7 @@ public sealed class PlatformEventPublisherRegistrationTests
[Fact]
public void NullPublisherRegisteredWhenEventsDisabled()
{
using var factory = new ScannerApplicationFactory(configuration =>
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
{
configuration["scanner:events:enabled"] = "false";
configuration["scanner:events:dsn"] = string.Empty;
@@ -40,7 +40,7 @@ public sealed class PlatformEventPublisherRegistrationTests
try
{
using var factory = new ScannerApplicationFactory(configuration =>
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
{
configuration["scanner:events:enabled"] = "true";
configuration["scanner:events:driver"] = "redis";

View File

@@ -1,3 +1,4 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Net;
using System.Net.Http.Json;
@@ -17,7 +18,7 @@ public sealed class ReachabilityDriftEndpointsTests
public async Task GetDriftReturnsNotFoundWhenNoResultAndNoBaseScanProvided()
{
using var secrets = new TestSurfaceSecretsScope();
using var factory = new ScannerApplicationFactory(configuration =>
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
{
configuration["scanner:authority:enabled"] = "false";
});
@@ -35,15 +36,15 @@ public sealed class ReachabilityDriftEndpointsTests
public async Task GetDriftComputesResultAndListsDriftedSinks()
{
using var secrets = new TestSurfaceSecretsScope();
using var factory = new ScannerApplicationFactory(configuration =>
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
{
configuration["scanner:authority:enabled"] = "false";
});
using var client = factory.CreateClient();
var baseScanId = await CreateScanAsync(client);
var headScanId = await CreateScanAsync(client);
var baseScanId = await CreateScanAsync(client, "base");
var headScanId = await CreateScanAsync(client, "head");
await SeedCallGraphSnapshotsAsync(factory.Services, baseScanId, headScanId);
@@ -134,7 +135,7 @@ public sealed class ReachabilityDriftEndpointsTests
return provisional with { GraphDigest = CallGraphDigests.ComputeGraphDigest(provisional) };
}
private static async Task<string> CreateScanAsync(HttpClient client)
private static async Task<string> CreateScanAsync(HttpClient client, string? clientRequestId = null)
{
var response = await client.PostAsJsonAsync("/api/v1/scans", new ScanSubmitRequest
{
@@ -142,6 +143,11 @@ public sealed class ReachabilityDriftEndpointsTests
{
Reference = "example.com/demo:1.0",
Digest = "sha256:0123456789abcdef"
},
ClientRequestId = clientRequestId,
Metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["test.request"] = clientRequestId ?? string.Empty
}
});
@@ -161,4 +167,3 @@ public sealed class ReachabilityDriftEndpointsTests
int Count,
DriftedSink[] Sinks);
}

View File

@@ -35,7 +35,7 @@ rules:
var hmacKey = Convert.ToBase64String(Encoding.UTF8.GetBytes("scanner-report-hmac-key-2025!"));
using var factory = new ScannerApplicationFactory(configuration =>
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
{
configuration["scanner:signing:enabled"] = "true";
configuration["scanner:signing:keyId"] = "scanner-report-signing";
@@ -148,7 +148,7 @@ rules:
action: block
""";
using var factory = new ScannerApplicationFactory(
using var factory = new ScannerApplicationFactory().WithOverrides(
configuration =>
{
configuration["scanner:signing:enabled"] = "true";

View File

@@ -241,7 +241,7 @@ public sealed class RubyPackagesEndpointsTests
new EntryTraceNdjsonMetadata("scan-placeholder", digest, generatedAt));
using var secrets = new TestSurfaceSecretsScope();
using var factory = new ScannerApplicationFactory(configureServices: services =>
using var factory = new ScannerApplicationFactory().WithOverrides(configureServices: services =>
{
services.AddSingleton<IEntryTraceResultStore, RecordingEntryTraceResultStore>();
});

View File

@@ -74,7 +74,7 @@ public sealed class RuntimeEndpointsTests
[Fact]
public async Task RuntimeEventsEndpointEnforcesRateLimit()
{
using var factory = new ScannerApplicationFactory(configuration =>
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
{
configuration["scanner:runtime:perNodeBurst"] = "1";
configuration["scanner:runtime:perNodeEventsPerSecond"] = "1";
@@ -105,7 +105,7 @@ public sealed class RuntimeEndpointsTests
[Fact]
public async Task RuntimePolicyEndpointReturnsDecisions()
{
using var factory = new ScannerApplicationFactory(configuration =>
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
{
configuration["scanner:runtime:policyCacheTtlSeconds"] = "600";
});

View File

@@ -49,7 +49,7 @@ public sealed class RuntimeReconciliationTests
{
var mockObjectStore = new InMemoryArtifactObjectStore();
using var factory = new ScannerApplicationFactory(
using var factory = new ScannerApplicationFactory().WithOverrides(
configureServices: services =>
{
services.RemoveAll<IArtifactObjectStore>();
@@ -98,7 +98,7 @@ public sealed class RuntimeReconciliationTests
{
var mockObjectStore = new InMemoryArtifactObjectStore();
using var factory = new ScannerApplicationFactory(
using var factory = new ScannerApplicationFactory().WithOverrides(
configureServices: services =>
{
services.RemoveAll<IArtifactObjectStore>();
@@ -188,7 +188,7 @@ public sealed class RuntimeReconciliationTests
{
var mockObjectStore = new InMemoryArtifactObjectStore();
using var factory = new ScannerApplicationFactory(
using var factory = new ScannerApplicationFactory().WithOverrides(
configureServices: services =>
{
services.RemoveAll<IArtifactObjectStore>();
@@ -273,7 +273,7 @@ public sealed class RuntimeReconciliationTests
{
var mockObjectStore = new InMemoryArtifactObjectStore();
using var factory = new ScannerApplicationFactory(
using var factory = new ScannerApplicationFactory().WithOverrides(
configureServices: services =>
{
services.RemoveAll<IArtifactObjectStore>();
@@ -398,7 +398,7 @@ public sealed class RuntimeReconciliationTests
{
var mockObjectStore = new InMemoryArtifactObjectStore();
using var factory = new ScannerApplicationFactory(
using var factory = new ScannerApplicationFactory().WithOverrides(
configureServices: services =>
{
services.RemoveAll<IArtifactObjectStore>();

View File

@@ -16,7 +16,7 @@ public sealed class SbomEndpointsTests
public async Task SubmitSbomAcceptsCycloneDxJson()
{
using var secrets = new TestSurfaceSecretsScope();
using var factory = new ScannerApplicationFactory(configuration =>
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
{
configuration["scanner:authority:enabled"] = "false";
}, configureServices: services =>

View File

@@ -35,22 +35,36 @@ public sealed class ScannerApplicationFactory : WebApplicationFactory<ServiceSta
["scanner:features:enableSignedReports"] = "false"
};
private readonly Action<IDictionary<string, string?>>? configureConfiguration;
private readonly Action<IServiceCollection>? configureServices;
private Action<IDictionary<string, string?>>? configureConfiguration;
private Action<IServiceCollection>? configureServices;
public ScannerApplicationFactory(
Action<IDictionary<string, string?>>? configureConfiguration = null,
Action<IServiceCollection>? configureServices = null)
public ScannerApplicationFactory()
{
postgresFixture = new ScannerWebServicePostgresFixture();
postgresFixture.InitializeAsync().GetAwaiter().GetResult();
configuration["scanner:storage:dsn"] = postgresFixture.ConnectionString;
configuration["scanner:storage:database"] = postgresFixture.SchemaName;
}
public ScannerApplicationFactory(
Action<IDictionary<string, string?>>? configureConfiguration = null,
Action<IServiceCollection>? configureServices = null)
: this()
{
this.configureConfiguration = configureConfiguration;
this.configureServices = configureServices;
}
public ScannerApplicationFactory WithOverrides(
Action<IDictionary<string, string?>>? configureConfiguration = null,
Action<IServiceCollection>? configureServices = null)
{
this.configureConfiguration = configureConfiguration;
this.configureServices = configureServices;
return this;
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
configureConfiguration?.Invoke(configuration);

View File

@@ -0,0 +1,11 @@
using System;
namespace StellaOps.Scanner.WebService.Tests;
public sealed class ScannerApplicationFixture : IDisposable
{
public ScannerApplicationFactory Factory { get; } = new();
public void Dispose() => Factory.Dispose();
}

View File

@@ -13,7 +13,7 @@ public sealed partial class ScansEndpointsTests
public async Task EntropyEndpoint_AttachesSnapshot_AndSurfacesInStatus()
{
using var secrets = new TestSurfaceSecretsScope();
using var factory = new ScannerApplicationFactory(cfg =>
using var factory = new ScannerApplicationFactory().WithOverrides(cfg =>
{
cfg["scanner:authority:enabled"] = "false";
cfg["scanner:authority:allowAnonymousFallback"] = "true";

View File

@@ -24,7 +24,7 @@ public sealed partial class ScansEndpointsTests
using var secrets = new TestSurfaceSecretsScope();
var store = new InMemoryArtifactObjectStore();
using var factory = new ScannerApplicationFactory(configureConfiguration: cfg =>
using var factory = new ScannerApplicationFactory().WithOverrides(configureConfiguration: cfg =>
{
cfg["scanner:artifactStore:bucket"] = "replay-bucket";
},

View File

@@ -18,7 +18,7 @@ public sealed partial class ScansEndpointsTests
public async Task RecordModeService_AttachesReplayAndSurfacedInStatus()
{
using var secrets = new TestSurfaceSecretsScope();
using var factory = new ScannerApplicationFactory(cfg =>
using var factory = new ScannerApplicationFactory().WithOverrides(cfg =>
{
cfg["scanner:authority:enabled"] = "false";
});

View File

@@ -39,7 +39,7 @@ public sealed partial class ScansEndpointsTests
using var secrets = new TestSurfaceSecretsScope();
RecordingCoordinator coordinator = null!;
using var factory = new ScannerApplicationFactory(configuration =>
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
{
configuration["scanner:authority:enabled"] = "false";
}, configureServices: services =>
@@ -78,7 +78,7 @@ public sealed partial class ScansEndpointsTests
using var secrets = new TestSurfaceSecretsScope();
RecordingCoordinator coordinator = null!;
using var factory = new ScannerApplicationFactory(configuration =>
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
{
configuration["scanner:determinism:feedSnapshotId"] = "feed-2025-11-26";
configuration["scanner:determinism:policySnapshotId"] = "rev-42";
@@ -149,7 +149,7 @@ public sealed partial class ScansEndpointsTests
var ndjson = EntryTraceNdjsonWriter.Serialize(graph, new EntryTraceNdjsonMetadata(scanId, "sha256:test", generatedAt));
var storedResult = new EntryTraceResult(scanId, "sha256:test", generatedAt, graph, ndjson);
using var factory = new ScannerApplicationFactory(configureServices: services =>
using var factory = new ScannerApplicationFactory().WithOverrides(configureServices: services =>
{
services.AddSingleton<IEntryTraceResultStore>(new StubEntryTraceResultStore(storedResult));
});
@@ -169,7 +169,7 @@ public sealed partial class ScansEndpointsTests
public async Task GetEntryTraceReturnsNotFoundWhenMissing()
{
using var secrets = new TestSurfaceSecretsScope();
using var factory = new ScannerApplicationFactory(configureServices: services =>
using var factory = new ScannerApplicationFactory().WithOverrides(configureServices: services =>
{
services.AddSingleton<IEntryTraceResultStore>(new StubEntryTraceResultStore(null));
});

View File

@@ -28,7 +28,7 @@ public sealed class ScoreReplayEndpointsTests : IDisposable
public ScoreReplayEndpointsTests()
{
_secrets = new TestSurfaceSecretsScope();
_factory = new ScannerApplicationFactory(cfg =>
_factory = new ScannerApplicationFactory().WithOverrides(cfg =>
{
cfg["scanner:authority:enabled"] = "false";
cfg["scanner:scoreReplay:enabled"] = "true";

View File

@@ -14,7 +14,7 @@ namespace StellaOps.Scanner.WebService.Tests;
/// <summary>
/// Integration tests for the Unknowns API endpoints.
/// </summary>
public sealed class UnknownsEndpointsTests : IClassFixture<ScannerApplicationFactory>
public sealed class UnknownsEndpointsTests : IClassFixture<ScannerApplicationFixture>
{
private readonly HttpClient _client;
private static readonly JsonSerializerOptions JsonOptions = new()
@@ -22,9 +22,9 @@ public sealed class UnknownsEndpointsTests : IClassFixture<ScannerApplicationFac
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public UnknownsEndpointsTests(ScannerApplicationFactory factory)
public UnknownsEndpointsTests(ScannerApplicationFixture fixture)
{
_client = factory.CreateClient();
_client = fixture.Factory.CreateClient();
}
[Fact]