notify doctors work, audit work, new product advisory sprints

This commit is contained in:
master
2026-01-13 08:36:29 +02:00
parent b8868a5f13
commit 9ca7cb183e
343 changed files with 24492 additions and 3544 deletions

View File

@@ -10,7 +10,9 @@ public sealed class DenoRuntimeTraceRecorderTests
var root = TestPaths.CreateTemporaryDirectory();
try
{
var recorder = new DenoRuntimeTraceRecorder(root);
var recorder = new DenoRuntimeTraceRecorder(
root,
new FixedTimeProvider(DateTimeOffset.Parse("2025-11-17T12:00:00Z")));
recorder.AddPermissionUse(
absoluteModulePath: Path.Combine(root, "c.ts"),
@@ -58,4 +60,16 @@ public sealed class DenoRuntimeTraceRecorderTests
TestPaths.SafeDelete(root);
}
}
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _fixedTime;
public FixedTimeProvider(DateTimeOffset fixedTime)
{
_fixedTime = fixedTime;
}
public override DateTimeOffset GetUtcNow() => _fixedTime;
}
}

View File

@@ -61,13 +61,65 @@ public sealed class DenoRuntimeTraceRunnerTests
await File.WriteAllTextAsync(entrypoint, "console.log('hi')", TestContext.Current.CancellationToken);
using var entryEnv = new EnvironmentVariableScope("STELLA_DENO_ENTRYPOINT", entrypoint);
using var binaryEnv = new EnvironmentVariableScope("STELLA_DENO_BINARY", Guid.NewGuid().ToString("N"));
using var binaryEnv = new EnvironmentVariableScope("STELLA_DENO_BINARY", Path.Combine("tools", "deno"));
var context = new LanguageAnalyzerContext(root, TimeProvider.System);
var result = await DenoRuntimeTraceRunner.TryExecuteAsync(context, logger: null, TestContext.Current.CancellationToken);
Assert.False(result);
Assert.True(File.Exists(Path.Combine(root, DenoRuntimeShim.FileName)));
Assert.False(File.Exists(Path.Combine(root, DenoRuntimeShim.FileName)));
}
finally
{
TestPaths.SafeDelete(root);
}
}
[Fact]
public async Task ReturnsFalse_WhenEntrypointOutsideRoot()
{
var root = TestPaths.CreateTemporaryDirectory();
var externalRoot = TestPaths.CreateTemporaryDirectory();
try
{
var externalEntrypoint = Path.Combine(externalRoot, "main.ts");
await File.WriteAllTextAsync(externalEntrypoint, "console.log('hi')", TestContext.Current.CancellationToken);
using var entryEnv = new EnvironmentVariableScope("STELLA_DENO_ENTRYPOINT", externalEntrypoint);
var context = new LanguageAnalyzerContext(root, TimeProvider.System);
var result = await DenoRuntimeTraceRunner.TryExecuteAsync(context, logger: null, TestContext.Current.CancellationToken);
Assert.False(result);
Assert.False(File.Exists(Path.Combine(root, DenoRuntimeShim.FileName)));
Assert.False(File.Exists(Path.Combine(root, "deno-runtime.ndjson")));
}
finally
{
TestPaths.SafeDelete(externalRoot);
TestPaths.SafeDelete(root);
}
}
[Fact]
public async Task ReturnsFalse_WhenDenoBinaryDisallowed()
{
var root = TestPaths.CreateTemporaryDirectory();
try
{
var entrypoint = Path.Combine(root, "main.ts");
await File.WriteAllTextAsync(entrypoint, "console.log('hi')", TestContext.Current.CancellationToken);
using var entryEnv = new EnvironmentVariableScope("STELLA_DENO_ENTRYPOINT", entrypoint);
using var binaryEnv = new EnvironmentVariableScope("STELLA_DENO_BINARY", "not-deno");
var context = new LanguageAnalyzerContext(root, TimeProvider.System);
var result = await DenoRuntimeTraceRunner.TryExecuteAsync(context, logger: null, TestContext.Current.CancellationToken);
Assert.False(result);
Assert.False(File.Exists(Path.Combine(root, DenoRuntimeShim.FileName)));
}
finally
{
@@ -137,14 +189,7 @@ public sealed class DenoRuntimeTraceRunnerTests
EOF
""";
File.WriteAllText(path, script);
try
{
System.Diagnostics.Process.Start("chmod", $"+x {path}")?.WaitForExit();
}
catch
{
// best effort; on Windows this branch won't execute
}
File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
}
return path;

View File

@@ -92,11 +92,11 @@ public sealed class DenoWorkspaceNormalizerTests
if (vendorCacheEdges.Length == 0)
{
var sample = string.Join(
Environment.NewLine,
"\n",
graph.Edges
.Select(edge => $"{edge.ImportKind}:{edge.Specifier}:{edge.Provenance}")
.Take(10));
Assert.Fail($"Expected vendor cache edges but none were found. Sample edges:{Environment.NewLine}{sample}");
Assert.Fail($"Expected vendor cache edges but none were found. Sample edges:\n{sample}");
}
var vendorEdge = vendorCacheEdges.FirstOrDefault(
@@ -104,9 +104,9 @@ public sealed class DenoWorkspaceNormalizerTests
if (vendorEdge is null)
{
var details = string.Join(
Environment.NewLine,
"\n",
vendorCacheEdges.Select(edge => $"{edge.Specifier} [{edge.Provenance}] -> {edge.Resolution}"));
Assert.Fail($"Unable to locate vendor cache edge for std server.ts. Observed edges:{Environment.NewLine}{details}");
Assert.Fail($"Unable to locate vendor cache edge for std server.ts. Observed edges:\n{details}");
}
var npmBridgeEdges = graph.Edges
@@ -115,11 +115,11 @@ public sealed class DenoWorkspaceNormalizerTests
if (npmBridgeEdges.Length == 0)
{
var bridgeSample = string.Join(
Environment.NewLine,
"\n",
graph.Edges
.Select(edge => $"{edge.ImportKind}:{edge.Specifier}:{edge.Resolution}")
.Take(10));
Assert.Fail($"No npm bridge edges discovered. Sample:{Environment.NewLine}{bridgeSample}");
Assert.Fail($"No npm bridge edges discovered. Sample:\n{bridgeSample}");
}
Assert.Contains(

View File

@@ -3,6 +3,7 @@ using Xunit;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Unicode;
using StellaOps.Scanner.Analyzers.Lang;
using StellaOps.Scanner.Analyzers.Lang.Deno;
using StellaOps.Scanner.Analyzers.Lang.Deno.Tests.TestFixtures;
@@ -270,6 +271,6 @@ public sealed class DenoAnalyzerGoldenTests
private static readonly JsonSerializerOptions JsonSerializerOptionsProvider = new()
{
WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
Encoder = JavaScriptEncoder.Create(UnicodeRanges.BasicLatin)
};
}

View File

@@ -1,7 +1,12 @@
using System.Globalization;
using System.Threading;
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Tests.TestUtilities;
internal static class TestPaths
{
private static long _tempCounter;
public static string ResolveFixture(params string[] segments)
{
var baseDirectory = AppContext.BaseDirectory;
@@ -17,7 +22,8 @@ internal static class TestPaths
public static string CreateTemporaryDirectory()
{
var root = Path.Combine(AppContext.BaseDirectory, "tmp", Guid.NewGuid().ToString("N"));
var suffix = Interlocked.Increment(ref _tempCounter).ToString("D4", CultureInfo.InvariantCulture);
var root = Path.Combine(AppContext.BaseDirectory, "tmp", suffix);
Directory.CreateDirectory(root);
return root;
}

View File

@@ -0,0 +1,42 @@
using System.Collections.Immutable;
using System.Globalization;
using StellaOps.Scanner.Analyzers.Lang.DotNet.Internal;
using Xunit;
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Tests.DotNet.Bundling;
public sealed class BundlingSignalTests
{
[Fact]
public void ToMetadata_UsesInvariantCultureForNumericValues()
{
var originalCulture = CultureInfo.CurrentCulture;
var originalUiCulture = CultureInfo.CurrentUICulture;
try
{
CultureInfo.CurrentCulture = new CultureInfo("ar-SA");
CultureInfo.CurrentUICulture = new CultureInfo("ar-SA");
var signal = new BundlingSignal(
FilePath: "app.exe",
Kind: BundlingKind.ILMerge,
IsSkipped: false,
SkipReason: null,
Indicators: ImmutableArray<string>.Empty,
SizeBytes: 123456,
EstimatedBundledAssemblies: 42);
var metadata = signal.ToMetadata()
.ToDictionary(item => item.Key, item => item.Value, StringComparer.Ordinal);
Assert.Equal("123456", metadata["bundle.sizeBytes"]);
Assert.Equal("42", metadata["bundle.estimatedAssemblies"]);
}
finally
{
CultureInfo.CurrentCulture = originalCulture;
CultureInfo.CurrentUICulture = originalUiCulture;
}
}
}

View File

@@ -0,0 +1,30 @@
using StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.Callgraph;
using Xunit;
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Tests.DotNet.Callgraph;
public sealed class DotNetCallgraphBuilderTests
{
[Fact]
public void Build_UsesProvidedTimeProvider()
{
var fixedTime = new DateTimeOffset(2024, 1, 2, 3, 4, 5, TimeSpan.Zero);
var builder = new DotNetCallgraphBuilder("context-digest", new FixedTimeProvider(fixedTime));
var graph = builder.Build();
Assert.Equal(fixedTime, graph.Metadata.GeneratedAt);
}
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _utcNow;
public FixedTimeProvider(DateTimeOffset utcNow)
{
_utcNow = utcNow;
}
public override DateTimeOffset GetUtcNow() => _utcNow;
}
}

View File

@@ -8,7 +8,7 @@
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<!-- Disable Concelier test infrastructure - not needed for scanner tests -->
</PropertyGroup>

View File

@@ -1,4 +1,6 @@
using System.Globalization;
using System.Text;
using System.Threading;
using StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.Bundling;
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Tests.TestUtilities;
@@ -8,6 +10,7 @@ namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Tests.TestUtilities;
/// </summary>
internal static class DotNetFixtureBuilder
{
private static int _tempCounter;
/// <summary>
/// Creates a minimal SDK-style project file.
/// </summary>
@@ -373,7 +376,9 @@ internal static class DotNetFixtureBuilder
/// </summary>
public static string CreateTemporaryDirectory()
{
var path = Path.Combine(Path.GetTempPath(), "stellaops-tests", Guid.NewGuid().ToString("N"));
var counter = Interlocked.Increment(ref _tempCounter)
.ToString(CultureInfo.InvariantCulture);
var path = Path.Combine(Path.GetTempPath(), "stellaops-tests", $"run-{counter}");
Directory.CreateDirectory(path);
return path;
}

View File

@@ -2,11 +2,12 @@ using FluentAssertions;
using StellaOps.Scanner.Analyzers.Native.RuntimeCapture.Timeline;
using Xunit;
namespace StellaOps.Scanner.Analyzers.Native.Tests.RuntimeCapture.Timeline;
namespace StellaOps.Scanner.Analyzers.Native.Library.Tests.RuntimeCapture.Timeline;
public class TimelineBuilderTests
{
private readonly TimelineBuilder _builder = new();
private static readonly DateTimeOffset BaseTime = new(2024, 1, 1, 0, 0, 0, TimeSpan.Zero);
[Fact]
public void Build_WithNoObservations_ReturnsUnknownPosture()
@@ -78,15 +79,15 @@ public class TimelineBuilderTests
{
return new RuntimeEvidence
{
FirstObservation = DateTimeOffset.UtcNow.AddHours(-1),
LastObservation = DateTimeOffset.UtcNow,
FirstObservation = BaseTime.AddHours(-1),
LastObservation = BaseTime,
Observations = Array.Empty<RuntimeObservation>(),
Sessions = new[]
{
new RuntimeSession
{
StartTime = DateTimeOffset.UtcNow.AddHours(-1),
EndTime = DateTimeOffset.UtcNow,
StartTime = BaseTime.AddHours(-1),
EndTime = BaseTime,
Platform = "linux-x64"
}
},
@@ -96,7 +97,7 @@ public class TimelineBuilderTests
private static RuntimeEvidence CreateEvidenceWithoutComponent()
{
var now = DateTimeOffset.UtcNow;
var now = BaseTime;
return new RuntimeEvidence
{
FirstObservation = now.AddHours(-1),
@@ -127,7 +128,7 @@ public class TimelineBuilderTests
private static RuntimeEvidence CreateEvidenceWithNetworkExposure()
{
var now = DateTimeOffset.UtcNow;
var now = BaseTime;
return new RuntimeEvidence
{
FirstObservation = now.AddHours(-1),
@@ -166,7 +167,7 @@ public class TimelineBuilderTests
private static RuntimeEvidence CreateEvidenceOver24Hours()
{
var start = DateTimeOffset.UtcNow.AddHours(-24);
var start = BaseTime.AddHours(-24);
var observations = new List<RuntimeObservation>();
for (int i = 0; i < 24; i++)
@@ -200,7 +201,7 @@ public class TimelineBuilderTests
private static RuntimeEvidence CreateEvidenceWithComponentLoad()
{
var now = DateTimeOffset.UtcNow;
var now = BaseTime;
return new RuntimeEvidence
{
FirstObservation = now.AddHours(-1),
@@ -231,7 +232,7 @@ public class TimelineBuilderTests
private static RuntimeEvidence CreateEvidenceWith10Observations()
{
var now = DateTimeOffset.UtcNow;
var now = BaseTime;
var observations = new List<RuntimeObservation>();
for (int i = 0; i < 10; i++)

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Scanner.Analyzers.Native\StellaOps.Scanner.Analyzers.Native.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,13 @@
namespace StellaOps.Scanner.Analyzers.Native.Tests;
internal sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _fixedTime;
public FixedTimeProvider(DateTimeOffset fixedTime)
{
_fixedTime = fixedTime;
}
public override DateTimeOffset GetUtcNow() => _fixedTime;
}

View File

@@ -8,6 +8,7 @@
using System.Buffers.Binary;
using FluentAssertions;
using StellaOps.Scanner.Analyzers.Native.Hardening;
using StellaOps.Scanner.Analyzers.Native.Tests;
using Xunit;
namespace StellaOps.Scanner.Analyzers.Native.Tests.Hardening;
@@ -18,7 +19,10 @@ namespace StellaOps.Scanner.Analyzers.Native.Tests.Hardening;
/// </summary>
public class ElfHardeningExtractorTests
{
private readonly ElfHardeningExtractor _extractor = new();
private static readonly TimeProvider FixedTimeProvider =
new FixedTimeProvider(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero));
private readonly ElfHardeningExtractor _extractor = new(FixedTimeProvider);
#region Magic Detection Tests

View File

@@ -17,6 +17,8 @@ namespace StellaOps.Scanner.Analyzers.Native.Tests.Hardening;
/// </summary>
public class HardeningScoreCalculatorTests
{
private static readonly DateTimeOffset FixedTime = new(2024, 1, 1, 0, 0, 0, TimeSpan.Zero);
#region Score Range Tests
[Fact]
@@ -38,7 +40,7 @@ public class HardeningScoreCalculatorTests
Flags: flags,
HardeningScore: CalculateScore(flags, BinaryFormat.Elf),
MissingFlags: [],
ExtractedAt: DateTimeOffset.UtcNow);
ExtractedAt: FixedTime);
// Assert
result.HardeningScore.Should().BeGreaterThanOrEqualTo(0.8);
@@ -63,7 +65,7 @@ public class HardeningScoreCalculatorTests
Flags: flags,
HardeningScore: CalculateScore(flags, BinaryFormat.Elf),
MissingFlags: ["PIE", "RELRO", "NX", "STACK_CANARY", "FORTIFY"],
ExtractedAt: DateTimeOffset.UtcNow);
ExtractedAt: FixedTime);
// Assert
result.HardeningScore.Should().Be(0);
@@ -82,7 +84,7 @@ public class HardeningScoreCalculatorTests
Flags: flags,
HardeningScore: CalculateScore(flags, BinaryFormat.Elf),
MissingFlags: [],
ExtractedAt: DateTimeOffset.UtcNow);
ExtractedAt: FixedTime);
// Assert
result.HardeningScore.Should().Be(0);

View File

@@ -8,6 +8,7 @@
using System.Buffers.Binary;
using FluentAssertions;
using StellaOps.Scanner.Analyzers.Native.Hardening;
using StellaOps.Scanner.Analyzers.Native.Tests;
using Xunit;
namespace StellaOps.Scanner.Analyzers.Native.Tests.Hardening;
@@ -18,7 +19,10 @@ namespace StellaOps.Scanner.Analyzers.Native.Tests.Hardening;
/// </summary>
public class PeHardeningExtractorTests
{
private readonly PeHardeningExtractor _extractor = new();
private static readonly TimeProvider FixedTimeProvider =
new FixedTimeProvider(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero));
private readonly PeHardeningExtractor _extractor = new(FixedTimeProvider);
#region Magic Detection Tests

View File

@@ -3,6 +3,7 @@ using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography;
using StellaOps.Scanner.Analyzers.Native.Tests;
using StellaOps.Scanner.Analyzers.Native.Index;
using StellaOps.Scanner.ProofSpine;
using StellaOps.Scanner.ProofSpine.Options;
@@ -10,33 +11,21 @@ using Xunit;
namespace StellaOps.Scanner.Analyzers.Native.Index.Tests;
public sealed class OfflineBuildIdIndexSignatureTests : IDisposable
public sealed class OfflineBuildIdIndexSignatureTests
{
private readonly string _tempDir;
public OfflineBuildIdIndexSignatureTests()
{
_tempDir = Path.Combine(Path.GetTempPath(), $"buildid-sig-test-{Guid.NewGuid():N}");
Directory.CreateDirectory(_tempDir);
}
public void Dispose()
{
if (Directory.Exists(_tempDir))
{
Directory.Delete(_tempDir, recursive: true);
}
}
private static readonly TimeProvider FixedTimeProvider =
new FixedTimeProvider(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero));
[Fact]
public async Task LoadAsync_RequiresTrustedDsseSignature_WhenEnabled()
{
var indexPath = Path.Combine(_tempDir, "index.ndjson");
using var temp = TempDirectory.Create();
var indexPath = Path.Combine(temp.Path, "index.ndjson");
await File.WriteAllTextAsync(indexPath, """
{"build_id":"gnu-build-id:abc123","purl":"pkg:deb/debian/libc6@2.31","distro":"debian","confidence":"exact","indexed_at":"2025-01-15T10:00:00Z"}
""");
var signaturePath = Path.Combine(_tempDir, "index.ndjson.dsse.json");
var signaturePath = Path.Combine(temp.Path, "index.ndjson.dsse.json");
await File.WriteAllTextAsync(signaturePath, CreateDsseSignature(indexPath, expectedSha256: ComputeSha256Hex(indexPath)));
var dsseService = CreateTrustedDsseService(keyId: "buildid-index-test-key", secretBase64: Convert.ToBase64String("supersecret-supersecret-supersecret"u8.ToArray()));
@@ -48,7 +37,7 @@ public sealed class OfflineBuildIdIndexSignatureTests : IDisposable
RequireSignature = true,
});
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance, dsseService);
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance, FixedTimeProvider, dsseService);
await index.LoadAsync();
Assert.True(index.IsLoaded);
@@ -62,12 +51,13 @@ public sealed class OfflineBuildIdIndexSignatureTests : IDisposable
[Fact]
public async Task LoadAsync_RefusesToLoadIndex_WhenDigestDoesNotMatchSignaturePayload()
{
var indexPath = Path.Combine(_tempDir, "index.ndjson");
using var temp = TempDirectory.Create();
var indexPath = Path.Combine(temp.Path, "index.ndjson");
await File.WriteAllTextAsync(indexPath, """
{"build_id":"gnu-build-id:abc123","purl":"pkg:deb/debian/libc6@2.31"}
""");
var signaturePath = Path.Combine(_tempDir, "index.ndjson.dsse.json");
var signaturePath = Path.Combine(temp.Path, "index.ndjson.dsse.json");
await File.WriteAllTextAsync(signaturePath, CreateDsseSignature(indexPath, expectedSha256: "deadbeef"));
var dsseService = CreateTrustedDsseService(keyId: "buildid-index-test-key", secretBase64: Convert.ToBase64String("supersecret-supersecret-supersecret"u8.ToArray()));
@@ -79,7 +69,7 @@ public sealed class OfflineBuildIdIndexSignatureTests : IDisposable
RequireSignature = true,
});
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance, dsseService);
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance, FixedTimeProvider, dsseService);
await index.LoadAsync();
Assert.True(index.IsLoaded);
@@ -89,12 +79,13 @@ public sealed class OfflineBuildIdIndexSignatureTests : IDisposable
[Fact]
public async Task LoadAsync_RefusesToLoadIndex_WhenSignatureFileMissing()
{
var indexPath = Path.Combine(_tempDir, "index.ndjson");
using var temp = TempDirectory.Create();
var indexPath = Path.Combine(temp.Path, "index.ndjson");
await File.WriteAllTextAsync(indexPath, """
{"build_id":"gnu-build-id:abc123","purl":"pkg:deb/debian/libc6@2.31"}
""");
var signaturePath = Path.Combine(_tempDir, "missing.dsse.json");
var signaturePath = Path.Combine(temp.Path, "missing.dsse.json");
var dsseService = CreateTrustedDsseService(keyId: "buildid-index-test-key", secretBase64: Convert.ToBase64String("supersecret-supersecret-supersecret"u8.ToArray()));
var options = Options.Create(new BuildIdIndexOptions
@@ -104,7 +95,7 @@ public sealed class OfflineBuildIdIndexSignatureTests : IDisposable
RequireSignature = true,
});
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance, dsseService);
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance, FixedTimeProvider, dsseService);
await index.LoadAsync();
Assert.True(index.IsLoaded);

View File

@@ -1,6 +1,7 @@
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.Analyzers.Native.Tests;
using Xunit;
namespace StellaOps.Scanner.Analyzers.Native.Index.Tests;
@@ -8,23 +9,10 @@ namespace StellaOps.Scanner.Analyzers.Native.Index.Tests;
/// <summary>
/// Unit tests for <see cref="OfflineBuildIdIndex"/>.
/// </summary>
public sealed class OfflineBuildIdIndexTests : IDisposable
public sealed class OfflineBuildIdIndexTests
{
private readonly string _tempDir;
public OfflineBuildIdIndexTests()
{
_tempDir = Path.Combine(Path.GetTempPath(), $"buildid-test-{Guid.NewGuid():N}");
Directory.CreateDirectory(_tempDir);
}
public void Dispose()
{
if (Directory.Exists(_tempDir))
{
Directory.Delete(_tempDir, recursive: true);
}
}
private static readonly TimeProvider FixedTimeProvider =
new FixedTimeProvider(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero));
#region Loading Tests
@@ -32,7 +20,7 @@ public sealed class OfflineBuildIdIndexTests : IDisposable
public async Task LoadAsync_EmptyIndex_WhenNoPathConfigured()
{
var options = Options.Create(new BuildIdIndexOptions { IndexPath = null });
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance);
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance, FixedTimeProvider);
await index.LoadAsync();
@@ -44,7 +32,7 @@ public sealed class OfflineBuildIdIndexTests : IDisposable
public async Task LoadAsync_EmptyIndex_WhenFileNotFound()
{
var options = Options.Create(new BuildIdIndexOptions { IndexPath = "/nonexistent/file.ndjson" });
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance);
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance, FixedTimeProvider);
await index.LoadAsync();
@@ -55,7 +43,8 @@ public sealed class OfflineBuildIdIndexTests : IDisposable
[Fact]
public async Task LoadAsync_ParsesNdjsonEntries()
{
var indexPath = Path.Combine(_tempDir, "index.ndjson");
using var temp = TempDirectory.Create();
var indexPath = Path.Combine(temp.Path, "index.ndjson");
await File.WriteAllTextAsync(indexPath, """
{"build_id":"gnu-build-id:abc123","purl":"pkg:deb/debian/libc6@2.31","distro":"debian","confidence":"exact","indexed_at":"2025-01-15T10:00:00Z"}
{"build_id":"pe-cv:12345678-1234-1234-1234-123456789012-1","purl":"pkg:nuget/System.Text.Json@8.0.0","confidence":"inferred"}
@@ -63,7 +52,7 @@ public sealed class OfflineBuildIdIndexTests : IDisposable
""");
var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false });
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance);
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance, FixedTimeProvider);
await index.LoadAsync();
@@ -74,7 +63,8 @@ public sealed class OfflineBuildIdIndexTests : IDisposable
[Fact]
public async Task LoadAsync_SkipsEmptyLines()
{
var indexPath = Path.Combine(_tempDir, "index-empty-lines.ndjson");
using var temp = TempDirectory.Create();
var indexPath = Path.Combine(temp.Path, "index-empty-lines.ndjson");
await File.WriteAllTextAsync(indexPath, """
{"build_id":"gnu-build-id:abc123","purl":"pkg:deb/debian/libc6@2.31"}
@@ -83,7 +73,7 @@ public sealed class OfflineBuildIdIndexTests : IDisposable
""");
var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false });
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance);
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance, FixedTimeProvider);
await index.LoadAsync();
@@ -93,7 +83,8 @@ public sealed class OfflineBuildIdIndexTests : IDisposable
[Fact]
public async Task LoadAsync_SkipsCommentLines()
{
var indexPath = Path.Combine(_tempDir, "index-comments.ndjson");
using var temp = TempDirectory.Create();
var indexPath = Path.Combine(temp.Path, "index-comments.ndjson");
await File.WriteAllTextAsync(indexPath, """
# This is a comment
{"build_id":"gnu-build-id:abc123","purl":"pkg:deb/debian/libc6@2.31"}
@@ -102,7 +93,7 @@ public sealed class OfflineBuildIdIndexTests : IDisposable
""");
var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false });
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance);
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance, FixedTimeProvider);
await index.LoadAsync();
@@ -112,7 +103,8 @@ public sealed class OfflineBuildIdIndexTests : IDisposable
[Fact]
public async Task LoadAsync_SkipsInvalidJsonLines()
{
var indexPath = Path.Combine(_tempDir, "index-invalid.ndjson");
using var temp = TempDirectory.Create();
var indexPath = Path.Combine(temp.Path, "index-invalid.ndjson");
await File.WriteAllTextAsync(indexPath, """
{"build_id":"gnu-build-id:abc123","purl":"pkg:deb/debian/libc6@2.31"}
not valid json at all
@@ -120,7 +112,7 @@ public sealed class OfflineBuildIdIndexTests : IDisposable
""");
var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false });
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance);
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance, FixedTimeProvider);
await index.LoadAsync();
@@ -134,13 +126,14 @@ public sealed class OfflineBuildIdIndexTests : IDisposable
[Fact]
public async Task LookupAsync_ReturnsNull_WhenNotFound()
{
var indexPath = Path.Combine(_tempDir, "index.ndjson");
using var temp = TempDirectory.Create();
var indexPath = Path.Combine(temp.Path, "index.ndjson");
await File.WriteAllTextAsync(indexPath, """
{"build_id":"gnu-build-id:abc123","purl":"pkg:deb/debian/libc6@2.31"}
""");
var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false });
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance);
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance, FixedTimeProvider);
await index.LoadAsync();
var result = await index.LookupAsync("gnu-build-id:notfound");
@@ -151,13 +144,14 @@ public sealed class OfflineBuildIdIndexTests : IDisposable
[Fact]
public async Task LookupAsync_ReturnsNull_ForNullOrEmpty()
{
var indexPath = Path.Combine(_tempDir, "index.ndjson");
using var temp = TempDirectory.Create();
var indexPath = Path.Combine(temp.Path, "index.ndjson");
await File.WriteAllTextAsync(indexPath, """
{"build_id":"gnu-build-id:abc123","purl":"pkg:deb/debian/libc6@2.31"}
""");
var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false });
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance);
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance, FixedTimeProvider);
await index.LoadAsync();
Assert.Null(await index.LookupAsync(null!));
@@ -168,13 +162,14 @@ public sealed class OfflineBuildIdIndexTests : IDisposable
[Fact]
public async Task LookupAsync_FindsExactMatch()
{
var indexPath = Path.Combine(_tempDir, "index.ndjson");
using var temp = TempDirectory.Create();
var indexPath = Path.Combine(temp.Path, "index.ndjson");
await File.WriteAllTextAsync(indexPath, """
{"build_id":"gnu-build-id:abc123def456","purl":"pkg:deb/debian/libc6@2.31","version":"2.31","distro":"debian","confidence":"exact","indexed_at":"2025-01-15T10:00:00Z"}
""");
var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false });
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance);
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance, FixedTimeProvider);
await index.LoadAsync();
var result = await index.LookupAsync("gnu-build-id:abc123def456");
@@ -190,13 +185,14 @@ public sealed class OfflineBuildIdIndexTests : IDisposable
[Fact]
public async Task LookupAsync_CaseInsensitive()
{
var indexPath = Path.Combine(_tempDir, "index.ndjson");
using var temp = TempDirectory.Create();
var indexPath = Path.Combine(temp.Path, "index.ndjson");
await File.WriteAllTextAsync(indexPath, """
{"build_id":"gnu-build-id:ABC123DEF456","purl":"pkg:deb/debian/libc6@2.31"}
""");
var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false });
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance);
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance, FixedTimeProvider);
await index.LoadAsync();
// Query with lowercase
@@ -213,7 +209,8 @@ public sealed class OfflineBuildIdIndexTests : IDisposable
[Fact]
public async Task BatchLookupAsync_ReturnsFoundEntries()
{
var indexPath = Path.Combine(_tempDir, "index.ndjson");
using var temp = TempDirectory.Create();
var indexPath = Path.Combine(temp.Path, "index.ndjson");
await File.WriteAllTextAsync(indexPath, """
{"build_id":"gnu-build-id:aaa","purl":"pkg:deb/debian/liba@1.0"}
{"build_id":"gnu-build-id:bbb","purl":"pkg:deb/debian/libb@1.0"}
@@ -221,7 +218,7 @@ public sealed class OfflineBuildIdIndexTests : IDisposable
""");
var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false });
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance);
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance, FixedTimeProvider);
await index.LoadAsync();
var results = await index.BatchLookupAsync(["gnu-build-id:aaa", "gnu-build-id:notfound", "gnu-build-id:ccc"]);
@@ -234,13 +231,14 @@ public sealed class OfflineBuildIdIndexTests : IDisposable
[Fact]
public async Task BatchLookupAsync_SkipsNullAndEmpty()
{
var indexPath = Path.Combine(_tempDir, "index.ndjson");
using var temp = TempDirectory.Create();
var indexPath = Path.Combine(temp.Path, "index.ndjson");
await File.WriteAllTextAsync(indexPath, """
{"build_id":"gnu-build-id:aaa","purl":"pkg:deb/debian/liba@1.0"}
""");
var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false });
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance);
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance, FixedTimeProvider);
await index.LoadAsync();
var results = await index.BatchLookupAsync([null!, "", " ", "gnu-build-id:aaa"]);
@@ -263,12 +261,13 @@ public sealed class OfflineBuildIdIndexTests : IDisposable
[InlineData("", BuildIdConfidence.Heuristic)]
public async Task LoadAsync_ParsesConfidenceLevels(string confidenceValue, BuildIdConfidence expected)
{
var indexPath = Path.Combine(_tempDir, "index.ndjson");
using var temp = TempDirectory.Create();
var indexPath = Path.Combine(temp.Path, "index.ndjson");
var entry = new { build_id = "gnu-build-id:test", purl = "pkg:test/test@1.0", confidence = confidenceValue };
await File.WriteAllTextAsync(indexPath, JsonSerializer.Serialize(entry));
var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false });
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance);
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance, FixedTimeProvider);
await index.LoadAsync();
var result = await index.LookupAsync("gnu-build-id:test");

View File

@@ -0,0 +1,44 @@
using System.Runtime.CompilerServices;
namespace StellaOps.Scanner.Analyzers.Native.Index.Tests;
internal sealed class TempDirectory : IDisposable
{
public string Path { get; }
private TempDirectory(string path)
{
Path = path;
Directory.CreateDirectory(path);
}
public static TempDirectory Create([CallerMemberName] string? testName = null)
{
var safeName = Sanitize(testName ?? "unknown");
var path = System.IO.Path.Combine(
System.IO.Path.GetTempPath(),
"stellaops-tests",
"buildid-index",
safeName);
return new TempDirectory(path);
}
public void Dispose()
{
if (Directory.Exists(Path))
{
Directory.Delete(Path, recursive: true);
}
}
private static string Sanitize(string value)
{
var buffer = new char[value.Length];
for (var i = 0; i < value.Length; i++)
{
var ch = value[i];
buffer[i] = char.IsLetterOrDigit(ch) ? ch : '_';
}
return new string(buffer);
}
}

View File

@@ -14,6 +14,8 @@ namespace StellaOps.Scanner.Analyzers.Native.Tests.Reachability;
/// </summary>
public class RichgraphV1AlignmentTests
{
private static readonly DateTimeOffset FixedTime = new(2024, 1, 1, 0, 0, 0, TimeSpan.Zero);
/// <summary>
/// §8.2: SymbolID Construction uses sym: prefix with 16 hex chars.
/// </summary>
@@ -400,7 +402,7 @@ public class RichgraphV1AlignmentTests
{
// Arrange & Act
var metadata = new TestGraphMetadata(
GeneratedAt: DateTimeOffset.UtcNow,
GeneratedAt: FixedTime,
GeneratorVersion: "1.0.0",
LayerDigest: "sha256:layer123",
BinaryCount: 5,
@@ -410,7 +412,7 @@ public class RichgraphV1AlignmentTests
SyntheticRootCount: 8);
// Assert
metadata.GeneratedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromMinutes(1));
metadata.GeneratedAt.Should().Be(FixedTime);
metadata.GeneratorVersion.Should().Be("1.0.0");
metadata.LayerDigest.Should().StartWith("sha256:");
metadata.BinaryCount.Should().BeGreaterThan(0);

View File

@@ -1,10 +1,38 @@
using FluentAssertions;
using StellaOps.Determinism;
using StellaOps.Scanner.Analyzers.Native.RuntimeCapture;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Scanner.Analyzers.Native.Tests;
internal static class RuntimeCaptureTestClock
{
internal static readonly DateTime BaseTime = new(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc);
internal static readonly DateTimeOffset BaseTimeOffset = new(2024, 1, 1, 0, 0, 0, TimeSpan.Zero);
}
internal static class RuntimeCaptureTestFactory
{
internal static TimeProvider CreateTimeProvider() => new FixedTimeProvider(RuntimeCaptureTestClock.BaseTimeOffset);
internal static IGuidProvider CreateGuidProvider() => new SequentialGuidProvider();
internal static IRuntimeCaptureAdapter? CreatePlatformAdapter(TimeProvider timeProvider, IGuidProvider guidProvider)
{
if (OperatingSystem.IsLinux())
return new LinuxEbpfCaptureAdapter(timeProvider, guidProvider);
if (OperatingSystem.IsWindows())
return new WindowsEtwCaptureAdapter(timeProvider, guidProvider);
if (OperatingSystem.IsMacOS())
return new MacOsDyldCaptureAdapter(timeProvider, guidProvider);
return null;
}
}
public class RuntimeCaptureOptionsTests
{
[Trait("Category", TestCategories.Unit)]
@@ -261,7 +289,7 @@ public class RuntimeEvidenceAggregatorTests
var events = new[]
{
new RuntimeLoadEvent(
DateTime.UtcNow.AddMinutes(-5),
RuntimeCaptureTestClock.BaseTime.AddMinutes(-5),
ProcessId: 1234,
ThreadId: 1,
LoadType: RuntimeLoadType.Dlopen,
@@ -273,7 +301,7 @@ public class RuntimeEvidenceAggregatorTests
CallerModule: "myapp",
CallerAddress: 0x400000),
new RuntimeLoadEvent(
DateTime.UtcNow.AddMinutes(-4),
RuntimeCaptureTestClock.BaseTime.AddMinutes(-4),
ProcessId: 1234,
ThreadId: 1,
LoadType: RuntimeLoadType.Dlopen,
@@ -288,8 +316,8 @@ public class RuntimeEvidenceAggregatorTests
var session = new RuntimeCaptureSession(
SessionId: "test-session",
StartTime: DateTime.UtcNow.AddMinutes(-10),
EndTime: DateTime.UtcNow,
StartTime: RuntimeCaptureTestClock.BaseTime.AddMinutes(-10),
EndTime: RuntimeCaptureTestClock.BaseTime,
Platform: "linux",
CaptureMethod: "ebpf",
TargetProcessId: 1234,
@@ -315,7 +343,7 @@ public class RuntimeEvidenceAggregatorTests
public void Aggregate_DuplicateLoads_AggregatesCorrectly()
{
// Arrange
var baseTime = DateTime.UtcNow.AddMinutes(-10);
var baseTime = RuntimeCaptureTestClock.BaseTime.AddMinutes(-10);
var events = new[]
{
new RuntimeLoadEvent(baseTime, 1, 1, RuntimeLoadType.Dlopen, "libc.so.6", "/lib/libc.so.6", null, true, null, null, null),
@@ -323,7 +351,7 @@ public class RuntimeEvidenceAggregatorTests
new RuntimeLoadEvent(baseTime.AddMinutes(2), 1, 1, RuntimeLoadType.Dlopen, "libc.so.6", "/lib/libc.so.6", null, true, null, null, null),
};
var session = new RuntimeCaptureSession("test", baseTime, DateTime.UtcNow, "linux", "ebpf", 1, events, 0, 0);
var session = new RuntimeCaptureSession("test", baseTime, RuntimeCaptureTestClock.BaseTime, "linux", "ebpf", 1, events, 0, 0);
// Act
var evidence = RuntimeEvidenceAggregator.Aggregate([session]);
@@ -341,10 +369,19 @@ public class RuntimeEvidenceAggregatorTests
// Arrange
var events = new[]
{
new RuntimeLoadEvent(DateTime.UtcNow, 1, 1, RuntimeLoadType.Dlopen, "missing.so", null, null, false, -1, null, null),
new RuntimeLoadEvent(RuntimeCaptureTestClock.BaseTime, 1, 1, RuntimeLoadType.Dlopen, "missing.so", null, null, false, -1, null, null),
};
var session = new RuntimeCaptureSession("test", DateTime.UtcNow.AddMinutes(-1), DateTime.UtcNow, "linux", "ebpf", 1, events, 0, 0);
var session = new RuntimeCaptureSession(
"test",
RuntimeCaptureTestClock.BaseTime.AddMinutes(-1),
RuntimeCaptureTestClock.BaseTime,
"linux",
"ebpf",
1,
events,
0,
0);
// Act
var evidence = RuntimeEvidenceAggregator.Aggregate([session]);
@@ -359,8 +396,8 @@ public class RuntimeEvidenceAggregatorTests
public void Aggregate_MultipleSessions_MergesCorrectly()
{
// Arrange
var time1 = DateTime.UtcNow.AddHours(-2);
var time2 = DateTime.UtcNow.AddHours(-1);
var time1 = RuntimeCaptureTestClock.BaseTime.AddHours(-2);
var time2 = RuntimeCaptureTestClock.BaseTime.AddHours(-1);
var session1 = new RuntimeCaptureSession(
"s1", time1, time1.AddMinutes(30), "linux", "ebpf", 1,
@@ -387,8 +424,11 @@ public class RuntimeCaptureAdapterFactoryTests
[Fact]
public void CreateForCurrentPlatform_ReturnsAdapter()
{
var timeProvider = RuntimeCaptureTestFactory.CreateTimeProvider();
var guidProvider = RuntimeCaptureTestFactory.CreateGuidProvider();
// Act
var adapter = RuntimeCaptureAdapterFactory.CreateForCurrentPlatform();
var adapter = RuntimeCaptureAdapterFactory.CreateForCurrentPlatform(timeProvider, guidProvider);
// Assert
// Should return an adapter on Linux/Windows/macOS, null on other platforms
@@ -406,8 +446,11 @@ public class RuntimeCaptureAdapterFactoryTests
[Fact]
public void GetAvailableAdapters_ReturnsAdaptersForCurrentPlatform()
{
var timeProvider = RuntimeCaptureTestFactory.CreateTimeProvider();
var guidProvider = RuntimeCaptureTestFactory.CreateGuidProvider();
// Act
var adapters = RuntimeCaptureAdapterFactory.GetAvailableAdapters();
var adapters = RuntimeCaptureAdapterFactory.GetAvailableAdapters(timeProvider, guidProvider);
// Assert
if (OperatingSystem.IsLinux() || OperatingSystem.IsWindows() || OperatingSystem.IsMacOS())
@@ -431,8 +474,8 @@ public class SandboxCaptureTests
// Arrange
var mockEvents = new[]
{
new RuntimeLoadEvent(DateTime.UtcNow, 1, 1, RuntimeLoadType.Dlopen, "libtest.so", "/lib/libtest.so", null, true, null, null, null),
new RuntimeLoadEvent(DateTime.UtcNow, 1, 1, RuntimeLoadType.Dlopen, "libother.so", "/lib/libother.so", null, true, null, null, null),
new RuntimeLoadEvent(RuntimeCaptureTestClock.BaseTime, 1, 1, RuntimeLoadType.Dlopen, "libtest.so", "/lib/libtest.so", null, true, null, null, null),
new RuntimeLoadEvent(RuntimeCaptureTestClock.BaseTime, 1, 1, RuntimeLoadType.Dlopen, "libother.so", "/lib/libother.so", null, true, null, null, null),
};
var options = new RuntimeCaptureOptions
@@ -446,13 +489,9 @@ public class SandboxCaptureTests
}
};
IRuntimeCaptureAdapter? adapter = null;
if (OperatingSystem.IsLinux())
adapter = new LinuxEbpfCaptureAdapter();
else if (OperatingSystem.IsWindows())
adapter = new WindowsEtwCaptureAdapter();
else if (OperatingSystem.IsMacOS())
adapter = new MacOsDyldCaptureAdapter();
var timeProvider = RuntimeCaptureTestFactory.CreateTimeProvider();
var guidProvider = RuntimeCaptureTestFactory.CreateGuidProvider();
var adapter = RuntimeCaptureTestFactory.CreatePlatformAdapter(timeProvider, guidProvider);
if (adapter == null)
return; // Skip on unsupported platforms
@@ -486,13 +525,9 @@ public class SandboxCaptureTests
}
};
IRuntimeCaptureAdapter? adapter = null;
if (OperatingSystem.IsLinux())
adapter = new LinuxEbpfCaptureAdapter();
else if (OperatingSystem.IsWindows())
adapter = new WindowsEtwCaptureAdapter();
else if (OperatingSystem.IsMacOS())
adapter = new MacOsDyldCaptureAdapter();
var timeProvider = RuntimeCaptureTestFactory.CreateTimeProvider();
var guidProvider = RuntimeCaptureTestFactory.CreateGuidProvider();
var adapter = RuntimeCaptureTestFactory.CreatePlatformAdapter(timeProvider, guidProvider);
if (adapter == null)
return; // Skip on unsupported platforms
@@ -527,13 +562,9 @@ public class SandboxCaptureTests
}
};
IRuntimeCaptureAdapter? adapter = null;
if (OperatingSystem.IsLinux())
adapter = new LinuxEbpfCaptureAdapter();
else if (OperatingSystem.IsWindows())
adapter = new WindowsEtwCaptureAdapter();
else if (OperatingSystem.IsMacOS())
adapter = new MacOsDyldCaptureAdapter();
var timeProvider = RuntimeCaptureTestFactory.CreateTimeProvider();
var guidProvider = RuntimeCaptureTestFactory.CreateGuidProvider();
var adapter = RuntimeCaptureTestFactory.CreatePlatformAdapter(timeProvider, guidProvider);
if (adapter == null)
return; // Skip on unsupported platforms
@@ -558,7 +589,7 @@ public class RuntimeEvidenceModelTests
public void RuntimeLoadEvent_RecordEquality_Works()
{
// Arrange
var time = DateTime.UtcNow;
var time = RuntimeCaptureTestClock.BaseTime;
var event1 = new RuntimeLoadEvent(time, 1, 1, RuntimeLoadType.Dlopen, "lib.so", "/lib.so", null, true, null, null, null);
var event2 = new RuntimeLoadEvent(time, 1, 1, RuntimeLoadType.Dlopen, "lib.so", "/lib.so", null, true, null, null, null);
var event3 = new RuntimeLoadEvent(time, 2, 1, RuntimeLoadType.Dlopen, "lib.so", "/lib.so", null, true, null, null, null);
@@ -580,10 +611,54 @@ public class RuntimeEvidenceModelTests
{
// Verify each type can be used to create an event
var evt = new RuntimeLoadEvent(
DateTime.UtcNow, 1, 1, loadType,
RuntimeCaptureTestClock.BaseTime, 1, 1, loadType,
"test.so", "/test.so", null, true, null, null, null);
evt.LoadType.Should().Be(loadType);
}
}
}
public class CaptureDurationTimerTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task RunAsync_CallsStopWithProvidedToken()
{
using var captureCts = new CancellationTokenSource();
using var stopCts = new CancellationTokenSource();
var tcs = new TaskCompletionSource<CancellationToken>(TaskCreationOptions.RunContinuationsAsynchronously);
Task StopAsync(CancellationToken token)
{
tcs.TrySetResult(token);
return Task.CompletedTask;
}
var timerTask = CaptureDurationTimer.RunAsync(TimeSpan.Zero, StopAsync, captureCts.Token, stopCts.Token);
var observedToken = await tcs.Task.WaitAsync(TimeSpan.FromSeconds(1));
observedToken.Should().Be(stopCts.Token);
await timerTask;
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task RunAsync_CanceledCapture_DoesNotInvokeStop()
{
using var captureCts = new CancellationTokenSource();
using var stopCts = new CancellationTokenSource();
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
Task StopAsync(CancellationToken token)
{
tcs.TrySetResult();
return Task.CompletedTask;
}
captureCts.Cancel();
await CaptureDurationTimer.RunAsync(TimeSpan.FromMinutes(1), StopAsync, captureCts.Token, stopCts.Token);
tcs.Task.IsCompleted.Should().BeFalse();
}
}

View File

@@ -4,7 +4,7 @@
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
</PropertyGroup>
@@ -13,11 +13,6 @@
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
<!-- Exclude TimelineBuilderTests.cs as the Timeline namespace is in a different project -->
<ItemGroup>
<Compile Remove="RuntimeCapture\Timeline\TimelineBuilderTests.cs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
@@ -32,4 +27,4 @@
<PropertyGroup>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
</Project>
</Project>

View File

@@ -426,6 +426,20 @@ public class CallGraphDigestsTests
Assert.Equal("native:SSL_read", stableId);
}
[Fact]
public void ComputeResultDigest_IsDeterministic()
{
// Arrange
var result = CreateMinimalResult();
// Act
var digest1 = CallGraphDigests.ComputeResultDigest(result);
var digest2 = CallGraphDigests.ComputeResultDigest(result);
// Assert
Assert.Equal(digest1, digest2);
}
private static CallGraphSnapshot CreateMinimalSnapshot()
{
return new CallGraphSnapshot(
@@ -453,6 +467,24 @@ public class CallGraphDigestsTests
);
}
private static ReachabilityAnalysisResult CreateMinimalResult()
{
return new ReachabilityAnalysisResult(
ScanId: "test-scan-001",
GraphDigest: "sha256:graph",
Language: "native",
ComputedAt: DateTimeOffset.UtcNow,
ReachableNodeIds: ImmutableArray.Create("entry", "node-a"),
ReachableSinkIds: ImmutableArray.Create("sink-a"),
Paths: ImmutableArray.Create(
new ReachabilityPath(
EntrypointId: "entry",
SinkId: "sink-a",
NodeIds: ImmutableArray.Create("entry", "node-a", "sink-a"))),
ResultDigest: string.Empty
);
}
private static bool IsValidHex(string hex)
{
if (string.IsNullOrEmpty(hex))

View File

@@ -1,7 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) StellaOps
using System.Globalization;
using FluentAssertions;
using StellaOps.Determinism;
using StellaOps.Scanner.Explainability.Assumptions;
using StellaOps.Scanner.Reachability.Stack;
@@ -10,7 +12,7 @@ namespace StellaOps.Scanner.Reachability.Stack.Tests;
public class ReachabilityStackEvaluatorTests
{
private readonly ReachabilityStackEvaluator _evaluator = new();
private readonly ReachabilityStackEvaluator _evaluator = new(new SequentialGuidProvider());
private static VulnerableSymbol CreateTestSymbol() => new(
Name: "EVP_DecryptUpdate",
@@ -172,17 +174,19 @@ public class ReachabilityStackEvaluatorTests
var layer1 = CreateLayer1(isReachable: true, ConfidenceLevel.High);
var layer2 = CreateLayer2(isResolved: true, ConfidenceLevel.High);
var layer3 = CreateLayer3(isGated: false, GatingOutcome.NotGated, ConfidenceLevel.High);
var timeProvider = new FixedTimeProvider(new DateTimeOffset(2026, 1, 12, 9, 0, 0, TimeSpan.Zero));
var expectedId = new SequentialGuidProvider().NewGuid().ToString("N", CultureInfo.InvariantCulture);
var stack = _evaluator.Evaluate("finding-123", symbol, layer1, layer2, layer3);
var stack = _evaluator.Evaluate("finding-123", symbol, layer1, layer2, layer3, timeProvider);
stack.Id.Should().NotBeNullOrEmpty();
stack.Id.Should().Be(expectedId);
stack.FindingId.Should().Be("finding-123");
stack.Symbol.Should().Be(symbol);
stack.StaticCallGraph.Should().Be(layer1);
stack.BinaryResolution.Should().Be(layer2);
stack.RuntimeGating.Should().Be(layer3);
stack.Verdict.Should().Be(ReachabilityVerdict.Exploitable);
stack.AnalyzedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
stack.AnalyzedAt.Should().Be(timeProvider.GetUtcNow());
stack.Explanation.Should().NotBeNullOrEmpty();
}
@@ -422,4 +426,16 @@ public class ReachabilityStackEvaluatorTests
}
#endregion
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _fixedTime;
public FixedTimeProvider(DateTimeOffset fixedTime)
{
_fixedTime = fixedTime;
}
public override DateTimeOffset GetUtcNow() => _fixedTime;
}
}

View File

@@ -14,5 +14,6 @@
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Scanner.Reachability\StellaOps.Scanner.Reachability.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Determinism.Abstractions/StellaOps.Determinism.Abstractions.csproj" />
</ItemGroup>
</Project>
</Project>

View File

@@ -18,6 +18,7 @@
<ProjectReference Include="..\..\__Libraries\StellaOps.Scanner.Reachability\StellaOps.Scanner.Reachability.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Scanner.Cache\StellaOps.Scanner.Cache.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Canonical.Json/StellaOps.Canonical.Json.csproj" />
<ProjectReference Include="../../../BinaryIndex/__Libraries/StellaOps.BinaryIndex.Decompiler/StellaOps.BinaryIndex.Decompiler.csproj" />
<ProjectReference Include="../../../BinaryIndex/__Libraries/StellaOps.BinaryIndex.Ghidra/StellaOps.BinaryIndex.Ghidra.csproj" />
</ItemGroup>

View File

@@ -1,7 +1,11 @@
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using Org.BouncyCastle.Crypto.Generators;
using Org.BouncyCastle.Crypto.Parameters;
using Org.BouncyCastle.Security;
using StellaOps.Attestor.Envelope;
using StellaOps.Canonical.Json;
using StellaOps.Scanner.Reachability.Witnesses;
using Xunit;
@@ -137,6 +141,49 @@ public class WitnessDsseSignerTests
Assert.Equal(result1.PayloadBytes, result2.PayloadBytes);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void SignWitness_UsesCanonicalPayloadAndDssePae()
{
// Arrange
var witness = CreateTestWitness();
var (privateKey, publicKey) = CreateTestKeyPair();
var signingKey = EnvelopeKey.CreateEd25519Signer(privateKey, publicKey);
var signer = new WitnessDsseSigner(new EnvelopeSignatureService());
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Encoder = JavaScriptEncoder.Default
};
// Act
var signResult = signer.SignWitness(witness, signingKey, TestCancellationToken);
// Assert
Assert.True(signResult.IsSuccess, signResult.Error);
Assert.NotNull(signResult.Envelope);
Assert.NotNull(signResult.PayloadBytes);
var payloadBytes = signResult.PayloadBytes!;
var expectedPayload = CanonJson.Canonicalize(witness, options);
Assert.Equal(expectedPayload, payloadBytes);
var verifyKey = EnvelopeKey.CreateEd25519Verifier(publicKey);
var signatureBytes = Convert.FromBase64String(signResult.Envelope!.Signatures[0].Signature);
var envelopeSignature = new EnvelopeSignature(signingKey.KeyId, signingKey.AlgorithmId, signatureBytes);
var verifyResult = new EnvelopeSignatureService().VerifyDsse(
WitnessSchema.DssePayloadType,
payloadBytes,
envelopeSignature,
verifyKey,
TestCancellationToken);
Assert.True(verifyResult.IsSuccess);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void VerifyWitness_WithInvalidPayloadType_ReturnsFails()

View File

@@ -1,7 +1,11 @@
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using Org.BouncyCastle.Crypto.Generators;
using Org.BouncyCastle.Crypto.Parameters;
using Org.BouncyCastle.Security;
using StellaOps.Attestor.Envelope;
using StellaOps.Canonical.Json;
using StellaOps.Scanner.Reachability.Witnesses;
using StellaOps.TestKit;
using Xunit;
@@ -286,6 +290,49 @@ public sealed class SuppressionDsseSignerTests
verifyResult.Witness.Evidence.Unreachability?.UnreachableSymbol);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void SignWitness_UsesCanonicalPayloadAndDssePae()
{
// Arrange
var witness = CreateTestWitness();
var (privateKey, publicKey) = CreateTestKeyPair();
var signingKey = EnvelopeKey.CreateEd25519Signer(privateKey, publicKey);
var signer = new SuppressionDsseSigner(new EnvelopeSignatureService());
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Encoder = JavaScriptEncoder.Default
};
// Act
var signResult = signer.SignWitness(witness, signingKey, TestCancellationToken);
// Assert
Assert.True(signResult.IsSuccess, signResult.Error);
Assert.NotNull(signResult.Envelope);
Assert.NotNull(signResult.PayloadBytes);
var payloadBytes = signResult.PayloadBytes!;
var expectedPayload = CanonJson.Canonicalize(witness, options);
Assert.Equal(expectedPayload, payloadBytes);
var verifyKey = EnvelopeKey.CreateEd25519Verifier(publicKey);
var signatureBytes = Convert.FromBase64String(signResult.Envelope!.Signatures[0].Signature);
var envelopeSignature = new EnvelopeSignature(signingKey.KeyId, signingKey.AlgorithmId, signatureBytes);
var verifyResult = new EnvelopeSignatureService().VerifyDsse(
SuppressionWitnessSchema.DssePayloadType,
payloadBytes,
envelopeSignature,
verifyKey,
TestCancellationToken);
Assert.True(verifyResult.IsSuccess);
}
private sealed class FixedRandomGenerator : Org.BouncyCastle.Crypto.Prng.IRandomGenerator
{
private byte _value;

View File

@@ -4,6 +4,7 @@ using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Scanner.Sbomer.BuildXPlugin;
using StellaOps.Scanner.Sbomer.BuildXPlugin.Attestation;
using StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
@@ -17,7 +18,9 @@ public sealed class AttestorClientTests
public async Task SendPlaceholderAsync_PostsJsonPayload()
{
var handler = new RecordingHandler(new HttpResponseMessage(HttpStatusCode.Accepted));
using var httpClient = new HttpClient(handler);
using var provider = BuildHttpClientProvider(handler);
var factory = provider.GetRequiredService<IHttpClientFactory>();
using var httpClient = factory.CreateClient("attestor");
var client = new AttestorClient(httpClient);
var document = BuildDescriptorDocument();
@@ -43,7 +46,9 @@ public sealed class AttestorClientTests
{
Content = new StringContent("invalid")
});
using var httpClient = new HttpClient(handler);
using var provider = BuildHttpClientProvider(handler);
var factory = provider.GetRequiredService<IHttpClientFactory>();
using var httpClient = factory.CreateClient("attestor");
var client = new AttestorClient(httpClient);
var document = BuildDescriptorDocument();
@@ -54,12 +59,21 @@ public sealed class AttestorClientTests
private static DescriptorDocument BuildDescriptorDocument()
{
var createdAt = DateTimeOffset.Parse("2026-01-13T00:00:00Z");
var subject = new DescriptorSubject("application/vnd.oci.image.manifest.v1+json", "sha256:img");
var artifact = new DescriptorArtifact("application/vnd.cyclonedx+json; version=1.7", "sha256:sbom", 42, new System.Collections.Generic.Dictionary<string, string>());
var provenance = new DescriptorProvenance("pending", "sha256:dsse", "nonce", "https://attestor.example.com/api/v1/provenance", "https://slsa.dev/provenance/v1");
var generatorMetadata = new DescriptorGeneratorMetadata("generator", "1.0.0");
var metadata = new System.Collections.Generic.Dictionary<string, string>();
return new DescriptorDocument("schema", DateTimeOffset.UtcNow, generatorMetadata, subject, artifact, provenance, metadata);
return new DescriptorDocument("schema", createdAt, generatorMetadata, subject, artifact, provenance, metadata);
}
private static ServiceProvider BuildHttpClientProvider(HttpMessageHandler handler)
{
var services = new ServiceCollection();
services.AddHttpClient("attestor")
.ConfigurePrimaryHttpMessageHandler(() => handler);
return services.BuildServiceProvider();
}
private sealed class RecordingHandler : HttpMessageHandler

View File

@@ -74,6 +74,15 @@ public sealed class DescriptorCommandSurfaceTests
throw new FileNotFoundException($"BuildX plug-in assembly not found at '{pluginAssembly}'.");
}
EnsurePathWithinRoot(actualRepoRoot, pluginAssembly, "Plug-in assembly");
EnsurePathWithinRoot(actualRepoRoot, manifestDirectory, "Manifest directory");
EnsurePathWithinRoot(temp.Path, casRoot, "CAS root");
EnsurePathWithinRoot(temp.Path, sbomPath, "SBOM path");
EnsurePathWithinRoot(temp.Path, layerFragmentsPath, "Layer fragments path");
EnsurePathWithinRoot(temp.Path, entryTraceGraphPath, "Entry trace graph path");
EnsurePathWithinRoot(temp.Path, entryTraceNdjsonPath, "Entry trace NDJSON path");
EnsurePathWithinRoot(temp.Path, manifestOutputPath, "Manifest output path");
var psi = new ProcessStartInfo("dotnet")
{
RedirectStandardOutput = true,
@@ -107,10 +116,11 @@ public sealed class DescriptorCommandSurfaceTests
psi.ArgumentList.Add("--surface-manifest-output");
psi.ArgumentList.Add(manifestOutputPath);
ValidateProcessStartInfo(psi);
var process = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start BuildX plug-in process.");
var stdout = await process.StandardOutput.ReadToEndAsync();
var stderr = await process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync();
await process.WaitForExitAsync(TestContext.Current.CancellationToken);
Assert.True(process.ExitCode == 0, $"Descriptor command failed.\nSTDOUT: {stdout}\nSTDERR: {stderr}");
@@ -163,4 +173,33 @@ public sealed class DescriptorCommandSurfaceTests
var digest = hash.ComputeHash(bytes, HashAlgorithms.Sha256);
return $"sha256:{Convert.ToHexString(digest).ToLowerInvariant()}";
}
private static void ValidateProcessStartInfo(ProcessStartInfo psi)
{
if (!string.Equals(System.IO.Path.GetFileName(psi.FileName), "dotnet", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Only dotnet execution is permitted for plug-in tests.");
}
foreach (var argument in psi.ArgumentList)
{
if (argument.Contains('\n') || argument.Contains('\r'))
{
throw new InvalidOperationException("Process arguments must not contain newline characters.");
}
}
}
private static void EnsurePathWithinRoot(string root, string candidatePath, string label)
{
var normalizedRoot = System.IO.Path.GetFullPath(root)
.TrimEnd(System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar)
+ System.IO.Path.DirectorySeparatorChar;
var normalizedPath = System.IO.Path.GetFullPath(candidatePath);
if (!normalizedPath.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"{label} must stay under '{normalizedRoot}'.");
}
}
}

View File

@@ -1,8 +1,12 @@
using System;
using System.IO;
using System.Linq;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Canonical.Json;
using StellaOps.Cryptography;
using StellaOps.Scanner.Sbomer.BuildXPlugin.Surface;
using StellaOps.Scanner.Sbomer.BuildXPlugin.Tests.TestUtilities;
@@ -43,7 +47,8 @@ public sealed class SurfaceManifestWriterTests
EntryTraceNdjsonPath: ndjsonPath,
ManifestOutputPath: manifestOutputPath);
var writer = new SurfaceManifestWriter(TimeProvider.System, CryptoHashFactory.CreateDefault());
var timeProvider = new FixedTimeProvider(new DateTimeOffset(2026, 1, 13, 0, 0, 0, TimeSpan.Zero));
var writer = new SurfaceManifestWriter(timeProvider, CryptoHashFactory.CreateDefault());
var result = await writer.WriteAsync(options, TestContext.Current.CancellationToken);
Assert.NotNull(result);
@@ -67,6 +72,10 @@ public sealed class SurfaceManifestWriterTests
Assert.False(string.IsNullOrWhiteSpace(artifact.ManifestArtifact.Uri));
Assert.StartsWith("cas://scanner-artifacts/", artifact.ManifestArtifact.Uri, StringComparison.Ordinal);
}
var canonicalBytes = CanonJson.Canonicalize(result.Document, CreateManifestJsonOptions());
var manifestBytes = await File.ReadAllBytesAsync(result.ManifestPath, TestContext.Current.CancellationToken);
Assert.Equal(canonicalBytes, manifestBytes);
}
[Fact]
@@ -89,8 +98,60 @@ public sealed class SurfaceManifestWriterTests
EntryTraceNdjsonPath: null,
ManifestOutputPath: null);
var writer = new SurfaceManifestWriter(TimeProvider.System, CryptoHashFactory.CreateDefault());
var timeProvider = new FixedTimeProvider(new DateTimeOffset(2026, 1, 13, 0, 0, 0, TimeSpan.Zero));
var writer = new SurfaceManifestWriter(timeProvider, CryptoHashFactory.CreateDefault());
var result = await writer.WriteAsync(options, TestContext.Current.CancellationToken);
Assert.Null(result);
}
[Fact]
public async Task WriteAsync_DefaultsWorkerInstanceToComponent()
{
await using var temp = new TempDirectory();
var fragmentsPath = Path.Combine(temp.Path, "layer-fragments.json");
await File.WriteAllTextAsync(fragmentsPath, "[]");
var options = new SurfaceOptions(
CacheRoot: temp.Path,
CacheBucket: "scanner-artifacts",
RootPrefix: "scanner",
Tenant: "tenant-a",
Component: "scanner.buildx",
ComponentVersion: "1.2.3",
WorkerInstance: "",
Attempt: 1,
ImageDigest: "sha256:feedface",
ScanId: "scan-123",
LayerFragmentsPath: fragmentsPath,
EntryTraceGraphPath: null,
EntryTraceNdjsonPath: null,
ManifestOutputPath: null);
var timeProvider = new FixedTimeProvider(new DateTimeOffset(2026, 1, 13, 0, 0, 0, TimeSpan.Zero));
var writer = new SurfaceManifestWriter(timeProvider, CryptoHashFactory.CreateDefault());
var result = await writer.WriteAsync(options, TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.Equal("scanner.buildx", result!.Document.Source?.WorkerInstance);
}
private static JsonSerializerOptions CreateManifestJsonOptions()
=> new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false,
Encoder = JavaScriptEncoder.Default
};
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _utcNow;
public FixedTimeProvider(DateTimeOffset utcNow)
{
_utcNow = utcNow;
}
public override DateTimeOffset GetUtcNow() => _utcNow;
}
}

View File

@@ -1,16 +1,20 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Tests.TestUtilities;
internal sealed class TempDirectory : IDisposable, IAsyncDisposable
{
private static int _sequence;
public string Path { get; }
public TempDirectory()
{
Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"stellaops-buildx-{Guid.NewGuid():N}");
var suffix = Interlocked.Increment(ref _sequence);
Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"stellaops-buildx-{suffix:D4}");
Directory.CreateDirectory(Path);
}

View File

@@ -24,7 +24,8 @@ public sealed class ActionablesEndpointsTests
[Fact]
public async Task GetDeltaActionables_ValidDeltaId_ReturnsActionables()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/actionables/delta/delta-12345678");
@@ -40,7 +41,8 @@ public sealed class ActionablesEndpointsTests
[Fact]
public async Task GetDeltaActionables_SortedByPriority()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/actionables/delta/delta-12345678");
@@ -58,7 +60,8 @@ public sealed class ActionablesEndpointsTests
[Fact]
public async Task GetActionablesByPriority_Critical_FiltersCorrectly()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/actionables/delta/delta-12345678/by-priority/critical");
@@ -73,7 +76,8 @@ public sealed class ActionablesEndpointsTests
[Fact]
public async Task GetActionablesByPriority_InvalidPriority_ReturnsBadRequest()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/actionables/delta/delta-12345678/by-priority/invalid");
@@ -84,7 +88,8 @@ public sealed class ActionablesEndpointsTests
[Fact]
public async Task GetActionablesByType_Upgrade_FiltersCorrectly()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/actionables/delta/delta-12345678/by-type/upgrade");
@@ -99,7 +104,8 @@ public sealed class ActionablesEndpointsTests
[Fact]
public async Task GetActionablesByType_Vex_FiltersCorrectly()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/actionables/delta/delta-12345678/by-type/vex");
@@ -114,7 +120,8 @@ public sealed class ActionablesEndpointsTests
[Fact]
public async Task GetActionablesByType_InvalidType_ReturnsBadRequest()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/actionables/delta/delta-12345678/by-type/invalid");
@@ -125,7 +132,8 @@ public sealed class ActionablesEndpointsTests
[Fact]
public async Task GetDeltaActionables_IncludesEstimatedEffort()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/actionables/delta/delta-12345678");

View File

@@ -18,27 +18,27 @@ namespace StellaOps.Scanner.WebService.Tests;
[Trait("Category", "Integration")]
[Trait("Sprint", "3801.0001")]
public sealed class ApprovalEndpointsTests : IDisposable
public sealed class ApprovalEndpointsTests : IAsyncLifetime
{
private readonly TestSurfaceSecretsScope _secrets;
private readonly ScannerApplicationFactory _factory;
private readonly HttpClient _client;
private TestSurfaceSecretsScope _secrets = null!;
private ScannerApplicationFactory _factory = null!;
private HttpClient _client = null!;
public ApprovalEndpointsTests()
public async Task InitializeAsync()
{
_secrets = new TestSurfaceSecretsScope();
// Use default factory without auth overrides - same pattern as ManifestEndpointsTests
// The factory defaults to anonymous auth which allows all policy assertions
_factory = new ScannerApplicationFactory();
await _factory.InitializeAsync();
_client = _factory.CreateClient();
}
public void Dispose()
public async Task DisposeAsync()
{
_client.Dispose();
_factory.Dispose();
await _factory.DisposeAsync();
_secrets.Dispose();
}

View File

@@ -10,7 +10,7 @@ public sealed class AuthorizationTests
[Fact]
public async Task ApiRoutesRequireAuthenticationWhenAuthorityEnabled()
{
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
await using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
{
configuration["scanner:authority:enabled"] = "true";
configuration["scanner:authority:allowAnonymousFallback"] = "false";
@@ -19,6 +19,7 @@ public sealed class AuthorizationTests
configuration["scanner:authority:clientId"] = "scanner-web";
configuration["scanner:authority:clientSecret"] = "secret";
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/__auth-probe");

View File

@@ -25,7 +25,8 @@ public sealed class BaselineEndpointsTests
[Fact]
public async Task GetRecommendations_ValidDigest_ReturnsRecommendations()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/baselines/recommendations/sha256:artifact123", TestContext.Current.CancellationToken);
@@ -42,7 +43,8 @@ public sealed class BaselineEndpointsTests
[Fact]
public async Task GetRecommendations_WithEnvironment_FiltersCorrectly()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/baselines/recommendations/sha256:artifact123?environment=production", TestContext.Current.CancellationToken);
@@ -57,7 +59,8 @@ public sealed class BaselineEndpointsTests
[Fact]
public async Task GetRecommendations_IncludesRationale()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/baselines/recommendations/sha256:artifact123", TestContext.Current.CancellationToken);
@@ -76,7 +79,8 @@ public sealed class BaselineEndpointsTests
[Fact]
public async Task GetRationale_ValidDigests_ReturnsDetailedRationale()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/baselines/rationale/sha256:base123/sha256:head456");
@@ -95,7 +99,8 @@ public sealed class BaselineEndpointsTests
[Fact]
public async Task GetRationale_IncludesSelectionCriteria()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/baselines/rationale/sha256:baseline-base123/sha256:head456");
@@ -110,7 +115,8 @@ public sealed class BaselineEndpointsTests
[Fact]
public async Task GetRecommendations_DefaultIsFirst()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/baselines/recommendations/sha256:artifact123", TestContext.Current.CancellationToken);

View File

@@ -3,6 +3,8 @@
// Task: TRI-MASTER-0007 - Performance benchmark suite (TTFS)
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Columns;
using BenchmarkDotNet.Configs;
@@ -18,10 +20,10 @@ namespace StellaOps.Scanner.WebService.Tests.Benchmarks;
/// TTFS (Time-To-First-Signal) performance benchmarks for triage workflows.
/// Measures the latency from request initiation to first meaningful evidence display.
///
/// Target KPIs (from Triage Advisory §3):
/// Target KPIs (from Triage Advisory section 3):
/// - TTFS p95 < 1.5s (with 100ms RTT, 1% loss)
/// - Clicks-to-Closure median < 6 clicks
/// - Evidence Completeness 90%
/// - Evidence Completeness >= 90%
/// </summary>
[Config(typeof(TtfsBenchmarkConfig))]
[MemoryDiagnoser]
@@ -149,7 +151,7 @@ public sealed class TtfsPerformanceTests
{
// Arrange
var cache = new MockEvidenceCache();
var alertId = Guid.NewGuid().ToString();
const string alertId = "alert-test-0001";
// Act
var sw = Stopwatch.StartNew();
@@ -189,7 +191,7 @@ public sealed class TtfsPerformanceTests
{
// Arrange
var cache = new MockEvidenceCache();
var alertId = Guid.NewGuid().ToString();
const string alertId = "alert-test-0002";
var evidence = cache.GetEvidence(alertId);
// Act
@@ -235,7 +237,7 @@ public sealed class TtfsPerformanceTests
{
// Arrange
var cache = new MockEvidenceCache();
var alertId = Guid.NewGuid().ToString();
const string alertId = "alert-test-0003";
// Act
var evidence = cache.GetEvidence(alertId);
@@ -290,11 +292,11 @@ public sealed class MockAlertDataStore
_alerts = Enumerable.Range(0, alertCount)
.Select(i => new Alert
{
Id = Guid.NewGuid().ToString(),
Id = $"alert-{i:D6}",
CveId = $"CVE-2024-{10000 + i}",
Severity = _random.Next(0, 4) switch { 0 => "LOW", 1 => "MEDIUM", 2 => "HIGH", _ => "CRITICAL" },
Status = "open",
CreatedAt = DateTime.UtcNow.AddDays(-_random.Next(1, 30))
CreatedAt = TtfsTestClock.FixedUtc.AddDays(-_random.Next(1, 30))
})
.ToList();
}
@@ -303,9 +305,6 @@ public sealed class MockAlertDataStore
public AlertListResult GetAlerts(int page, int pageSize)
{
// Simulate DB query latency
Thread.Sleep(5);
var skip = (page - 1) * pageSize;
return new AlertListResult
{
@@ -318,14 +317,12 @@ public sealed class MockAlertDataStore
public Alert GetAlert(string id)
{
Thread.Sleep(2);
return _alerts.First(a => a.Id == id);
}
public DecisionResult RecordDecision(string alertId, DecisionRequest request)
{
Thread.Sleep(3);
return new DecisionResult { Success = true, DecisionId = Guid.NewGuid().ToString() };
return new DecisionResult { Success = true, DecisionId = $"decision-{alertId}" };
}
}
@@ -333,9 +330,6 @@ public sealed class MockEvidenceCache
{
public EvidenceBundle GetEvidence(string alertId)
{
// Simulate evidence retrieval latency
Thread.Sleep(10);
return new EvidenceBundle
{
AlertId = alertId,
@@ -357,7 +351,7 @@ public sealed class MockEvidenceCache
VexStatus = new VexStatusEvidence
{
Status = "under_investigation",
LastUpdated = DateTime.UtcNow.AddDays(-2)
LastUpdated = TtfsTestClock.FixedUtc.AddDays(-2)
},
GraphRevision = new GraphRevisionEvidence
{
@@ -374,16 +368,23 @@ public static class ReplayTokenGenerator
public static ReplayToken Generate(string alertId, EvidenceBundle evidence)
{
// Simulate token generation
var hash = $"{alertId}:{evidence.Reachability?.Tier}:{evidence.VexStatus?.Status}".GetHashCode();
var input = $"{alertId}:{evidence.Reachability?.Tier}:{evidence.VexStatus?.Status}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
var hex = Convert.ToHexString(hash).ToLowerInvariant();
return new ReplayToken
{
Token = $"replay_{Math.Abs(hash):x8}",
Token = $"replay_{hex[..8]}",
AlertId = alertId,
GeneratedAt = DateTime.UtcNow
GeneratedAt = TtfsTestClock.FixedUtc
};
}
}
internal static class TtfsTestClock
{
public static readonly DateTime FixedUtc = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
}
#endregion
#region Models

View File

@@ -14,10 +14,11 @@ public sealed class CallGraphEndpointsTests
public async Task SubmitCallGraphRequiresContentDigestHeader()
{
using var secrets = new TestSurfaceSecretsScope();
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
await using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
{
configuration["scanner:authority:enabled"] = "false";
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
@@ -34,10 +35,11 @@ public sealed class CallGraphEndpointsTests
public async Task SubmitCallGraphReturnsAcceptedAndDetectsDuplicates()
{
using var secrets = new TestSurfaceSecretsScope();
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
await using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
{
configuration["scanner:authority:enabled"] = "false";
});
await factory.InitializeAsync();
using var client = factory.CreateClient();

View File

@@ -9,6 +9,7 @@ using FluentAssertions;
using StellaOps.TestKit;
using StellaOps.TestKit.Fixtures;
using Xunit;
using Xunit.Abstractions;
namespace StellaOps.Scanner.WebService.Tests.Contract;
@@ -22,10 +23,12 @@ public sealed class ScannerOpenApiContractTests : IClassFixture<ScannerApplicati
{
private readonly ScannerApplicationFactory _factory;
private readonly string _snapshotPath;
private readonly ITestOutputHelper _output;
public ScannerOpenApiContractTests(ScannerApplicationFactory factory)
public ScannerOpenApiContractTests(ScannerApplicationFactory factory, ITestOutputHelper output)
{
_factory = factory;
_output = output;
_snapshotPath = Path.Combine(AppContext.BaseDirectory, "Contract", "Expected", "scanner-openapi.json");
}
@@ -79,10 +82,10 @@ public sealed class ScannerOpenApiContractTests : IClassFixture<ScannerApplicati
// Log non-breaking changes for awareness
if (changes.NonBreakingChanges.Count > 0)
{
Console.WriteLine("Non-breaking API changes detected:");
_output.WriteLine("Non-breaking API changes detected:");
foreach (var change in changes.NonBreakingChanges)
{
Console.WriteLine($" + {change}");
_output.WriteLine($" + {change}");
}
}
}

View File

@@ -25,7 +25,8 @@ public sealed class CounterfactualEndpointsTests
[Fact]
public async Task PostCompute_ValidRequest_ReturnsCounterfactuals()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var request = new CounterfactualRequestDto
@@ -52,7 +53,8 @@ public sealed class CounterfactualEndpointsTests
[Fact]
public async Task PostCompute_MissingFindingId_ReturnsBadRequest()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var request = new CounterfactualRequestDto
@@ -69,7 +71,8 @@ public sealed class CounterfactualEndpointsTests
[Fact]
public async Task PostCompute_IncludesVexPath()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var request = new CounterfactualRequestDto
@@ -90,7 +93,8 @@ public sealed class CounterfactualEndpointsTests
[Fact]
public async Task PostCompute_IncludesReachabilityPath()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var request = new CounterfactualRequestDto
@@ -111,7 +115,8 @@ public sealed class CounterfactualEndpointsTests
[Fact]
public async Task PostCompute_IncludesExceptionPath()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var request = new CounterfactualRequestDto
@@ -132,7 +137,8 @@ public sealed class CounterfactualEndpointsTests
[Fact]
public async Task PostCompute_WithMaxPaths_LimitsResults()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var request = new CounterfactualRequestDto
@@ -154,7 +160,8 @@ public sealed class CounterfactualEndpointsTests
[Fact]
public async Task GetForFinding_ValidId_ReturnsCounterfactuals()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/counterfactuals/finding/finding-123");
@@ -169,7 +176,8 @@ public sealed class CounterfactualEndpointsTests
[Fact]
public async Task GetScanSummary_ValidId_ReturnsSummary()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/counterfactuals/scan/scan-123/summary");
@@ -185,7 +193,8 @@ public sealed class CounterfactualEndpointsTests
[Fact]
public async Task GetScanSummary_IncludesPathCounts()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/counterfactuals/scan/scan-123/summary");
@@ -203,7 +212,8 @@ public sealed class CounterfactualEndpointsTests
[Fact]
public async Task PostCompute_PathsHaveConditions()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var request = new CounterfactualRequestDto

View File

@@ -25,7 +25,8 @@ public sealed class DeltaCompareEndpointsTests
[Fact]
public async Task PostCompare_ValidRequest_ReturnsComparisonResult()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var request = new DeltaCompareRequestDto
@@ -54,7 +55,8 @@ public sealed class DeltaCompareEndpointsTests
[Fact]
public async Task PostCompare_MissingBaseDigest_ReturnsBadRequest()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var request = new DeltaCompareRequestDto
@@ -71,7 +73,8 @@ public sealed class DeltaCompareEndpointsTests
[Fact]
public async Task PostCompare_MissingTargetDigest_ReturnsBadRequest()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var request = new DeltaCompareRequestDto
@@ -88,7 +91,8 @@ public sealed class DeltaCompareEndpointsTests
[Fact]
public async Task GetQuickDiff_ValidDigests_ReturnsQuickSummary()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/delta/quick?baseDigest=sha256:base123&targetDigest=sha256:target456");
@@ -106,7 +110,8 @@ public sealed class DeltaCompareEndpointsTests
[Fact]
public async Task GetQuickDiff_MissingDigest_ReturnsBadRequest()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/delta/quick?baseDigest=sha256:base123");
@@ -117,7 +122,8 @@ public sealed class DeltaCompareEndpointsTests
[Fact]
public async Task GetComparison_NotFound_ReturnsNotFound()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/delta/nonexistent-id");
@@ -128,7 +134,8 @@ public sealed class DeltaCompareEndpointsTests
[Fact]
public async Task PostCompare_DeterministicComparisonId_SameInputsSameId()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var request = new DeltaCompareRequestDto

View File

@@ -17,14 +17,14 @@ namespace StellaOps.Scanner.WebService.Tests;
[Trait("Category", "Integration")]
[Trait("Sprint", "3410.0002")]
public sealed class EpssEndpointsTests : IDisposable
public sealed class EpssEndpointsTests : IAsyncLifetime
{
private readonly TestSurfaceSecretsScope _secrets;
private readonly InMemoryEpssProvider _epssProvider;
private readonly ScannerApplicationFactory _factory;
private readonly HttpClient _client;
private TestSurfaceSecretsScope _secrets = null!;
private InMemoryEpssProvider _epssProvider = null!;
private ScannerApplicationFactory _factory = null!;
private HttpClient _client = null!;
public EpssEndpointsTests()
public async Task InitializeAsync()
{
_secrets = new TestSurfaceSecretsScope();
_epssProvider = new InMemoryEpssProvider();
@@ -37,13 +37,14 @@ public sealed class EpssEndpointsTests : IDisposable
services.AddSingleton<IEpssProvider>(_epssProvider);
});
await _factory.InitializeAsync();
_client = _factory.CreateClient();
}
public void Dispose()
public async Task DisposeAsync()
{
_client.Dispose();
_factory.Dispose();
await _factory.DisposeAsync();
_secrets.Dispose();
}

View File

@@ -25,10 +25,11 @@ public sealed class EvidenceEndpointsTests
public async Task GetEvidence_ReturnsBadRequest_WhenScanIdInvalid()
{
using var secrets = new TestSurfaceSecretsScope();
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
await using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
{
configuration["scanner:authority:enabled"] = "false";
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
// Empty scan ID - route doesn't match
@@ -42,10 +43,11 @@ public sealed class EvidenceEndpointsTests
public async Task GetEvidence_ReturnsNotFound_WhenScanDoesNotExist()
{
using var secrets = new TestSurfaceSecretsScope();
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
await using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
{
configuration["scanner:authority:enabled"] = "false";
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync(
@@ -60,10 +62,11 @@ public sealed class EvidenceEndpointsTests
{
// When no finding ID is provided, the route matches the list endpoint
using var secrets = new TestSurfaceSecretsScope();
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
await using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
{
configuration["scanner:authority:enabled"] = "false";
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
// Create a scan first
@@ -81,10 +84,11 @@ public sealed class EvidenceEndpointsTests
public async Task ListEvidence_ReturnsEmptyList_WhenNoFindings()
{
using var secrets = new TestSurfaceSecretsScope();
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
await using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
{
configuration["scanner:authority:enabled"] = "false";
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
var scanId = await CreateScanAsync(client);
@@ -106,10 +110,11 @@ public sealed class EvidenceEndpointsTests
// The current implementation returns empty list for non-existent scans
// because the reachability service returns empty findings for unknown scans
using var secrets = new TestSurfaceSecretsScope();
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
await using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
{
configuration["scanner:authority:enabled"] = "false";
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/scans/nonexistent-scan/evidence");

View File

@@ -21,10 +21,11 @@ public sealed class FindingsEvidenceControllerTests
public async Task GetEvidence_ReturnsNotFound_WhenFindingMissing()
{
using var secrets = new TestSurfaceSecretsScope();
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
await using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
{
configuration["scanner:authority:enabled"] = "false";
});
await factory.InitializeAsync();
await EnsureTriageSchemaAsync(factory);
using var client = factory.CreateClient();
@@ -38,10 +39,11 @@ public sealed class FindingsEvidenceControllerTests
public async Task GetEvidence_ReturnsForbidden_WhenRawScopeMissing()
{
using var secrets = new TestSurfaceSecretsScope();
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
await using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
{
configuration["scanner:authority:enabled"] = "false";
});
await factory.InitializeAsync();
await EnsureTriageSchemaAsync(factory);
using var client = factory.CreateClient();
@@ -55,10 +57,11 @@ public sealed class FindingsEvidenceControllerTests
public async Task GetEvidence_ReturnsEvidence_WhenFindingExists()
{
using var secrets = new TestSurfaceSecretsScope();
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
await using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
{
configuration["scanner:authority:enabled"] = "false";
});
await factory.InitializeAsync();
await EnsureTriageSchemaAsync(factory);
using var client = factory.CreateClient();
@@ -79,10 +82,11 @@ public sealed class FindingsEvidenceControllerTests
public async Task BatchEvidence_ReturnsBadRequest_WhenTooMany()
{
using var secrets = new TestSurfaceSecretsScope();
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
await using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
{
configuration["scanner:authority:enabled"] = "false";
});
await factory.InitializeAsync();
await EnsureTriageSchemaAsync(factory);
using var client = factory.CreateClient();
@@ -101,10 +105,11 @@ public sealed class FindingsEvidenceControllerTests
public async Task BatchEvidence_ReturnsResults_ForExistingFindings()
{
using var secrets = new TestSurfaceSecretsScope();
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
await using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
{
configuration["scanner:authority:enabled"] = "false";
});
await factory.InitializeAsync();
await EnsureTriageSchemaAsync(factory);
using var client = factory.CreateClient();

View File

@@ -10,7 +10,8 @@ public sealed class HealthEndpointsTests
[Fact]
public async Task HealthAndReadyEndpointsRespond()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var healthResponse = await client.GetAsync("/healthz");

View File

@@ -11,6 +11,7 @@ using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Determinism;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Domain;
using StellaOps.Scanner.WebService.Services;
@@ -28,6 +29,7 @@ public sealed class HumanApprovalAttestationServiceTests
{
private readonly FakeTimeProvider _timeProvider;
private readonly HumanApprovalAttestationService _service;
private readonly IGuidProvider _guidProvider = new SequentialGuidProvider();
public HumanApprovalAttestationServiceTests()
{
@@ -35,6 +37,7 @@ public sealed class HumanApprovalAttestationServiceTests
_service = new HumanApprovalAttestationService(
NullLogger<HumanApprovalAttestationService>.Instance,
MsOptions.Options.Create(new HumanApprovalAttestationOptions { DefaultApprovalTtlDays = 30 }),
_guidProvider,
_timeProvider);
}
@@ -319,7 +322,7 @@ public sealed class HumanApprovalAttestationServiceTests
public async Task GetAttestationAsync_NonExistentAttestation_ReturnsNull()
{
// Act
var result = await _service.GetAttestationAsync(ScanId.New(), "nonexistent");
var result = await _service.GetAttestationAsync(ScanId.New(_guidProvider), "nonexistent");
// Assert
result.Should().BeNull();
@@ -338,6 +341,7 @@ public sealed class HumanApprovalAttestationServiceTests
var service = new HumanApprovalAttestationService(
NullLogger<HumanApprovalAttestationService>.Instance,
MsOptions.Options.Create(new HumanApprovalAttestationOptions()),
_guidProvider,
expiredProvider);
// Need to create in this service instance for the store to be shared
@@ -354,7 +358,7 @@ public sealed class HumanApprovalAttestationServiceTests
await _service.CreateAttestationAsync(input);
// Act
var result = await _service.GetAttestationAsync(ScanId.New(), input.FindingId);
var result = await _service.GetAttestationAsync(ScanId.New(_guidProvider), input.FindingId);
// Assert
result.Should().BeNull();
@@ -384,7 +388,7 @@ public sealed class HumanApprovalAttestationServiceTests
public async Task GetApprovalsByScanAsync_MultipleApprovals_ReturnsAll()
{
// Arrange
var scanId = ScanId.New();
var scanId = ScanId.New(_guidProvider);
var input1 = CreateValidInput() with { ScanId = scanId, FindingId = "CVE-2024-0001" };
var input2 = CreateValidInput() with { ScanId = scanId, FindingId = "CVE-2024-0002" };
@@ -403,7 +407,7 @@ public sealed class HumanApprovalAttestationServiceTests
public async Task GetApprovalsByScanAsync_NoApprovals_ReturnsEmptyList()
{
// Act
var results = await _service.GetApprovalsByScanAsync(ScanId.New());
var results = await _service.GetApprovalsByScanAsync(ScanId.New(_guidProvider));
// Assert
results.Should().BeEmpty();
@@ -414,7 +418,7 @@ public sealed class HumanApprovalAttestationServiceTests
public async Task GetApprovalsByScanAsync_ExcludesRevokedApprovals()
{
// Arrange
var scanId = ScanId.New();
var scanId = ScanId.New(_guidProvider);
var input = CreateValidInput() with { ScanId = scanId };
await _service.CreateAttestationAsync(input);
await _service.RevokeApprovalAsync(scanId, input.FindingId, "admin", "Testing");
@@ -455,7 +459,7 @@ public sealed class HumanApprovalAttestationServiceTests
{
// Act
var result = await _service.RevokeApprovalAsync(
ScanId.New(),
ScanId.New(_guidProvider),
"nonexistent",
"admin@example.com",
"Testing");
@@ -541,7 +545,7 @@ public sealed class HumanApprovalAttestationServiceTests
{
return new HumanApprovalAttestationInput
{
ScanId = ScanId.New(),
ScanId = ScanId.New(_guidProvider),
FindingId = "CVE-2024-12345",
Decision = ApprovalDecision.AcceptRisk,
ApproverUserId = "security-lead@example.com",

View File

@@ -24,20 +24,25 @@ public sealed class IdempotencyMiddlewareTests
private const string IdempotencyKeyHeader = "X-Idempotency-Key";
private const string IdempotencyCachedHeader = "X-Idempotency-Cached";
private static ScannerApplicationFactory CreateFactory() =>
new ScannerApplicationFactory().WithOverrides(
private static async Task<ScannerApplicationFactory> CreateFactoryAsync()
{
var factory = new ScannerApplicationFactory().WithOverrides(
configureConfiguration: config =>
{
config["Scanner:Idempotency:Enabled"] = "true";
config["Scanner:Idempotency:Window"] = "24:00:00";
});
await factory.InitializeAsync();
return factory;
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task PostRequest_WithContentDigest_ReturnsIdempotencyKey()
{
// Arrange
await using var factory = CreateFactory();
await using var factory = await CreateFactoryAsync();
using var client = factory.CreateClient();
var content = new StringContent("""{"test":"data"}""", Encoding.UTF8, "application/json");
@@ -58,7 +63,7 @@ public sealed class IdempotencyMiddlewareTests
public async Task DuplicateRequest_WithSameContentDigest_ReturnsCachedResponse()
{
// Arrange
await using var factory = CreateFactory();
await using var factory = await CreateFactoryAsync();
using var client = factory.CreateClient();
var requestBody = """{"artifactDigest":"sha256:test123"}""";
@@ -84,7 +89,7 @@ public sealed class IdempotencyMiddlewareTests
public async Task DifferentRequests_WithDifferentDigests_AreProcessedSeparately()
{
// Arrange
await using var factory = CreateFactory();
await using var factory = await CreateFactoryAsync();
using var client = factory.CreateClient();
var requestBody1 = """{"artifactDigest":"sha256:unique1"}""";
@@ -110,7 +115,7 @@ public sealed class IdempotencyMiddlewareTests
public async Task GetRequest_BypassesIdempotencyMiddleware()
{
// Arrange
await using var factory = CreateFactory();
await using var factory = await CreateFactoryAsync();
using var client = factory.CreateClient();
// Act
@@ -125,7 +130,7 @@ public sealed class IdempotencyMiddlewareTests
public async Task PostRequest_WithoutContentDigest_ComputesDigest()
{
// Arrange
await using var factory = CreateFactory();
await using var factory = await CreateFactoryAsync();
using var client = factory.CreateClient();
var content = new StringContent("""{"test":"nodigest"}""", Encoding.UTF8, "application/json");

View File

@@ -27,7 +27,7 @@ public sealed class EvidenceIntegrationTests : IAsyncLifetime
private ScannerApplicationFactory _factory = null!;
private HttpClient _client = null!;
public ValueTask InitializeAsync()
public async ValueTask InitializeAsync()
{
_factory = new ScannerApplicationFactory().WithOverrides(
configuration =>
@@ -41,8 +41,8 @@ public sealed class EvidenceIntegrationTests : IAsyncLifetime
services.AddSingleton<IArtifactObjectStore>(new InMemoryArtifactObjectStore());
});
await _factory.InitializeAsync();
_client = _factory.CreateClient();
return ValueTask.CompletedTask;
}
public async ValueTask DisposeAsync()

View File

@@ -29,7 +29,7 @@ public sealed class PedigreeIntegrationTests : IAsyncLifetime
private ScannerApplicationFactory _factory = null!;
private HttpClient _client = null!;
public ValueTask InitializeAsync()
public async ValueTask InitializeAsync()
{
_factory = new ScannerApplicationFactory().WithOverrides(
configuration =>
@@ -48,8 +48,8 @@ public sealed class PedigreeIntegrationTests : IAsyncLifetime
services.AddSingleton<IPedigreeDataProvider>(new MockPedigreeDataProvider());
});
await _factory.InitializeAsync();
_client = _factory.CreateClient();
return ValueTask.CompletedTask;
}
public async ValueTask DisposeAsync()
@@ -412,7 +412,7 @@ public sealed class PedigreeIntegrationTests : IAsyncLifetime
return Task.FromResult<PedigreeData?>(null);
}
public Task<IReadOnlyDictionary<string, PedigreeData>> GetPedigreesBatchAsync(
public async Task<IReadOnlyDictionary<string, PedigreeData>> GetPedigreesBatchAsync(
IEnumerable<string> purls,
CancellationToken cancellationToken = default)
{
@@ -420,14 +420,14 @@ public sealed class PedigreeIntegrationTests : IAsyncLifetime
foreach (var purl in purls)
{
var data = GetPedigreeAsync(purl, cancellationToken).Result;
var data = await GetPedigreeAsync(purl, cancellationToken);
if (data != null)
{
results[purl] = data;
}
}
return Task.FromResult<IReadOnlyDictionary<string, PedigreeData>>(results);
return results;
}
}

View File

@@ -2,7 +2,7 @@
// ProofReplayWorkflowTests.cs
// Sprint: SPRINT_3500_0002_0003_proof_replay_api
// Task: T7 - Integration Tests for Proof Replay Workflow
// Description: End-to-end tests for scan manifest proofs workflow
// Description: End-to-end tests for scan -> manifest -> proofs workflow
// -----------------------------------------------------------------------------
using System.Net;
@@ -20,7 +20,7 @@ namespace StellaOps.Scanner.WebService.Tests.Integration;
/// <summary>
/// Integration tests for the complete proof replay workflow:
/// Submit scan Get manifest Replay score Get proofs.
/// Submit scan -> Get manifest -> Replay score -> Get proofs.
/// </summary>
public sealed class ProofReplayWorkflowTests
{
@@ -31,27 +31,28 @@ public sealed class ProofReplayWorkflowTests
{
// Arrange
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var scope = factory.Services.CreateScope();
var manifestRepository = scope.ServiceProvider.GetRequiredService<IScanManifestRepository>();
var bundleRepository = scope.ServiceProvider.GetRequiredService<IProofBundleRepository>();
var scanId = Guid.NewGuid();
var scanId = CreateGuid(1);
// Seed test data for the scan
var manifestRow = new ScanManifestRow
{
ManifestId = Guid.NewGuid(),
ManifestId = CreateGuid(2),
ScanId = scanId,
ManifestHash = "sha256:workflow-manifest",
SbomHash = "sha256:workflow-sbom",
RulesHash = "sha256:workflow-rules",
FeedHash = "sha256:workflow-feed",
PolicyHash = "sha256:workflow-policy",
ScanStartedAt = DateTimeOffset.UtcNow.AddMinutes(-10),
ScanCompletedAt = DateTimeOffset.UtcNow,
ScanStartedAt = FixedNow.AddMinutes(-10),
ScanCompletedAt = FixedNow,
ManifestContent = """{"version":"1.0","test":"workflow"}""",
ScannerVersion = "1.0.0-integration",
CreatedAt = DateTimeOffset.UtcNow
CreatedAt = FixedNow
};
await manifestRepository.SaveAsync(manifestRow);
@@ -62,7 +63,7 @@ public sealed class ProofReplayWorkflowTests
RootHash = "sha256:workflow-root",
BundleType = "standard",
BundleHash = "sha256:workflow-bundle",
CreatedAt = DateTimeOffset.UtcNow
CreatedAt = FixedNow
};
await bundleRepository.SaveAsync(proofBundle);
@@ -103,11 +104,12 @@ public sealed class ProofReplayWorkflowTests
{
// Arrange
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var scope = factory.Services.CreateScope();
var manifestRepository = scope.ServiceProvider.GetRequiredService<IScanManifestRepository>();
var bundleRepository = scope.ServiceProvider.GetRequiredService<IProofBundleRepository>();
var scanId = Guid.NewGuid();
var scanId = CreateGuid(3);
// Create two proof bundles with the same content should produce same hash
var manifestContent = """{"version":"1.0","inputs":{"deterministic":true,"seed":"test-seed-123"}}""";
@@ -115,18 +117,18 @@ public sealed class ProofReplayWorkflowTests
var manifestRow = new ScanManifestRow
{
ManifestId = Guid.NewGuid(),
ManifestId = CreateGuid(4),
ScanId = scanId,
ManifestHash = $"sha256:{expectedHash}",
SbomHash = "sha256:deterministic-sbom",
RulesHash = "sha256:deterministic-rules",
FeedHash = "sha256:deterministic-feed",
PolicyHash = "sha256:deterministic-policy",
ScanStartedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
ScanCompletedAt = DateTimeOffset.UtcNow,
ScanStartedAt = FixedNow.AddMinutes(-5),
ScanCompletedAt = FixedNow,
ManifestContent = manifestContent,
ScannerVersion = "1.0.0-deterministic",
CreatedAt = DateTimeOffset.UtcNow
CreatedAt = FixedNow
};
await manifestRepository.SaveAsync(manifestRow);
@@ -161,6 +163,7 @@ public sealed class ProofReplayWorkflowTests
{
config["Scanner:Idempotency:Enabled"] = "true";
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
var requestBody = """{"artifactDigest":"sha256:idempotent-test-123"}""";
@@ -195,8 +198,9 @@ public sealed class ProofReplayWorkflowTests
config["scanner:rateLimiting:manifestPermitLimit"] = "2";
config["scanner:rateLimiting:manifestWindow"] = "00:00:30";
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
var scanId = Guid.NewGuid();
var scanId = CreateGuid(5);
// Act - Send requests exceeding the limit
var responses = new List<HttpResponseMessage>();
@@ -226,8 +230,9 @@ public sealed class ProofReplayWorkflowTests
config["scanner:rateLimiting:manifestPermitLimit"] = "1";
config["scanner:rateLimiting:manifestWindow"] = "01:00:00";
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
var scanId = Guid.NewGuid();
var scanId = CreateGuid(6);
// First request
await client.GetAsync($"/api/v1/scans/{scanId}/manifest");
@@ -246,6 +251,11 @@ public sealed class ProofReplayWorkflowTests
#endregion
private static readonly DateTimeOffset FixedNow = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
private static Guid CreateGuid(int seed)
=> new($"00000000-0000-0000-0000-{seed:D12}");
#region Helper Methods
private static string ComputeSha256(string content)

View File

@@ -31,7 +31,7 @@ public sealed class ValidationIntegrationTests : IAsyncLifetime
private ScannerApplicationFactory _factory = null!;
private HttpClient _client = null!;
public ValueTask InitializeAsync()
public async ValueTask InitializeAsync()
{
_factory = new ScannerApplicationFactory().WithOverrides(
configuration =>
@@ -51,8 +51,8 @@ public sealed class ValidationIntegrationTests : IAsyncLifetime
services.AddSingleton<ISbomValidator>(new MockSbomValidator());
});
await _factory.InitializeAsync();
_client = _factory.CreateClient();
return ValueTask.CompletedTask;
}
public async ValueTask DisposeAsync()
@@ -159,7 +159,7 @@ public sealed class ValidationIntegrationTests : IAsyncLifetime
sbomBytes,
SbomFormat.CycloneDxJson,
validationOptions,
CancellationToken.None);
TestContext.Current.CancellationToken);
// Assert
Assert.NotNull(result);
@@ -189,7 +189,7 @@ public sealed class ValidationIntegrationTests : IAsyncLifetime
spdxBytes,
SbomFormat.Spdx3JsonLd,
validationOptions,
CancellationToken.None);
TestContext.Current.CancellationToken);
// Assert
Assert.NotNull(result);
@@ -207,7 +207,7 @@ public sealed class ValidationIntegrationTests : IAsyncLifetime
Assert.True(mockValidator.SupportsFormat(SbomFormat.Spdx3JsonLd));
Assert.True(mockValidator.SupportsFormat(SbomFormat.Unknown));
var info = await mockValidator.GetInfoAsync(CancellationToken.None);
var info = await mockValidator.GetInfoAsync(TestContext.Current.CancellationToken);
Assert.True(info.IsAvailable);
Assert.Contains(SbomFormat.CycloneDxJson, info.SupportedFormats);
}
@@ -229,7 +229,7 @@ public sealed class ValidationIntegrationTests : IAsyncLifetime
sbomBytes,
SbomFormat.CycloneDxJson,
validationOptions,
CancellationToken.None);
TestContext.Current.CancellationToken);
// Assert
Assert.NotNull(result);
@@ -254,7 +254,7 @@ public sealed class ValidationIntegrationTests : IAsyncLifetime
sbomBytes,
SbomFormat.CycloneDxJson,
validationOptions,
CancellationToken.None);
TestContext.Current.CancellationToken);
// Assert
Assert.NotNull(result);
@@ -310,11 +310,11 @@ public sealed class ValidationIntegrationTests : IAsyncLifetime
Reference = "example.com/validation-test:1.0",
Digest = "sha256:validation123"
}
});
}, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<ScanSubmitResponse>();
var payload = await response.Content.ReadFromJsonAsync<ScanSubmitResponse>(TestContext.Current.CancellationToken);
Assert.NotNull(payload);
return payload!.ScanId;
}

View File

@@ -34,11 +34,12 @@ public sealed class LayerSbomEndpointsTests
using var secrets = new TestSurfaceSecretsScope();
var mockService = new InMemoryLayerSbomService();
using var factory = new ScannerApplicationFactory()
await using var factory = new ScannerApplicationFactory()
.WithOverrides(configureServices: services =>
{
services.AddSingleton<ILayerSbomService>(mockService);
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
// Submit scan via HTTP POST to get scan ID
@@ -62,11 +63,12 @@ public sealed class LayerSbomEndpointsTests
public async Task ListLayers_WhenScanNotFound_Returns404()
{
using var secrets = new TestSurfaceSecretsScope();
using var factory = new ScannerApplicationFactory()
await using var factory = new ScannerApplicationFactory()
.WithOverrides(configureServices: services =>
{
services.AddSingleton<ILayerSbomService, InMemoryLayerSbomService>();
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync($"{BasePath}/scan-not-found/layers");
@@ -81,11 +83,12 @@ public sealed class LayerSbomEndpointsTests
using var secrets = new TestSurfaceSecretsScope();
var mockService = new InMemoryLayerSbomService();
using var factory = new ScannerApplicationFactory()
await using var factory = new ScannerApplicationFactory()
.WithOverrides(configureServices: services =>
{
services.AddSingleton<ILayerSbomService>(mockService);
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
var scanId = await SubmitScanAsync(client, imageDigest);
@@ -121,11 +124,12 @@ public sealed class LayerSbomEndpointsTests
using var secrets = new TestSurfaceSecretsScope();
var mockService = new InMemoryLayerSbomService();
using var factory = new ScannerApplicationFactory()
await using var factory = new ScannerApplicationFactory()
.WithOverrides(configureServices: services =>
{
services.AddSingleton<ILayerSbomService>(mockService);
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
var scanId = await SubmitScanAsync(client, imageDigest);
@@ -149,11 +153,12 @@ public sealed class LayerSbomEndpointsTests
using var secrets = new TestSurfaceSecretsScope();
var mockService = new InMemoryLayerSbomService();
using var factory = new ScannerApplicationFactory()
await using var factory = new ScannerApplicationFactory()
.WithOverrides(configureServices: services =>
{
services.AddSingleton<ILayerSbomService>(mockService);
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
var scanId = await SubmitScanAsync(client, imageDigest);
@@ -177,11 +182,12 @@ public sealed class LayerSbomEndpointsTests
using var secrets = new TestSurfaceSecretsScope();
var mockService = new InMemoryLayerSbomService();
using var factory = new ScannerApplicationFactory()
await using var factory = new ScannerApplicationFactory()
.WithOverrides(configureServices: services =>
{
services.AddSingleton<ILayerSbomService>(mockService);
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
var scanId = await SubmitScanAsync(client, imageDigest);
@@ -201,11 +207,12 @@ public sealed class LayerSbomEndpointsTests
public async Task GetLayerSbom_WhenScanNotFound_Returns404()
{
using var secrets = new TestSurfaceSecretsScope();
using var factory = new ScannerApplicationFactory()
await using var factory = new ScannerApplicationFactory()
.WithOverrides(configureServices: services =>
{
services.AddSingleton<ILayerSbomService, InMemoryLayerSbomService>();
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync($"{BasePath}/scan-not-found/layers/sha256:layer123/sbom");
@@ -220,11 +227,12 @@ public sealed class LayerSbomEndpointsTests
using var secrets = new TestSurfaceSecretsScope();
var mockService = new InMemoryLayerSbomService();
using var factory = new ScannerApplicationFactory()
await using var factory = new ScannerApplicationFactory()
.WithOverrides(configureServices: services =>
{
services.AddSingleton<ILayerSbomService>(mockService);
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
var scanId = await SubmitScanAsync(client, imageDigest);
@@ -246,11 +254,12 @@ public sealed class LayerSbomEndpointsTests
using var secrets = new TestSurfaceSecretsScope();
var mockService = new InMemoryLayerSbomService();
using var factory = new ScannerApplicationFactory()
await using var factory = new ScannerApplicationFactory()
.WithOverrides(configureServices: services =>
{
services.AddSingleton<ILayerSbomService>(mockService);
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
var scanId = await SubmitScanAsync(client, imageDigest);
@@ -273,11 +282,12 @@ public sealed class LayerSbomEndpointsTests
public async Task GetCompositionRecipe_WhenScanNotFound_Returns404()
{
using var secrets = new TestSurfaceSecretsScope();
using var factory = new ScannerApplicationFactory()
await using var factory = new ScannerApplicationFactory()
.WithOverrides(configureServices: services =>
{
services.AddSingleton<ILayerSbomService, InMemoryLayerSbomService>();
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync($"{BasePath}/scan-not-found/composition-recipe");
@@ -292,11 +302,12 @@ public sealed class LayerSbomEndpointsTests
using var secrets = new TestSurfaceSecretsScope();
var mockService = new InMemoryLayerSbomService();
using var factory = new ScannerApplicationFactory()
await using var factory = new ScannerApplicationFactory()
.WithOverrides(configureServices: services =>
{
services.AddSingleton<ILayerSbomService>(mockService);
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
var scanId = await SubmitScanAsync(client, imageDigest);
@@ -326,12 +337,13 @@ public sealed class LayerSbomEndpointsTests
Errors = ImmutableArray<string>.Empty,
});
using var factory = new ScannerApplicationFactory()
await using var factory = new ScannerApplicationFactory()
.WithOverrides(configureServices: services =>
{
services.RemoveAll<ILayerSbomService>();
services.AddSingleton<ILayerSbomService>(mockService);
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.PostAsync($"{BasePath}/{scanId}/composition-recipe/verify", null);
@@ -359,12 +371,13 @@ public sealed class LayerSbomEndpointsTests
Errors = ImmutableArray.Create("Merkle root mismatch: expected sha256:abc, got sha256:def"),
});
using var factory = new ScannerApplicationFactory()
await using var factory = new ScannerApplicationFactory()
.WithOverrides(configureServices: services =>
{
services.RemoveAll<ILayerSbomService>();
services.AddSingleton<ILayerSbomService>(mockService);
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.PostAsync($"{BasePath}/{scanId}/composition-recipe/verify", null);
@@ -382,12 +395,13 @@ public sealed class LayerSbomEndpointsTests
[Fact]
public async Task VerifyCompositionRecipe_WhenScanNotFound_Returns404()
{
using var factory = new ScannerApplicationFactory()
await using var factory = new ScannerApplicationFactory()
.WithOverrides(configureServices: services =>
{
services.RemoveAll<ILayerSbomService>();
services.AddSingleton<ILayerSbomService, InMemoryLayerSbomService>();
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.PostAsync($"{BasePath}/scan-not-found/composition-recipe/verify", null);

View File

@@ -35,26 +35,27 @@ public sealed class ManifestEndpointsTests
{
// Arrange
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
using var scope = factory.Services.CreateScope();
var manifestRepository = scope.ServiceProvider.GetRequiredService<IScanManifestRepository>();
var scanId = Guid.NewGuid();
var scanId = CreateGuid(1);
var manifestRow = new ScanManifestRow
{
ManifestId = Guid.NewGuid(),
ManifestId = CreateGuid(2),
ScanId = scanId,
ManifestHash = "sha256:manifest123",
SbomHash = "sha256:sbom123",
RulesHash = "sha256:rules123",
FeedHash = "sha256:feed123",
PolicyHash = "sha256:policy123",
ScanStartedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
ScanCompletedAt = DateTimeOffset.UtcNow,
ScanStartedAt = FixedNow.AddMinutes(-5),
ScanCompletedAt = FixedNow,
ManifestContent = """{"version":"1.0","inputs":{"sbomHash":"sha256:sbom123"}}""",
ScannerVersion = "1.0.0-test",
CreatedAt = DateTimeOffset.UtcNow
CreatedAt = FixedNow
};
await manifestRepository.SaveAsync(manifestRow, TestContext.Current.CancellationToken);
@@ -82,8 +83,9 @@ public sealed class ManifestEndpointsTests
{
// Arrange
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var scanId = Guid.NewGuid();
var scanId = CreateGuid(3);
// Act
var response = await client.GetAsync($"/api/v1/scans/{scanId}/manifest", TestContext.Current.CancellationToken);
@@ -98,6 +100,7 @@ public sealed class ManifestEndpointsTests
{
// Arrange
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
// Act
@@ -113,11 +116,12 @@ public sealed class ManifestEndpointsTests
{
// Arrange
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
using var scope = factory.Services.CreateScope();
var manifestRepository = scope.ServiceProvider.GetRequiredService<IScanManifestRepository>();
var scanId = Guid.NewGuid();
var scanId = CreateGuid(4);
var manifestContent = JsonSerializer.Serialize(new
{
@@ -133,18 +137,18 @@ public sealed class ManifestEndpointsTests
var manifestRow = new ScanManifestRow
{
ManifestId = Guid.NewGuid(),
ManifestId = CreateGuid(5),
ScanId = scanId,
ManifestHash = "sha256:manifest456",
SbomHash = "sha256:sbom123",
RulesHash = "sha256:rules123",
FeedHash = "sha256:feed123",
PolicyHash = "sha256:policy123",
ScanStartedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
ScanCompletedAt = DateTimeOffset.UtcNow,
ScanStartedAt = FixedNow.AddMinutes(-5),
ScanCompletedAt = FixedNow,
ManifestContent = manifestContent,
ScannerVersion = "1.0.0-test",
CreatedAt = DateTimeOffset.UtcNow
CreatedAt = FixedNow
};
await manifestRepository.SaveAsync(manifestRow, TestContext.Current.CancellationToken);
@@ -173,26 +177,27 @@ public sealed class ManifestEndpointsTests
{
// Arrange
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
using var scope = factory.Services.CreateScope();
var manifestRepository = scope.ServiceProvider.GetRequiredService<IScanManifestRepository>();
var scanId = Guid.NewGuid();
var scanId = CreateGuid(6);
var manifestRow = new ScanManifestRow
{
ManifestId = Guid.NewGuid(),
ManifestId = CreateGuid(7),
ScanId = scanId,
ManifestHash = "sha256:content-digest-test",
SbomHash = "sha256:sbom789",
RulesHash = "sha256:rules789",
FeedHash = "sha256:feed789",
PolicyHash = "sha256:policy789",
ScanStartedAt = DateTimeOffset.UtcNow.AddMinutes(-2),
ScanCompletedAt = DateTimeOffset.UtcNow,
ScanStartedAt = FixedNow.AddMinutes(-2),
ScanCompletedAt = FixedNow,
ManifestContent = """{"test":"content-digest"}""",
ScannerVersion = "1.0.0-test",
CreatedAt = DateTimeOffset.UtcNow
CreatedAt = FixedNow
};
await manifestRepository.SaveAsync(manifestRow, TestContext.Current.CancellationToken);
@@ -219,8 +224,9 @@ public sealed class ManifestEndpointsTests
{
// Arrange
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var scanId = Guid.NewGuid();
var scanId = CreateGuid(8);
// Act
var response = await client.GetAsync($"/api/v1/scans/{scanId}/proofs");
@@ -240,11 +246,12 @@ public sealed class ManifestEndpointsTests
{
// Arrange
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
using var scope = factory.Services.CreateScope();
var bundleRepository = scope.ServiceProvider.GetRequiredService<IProofBundleRepository>();
var scanId = Guid.NewGuid();
var scanId = CreateGuid(9);
var bundle1 = new ProofBundleRow
{
@@ -252,7 +259,7 @@ public sealed class ManifestEndpointsTests
RootHash = "sha256:root1",
BundleType = "standard",
BundleHash = "sha256:bundle1",
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-5)
CreatedAt = FixedNow.AddMinutes(-5)
};
var bundle2 = new ProofBundleRow
@@ -261,7 +268,7 @@ public sealed class ManifestEndpointsTests
RootHash = "sha256:root2",
BundleType = "extended",
BundleHash = "sha256:bundle2",
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-2)
CreatedAt = FixedNow.AddMinutes(-2)
};
await bundleRepository.SaveAsync(bundle1);
@@ -286,6 +293,7 @@ public sealed class ManifestEndpointsTests
{
// Arrange
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
// Act
@@ -305,11 +313,12 @@ public sealed class ManifestEndpointsTests
{
// Arrange
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
using var scope = factory.Services.CreateScope();
var bundleRepository = scope.ServiceProvider.GetRequiredService<IProofBundleRepository>();
var scanId = Guid.NewGuid();
var scanId = CreateGuid(10);
var rootHash = "sha256:detailroot1";
var bundle = new ProofBundleRow
@@ -324,8 +333,8 @@ public sealed class ManifestEndpointsTests
VexHash = "sha256:vex1",
SignatureKeyId = "key-001",
SignatureAlgorithm = "ed25519",
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-3),
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30)
CreatedAt = FixedNow.AddMinutes(-3),
ExpiresAt = FixedNow.AddDays(30)
};
await bundleRepository.SaveAsync(bundle);
@@ -356,8 +365,9 @@ public sealed class ManifestEndpointsTests
{
// Arrange
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var scanId = Guid.NewGuid();
var scanId = CreateGuid(11);
// Act
var response = await client.GetAsync($"/api/v1/scans/{scanId}/proofs/sha256:nonexistent");
@@ -372,12 +382,13 @@ public sealed class ManifestEndpointsTests
{
// Arrange
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
using var scope = factory.Services.CreateScope();
var bundleRepository = scope.ServiceProvider.GetRequiredService<IProofBundleRepository>();
var scanId1 = Guid.NewGuid();
var scanId2 = Guid.NewGuid();
var scanId1 = CreateGuid(12);
var scanId2 = CreateGuid(13);
var rootHash = "sha256:crossscanroot";
var bundle = new ProofBundleRow
@@ -386,7 +397,7 @@ public sealed class ManifestEndpointsTests
RootHash = rootHash,
BundleType = "standard",
BundleHash = "sha256:crossscanbundle",
CreatedAt = DateTimeOffset.UtcNow
CreatedAt = FixedNow
};
await bundleRepository.SaveAsync(bundle);
@@ -404,6 +415,7 @@ public sealed class ManifestEndpointsTests
{
// Arrange
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
// Act
@@ -419,8 +431,9 @@ public sealed class ManifestEndpointsTests
{
// Arrange
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var scanId = Guid.NewGuid();
var scanId = CreateGuid(14);
// Act - Trailing slash with empty root hash
var response = await client.GetAsync($"/api/v1/scans/{scanId}/proofs/");
@@ -431,4 +444,9 @@ public sealed class ManifestEndpointsTests
}
#endregion
private static readonly DateTimeOffset FixedNow = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
private static Guid CreateGuid(int seed)
=> new($"00000000-0000-0000-0000-{seed:D12}");
}

View File

@@ -29,7 +29,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().WithOverrides(config =>
await using var factory = new ScannerApplicationFactory().WithOverrides(config =>
{
config["Scanner:OfflineKit:Enabled"] = "true";
config["Scanner:OfflineKit:RequireDsse"] = "true";
@@ -39,6 +39,7 @@ public sealed class OfflineKitEndpointsTests
config["Scanner:OfflineKit:TrustAnchors:0:PurlPattern"] = "*";
config["Scanner:OfflineKit:TrustAnchors:0:AllowedKeyIds:0"] = keyId;
});
await factory.InitializeAsync();
using var configured = factory.WithWebHostBuilder(builder => builder.UseContentRoot(contentRoot.Path));
using var client = configured.CreateClient();
@@ -97,7 +98,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().WithOverrides(config =>
await using var factory = new ScannerApplicationFactory().WithOverrides(config =>
{
config["Scanner:OfflineKit:Enabled"] = "true";
config["Scanner:OfflineKit:RequireDsse"] = "true";
@@ -107,6 +108,7 @@ public sealed class OfflineKitEndpointsTests
config["Scanner:OfflineKit:TrustAnchors:0:PurlPattern"] = "*";
config["Scanner:OfflineKit:TrustAnchors:0:AllowedKeyIds:0"] = keyId;
});
await factory.InitializeAsync();
using var configured = factory.WithWebHostBuilder(builder => builder.UseContentRoot(contentRoot.Path));
using var client = configured.CreateClient();
@@ -151,12 +153,13 @@ 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().WithOverrides(config =>
await using var factory = new ScannerApplicationFactory().WithOverrides(config =>
{
config["Scanner:OfflineKit:Enabled"] = "true";
config["Scanner:OfflineKit:RequireDsse"] = "false";
config["Scanner:OfflineKit:RekorOfflineMode"] = "false";
});
await factory.InitializeAsync();
using var configured = factory.WithWebHostBuilder(builder => builder.UseContentRoot(contentRoot.Path));
using var client = configured.CreateClient();
@@ -192,7 +195,7 @@ public sealed class OfflineKitEndpointsTests
var auditEmitter = new CapturingAuditEmitter();
using var factory = new ScannerApplicationFactory().WithOverrides(config =>
await using var factory = new ScannerApplicationFactory().WithOverrides(config =>
{
config["Scanner:OfflineKit:Enabled"] = "true";
config["Scanner:OfflineKit:RequireDsse"] = "false";
@@ -202,6 +205,7 @@ public sealed class OfflineKitEndpointsTests
services.RemoveAll<IOfflineKitAuditEmitter>();
services.AddSingleton<IOfflineKitAuditEmitter>(auditEmitter);
});
await factory.InitializeAsync();
using var configured = factory.WithWebHostBuilder(builder => builder.UseContentRoot(contentRoot.Path));
using var client = configured.CreateClient();
@@ -241,10 +245,11 @@ public sealed class OfflineKitEndpointsTests
{
using var contentRoot = new TempDirectory();
using var factory = new ScannerApplicationFactory().WithOverrides(config =>
await using var factory = new ScannerApplicationFactory().WithOverrides(config =>
{
config["Scanner:OfflineKit:Enabled"] = "true";
});
await factory.InitializeAsync();
using var configured = factory.WithWebHostBuilder(builder => builder.UseContentRoot(contentRoot.Path));
using var client = configured.CreateClient();
@@ -262,11 +267,12 @@ public sealed class OfflineKitEndpointsTests
var bundleBytes = Encoding.UTF8.GetBytes("deterministic-offline-kit-bundle");
var bundleSha = ComputeSha256Hex(bundleBytes);
using var factory = new ScannerApplicationFactory().WithOverrides(config =>
await using var factory = new ScannerApplicationFactory().WithOverrides(config =>
{
config["Scanner:OfflineKit:Enabled"] = "true";
config["Scanner:OfflineKit:RequireDsse"] = "false";
});
await factory.InitializeAsync();
using var configured = factory.WithWebHostBuilder(builder => builder.UseContentRoot(contentRoot.Path));
using var client = configured.CreateClient();
@@ -304,10 +310,11 @@ public sealed class OfflineKitEndpointsTests
{
using var contentRoot = new TempDirectory();
using var factory = new ScannerApplicationFactory().WithOverrides(config =>
await using var factory = new ScannerApplicationFactory().WithOverrides(config =>
{
config["Scanner:OfflineKit:Enabled"] = "true";
});
await factory.InitializeAsync();
using var configured = factory.WithWebHostBuilder(builder => builder.UseContentRoot(contentRoot.Path));
using var client = configured.CreateClient();
@@ -346,10 +353,11 @@ public sealed class OfflineKitEndpointsTests
{
using var contentRoot = new TempDirectory();
using var factory = new ScannerApplicationFactory().WithOverrides(config =>
await using var factory = new ScannerApplicationFactory().WithOverrides(config =>
{
config["Scanner:OfflineKit:Enabled"] = "true";
});
await factory.InitializeAsync();
using var configured = factory.WithWebHostBuilder(builder => builder.UseContentRoot(contentRoot.Path));
using var client = configured.CreateClient();
@@ -382,10 +390,11 @@ public sealed class OfflineKitEndpointsTests
{
using var contentRoot = new TempDirectory();
using var factory = new ScannerApplicationFactory().WithOverrides(config =>
await using var factory = new ScannerApplicationFactory().WithOverrides(config =>
{
config["Scanner:OfflineKit:Enabled"] = "true";
});
await factory.InitializeAsync();
using var configured = factory.WithWebHostBuilder(builder => builder.UseContentRoot(contentRoot.Path));
using var client = configured.CreateClient();
@@ -425,10 +434,11 @@ public sealed class OfflineKitEndpointsTests
{
using var contentRoot = new TempDirectory();
using var factory = new ScannerApplicationFactory().WithOverrides(config =>
await using var factory = new ScannerApplicationFactory().WithOverrides(config =>
{
config["Scanner:OfflineKit:Enabled"] = "true";
});
await factory.InitializeAsync();
using var configured = factory.WithWebHostBuilder(builder => builder.UseContentRoot(contentRoot.Path));
using var client = configured.CreateClient();
@@ -469,10 +479,11 @@ public sealed class OfflineKitEndpointsTests
{
using var contentRoot = new TempDirectory();
using var factory = new ScannerApplicationFactory().WithOverrides(config =>
await using var factory = new ScannerApplicationFactory().WithOverrides(config =>
{
config["Scanner:OfflineKit:Enabled"] = "false";
});
await factory.InitializeAsync();
using var configured = factory.WithWebHostBuilder(builder => builder.UseContentRoot(contentRoot.Path));
using var client = configured.CreateClient();
@@ -498,10 +509,11 @@ public sealed class OfflineKitEndpointsTests
{
using var contentRoot = new TempDirectory();
using var factory = new ScannerApplicationFactory().WithOverrides(config =>
await using var factory = new ScannerApplicationFactory().WithOverrides(config =>
{
config["Scanner:OfflineKit:Enabled"] = "true";
});
await factory.InitializeAsync();
using var configured = factory.WithWebHostBuilder(builder => builder.UseContentRoot(contentRoot.Path));
using var client = configured.CreateClient();
@@ -517,10 +529,11 @@ public sealed class OfflineKitEndpointsTests
{
using var contentRoot = new TempDirectory();
using var factory = new ScannerApplicationFactory().WithOverrides(config =>
await using var factory = new ScannerApplicationFactory().WithOverrides(config =>
{
config["Scanner:OfflineKit:Enabled"] = "true";
});
await factory.InitializeAsync();
using var configured = factory.WithWebHostBuilder(builder => builder.UseContentRoot(contentRoot.Path));
using var client = configured.CreateClient();
@@ -536,10 +549,11 @@ public sealed class OfflineKitEndpointsTests
{
using var contentRoot = new TempDirectory();
using var factory = new ScannerApplicationFactory().WithOverrides(config =>
await using var factory = new ScannerApplicationFactory().WithOverrides(config =>
{
config["Scanner:OfflineKit:Enabled"] = "true";
});
await factory.InitializeAsync();
using var configured = factory.WithWebHostBuilder(builder => builder.UseContentRoot(contentRoot.Path));
using var client = configured.CreateClient();

View File

@@ -13,11 +13,12 @@ public sealed class PlatformEventPublisherRegistrationTests
[Fact]
public void NullPublisherRegisteredWhenEventsDisabled()
{
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
await using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
{
configuration["scanner:events:enabled"] = "false";
configuration["scanner:events:dsn"] = string.Empty;
});
await factory.InitializeAsync();
using var scope = factory.Services.CreateScope();
var publisher = scope.ServiceProvider.GetRequiredService<IPlatformEventPublisher>();
@@ -44,7 +45,7 @@ public sealed class PlatformEventPublisherRegistrationTests
try
{
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
await using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
{
configuration["scanner:events:enabled"] = "true";
configuration["scanner:events:driver"] = "redis";
@@ -53,6 +54,7 @@ public sealed class PlatformEventPublisherRegistrationTests
configuration["scanner:events:publishTimeoutSeconds"] = "1";
configuration["scanner:events:maxStreamLength"] = "100";
});
await factory.InitializeAsync();
using var scope = factory.Services.CreateScope();
var options = scope.ServiceProvider.GetRequiredService<IOptions<ScannerWebServiceOptions>>().Value;

View File

@@ -11,6 +11,7 @@ using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Determinism;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Domain;
using StellaOps.Scanner.WebService.Services;
@@ -28,6 +29,7 @@ public sealed class PolicyDecisionAttestationServiceTests
{
private readonly FakeTimeProvider _timeProvider;
private readonly PolicyDecisionAttestationService _service;
private readonly IGuidProvider _guidProvider = new SequentialGuidProvider();
public PolicyDecisionAttestationServiceTests()
{
@@ -284,7 +286,7 @@ public sealed class PolicyDecisionAttestationServiceTests
{
// Act
var result = await _service.GetAttestationAsync(
ScanId.New(),
ScanId.New(_guidProvider),
"CVE-2024-00000@pkg:npm/nonexistent@1.0.0");
// Assert
@@ -301,7 +303,7 @@ public sealed class PolicyDecisionAttestationServiceTests
// Act
var result = await _service.GetAttestationAsync(
ScanId.New(), // Different scan ID
ScanId.New(_guidProvider), // Different scan ID
input.FindingId);
// Assert
@@ -393,7 +395,7 @@ public sealed class PolicyDecisionAttestationServiceTests
{
return new PolicyDecisionInput
{
ScanId = ScanId.New(),
ScanId = ScanId.New(_guidProvider),
FindingId = "CVE-2024-12345@pkg:npm/stripe@6.1.2",
Cve = "CVE-2024-12345",
ComponentPurl = "pkg:npm/stripe@6.1.2",

View File

@@ -18,7 +18,8 @@ public sealed class PolicyEndpointsTests
[Fact]
public async Task PolicySchemaReturnsEmbeddedSchema()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/policy/schema", TestContext.Current.CancellationToken);
@@ -34,7 +35,8 @@ public sealed class PolicyEndpointsTests
[Fact]
public async Task PolicyDiagnosticsReturnsRecommendations()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var request = new PolicyDiagnosticsRequestDto
@@ -62,7 +64,8 @@ public sealed class PolicyEndpointsTests
[Fact]
public async Task PolicyPreviewUsesProposedPolicy()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
const string policyYaml = """

View File

@@ -22,6 +22,7 @@ public sealed class ProofSpineEndpointsTests
public async Task GetSpine_ReturnsSpine_WithVerification()
{
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var scope = factory.Services.CreateScope();
var builder = scope.ServiceProvider.GetRequiredService<ProofSpineBuilder>();
@@ -62,6 +63,7 @@ public sealed class ProofSpineEndpointsTests
public async Task GetSpine_ReturnsCbor_WhenAcceptHeaderRequestsCbor()
{
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var scope = factory.Services.CreateScope();
var builder = scope.ServiceProvider.GetRequiredService<ProofSpineBuilder>();
@@ -99,6 +101,7 @@ public sealed class ProofSpineEndpointsTests
public async Task ListSpinesByScan_ReturnsSummaries_WithSegmentCount()
{
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var scope = factory.Services.CreateScope();
var builder = scope.ServiceProvider.GetRequiredService<ProofSpineBuilder>();
@@ -138,6 +141,7 @@ public sealed class ProofSpineEndpointsTests
public async Task ListSpinesByScan_ReturnsCbor_WhenAcceptHeaderRequestsCbor()
{
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var scope = factory.Services.CreateScope();
var builder = scope.ServiceProvider.GetRequiredService<ProofSpineBuilder>();
@@ -177,6 +181,7 @@ public sealed class ProofSpineEndpointsTests
public async Task GetSpine_ReturnsInvalidStatus_WhenSegmentTampered()
{
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var scope = factory.Services.CreateScope();
var builder = scope.ServiceProvider.GetRequiredService<ProofSpineBuilder>();

View File

@@ -22,8 +22,11 @@ public sealed class RateLimitingTests
private const string RateLimitRemainingHeader = "X-RateLimit-Remaining";
private const string RetryAfterHeader = "Retry-After";
private static ScannerApplicationFactory CreateFactory(int permitLimit = 100, int windowSeconds = 3600) =>
new ScannerApplicationFactory().WithOverrides(
private static async Task<ScannerApplicationFactory> CreateFactoryAsync(
int permitLimit = 100,
int windowSeconds = 3600)
{
var factory = new ScannerApplicationFactory().WithOverrides(
configureConfiguration: config =>
{
config["scanner:rateLimiting:scoreReplayPermitLimit"] = permitLimit.ToString();
@@ -34,12 +37,16 @@ public sealed class RateLimitingTests
config["scanner:rateLimiting:proofBundleWindow"] = TimeSpan.FromSeconds(windowSeconds).ToString();
});
await factory.InitializeAsync();
return factory;
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ManifestEndpoint_IncludesRateLimitHeaders()
{
// Arrange
await using var factory = CreateFactory();
await using var factory = await CreateFactoryAsync();
using var client = factory.CreateClient();
var scanId = Guid.NewGuid();
@@ -58,7 +65,7 @@ public sealed class RateLimitingTests
public async Task ProofBundleEndpoint_IncludesRateLimitHeaders()
{
// Arrange
await using var factory = CreateFactory();
await using var factory = await CreateFactoryAsync();
using var client = factory.CreateClient();
var scanId = Guid.NewGuid();
@@ -76,7 +83,7 @@ public sealed class RateLimitingTests
public async Task ExcessiveRequests_Returns429()
{
// Arrange - Create factory with very low rate limit for testing
await using var factory = CreateFactory(permitLimit: 2, windowSeconds: 60);
await using var factory = await CreateFactoryAsync(permitLimit: 2, windowSeconds: 60);
using var client = factory.CreateClient();
var scanId = Guid.NewGuid();
@@ -103,7 +110,7 @@ public sealed class RateLimitingTests
public async Task RateLimited_Returns429WithRetryAfter()
{
// Arrange
await using var factory = CreateFactory(permitLimit: 1, windowSeconds: 3600);
await using var factory = await CreateFactoryAsync(permitLimit: 1, windowSeconds: 3600);
using var client = factory.CreateClient();
var scanId = Guid.NewGuid();
@@ -126,7 +133,7 @@ public sealed class RateLimitingTests
public async Task HealthEndpoint_NotRateLimited()
{
// Arrange
await using var factory = CreateFactory(permitLimit: 1);
await using var factory = await CreateFactoryAsync(permitLimit: 1);
using var client = factory.CreateClient();
// Act - Send multiple health requests
@@ -146,7 +153,7 @@ public sealed class RateLimitingTests
public async Task RateLimitedResponse_HasProblemDetails()
{
// Arrange
await using var factory = CreateFactory(permitLimit: 1, windowSeconds: 3600);
await using var factory = await CreateFactoryAsync(permitLimit: 1, windowSeconds: 3600);
using var client = factory.CreateClient();
var scanId = Guid.NewGuid();
@@ -173,7 +180,7 @@ public sealed class RateLimitingTests
// In practice, this requires setting up different auth contexts
// Arrange
await using var factory = CreateFactory();
await using var factory = await CreateFactoryAsync();
using var client = factory.CreateClient();
var scanId = Guid.NewGuid();

View File

@@ -22,10 +22,11 @@ public sealed class ReachabilityDriftEndpointsTests
public async Task GetDriftReturnsNotFoundWhenNoResultAndNoBaseScanProvided()
{
using var secrets = new TestSurfaceSecretsScope();
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
await using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
{
configuration["scanner:authority:enabled"] = "false";
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
@@ -41,10 +42,11 @@ public sealed class ReachabilityDriftEndpointsTests
public async Task GetDriftComputesResultAndListsDriftedSinks()
{
using var secrets = new TestSurfaceSecretsScope();
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
await using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
{
configuration["scanner:authority:enabled"] = "false";
});
await factory.InitializeAsync();
using var client = factory.CreateClient();

View File

@@ -13,6 +13,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Auth.Abstractions;
using StellaOps.Determinism;
using StellaOps.Policy;
using StellaOps.Scanner.Storage.Models;
using StellaOps.Scanner.Storage.Services;
@@ -36,7 +37,13 @@ public sealed class ReportEventDispatcherTests
{
var publisher = new RecordingEventPublisher();
var tracker = new RecordingClassificationChangeTracker();
var dispatcher = new ReportEventDispatcher(publisher, tracker, Microsoft.Extensions.Options.Options.Create(new ScannerWebServiceOptions()), TimeProvider.System, NullLogger<ReportEventDispatcher>.Instance);
var dispatcher = new ReportEventDispatcher(
publisher,
tracker,
Microsoft.Extensions.Options.Options.Create(new ScannerWebServiceOptions()),
new SequentialGuidProvider(),
TimeProvider.System,
NullLogger<ReportEventDispatcher>.Instance);
var cancellationToken = TestContext.Current.CancellationToken;
var request = new ReportRequestDto
@@ -177,7 +184,13 @@ public sealed class ReportEventDispatcherTests
{
var publisher = new RecordingEventPublisher();
var tracker = new RecordingClassificationChangeTracker();
var dispatcher = new ReportEventDispatcher(publisher, tracker, Microsoft.Extensions.Options.Options.Create(new ScannerWebServiceOptions()), TimeProvider.System, NullLogger<ReportEventDispatcher>.Instance);
var dispatcher = new ReportEventDispatcher(
publisher,
tracker,
Microsoft.Extensions.Options.Options.Create(new ScannerWebServiceOptions()),
new SequentialGuidProvider(),
TimeProvider.System,
NullLogger<ReportEventDispatcher>.Instance);
var cancellationToken = TestContext.Current.CancellationToken;
var request = new ReportRequestDto
@@ -262,7 +275,13 @@ public sealed class ReportEventDispatcherTests
{
ThrowOnTrack = true
};
var dispatcher = new ReportEventDispatcher(publisher, tracker, Microsoft.Extensions.Options.Options.Create(new ScannerWebServiceOptions()), TimeProvider.System, NullLogger<ReportEventDispatcher>.Instance);
var dispatcher = new ReportEventDispatcher(
publisher,
tracker,
Microsoft.Extensions.Options.Options.Create(new ScannerWebServiceOptions()),
new SequentialGuidProvider(),
TimeProvider.System,
NullLogger<ReportEventDispatcher>.Instance);
var cancellationToken = TestContext.Current.CancellationToken;
var request = new ReportRequestDto
@@ -333,7 +352,13 @@ public sealed class ReportEventDispatcherTests
var publisher = new RecordingEventPublisher();
var tracker = new RecordingClassificationChangeTracker();
var dispatcher = new ReportEventDispatcher(publisher, tracker, options, TimeProvider.System, NullLogger<ReportEventDispatcher>.Instance);
var dispatcher = new ReportEventDispatcher(
publisher,
tracker,
options,
new SequentialGuidProvider(),
TimeProvider.System,
NullLogger<ReportEventDispatcher>.Instance);
var cancellationToken = TestContext.Current.CancellationToken;
var request = new ReportRequestDto

View File

@@ -39,7 +39,7 @@ rules:
var hmacKey = Convert.ToBase64String(Encoding.UTF8.GetBytes("scanner-report-hmac-key-2025!"));
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
await using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
{
configuration["scanner:signing:enabled"] = "true";
configuration["scanner:signing:keyId"] = "scanner-report-signing";
@@ -47,6 +47,7 @@ rules:
configuration["scanner:signing:keyPem"] = hmacKey;
configuration["scanner:features:enableSignedReports"] = "true";
});
await factory.InitializeAsync();
var store = factory.Services.GetRequiredService<PolicySnapshotStore>();
await store.SaveAsync(
@@ -110,7 +111,8 @@ rules:
[Fact]
public async Task ReportsEndpointValidatesDigest()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var request = new ReportRequestDto
@@ -127,7 +129,8 @@ rules:
[Fact]
public async Task ReportsEndpointReturnsServiceUnavailableWhenPolicyMissing()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var request = new ReportRequestDto
@@ -155,7 +158,7 @@ rules:
action: block
""";
using var factory = new ScannerApplicationFactory().WithOverrides(
await using var factory = new ScannerApplicationFactory().WithOverrides(
configuration =>
{
configuration["scanner:signing:enabled"] = "true";
@@ -176,6 +179,7 @@ rules:
services.AddSingleton<RecordingPlatformEventPublisher>();
services.AddSingleton<IPlatformEventPublisher>(sp => sp.GetRequiredService<RecordingPlatformEventPublisher>());
});
await factory.InitializeAsync();
var store = factory.Services.GetRequiredService<PolicySnapshotStore>();
var saveResult = await store.SaveAsync(

View File

@@ -11,6 +11,7 @@ using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Determinism;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Domain;
using StellaOps.Scanner.WebService.Services;
@@ -28,6 +29,7 @@ public sealed class RichGraphAttestationServiceTests
{
private readonly FakeTimeProvider _timeProvider;
private readonly RichGraphAttestationService _service;
private readonly IGuidProvider _guidProvider = new SequentialGuidProvider();
public RichGraphAttestationServiceTests()
{
@@ -302,7 +304,7 @@ public sealed class RichGraphAttestationServiceTests
public async Task GetAttestationAsync_NonExistentAttestation_ReturnsNull()
{
// Act
var result = await _service.GetAttestationAsync(ScanId.New(), "nonexistent-graph");
var result = await _service.GetAttestationAsync(ScanId.New(_guidProvider), "nonexistent-graph");
// Assert
result.Should().BeNull();
@@ -317,7 +319,7 @@ public sealed class RichGraphAttestationServiceTests
await _service.CreateAttestationAsync(input);
// Act
var result = await _service.GetAttestationAsync(ScanId.New(), input.GraphId);
var result = await _service.GetAttestationAsync(ScanId.New(_guidProvider), input.GraphId);
// Assert
result.Should().BeNull();
@@ -396,7 +398,7 @@ public sealed class RichGraphAttestationServiceTests
{
return new RichGraphAttestationInput
{
ScanId = ScanId.New(),
ScanId = ScanId.New(_guidProvider),
GraphId = $"richgraph-{Guid.NewGuid():N}",
GraphDigest = "sha256:abc123def456789",
NodeCount = 1234,

View File

@@ -29,7 +29,8 @@ public sealed class RubyPackagesEndpointsTests
public async Task GetRubyPackagesReturnsNotFoundWhenInventoryMissing()
{
using var secrets = new TestSurfaceSecretsScope();
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/scans/scan-ruby-missing/ruby-packages");
@@ -46,7 +47,8 @@ public sealed class RubyPackagesEndpointsTests
var generatedAt = DateTime.UtcNow.AddMinutes(-10);
using var secrets = new TestSurfaceSecretsScope();
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using (var serviceScope = factory.Services.CreateScope())
{
@@ -97,7 +99,8 @@ public sealed class RubyPackagesEndpointsTests
var generatedAt = DateTime.UtcNow.AddMinutes(-5);
using var secrets = new TestSurfaceSecretsScope();
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
string? scanId = null;
using (var scope = factory.Services.CreateScope())
@@ -155,7 +158,8 @@ public sealed class RubyPackagesEndpointsTests
const string reference = "ghcr.io/demo/ruby-service:latest";
const string digest = "sha512:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd";
using var secrets = new TestSurfaceSecretsScope();
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
string? scanId = null;
using (var scope = factory.Services.CreateScope())
@@ -248,10 +252,11 @@ public sealed class RubyPackagesEndpointsTests
new EntryTraceNdjsonMetadata("scan-placeholder", digest, generatedAt));
using var secrets = new TestSurfaceSecretsScope();
using var factory = new ScannerApplicationFactory().WithOverrides(configureServices: services =>
await using var factory = new ScannerApplicationFactory().WithOverrides(configureServices: services =>
{
services.AddSingleton<IEntryTraceResultStore, RecordingEntryTraceResultStore>();
});
await factory.InitializeAsync();
string? canonicalScanId = null;
using (var scope = factory.Services.CreateScope())

View File

@@ -23,7 +23,8 @@ public sealed class RuntimeEndpointsTests
[Fact]
public async Task RuntimeEventsEndpointPersistsEvents()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var request = new RuntimeEventsIngestRequestDto
@@ -62,7 +63,8 @@ public sealed class RuntimeEndpointsTests
[Fact]
public async Task RuntimeEventsEndpointRejectsUnsupportedSchema()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var envelope = CreateEnvelope("evt-100", schemaVersion: "zastava.runtime.event@v2.0");
@@ -80,13 +82,14 @@ public sealed class RuntimeEndpointsTests
[Fact]
public async Task RuntimeEventsEndpointEnforcesRateLimit()
{
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
await using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
{
configuration["scanner:runtime:perNodeBurst"] = "1";
configuration["scanner:runtime:perNodeEventsPerSecond"] = "1";
configuration["scanner:runtime:perTenantBurst"] = "1";
configuration["scanner:runtime:perTenantEventsPerSecond"] = "1";
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
var request = new RuntimeEventsIngestRequestDto
@@ -112,10 +115,11 @@ public sealed class RuntimeEndpointsTests
[Fact]
public async Task RuntimePolicyEndpointReturnsDecisions()
{
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
await using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
{
configuration["scanner:runtime:policyCacheTtlSeconds"] = "600";
});
await factory.InitializeAsync();
const string imageDigest = "sha256:deadbeef";
@@ -170,20 +174,20 @@ rules:
await links.UpsertAsync(new LinkDocument
{
Id = Guid.NewGuid().ToString("N"),
Id = "link-0001",
FromType = LinkSourceType.Image,
FromDigest = imageDigest,
ArtifactId = sbomArtifactId,
CreatedAtUtc = DateTime.UtcNow
CreatedAtUtc = FixedUtc
}, TestContext.Current.CancellationToken);
await links.UpsertAsync(new LinkDocument
{
Id = Guid.NewGuid().ToString("N"),
Id = "link-0002",
FromType = LinkSourceType.Image,
FromDigest = imageDigest,
ArtifactId = attestationArtifactId,
CreatedAtUtc = DateTime.UtcNow
CreatedAtUtc = FixedUtc
}, TestContext.Current.CancellationToken);
}
@@ -195,7 +199,10 @@ rules:
CreateEnvelope("evt-211", imageDigest: imageDigest, buildId: "1122AABBCCDDEEFF00112233445566778899AABB")
}
};
var ingestResponse = await client.PostAsJsonAsync("/api/v1/runtime/events", ingestRequest);
var ingestResponse = await client.PostAsJsonAsync(
"/api/v1/runtime/events",
ingestRequest,
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Accepted, ingestResponse.StatusCode);
var request = new RuntimePolicyRequestDto
@@ -205,7 +212,10 @@ rules:
Labels = new Dictionary<string, string> { ["app"] = "api" }
};
var response = await client.PostAsJsonAsync("/api/v1/policy/runtime", request);
var response = await client.PostAsJsonAsync(
"/api/v1/policy/runtime",
request,
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var raw = await response.Content.ReadAsStringAsync();
@@ -214,7 +224,7 @@ rules:
Assert.True(payload is not null, $"Runtime policy response: {raw}");
Assert.Equal(600, payload!.TtlSeconds);
Assert.NotNull(payload.PolicyRevision);
Assert.True(payload.ExpiresAtUtc > DateTimeOffset.UtcNow);
Assert.True(payload.ExpiresAtUtc > FixedNow);
var decision = payload.Results[imageDigest];
Assert.Equal("pass", decision.PolicyVerdict);
@@ -232,7 +242,6 @@ rules:
Assert.NotNull(decision.BuildIds);
Assert.Contains("1122aabbccddeeff00112233445566778899aabb", decision.BuildIds!);
var metadataString = decision.Metadata;
Console.WriteLine($"Runtime policy metadata: {metadataString ?? "<null>"}");
Assert.False(string.IsNullOrWhiteSpace(metadataString));
using var metadataDocument = JsonDocument.Parse(decision.Metadata!);
Assert.True(metadataDocument.RootElement.TryGetProperty("heuristics", out _));
@@ -242,7 +251,8 @@ rules:
[Fact]
public async Task RuntimePolicyEndpointFlagsUnsignedAndMissingSbom()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
const string imageDigest = "sha256:feedface";
@@ -268,10 +278,10 @@ rules: []
{
Namespace = "payments",
Images = new[] { imageDigest }
});
}, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<RuntimePolicyResponseDto>();
var payload = await response.Content.ReadFromJsonAsync<RuntimePolicyResponseDto>(TestContext.Current.CancellationToken);
Assert.NotNull(payload);
var decision = payload!.Results[imageDigest];
@@ -299,7 +309,8 @@ rules: []
[Fact]
public async Task RuntimePolicyEndpointValidatesRequest()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var request = new RuntimePolicyRequestDto
@@ -307,7 +318,7 @@ rules: []
Images = Array.Empty<string>()
};
var response = await client.PostAsJsonAsync("/api/v1/policy/runtime", request);
var response = await client.PostAsJsonAsync("/api/v1/policy/runtime", request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
@@ -321,7 +332,7 @@ rules: []
var runtimeEvent = new RuntimeEvent
{
EventId = eventId,
When = DateTimeOffset.UtcNow,
When = FixedNow,
Kind = RuntimeEventKind.ContainerStart,
Tenant = "tenant-alpha",
Node = "node-a",
@@ -363,4 +374,7 @@ rules: []
Event = runtimeEvent
};
}
private static readonly DateTimeOffset FixedNow = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
private static readonly DateTime FixedUtc = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
}

View File

@@ -30,7 +30,8 @@ public sealed class RuntimeReconciliationTests
[Fact]
public async Task ReconcileEndpoint_WithNoRuntimeEvents_ReturnsNotFound()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var request = new RuntimeReconcileRequestDto
@@ -54,12 +55,13 @@ public sealed class RuntimeReconciliationTests
{
var mockObjectStore = new InMemoryArtifactObjectStore();
using var factory = new ScannerApplicationFactory().WithOverrides(
await using var factory = new ScannerApplicationFactory().WithOverrides(
configureServices: services =>
{
services.RemoveAll<IArtifactObjectStore>();
services.AddSingleton<IArtifactObjectStore>(mockObjectStore);
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
// Ingest runtime event with loaded libraries
@@ -104,12 +106,13 @@ public sealed class RuntimeReconciliationTests
{
var mockObjectStore = new InMemoryArtifactObjectStore();
using var factory = new ScannerApplicationFactory().WithOverrides(
await using var factory = new ScannerApplicationFactory().WithOverrides(
configureServices: services =>
{
services.RemoveAll<IArtifactObjectStore>();
services.AddSingleton<IArtifactObjectStore>(mockObjectStore);
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
// Setup: Create SBOM artifact with components
@@ -195,12 +198,13 @@ public sealed class RuntimeReconciliationTests
{
var mockObjectStore = new InMemoryArtifactObjectStore();
using var factory = new ScannerApplicationFactory().WithOverrides(
await using var factory = new ScannerApplicationFactory().WithOverrides(
configureServices: services =>
{
services.RemoveAll<IArtifactObjectStore>();
services.AddSingleton<IArtifactObjectStore>(mockObjectStore);
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
const string imageDigest = "sha256:pathtest123";
@@ -281,12 +285,13 @@ public sealed class RuntimeReconciliationTests
{
var mockObjectStore = new InMemoryArtifactObjectStore();
using var factory = new ScannerApplicationFactory().WithOverrides(
await using var factory = new ScannerApplicationFactory().WithOverrides(
configureServices: services =>
{
services.RemoveAll<IArtifactObjectStore>();
services.AddSingleton<IArtifactObjectStore>(mockObjectStore);
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
const string imageDigest = "sha256:eventidtest";
@@ -368,7 +373,8 @@ public sealed class RuntimeReconciliationTests
[Fact]
public async Task ReconcileEndpoint_WithNonExistentEventId_ReturnsNotFound()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var request = new RuntimeReconcileRequestDto
@@ -390,7 +396,8 @@ public sealed class RuntimeReconciliationTests
[Fact]
public async Task ReconcileEndpoint_WithMissingImageDigest_ReturnsBadRequest()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var request = new RuntimeReconcileRequestDto
@@ -409,12 +416,13 @@ public sealed class RuntimeReconciliationTests
{
var mockObjectStore = new InMemoryArtifactObjectStore();
using var factory = new ScannerApplicationFactory().WithOverrides(
await using var factory = new ScannerApplicationFactory().WithOverrides(
configureServices: services =>
{
services.RemoveAll<IArtifactObjectStore>();
services.AddSingleton<IArtifactObjectStore>(mockObjectStore);
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
const string imageDigest = "sha256:mixedtest";

View File

@@ -19,7 +19,7 @@ public sealed class SbomEndpointsTests
public async Task SubmitSbomAcceptsCycloneDxJson()
{
using var secrets = new TestSurfaceSecretsScope();
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
await using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
{
configuration["scanner:authority:enabled"] = "false";
}, configureServices: services =>
@@ -27,6 +27,7 @@ public sealed class SbomEndpointsTests
services.RemoveAll<IArtifactObjectStore>();
services.AddSingleton<IArtifactObjectStore>(new InMemoryArtifactObjectStore());
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
var scanId = await CreateScanAsync(client);

View File

@@ -18,7 +18,7 @@ public sealed class SbomUploadEndpointsTests
public async Task Upload_accepts_cyclonedx_fixture_and_returns_record()
{
using var secrets = new TestSurfaceSecretsScope();
using var factory = CreateFactory();
await using var factory = await CreateFactoryAsync();
using var client = factory.CreateClient();
var request = new SbomUploadRequestDto
@@ -64,7 +64,7 @@ public sealed class SbomUploadEndpointsTests
public async Task Upload_accepts_spdx_fixture_and_reports_quality_score()
{
using var secrets = new TestSurfaceSecretsScope();
using var factory = CreateFactory();
await using var factory = await CreateFactoryAsync();
using var client = factory.CreateClient();
var request = new SbomUploadRequestDto
@@ -90,7 +90,7 @@ public sealed class SbomUploadEndpointsTests
public async Task Upload_rejects_unknown_format()
{
using var secrets = new TestSurfaceSecretsScope();
using var factory = CreateFactory();
await using var factory = await CreateFactoryAsync();
using var client = factory.CreateClient();
var invalid = new SbomUploadRequestDto
@@ -103,9 +103,9 @@ public sealed class SbomUploadEndpointsTests
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
private static ScannerApplicationFactory CreateFactory()
private static async Task<ScannerApplicationFactory> CreateFactoryAsync()
{
return new ScannerApplicationFactory().WithOverrides(configuration =>
var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
{
configuration["scanner:authority:enabled"] = "false";
}, configureServices: services =>
@@ -113,6 +113,9 @@ public sealed class SbomUploadEndpointsTests
services.RemoveAll<IArtifactObjectStore>();
services.AddSingleton<IArtifactObjectStore>(new InMemoryArtifactObjectStore());
});
await factory.InitializeAsync();
return factory;
}
private static string LoadFixtureBase64(string fileName)

View File

@@ -21,12 +21,12 @@ using StellaOps.Scanner.Triage;
using StellaOps.Determinism;
using StellaOps.Scanner.WebService.Diagnostics;
using StellaOps.Scanner.WebService.Services;
using Xunit;
namespace StellaOps.Scanner.WebService.Tests;
public sealed class ScannerApplicationFactory : WebApplicationFactory<ServiceStatus>
public sealed class ScannerApplicationFactory : WebApplicationFactory<ServiceStatus>, IAsyncLifetime, IAsyncDisposable
{
private readonly ScannerWebServicePostgresFixture? postgresFixture;
private readonly bool skipPostgres;
private readonly Dictionary<string, string?> configuration = new(StringComparer.OrdinalIgnoreCase)
{
@@ -53,6 +53,10 @@ public sealed class ScannerApplicationFactory : WebApplicationFactory<ServiceSta
private Action<IDictionary<string, string?>>? configureConfiguration;
private Action<IServiceCollection>? configureServices;
private bool useTestAuthentication;
private ScannerWebServicePostgresFixture? postgresFixture;
private Task? initializationTask;
private bool initialized;
private bool disposed;
public ScannerApplicationFactory() : this(skipPostgres: false)
{
@@ -61,25 +65,16 @@ public sealed class ScannerApplicationFactory : WebApplicationFactory<ServiceSta
private ScannerApplicationFactory(bool skipPostgres)
{
this.skipPostgres = skipPostgres;
initialized = skipPostgres;
if (!skipPostgres)
{
postgresFixture = new ScannerWebServicePostgresFixture();
postgresFixture.InitializeAsync().GetAwaiter().GetResult();
return;
}
var connectionBuilder = new NpgsqlConnectionStringBuilder(postgresFixture.ConnectionString)
{
SearchPath = $"{postgresFixture.SchemaName},public"
};
configuration["scanner:storage:dsn"] = connectionBuilder.ToString();
configuration["scanner:storage:database"] = postgresFixture.SchemaName;
}
else
{
// Lightweight mode: use stub connection string
configuration["scanner:storage:dsn"] = "Host=localhost;Database=test;";
configuration["scanner:storage:database"] = "test";
}
// Lightweight mode: use stub connection string
configuration["scanner:storage:dsn"] = "Host=localhost;Database=test;";
configuration["scanner:storage:database"] = "test";
}
/// <summary>
@@ -109,8 +104,62 @@ public sealed class ScannerApplicationFactory : WebApplicationFactory<ServiceSta
return this;
}
public Task InitializeAsync()
{
initializationTask ??= InitializeCoreAsync();
return initializationTask;
}
private async Task InitializeCoreAsync()
{
if (initialized)
{
return;
}
if (skipPostgres)
{
initialized = true;
return;
}
postgresFixture = new ScannerWebServicePostgresFixture();
await postgresFixture.InitializeAsync();
var connectionBuilder = new NpgsqlConnectionStringBuilder(postgresFixture.ConnectionString)
{
SearchPath = $"{postgresFixture.SchemaName},public"
};
configuration["scanner:storage:dsn"] = connectionBuilder.ToString();
configuration["scanner:storage:database"] = postgresFixture.SchemaName;
initialized = true;
}
Task IAsyncLifetime.DisposeAsync() => DisposeAsync().AsTask();
public async ValueTask DisposeAsync()
{
if (disposed)
{
return;
}
disposed = true;
base.Dispose();
if (postgresFixture is not null)
{
await postgresFixture.DisposeAsync();
}
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
if (!initialized)
{
throw new InvalidOperationException("ScannerApplicationFactory must be initialized via InitializeAsync before use.");
}
configureConfiguration?.Invoke(configuration);
builder.UseEnvironment("Testing");
@@ -200,16 +249,6 @@ public sealed class ScannerApplicationFactory : WebApplicationFactory<ServiceSta
});
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (disposing && postgresFixture is not null)
{
postgresFixture.DisposeAsync().GetAwaiter().GetResult();
}
}
private sealed class TestSurfaceValidatorRunner : ISurfaceValidatorRunner
{
public ValueTask<SurfaceValidationResult> RunAllAsync(

View File

@@ -1,10 +1,11 @@
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;
using Xunit;
namespace StellaOps.Scanner.WebService.Tests;
public sealed class ScannerApplicationFixture : IDisposable
public sealed class ScannerApplicationFixture : IAsyncLifetime
{
private ScannerApplicationFactory? _authenticatedFactory;
@@ -22,10 +23,12 @@ public sealed class ScannerApplicationFixture : IDisposable
return client;
}
public void Dispose()
public Task InitializeAsync() => Factory.InitializeAsync();
public async Task DisposeAsync()
{
_authenticatedFactory?.Dispose();
Factory.Dispose();
_authenticatedFactory = null;
await Factory.DisposeAsync();
}
}

View File

@@ -177,6 +177,16 @@ public sealed class ScannerSurfaceSecretConfiguratorTests
_handles = handles ?? throw new ArgumentNullException(nameof(handles));
}
public SurfaceSecretHandle Get(SurfaceSecretRequest request)
{
if (_handles.TryGetValue(request.SecretType, out var handle))
{
return handle;
}
throw new SurfaceSecretNotFoundException(request);
}
public ValueTask<SurfaceSecretHandle> GetAsync(SurfaceSecretRequest request, CancellationToken cancellationToken = default)
{
if (_handles.TryGetValue(request.SecretType, out var handle))

View File

@@ -16,11 +16,12 @@ public sealed partial class ScansEndpointsTests
public async Task EntropyEndpoint_AttachesSnapshot_AndSurfacesInStatus()
{
using var secrets = new TestSurfaceSecretsScope();
using var factory = new ScannerApplicationFactory().WithOverrides(cfg =>
await using var factory = new ScannerApplicationFactory().WithOverrides(cfg =>
{
cfg["scanner:authority:enabled"] = "false";
cfg["scanner:authority:allowAnonymousFallback"] = "true";
});
await factory.InitializeAsync();
using var client = factory.CreateClient();

View File

@@ -27,7 +27,7 @@ public sealed partial class ScansEndpointsTests
using var secrets = new TestSurfaceSecretsScope();
var store = new InMemoryArtifactObjectStore();
using var factory = new ScannerApplicationFactory().WithOverrides(configureConfiguration: cfg =>
await using var factory = new ScannerApplicationFactory().WithOverrides(configureConfiguration: cfg =>
{
cfg["scanner:artifactStore:bucket"] = "replay-bucket";
},
@@ -36,6 +36,7 @@ public sealed partial class ScansEndpointsTests
services.RemoveAll<IArtifactObjectStore>();
services.AddSingleton<IArtifactObjectStore>(store);
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
var submit = await client.PostAsJsonAsync("/api/v1/scans", new { image = new { digest = "sha256:demo" } }, TestContext.Current.CancellationToken);

View File

@@ -21,10 +21,11 @@ public sealed partial class ScansEndpointsTests
public async Task RecordModeService_AttachesReplayAndSurfacedInStatus()
{
using var secrets = new TestSurfaceSecretsScope();
using var factory = new ScannerApplicationFactory().WithOverrides(cfg =>
await using var factory = new ScannerApplicationFactory().WithOverrides(cfg =>
{
cfg["scanner:authority:enabled"] = "false";
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
var submitResponse = await client.PostAsJsonAsync("/api/v1/scans", new

View File

@@ -25,7 +25,8 @@ public sealed partial class ScansEndpointsTests
public async Task SubmitScanValidatesImageDescriptor()
{
using var secrets = new TestSurfaceSecretsScope();
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/v1/scans", new
@@ -43,7 +44,7 @@ public sealed partial class ScansEndpointsTests
using var secrets = new TestSurfaceSecretsScope();
RecordingCoordinator coordinator = null!;
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
await using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
{
configuration["scanner:authority:enabled"] = "false";
}, configureServices: services =>
@@ -57,6 +58,7 @@ public sealed partial class ScansEndpointsTests
return coordinator;
});
});
await factory.InitializeAsync();
using var client = factory.CreateClient(new WebApplicationFactoryClientOptions
{
@@ -83,7 +85,7 @@ public sealed partial class ScansEndpointsTests
using var secrets = new TestSurfaceSecretsScope();
RecordingCoordinator coordinator = null!;
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
await using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
{
configuration["scanner:determinism:feedSnapshotId"] = "feed-2025-11-26";
configuration["scanner:determinism:policySnapshotId"] = "rev-42";
@@ -98,6 +100,7 @@ public sealed partial class ScansEndpointsTests
return coordinator;
});
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
var request = new ScanSubmitRequest
@@ -155,10 +158,11 @@ 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().WithOverrides(configureServices: services =>
await using var factory = new ScannerApplicationFactory().WithOverrides(configureServices: services =>
{
services.AddSingleton<IEntryTraceResultStore>(new StubEntryTraceResultStore(storedResult));
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync($"/api/v1/scans/{scanId}/entrytrace");
@@ -176,10 +180,11 @@ public sealed partial class ScansEndpointsTests
public async Task GetEntryTraceReturnsNotFoundWhenMissing()
{
using var secrets = new TestSurfaceSecretsScope();
using var factory = new ScannerApplicationFactory().WithOverrides(configureServices: services =>
await using var factory = new ScannerApplicationFactory().WithOverrides(configureServices: services =>
{
services.AddSingleton<IEntryTraceResultStore>(new StubEntryTraceResultStore(null));
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/scans/scan-missing/entrytrace");

View File

@@ -19,13 +19,13 @@ namespace StellaOps.Scanner.WebService.Tests;
/// </summary>
[Trait("Category", "Integration")]
[Trait("Sprint", "3401.0002")]
public sealed class ScoreReplayEndpointsTests : IDisposable
public sealed class ScoreReplayEndpointsTests : IAsyncLifetime
{
private readonly TestSurfaceSecretsScope _secrets;
private readonly ScannerApplicationFactory _factory;
private readonly HttpClient _client;
private TestSurfaceSecretsScope _secrets = null!;
private ScannerApplicationFactory _factory = null!;
private HttpClient _client = null!;
public ScoreReplayEndpointsTests()
public async Task InitializeAsync()
{
_secrets = new TestSurfaceSecretsScope();
_factory = new ScannerApplicationFactory().WithOverrides(cfg =>
@@ -33,13 +33,14 @@ public sealed class ScoreReplayEndpointsTests : IDisposable
cfg["scanner:authority:enabled"] = "false";
cfg["scanner:scoreReplay:enabled"] = "true";
});
await _factory.InitializeAsync();
_client = _factory.CreateClient();
}
public void Dispose()
public async Task DisposeAsync()
{
_client.Dispose();
_factory.Dispose();
await _factory.DisposeAsync();
_secrets.Dispose();
}

View File

@@ -33,8 +33,9 @@ public sealed class ScannerAuthorizationTests
[InlineData("/api/v1/sbom/upload")]
public async Task ProtectedPostEndpoints_RequireAuthentication(string endpoint)
{
using var factory = new ScannerApplicationFactory().WithOverrides(
await using var factory = new ScannerApplicationFactory().WithOverrides(
useTestAuthentication: true);
await factory.InitializeAsync();
using var client = factory.CreateClient();
@@ -59,7 +60,8 @@ public sealed class ScannerAuthorizationTests
[InlineData("/readyz")]
public async Task HealthEndpoints_ArePubliclyAccessible(string endpoint)
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync(endpoint, TestContext.Current.CancellationToken);
@@ -81,8 +83,9 @@ public sealed class ScannerAuthorizationTests
[Fact]
public async Task ExpiredToken_IsRejected()
{
using var factory = new ScannerApplicationFactory().WithOverrides(
await using var factory = new ScannerApplicationFactory().WithOverrides(
useTestAuthentication: true);
await factory.InitializeAsync();
using var client = factory.CreateClient();
@@ -111,8 +114,9 @@ public sealed class ScannerAuthorizationTests
[InlineData("Bearer only-one-part")]
public async Task MalformedToken_IsRejected(string token)
{
using var factory = new ScannerApplicationFactory().WithOverrides(
await using var factory = new ScannerApplicationFactory().WithOverrides(
useTestAuthentication: true);
await factory.InitializeAsync();
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
@@ -134,8 +138,9 @@ public sealed class ScannerAuthorizationTests
[Fact]
public async Task TokenWithWrongIssuer_IsRejected()
{
using var factory = new ScannerApplicationFactory().WithOverrides(
await using var factory = new ScannerApplicationFactory().WithOverrides(
useTestAuthentication: true);
await factory.InitializeAsync();
using var client = factory.CreateClient();
@@ -160,8 +165,9 @@ public sealed class ScannerAuthorizationTests
[Fact]
public async Task TokenWithWrongAudience_IsRejected()
{
using var factory = new ScannerApplicationFactory().WithOverrides(
await using var factory = new ScannerApplicationFactory().WithOverrides(
useTestAuthentication: true);
await factory.InitializeAsync();
using var client = factory.CreateClient();
@@ -190,7 +196,8 @@ public sealed class ScannerAuthorizationTests
[Fact]
public async Task AnonymousFallback_AllowsAccess_WhenNoAuthConfigured()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync("/healthz", TestContext.Current.CancellationToken);
@@ -208,8 +215,9 @@ public sealed class ScannerAuthorizationTests
[Fact]
public async Task AnonymousFallback_DeniesAccess_WhenAuthRequired()
{
using var factory = new ScannerApplicationFactory().WithOverrides(
await using var factory = new ScannerApplicationFactory().WithOverrides(
useTestAuthentication: true);
await factory.InitializeAsync();
using var client = factory.CreateClient();
@@ -234,8 +242,9 @@ public sealed class ScannerAuthorizationTests
[Fact]
public async Task WriteOperations_RequireAuthentication()
{
using var factory = new ScannerApplicationFactory().WithOverrides(
await using var factory = new ScannerApplicationFactory().WithOverrides(
useTestAuthentication: true);
await factory.InitializeAsync();
using var client = factory.CreateClient();
@@ -256,8 +265,9 @@ public sealed class ScannerAuthorizationTests
[Fact]
public async Task DeleteOperations_RequireAuthentication()
{
using var factory = new ScannerApplicationFactory().WithOverrides(
await using var factory = new ScannerApplicationFactory().WithOverrides(
useTestAuthentication: true);
await factory.InitializeAsync();
using var client = factory.CreateClient();
@@ -281,7 +291,8 @@ public sealed class ScannerAuthorizationTests
[Fact]
public async Task RequestWithoutTenant_IsHandledAppropriately()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
// Request without tenant header - use health endpoint
@@ -305,7 +316,8 @@ public sealed class ScannerAuthorizationTests
[Fact]
public async Task Responses_ContainSecurityHeaders()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync("/healthz", TestContext.Current.CancellationToken);
@@ -321,7 +333,8 @@ public sealed class ScannerAuthorizationTests
[Fact]
public async Task Cors_IsProperlyConfigured()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Options, "/healthz");
@@ -349,8 +362,9 @@ public sealed class ScannerAuthorizationTests
[Fact]
public async Task ValidToken_IsAccepted()
{
using var factory = new ScannerApplicationFactory().WithOverrides(
await using var factory = new ScannerApplicationFactory().WithOverrides(
useTestAuthentication: true);
await factory.InitializeAsync();
using var client = factory.CreateClient();

View File

@@ -17,7 +17,7 @@ public sealed class SurfaceManifestStoreOptionsConfiguratorTests
[Fact]
public void Configure_UsesSurfaceEnvironmentAndCacheRoot()
{
var cacheRoot = new DirectoryInfo(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")));
var cacheRoot = new DirectoryInfo(Path.Combine(Path.GetTempPath(), "stellaops-tests", "surface-manifest"));
var settings = new SurfaceEnvironmentSettings(
new Uri("https://surface.example"),
"surface-bucket",
@@ -30,7 +30,7 @@ public sealed class SurfaceManifestStoreOptionsConfiguratorTests
"tenant-a",
new SurfaceTlsConfiguration(null, null, new X509Certificate2Collection()))
{
CreatedAtUtc = DateTimeOffset.UtcNow
CreatedAtUtc = FixedNow
};
var environment = new StubSurfaceEnvironment(settings);
@@ -45,6 +45,8 @@ public sealed class SurfaceManifestStoreOptionsConfiguratorTests
Assert.Equal(Path.Combine(cacheRoot.FullName, "manifests"), options.RootDirectory);
}
private static readonly DateTimeOffset FixedNow = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
private sealed class StubSurfaceEnvironment : ISurfaceEnvironment
{
public StubSurfaceEnvironment(SurfaceEnvironmentSettings settings)

View File

@@ -24,7 +24,8 @@ public sealed class TriageStatusEndpointsTests
[Fact]
public async Task GetFindingStatus_NotFound_ReturnsNotFound()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/triage/findings/nonexistent-finding");
@@ -35,7 +36,8 @@ public sealed class TriageStatusEndpointsTests
[Fact]
public async Task PostUpdateStatus_ValidRequest_ReturnsUpdatedStatus()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var request = new UpdateTriageStatusRequestDto
@@ -54,7 +56,8 @@ public sealed class TriageStatusEndpointsTests
[Fact]
public async Task PostVexStatement_ValidRequest_ReturnsResponse()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var request = new SubmitVexStatementRequestDto
@@ -73,7 +76,8 @@ public sealed class TriageStatusEndpointsTests
[Fact]
public async Task PostQuery_EmptyFilters_ReturnsResults()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var request = new BulkTriageQueryRequestDto
@@ -94,7 +98,8 @@ public sealed class TriageStatusEndpointsTests
[Fact]
public async Task PostQuery_WithLaneFilter_FiltersCorrectly()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var request = new BulkTriageQueryRequestDto
@@ -114,7 +119,8 @@ public sealed class TriageStatusEndpointsTests
[Fact]
public async Task PostQuery_WithVerdictFilter_FiltersCorrectly()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var request = new BulkTriageQueryRequestDto
@@ -134,7 +140,8 @@ public sealed class TriageStatusEndpointsTests
[Fact]
public async Task GetSummary_ValidDigest_ReturnsSummary()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/triage/summary?artifactDigest=sha256:artifact123");
@@ -150,7 +157,8 @@ public sealed class TriageStatusEndpointsTests
[Fact]
public async Task GetSummary_IncludesAllLanes()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/triage/summary?artifactDigest=sha256:artifact123");
@@ -168,7 +176,8 @@ public sealed class TriageStatusEndpointsTests
[Fact]
public async Task GetSummary_IncludesAllVerdicts()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/triage/summary?artifactDigest=sha256:artifact123");
@@ -186,7 +195,8 @@ public sealed class TriageStatusEndpointsTests
[Fact]
public async Task PostQuery_ResponseIncludesSummary()
{
using var factory = new ScannerApplicationFactory();
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var request = new BulkTriageQueryRequestDto

View File

@@ -185,7 +185,7 @@ public sealed class UnifiedEvidenceServiceTests
DeltaId = "delta-101",
PreviousScanId = "scan-099",
CurrentScanId = "scan-100",
ComparedAt = DateTimeOffset.UtcNow,
ComparedAt = FixedNow,
Summary = new DeltaSummaryDto
{
AddedCount = 5,
@@ -306,7 +306,7 @@ public sealed class UnifiedEvidenceServiceTests
ComponentPurl = "pkg:npm/test@1.0.0",
Manifests = CreateMinimalManifests(),
Verification = CreateMinimalVerification(),
GeneratedAt = DateTimeOffset.UtcNow,
GeneratedAt = FixedNow,
// All tabs null
Sbom = null,
Reachability = null,
@@ -346,7 +346,7 @@ public sealed class UnifiedEvidenceServiceTests
Status = "affected",
TrustScore = 1.0,
MeetsPolicyThreshold = true,
IssuedAt = DateTimeOffset.UtcNow
IssuedAt = FixedNow
},
new VexClaimDto
{
@@ -355,7 +355,7 @@ public sealed class UnifiedEvidenceServiceTests
Status = "not_affected",
TrustScore = 0.95,
MeetsPolicyThreshold = true,
IssuedAt = DateTimeOffset.UtcNow.AddDays(-1)
IssuedAt = FixedNow.AddDays(-1)
},
new VexClaimDto
{
@@ -364,12 +364,12 @@ public sealed class UnifiedEvidenceServiceTests
Status = "under_investigation",
TrustScore = 0.6,
MeetsPolicyThreshold = false,
IssuedAt = DateTimeOffset.UtcNow.AddDays(-7)
IssuedAt = FixedNow.AddDays(-7)
}
},
Manifests = CreateMinimalManifests(),
Verification = CreateMinimalVerification(),
GeneratedAt = DateTimeOffset.UtcNow
GeneratedAt = FixedNow
};
// Assert
@@ -429,7 +429,7 @@ public sealed class UnifiedEvidenceServiceTests
EvidenceBundleUrl = "https://api.stellaops.local/bundles/bundle-123",
Manifests = CreateMinimalManifests(),
Verification = CreateMinimalVerification(),
GeneratedAt = DateTimeOffset.UtcNow
GeneratedAt = FixedNow
};
// Assert
@@ -455,7 +455,7 @@ public sealed class UnifiedEvidenceServiceTests
AttestationsVerified = true,
EvidenceComplete = true,
Issues = null,
VerifiedAt = DateTimeOffset.UtcNow
VerifiedAt = FixedNow
};
// Assert
@@ -478,7 +478,7 @@ public sealed class UnifiedEvidenceServiceTests
AttestationsVerified = false,
EvidenceComplete = true,
Issues = new[] { "Attestation signature verification failed" },
VerifiedAt = DateTimeOffset.UtcNow
VerifiedAt = FixedNow
};
// Assert
@@ -505,7 +505,7 @@ public sealed class UnifiedEvidenceServiceTests
"Attestation not found",
"VEX evidence missing"
},
VerifiedAt = DateTimeOffset.UtcNow
VerifiedAt = FixedNow
};
// Assert
@@ -747,7 +747,7 @@ public sealed class UnifiedEvidenceServiceTests
EvidenceBundleUrl = "https://api.stellaops.local/bundles/scan-001-finding-001",
Manifests = CreateMinimalManifests(),
Verification = CreateMinimalVerification(),
GeneratedAt = DateTimeOffset.UtcNow
GeneratedAt = FixedNow
};
// Assert
@@ -809,7 +809,7 @@ public sealed class UnifiedEvidenceServiceTests
Status = "not_affected",
TrustScore = 0.95,
MeetsPolicyThreshold = true,
IssuedAt = DateTimeOffset.UtcNow
IssuedAt = FixedNow
}
},
Attestations = new[]
@@ -827,7 +827,7 @@ public sealed class UnifiedEvidenceServiceTests
DeltaId = "delta-001",
PreviousScanId = "scan-099",
CurrentScanId = "scan-100",
ComparedAt = DateTimeOffset.UtcNow
ComparedAt = FixedNow
},
Policy = new PolicyEvidenceDto
{
@@ -838,7 +838,7 @@ public sealed class UnifiedEvidenceServiceTests
Manifests = CreateMinimalManifests(),
Verification = CreateMinimalVerification(),
ReplayCommand = "stellaops replay --target pkg:npm/lodash@4.17.21 --verify",
GeneratedAt = DateTimeOffset.UtcNow
GeneratedAt = FixedNow
};
private static string DetermineVerificationStatus(
@@ -852,6 +852,8 @@ public sealed class UnifiedEvidenceServiceTests
}
#endregion
private static readonly DateTimeOffset FixedNow = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
}
/// <summary>

View File

@@ -24,12 +24,13 @@ public sealed class VexGateEndpointsTests
[Fact]
public async Task GetGatePolicy_ReturnsPolicy()
{
using var factory = new ScannerApplicationFactory()
await using var factory = new ScannerApplicationFactory()
.WithOverrides(configureServices: services =>
{
services.RemoveAll<IVexGateQueryService>();
services.AddSingleton<IVexGateQueryService, InMemoryVexGateQueryService>();
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync($"{BasePath}/gate-policy");
@@ -44,12 +45,13 @@ public sealed class VexGateEndpointsTests
[Fact]
public async Task GetGatePolicy_WithTenantId_ReturnsPolicy()
{
using var factory = new ScannerApplicationFactory()
await using var factory = new ScannerApplicationFactory()
.WithOverrides(configureServices: services =>
{
services.RemoveAll<IVexGateQueryService>();
services.AddSingleton<IVexGateQueryService, InMemoryVexGateQueryService>();
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync($"{BasePath}/gate-policy?tenantId=tenant-a");
@@ -62,12 +64,13 @@ public sealed class VexGateEndpointsTests
[Fact]
public async Task GetGateResults_WhenScanNotFound_Returns404()
{
using var factory = new ScannerApplicationFactory()
await using var factory = new ScannerApplicationFactory()
.WithOverrides(configureServices: services =>
{
services.RemoveAll<IVexGateQueryService>();
services.AddSingleton<IVexGateQueryService, InMemoryVexGateQueryService>();
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync($"{BasePath}/scan-not-exists/gate-results");
@@ -78,16 +81,17 @@ public sealed class VexGateEndpointsTests
[Fact]
public async Task GetGateResults_WhenScanExists_ReturnsResults()
{
var scanId = "scan-" + Guid.NewGuid().ToString("N");
var scanId = "scan-0001";
var mockService = new InMemoryVexGateQueryService();
mockService.AddScanResult(scanId, CreateTestGateResults(scanId));
using var factory = new ScannerApplicationFactory()
await using var factory = new ScannerApplicationFactory()
.WithOverrides(configureServices: services =>
{
services.RemoveAll<IVexGateQueryService>();
services.AddSingleton<IVexGateQueryService>(mockService);
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync($"{BasePath}/{scanId}/gate-results");
@@ -103,16 +107,17 @@ public sealed class VexGateEndpointsTests
[Fact]
public async Task GetGateResults_WithDecisionFilter_ReturnsFilteredResults()
{
var scanId = "scan-" + Guid.NewGuid().ToString("N");
var scanId = "scan-0002";
var mockService = new InMemoryVexGateQueryService();
mockService.AddScanResult(scanId, CreateTestGateResults(scanId, blockedCount: 3, warnCount: 5, passCount: 10));
using var factory = new ScannerApplicationFactory()
await using var factory = new ScannerApplicationFactory()
.WithOverrides(configureServices: services =>
{
services.RemoveAll<IVexGateQueryService>();
services.AddSingleton<IVexGateQueryService>(mockService);
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync($"{BasePath}/{scanId}/gate-results?decision=Block");
@@ -126,12 +131,13 @@ public sealed class VexGateEndpointsTests
[Fact]
public async Task GetGateSummary_WhenScanNotFound_Returns404()
{
using var factory = new ScannerApplicationFactory()
await using var factory = new ScannerApplicationFactory()
.WithOverrides(configureServices: services =>
{
services.RemoveAll<IVexGateQueryService>();
services.AddSingleton<IVexGateQueryService, InMemoryVexGateQueryService>();
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync($"{BasePath}/scan-not-exists/gate-summary");
@@ -142,16 +148,17 @@ public sealed class VexGateEndpointsTests
[Fact]
public async Task GetGateSummary_WhenScanExists_ReturnsSummary()
{
var scanId = "scan-" + Guid.NewGuid().ToString("N");
var scanId = "scan-0003";
var mockService = new InMemoryVexGateQueryService();
mockService.AddScanResult(scanId, CreateTestGateResults(scanId, blockedCount: 2, warnCount: 8, passCount: 40));
using var factory = new ScannerApplicationFactory()
await using var factory = new ScannerApplicationFactory()
.WithOverrides(configureServices: services =>
{
services.RemoveAll<IVexGateQueryService>();
services.AddSingleton<IVexGateQueryService>(mockService);
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync($"{BasePath}/{scanId}/gate-summary");
@@ -168,12 +175,13 @@ public sealed class VexGateEndpointsTests
[Fact]
public async Task GetBlockedFindings_WhenScanNotFound_Returns404()
{
using var factory = new ScannerApplicationFactory()
await using var factory = new ScannerApplicationFactory()
.WithOverrides(configureServices: services =>
{
services.RemoveAll<IVexGateQueryService>();
services.AddSingleton<IVexGateQueryService, InMemoryVexGateQueryService>();
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync($"{BasePath}/scan-not-exists/gate-blocked");
@@ -184,16 +192,17 @@ public sealed class VexGateEndpointsTests
[Fact]
public async Task GetBlockedFindings_WhenScanExists_ReturnsOnlyBlocked()
{
var scanId = "scan-" + Guid.NewGuid().ToString("N");
var scanId = "scan-0004";
var mockService = new InMemoryVexGateQueryService();
mockService.AddScanResult(scanId, CreateTestGateResults(scanId, blockedCount: 5, warnCount: 10, passCount: 20));
using var factory = new ScannerApplicationFactory()
await using var factory = new ScannerApplicationFactory()
.WithOverrides(configureServices: services =>
{
services.RemoveAll<IVexGateQueryService>();
services.AddSingleton<IVexGateQueryService>(mockService);
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync($"{BasePath}/{scanId}/gate-blocked");
@@ -238,7 +247,7 @@ public sealed class VexGateEndpointsTests
Passed = passCount,
Warned = warnCount,
Blocked = blockedCount,
EvaluatedAt = DateTimeOffset.UtcNow,
EvaluatedAt = FixedNow,
},
GatedFindings = findings,
};
@@ -248,7 +257,7 @@ public sealed class VexGateEndpointsTests
{
return new GatedFindingDto
{
FindingId = $"finding-{Guid.NewGuid():N}",
FindingId = $"finding-{cve.ToLowerInvariant()}",
Cve = cve,
Purl = purl,
Decision = decision,
@@ -269,6 +278,8 @@ public sealed class VexGateEndpointsTests
},
};
}
private static readonly DateTimeOffset FixedNow = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
}
/// <summary>

View File

@@ -486,6 +486,22 @@ public sealed class EntryTraceExecutionServiceTests : IDisposable
_throwOnMissing = throwOnMissing;
}
public SurfaceSecretHandle Get(SurfaceSecretRequest request)
{
var key = (request.SecretType, request.Name ?? string.Empty);
if (_secrets.TryGetValue(key, out var payload))
{
return SurfaceSecretHandle.FromBytes(payload);
}
if (_throwOnMissing)
{
throw new SurfaceSecretNotFoundException(request);
}
return SurfaceSecretHandle.Empty;
}
public ValueTask<SurfaceSecretHandle> GetAsync(SurfaceSecretRequest request, CancellationToken cancellationToken = default)
{
var key = (request.SecretType, request.Name ?? string.Empty);

View File

@@ -145,6 +145,12 @@ public sealed class RegistrySecretStageExecutorTests
_json = json;
}
public SurfaceSecretHandle Get(SurfaceSecretRequest request)
{
var bytes = Encoding.UTF8.GetBytes(_json);
return SurfaceSecretHandle.FromBytes(bytes);
}
public ValueTask<SurfaceSecretHandle> GetAsync(SurfaceSecretRequest request, CancellationToken cancellationToken = default)
{
var bytes = Encoding.UTF8.GetBytes(_json);
@@ -154,6 +160,9 @@ public sealed class RegistrySecretStageExecutorTests
private sealed class MissingSecretProvider : ISurfaceSecretProvider
{
public SurfaceSecretHandle Get(SurfaceSecretRequest request)
=> throw new SurfaceSecretNotFoundException(request);
public ValueTask<SurfaceSecretHandle> GetAsync(SurfaceSecretRequest request, CancellationToken cancellationToken = default)
=> throw new SurfaceSecretNotFoundException(request);
}

View File

@@ -63,6 +63,8 @@ public sealed class ScannerStorageSurfaceSecretConfiguratorTests
_handle = handle;
}
public SurfaceSecretHandle Get(SurfaceSecretRequest request) => _handle;
public ValueTask<SurfaceSecretHandle> GetAsync(SurfaceSecretRequest request, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(_handle);
}