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

@@ -90,7 +90,7 @@ public class NativeFormatDetectorTests
Assert.Equal(NativeFormat.Elf, id.Format);
Assert.Equal("x86_64", id.CpuArchitecture);
Assert.Equal("/lib64/ld-linux-x86-64.so.2", id.InterpreterPath);
Assert.Equal("0102030405060708090a0b0c0d0e0f10", id.BuildId);
Assert.Equal("gnu-build-id:0102030405060708090a0b0c0d0e0f10", id.BuildId);
}
[Fact]
@@ -150,7 +150,7 @@ public class NativeFormatDetectorTests
var cmdOffset = 32;
BitConverter.GetBytes((uint)0x1B).CopyTo(buffer, cmdOffset); // LC_UUID
BitConverter.GetBytes((uint)32).CopyTo(buffer, cmdOffset + 4); // cmdsize
var uuid = Guid.NewGuid();
var uuid = Guid.Parse("f81e1e08-4373-4df0-8a9e-19c23e2addc5");
uuid.ToByteArray().CopyTo(buffer, cmdOffset + 8);
using var stream = new MemoryStream(buffer);
@@ -158,7 +158,7 @@ public class NativeFormatDetectorTests
Assert.True(detected);
Assert.Equal(NativeFormat.MachO, id.Format);
Assert.Equal(uuid.ToString(), id.Uuid);
Assert.Equal($"macho-uuid:{Convert.ToHexString(uuid.ToByteArray()).ToLowerInvariant()}", id.Uuid);
}
[Fact]

View File

@@ -19,7 +19,7 @@ public class PeImportParserTests : NativeTestBase
var info = ParsePe(pe);
info.Is64Bit.Should().BeFalse();
info.Machine.Should().Be("x86_64");
info.Machine.Should().Be("x86");
info.Subsystem.Should().Be(PeSubsystem.WindowsConsole);
}

View File

@@ -0,0 +1,42 @@
using StellaOps.Scanner.Storage.Epss;
using Xunit;
namespace StellaOps.Scanner.Storage.Tests;
public sealed class EpssChangeDetectorTests
{
[Fact]
public void ComputeFlags_MatchesExpectedBitmask()
{
var thresholds = EpssChangeDetector.DefaultThresholds;
var crossedHigh = EpssChangeDetector.ComputeFlags(
oldScore: 0.40,
newScore: 0.55,
oldPercentile: 0.90,
newPercentile: 0.95,
thresholds);
Assert.Equal(
EpssChangeFlags.CrossedHigh | EpssChangeFlags.BigJumpUp | EpssChangeFlags.TopPercentile,
crossedHigh);
var crossedLow = EpssChangeDetector.ComputeFlags(
oldScore: 0.60,
newScore: 0.45,
oldPercentile: 0.96,
newPercentile: 0.94,
thresholds);
Assert.Equal(
EpssChangeFlags.CrossedLow | EpssChangeFlags.BigJumpDown | EpssChangeFlags.LeftTopPercentile,
crossedLow);
var newScored = EpssChangeDetector.ComputeFlags(
oldScore: null,
newScore: 0.70,
oldPercentile: null,
newPercentile: 0.97,
thresholds);
Assert.Equal(EpssChangeFlags.NewScored | EpssChangeFlags.TopPercentile, newScored);
}
}

View File

@@ -0,0 +1,53 @@
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using StellaOps.Scanner.Storage.Epss;
using Xunit;
namespace StellaOps.Scanner.Storage.Tests;
public sealed class EpssCsvStreamParserTests
{
[Fact]
public async Task ParseGzip_ParsesRowsAndComputesDecompressedHash()
{
var csv = string.Join('\n',
[
"# EPSS v2025.12.17 published 2025-12-17",
"cve,epss,percentile",
"CVE-2024-0001,0.1,0.5",
"cve-2024-0002,1.0,1.0",
""
]);
var decompressedBytes = Encoding.UTF8.GetBytes(csv);
var expectedHash = "sha256:" + Convert.ToHexString(SHA256.HashData(decompressedBytes)).ToLowerInvariant();
await using var gzipBytes = new MemoryStream();
await using (var gzip = new GZipStream(gzipBytes, CompressionLevel.Optimal, leaveOpen: true))
{
await gzip.WriteAsync(decompressedBytes);
}
gzipBytes.Position = 0;
var parser = new EpssCsvStreamParser();
var session = parser.ParseGzip(gzipBytes);
var rows = new List<EpssScoreRow>();
await foreach (var row in session)
{
rows.Add(row);
}
Assert.Equal(2, session.RowCount);
Assert.Equal("v2025.12.17", session.ModelVersionTag);
Assert.Equal(new DateOnly(2025, 12, 17), session.PublishedDate);
Assert.Equal(expectedHash, session.DecompressedSha256);
Assert.Equal("CVE-2024-0001", rows[0].CveId);
Assert.Equal(0.1, rows[0].Score, precision: 6);
Assert.Equal(0.5, rows[0].Percentile, precision: 6);
Assert.Equal("CVE-2024-0002", rows[1].CveId);
}
}

View File

@@ -0,0 +1,126 @@
using Dapper;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.Storage.Epss;
using StellaOps.Scanner.Storage.Postgres;
using Xunit;
namespace StellaOps.Scanner.Storage.Tests;
[Collection("scanner-postgres")]
public sealed class EpssRepositoryIntegrationTests : IAsyncLifetime
{
private readonly ScannerPostgresFixture _fixture;
private ScannerDataSource _dataSource = null!;
private PostgresEpssRepository _repository = null!;
public EpssRepositoryIntegrationTests(ScannerPostgresFixture fixture)
{
_fixture = fixture;
}
public async Task InitializeAsync()
{
await _fixture.TruncateAllTablesAsync();
var options = new ScannerStorageOptions
{
Postgres = new StellaOps.Infrastructure.Postgres.Options.PostgresOptions
{
ConnectionString = _fixture.ConnectionString,
SchemaName = _fixture.SchemaName
}
};
_dataSource = new ScannerDataSource(Options.Create(options), NullLoggerFactory.Instance.CreateLogger<ScannerDataSource>());
_repository = new PostgresEpssRepository(_dataSource);
}
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task WriteSnapshot_ComputesChangesAndUpdatesCurrent()
{
var thresholds = EpssChangeDetector.DefaultThresholds;
var day1 = new DateOnly(2027, 1, 15);
var run1 = await _repository.BeginImportAsync(day1, "bundle://day1.csv.gz", DateTimeOffset.Parse("2027-01-15T00:05:00Z"), "sha256:day1");
Assert.Equal("PENDING", run1.Status);
var day1Rows = new[]
{
new EpssScoreRow("CVE-2024-0001", 0.40, 0.90),
new EpssScoreRow("CVE-2024-0002", 0.60, 0.96)
};
var write1 = await _repository.WriteSnapshotAsync(run1.ImportRunId, day1, DateTimeOffset.Parse("2027-01-15T00:06:00Z"), ToAsync(day1Rows));
Assert.Equal(day1Rows.Length, write1.RowCount);
await _repository.MarkImportSucceededAsync(run1.ImportRunId, write1.RowCount, decompressedSha256: "sha256:decompressed1", modelVersionTag: "v2027.01.15", publishedDate: day1);
var day2 = new DateOnly(2027, 1, 16);
var run2 = await _repository.BeginImportAsync(day2, "bundle://day2.csv.gz", DateTimeOffset.Parse("2027-01-16T00:05:00Z"), "sha256:day2");
var day2Rows = new[]
{
new EpssScoreRow("CVE-2024-0001", 0.55, 0.95),
new EpssScoreRow("CVE-2024-0002", 0.45, 0.94),
new EpssScoreRow("CVE-2024-0003", 0.70, 0.97)
};
var write2 = await _repository.WriteSnapshotAsync(run2.ImportRunId, day2, DateTimeOffset.Parse("2027-01-16T00:06:00Z"), ToAsync(day2Rows));
Assert.Equal(day2Rows.Length, write2.RowCount);
await _repository.MarkImportSucceededAsync(run2.ImportRunId, write2.RowCount, decompressedSha256: "sha256:decompressed2", modelVersionTag: "v2027.01.16", publishedDate: day2);
var current = await _repository.GetCurrentAsync(new[] { "CVE-2024-0001", "CVE-2024-0002", "CVE-2024-0003" });
Assert.Equal(3, current.Count);
Assert.Equal(day2, current["CVE-2024-0001"].ModelDate);
await using var connection = await _dataSource.OpenSystemConnectionAsync();
var changes = (await connection.QueryAsync<ChangeRow>(
"""
SELECT cve_id, old_score, new_score, old_percentile, new_percentile, flags
FROM epss_changes
WHERE model_date = @ModelDate
ORDER BY cve_id
""",
new { ModelDate = day2 })).ToList();
Assert.Equal(3, changes.Count);
var cve1 = changes.Single(c => c.cve_id == "CVE-2024-0001");
Assert.Equal(
(int)EpssChangeDetector.ComputeFlags(cve1.old_score, cve1.new_score, cve1.old_percentile, cve1.new_percentile, thresholds),
cve1.flags);
var cve2 = changes.Single(c => c.cve_id == "CVE-2024-0002");
Assert.Equal(
(int)EpssChangeDetector.ComputeFlags(cve2.old_score, cve2.new_score, cve2.old_percentile, cve2.new_percentile, thresholds),
cve2.flags);
var cve3 = changes.Single(c => c.cve_id == "CVE-2024-0003");
Assert.Null(cve3.old_score);
Assert.Equal(
(int)EpssChangeDetector.ComputeFlags(cve3.old_score, cve3.new_score, cve3.old_percentile, cve3.new_percentile, thresholds),
cve3.flags);
}
private static async IAsyncEnumerable<EpssScoreRow> ToAsync(IEnumerable<EpssScoreRow> rows)
{
foreach (var row in rows)
{
yield return row;
await Task.Yield();
}
}
private sealed class ChangeRow
{
public string cve_id { get; set; } = "";
public double? old_score { get; set; }
public double new_score { get; set; }
public double? old_percentile { get; set; }
public double new_percentile { get; set; }
public int flags { get; set; }
}
}

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]