audit, advisories and doctors/setup work

This commit is contained in:
master
2026-01-13 18:53:39 +02:00
parent 9ca7cb183e
commit d7be6ba34b
811 changed files with 54242 additions and 4056 deletions

View File

@@ -2,13 +2,13 @@
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Exe</OutputType>
<OutputType>Library</OutputType>
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" />
<PackageReference Include="Serilog" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Serilog" Version="4.2.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,211 @@
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography;
using StellaOps.Scanner.Contracts;
using Xunit;
namespace StellaOps.Scanner.Analyzers.Native.Tests;
public sealed class ElfSectionHashExtractorTests
{
private static readonly TimeProvider FixedTimeProvider =
new FixedTimeProvider(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero));
[Fact]
public async Task ExtractAsync_ValidElf_ReturnsAllSections()
{
var extractor = CreateExtractor();
var fixture = GetFixturePath("standard-amd64.elf");
var golden = LoadGolden("standard-amd64.golden.json");
var result = await extractor.ExtractAsync(fixture);
result.Should().NotBeNull();
result!.Sections.Should().HaveCount(5);
result.FileHash.Should().Be(golden.FileHash);
AssertSection(result.Sections, ".text", golden.Sections[".text"]);
AssertSection(result.Sections, ".rodata", golden.Sections[".rodata"]);
AssertSection(result.Sections, ".data", golden.Sections[".data"]);
AssertSection(result.Sections, ".symtab", golden.Sections[".symtab"]);
AssertSection(result.Sections, ".dynsym", golden.Sections[".dynsym"]);
}
[Fact]
public async Task ExtractAsync_StrippedElf_OmitsSymtab()
{
var extractor = CreateExtractor();
var fixture = GetFixturePath("stripped-amd64.elf");
var result = await extractor.ExtractAsync(fixture);
result.Should().NotBeNull();
result!.Sections.Select(section => section.Name)
.Should().NotContain(".symtab");
result.Sections.Select(section => section.Name)
.Should().Contain(".dynsym");
}
[Fact]
public async Task ExtractAsync_InvalidElf_ReturnsNull()
{
var extractor = CreateExtractor();
var fixture = GetFixturePath("corrupt.bin");
var result = await extractor.ExtractAsync(fixture);
result.Should().BeNull();
}
[Fact]
public async Task ExtractAsync_EmptySection_ReturnsEmptyHash()
{
var extractor = CreateExtractor();
var fixture = GetFixturePath("minimal-amd64.elf");
var golden = LoadGolden("minimal-amd64.golden.json");
var result = await extractor.ExtractAsync(fixture);
result.Should().NotBeNull();
AssertSection(result!.Sections, ".data", golden.Sections[".data"]);
}
[Fact]
public async Task ExtractFromBytesAsync_MatchesFileExtraction()
{
var extractor = CreateExtractor();
var fixture = GetFixturePath("standard-amd64.elf");
var bytes = await File.ReadAllBytesAsync(fixture);
var fromFile = await extractor.ExtractAsync(fixture);
var fromBytes = await extractor.ExtractFromBytesAsync(bytes, fixture);
fromFile.Should().NotBeNull();
fromBytes.Should().NotBeNull();
fromBytes!.FileHash.Should().Be(fromFile!.FileHash);
fromBytes.Sections.Should().Equal(fromFile.Sections);
}
[Fact]
public async Task ExtractAsync_LargeSection_RespectsLimit()
{
var options = new ElfSectionHashOptions
{
MaxSectionSizeBytes = 4
};
var extractor = new ElfSectionHashExtractor(
FixedTimeProvider,
Options.Create(options));
var fixture = GetFixturePath("standard-amd64.elf");
var result = await extractor.ExtractAsync(fixture);
result.Should().NotBeNull();
result!.Sections.Select(section => section.Name)
.Should().NotContain(".text");
}
[Fact]
public async Task ExtractAsync_IncludesBlake3_WhenConfigured()
{
var options = new ElfSectionHashOptions();
options.Algorithms.Add("blake3");
var extractor = new ElfSectionHashExtractor(
FixedTimeProvider,
Options.Create(options),
DefaultCryptoHash.CreateForTests());
var fixture = GetFixturePath("standard-amd64.elf");
var result = await extractor.ExtractAsync(fixture);
result.Should().NotBeNull();
result!.Sections.Should().OnlyContain(section => !string.IsNullOrWhiteSpace(section.Blake3));
}
[Fact]
public async Task ExtractAsync_DeterministicAcrossRuns()
{
var extractor = CreateExtractor();
var fixture = GetFixturePath("standard-amd64.elf");
var first = await extractor.ExtractAsync(fixture);
var second = await extractor.ExtractAsync(fixture);
first.Should().NotBeNull();
second.Should().NotBeNull();
first!.FileHash.Should().Be(second!.FileHash);
first.Sections.Should().Equal(second.Sections);
first.ExtractedAt.Should().Be(second.ExtractedAt);
}
private static ElfSectionHashExtractor CreateExtractor()
{
var options = new ElfSectionHashOptions();
return new ElfSectionHashExtractor(FixedTimeProvider, Options.Create(options));
}
private static string GetFixturePath(string fileName)
{
var root = FindRepoRoot();
return Path.Combine(root, "src", "Scanner", "__Tests", "__Datasets", "elf-section-hashes", fileName);
}
private static string FindRepoRoot()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current != null)
{
if (Directory.Exists(Path.Combine(current.FullName, "docs")) &&
Directory.Exists(Path.Combine(current.FullName, "src")))
{
return current.FullName;
}
current = current.Parent;
}
throw new DirectoryNotFoundException("Repo root not found for fixtures.");
}
private static GoldenResult LoadGolden(string fileName)
{
var path = GetFixturePath(fileName);
var json = File.ReadAllText(path);
var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var fileHash = root.GetProperty("fileHash").GetString() ?? string.Empty;
var sectionsElement = root.GetProperty("sections");
var sections = new Dictionary<string, GoldenSection>(StringComparer.Ordinal);
foreach (var section in sectionsElement.EnumerateObject())
{
var sectionValue = section.Value;
sections[section.Name] = new GoldenSection
{
Sha256 = sectionValue.GetProperty("sha256").GetString() ?? string.Empty,
Size = sectionValue.GetProperty("size").GetInt32()
};
}
return new GoldenResult(fileHash, sections);
}
private static void AssertSection(
IEnumerable<ElfSectionHash> sections,
string name,
GoldenSection expected)
{
var section = sections.Single(s => s.Name == name);
section.Sha256.Should().Be(expected.Sha256);
section.Size.Should().Be(expected.Size);
}
private sealed record GoldenResult(string FileHash, IReadOnlyDictionary<string, GoldenSection> Sections);
private sealed record GoldenSection
{
public required string Sha256 { get; init; }
public required int Size { get; init; }
}
}

View File

@@ -37,8 +37,8 @@ public sealed class SecretRevelationServiceTests
// Act
var result = _service.ApplyPolicy("AKIAIOSFODNN7EXAMPLE", context);
// Assert
Assert.StartsWith("AKIA", result);
// Assert - with 20 chars, reveals 2 prefix + 2 suffix with masking in between
Assert.StartsWith("AK", result);
Assert.EndsWith("LE", result);
Assert.Contains("*", result);
}
@@ -95,8 +95,9 @@ public sealed class SecretRevelationServiceTests
var result = _service.ApplyPolicy("short", context);
// Assert
// Should not reveal more than safe amount
Assert.Contains("*", result);
// Short values (< 8 chars after masking) return [SECRET: N chars] format
Assert.StartsWith("[SECRET:", result);
Assert.Contains("chars]", result);
}
[Fact]

View File

@@ -56,7 +56,7 @@ public sealed class SecurityCardGeneratorTests
CreateSnapshot("target"));
// Assert
Assert.Single(cards);
Assert.Single(cards, CancellationToken.None);
Assert.Equal(ChangeCategory.Security, cards[0].Category);
Assert.Equal(95, cards[0].Priority); // Critical = 95
Assert.Equal("CVE-2024-1234", cards[0].Cves![0]);
@@ -93,7 +93,7 @@ public sealed class SecurityCardGeneratorTests
CreateSnapshot("target"));
// Assert
Assert.Single(cards);
Assert.Single(cards, CancellationToken.None);
Assert.Equal(90, cards[0].Priority); // High (80) + KEV boost (10) = 90
Assert.Contains("actively exploited (KEV)", cards[0].Why.Text);
}
@@ -129,7 +129,7 @@ public sealed class SecurityCardGeneratorTests
CreateSnapshot("target"));
// Assert
Assert.Single(cards);
Assert.Single(cards, CancellationToken.None);
Assert.Equal(85, cards[0].Priority); // High (80) + reachable boost (5) = 85
Assert.Contains("reachable from entry points", cards[0].Why.Text);
}
@@ -157,7 +157,7 @@ public sealed class SecurityCardGeneratorTests
// Arrange
var changes = new List<MaterialRiskChange>
{
new()
new(, CancellationToken.None)
{
ChangeId = "fixed-change-id",
RuleId = "cve-new",
@@ -175,7 +175,7 @@ public sealed class SecurityCardGeneratorTests
// Act
var cards1 = await _generator.GenerateCardsAsync(CreateSnapshot("base"), CreateSnapshot("target"));
var cards2 = await _generator.GenerateCardsAsync(CreateSnapshot("base"), CreateSnapshot("target"));
var cards2 = await _generator.GenerateCardsAsync(CreateSnapshot("base", CancellationToken.None), CreateSnapshot("target"));
// Assert
Assert.Equal(cards1[0].CardId, cards2[0].CardId);
@@ -189,3 +189,4 @@ public sealed class SecurityCardGeneratorTests
SbomDigest = $"sha256:sbom-{id}"
};
}

View File

@@ -12,7 +12,7 @@
<ItemGroup>
<PackageReference Include="Moq" />
<PackageReference Include="Microsoft.Extensions.Time.Testing" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,55 @@
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Time.Testing;
using Moq;
using StellaOps.Determinism;
using StellaOps.Scanner.Sources.ConnectionTesters;
using StellaOps.Scanner.Sources.Domain;
using StellaOps.Scanner.Sources.Services;
using Xunit;
namespace StellaOps.Scanner.Sources.Tests.ConnectionTesters;
public class GitConnectionTesterTests
{
[Fact]
public async Task TestAsync_WithSshUrl_ReturnsNotValidated()
{
var config = JsonDocument.Parse("""
{
"provider": "GitHub",
"repositoryUrl": "git@github.com:stellaops/sample.git",
"branches": { "include": ["main"] },
"triggers": { "onPush": true, "onPullRequest": false, "onTag": false },
"scanOptions": { "analyzers": ["sbom"] },
"authMethod": "Ssh"
}
""");
var guidProvider = new SequentialGuidProvider();
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));
var source = SbomSource.Create(
tenantId: "tenant-1",
name: "git-source",
sourceType: SbomSourceType.Git,
configuration: config,
createdBy: "tester",
timeProvider: timeProvider,
guidProvider: guidProvider);
var httpClientFactory = new Mock<IHttpClientFactory>();
var credentialResolver = new Mock<ICredentialResolver>();
var logger = new Mock<ILogger<GitConnectionTester>>();
var tester = new GitConnectionTester(
httpClientFactory.Object,
credentialResolver.Object,
logger.Object,
timeProvider);
var result = await tester.TestAsync(source, overrideCredentials: null, ct: default);
result.Success.Should().BeFalse();
result.Message.Should().Contain("SSH");
result.Details.Should().ContainKey("note");
}
}

View File

@@ -1,5 +1,7 @@
using System.Globalization;
using FluentAssertions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Determinism;
using StellaOps.Scanner.Sources.Domain;
using Xunit;
@@ -7,14 +9,16 @@ namespace StellaOps.Scanner.Sources.Tests.Domain;
public class SbomSourceRunTests
{
private static readonly FakeTimeProvider TimeProvider = new(DateTimeOffset.Parse("2026-01-01T00:00:00Z"));
private static readonly FakeTimeProvider TimeProvider = new(
DateTimeOffset.Parse("2026-01-01T00:00:00Z", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind));
[Fact]
public void Create_WithValidInputs_CreatesRunInRunningStatus()
{
// Arrange
var sourceId = Guid.NewGuid();
var correlationId = Guid.NewGuid().ToString("N");
var guidProvider = new SequentialGuidProvider();
var sourceId = new Guid("11111111-1111-1111-1111-111111111111");
var correlationId = new Guid("22222222-2222-2222-2222-222222222222").ToString("N");
// Act
var run = SbomSourceRun.Create(
@@ -23,10 +27,11 @@ public class SbomSourceRunTests
trigger: SbomSourceRunTrigger.Manual,
correlationId: correlationId,
timeProvider: TimeProvider,
guidProvider: guidProvider,
triggerDetails: "Triggered by user");
// Assert
run.RunId.Should().NotBeEmpty();
run.RunId.Should().Be(new Guid("00000000-0000-0000-0000-000000000001"));
run.SourceId.Should().Be(sourceId);
run.TenantId.Should().Be("tenant-1");
run.Trigger.Should().Be(SbomSourceRunTrigger.Manual);
@@ -41,7 +46,7 @@ public class SbomSourceRunTests
public void SetDiscoveredItems_UpdatesDiscoveryCount()
{
// Arrange
var run = CreateTestRun();
var run = CreateTestRun(new SequentialGuidProvider());
// Act
run.SetDiscoveredItems(10);
@@ -54,13 +59,14 @@ public class SbomSourceRunTests
public void RecordItemSuccess_IncrementsCounts()
{
// Arrange
var run = CreateTestRun();
var guidProvider = new SequentialGuidProvider();
var run = CreateTestRun(guidProvider);
run.SetDiscoveredItems(5);
// Act
var scanJobId = Guid.NewGuid();
var scanJobId = guidProvider.NewGuid();
run.RecordItemSuccess(scanJobId);
run.RecordItemSuccess(Guid.NewGuid());
run.RecordItemSuccess(guidProvider.NewGuid());
// Assert
run.ItemsScanned.Should().Be(2);
@@ -72,7 +78,7 @@ public class SbomSourceRunTests
public void RecordItemFailure_IncrementsCounts()
{
// Arrange
var run = CreateTestRun();
var run = CreateTestRun(new SequentialGuidProvider());
run.SetDiscoveredItems(5);
// Act
@@ -89,7 +95,7 @@ public class SbomSourceRunTests
public void RecordItemSkipped_IncrementsCounts()
{
// Arrange
var run = CreateTestRun();
var run = CreateTestRun(new SequentialGuidProvider());
run.SetDiscoveredItems(5);
// Act
@@ -104,11 +110,12 @@ public class SbomSourceRunTests
public void Complete_SetsSuccessStatusAndCompletedAt()
{
// Arrange
var run = CreateTestRun();
var guidProvider = new SequentialGuidProvider();
var run = CreateTestRun(guidProvider);
run.SetDiscoveredItems(3);
run.RecordItemSuccess(Guid.NewGuid());
run.RecordItemSuccess(Guid.NewGuid());
run.RecordItemSuccess(Guid.NewGuid());
run.RecordItemSuccess(guidProvider.NewGuid());
run.RecordItemSuccess(guidProvider.NewGuid());
run.RecordItemSuccess(guidProvider.NewGuid());
// Act
run.Complete(TimeProvider);
@@ -123,7 +130,7 @@ public class SbomSourceRunTests
public void Fail_SetsFailedStatusAndErrorMessage()
{
// Arrange
var run = CreateTestRun();
var run = CreateTestRun(new SequentialGuidProvider());
// Act
run.Fail("Connection timeout", TimeProvider, "Stack trace here");
@@ -139,7 +146,7 @@ public class SbomSourceRunTests
public void Cancel_SetsCancelledStatus()
{
// Arrange
var run = CreateTestRun();
var run = CreateTestRun(new SequentialGuidProvider());
// Act
run.Cancel("User requested cancellation", TimeProvider);
@@ -154,15 +161,16 @@ public class SbomSourceRunTests
public void MixedResults_TracksAllCountsCorrectly()
{
// Arrange
var run = CreateTestRun();
var guidProvider = new SequentialGuidProvider();
var run = CreateTestRun(guidProvider);
run.SetDiscoveredItems(10);
// Act
run.RecordItemSuccess(Guid.NewGuid()); // 1 success
run.RecordItemSuccess(Guid.NewGuid()); // 2 successes
run.RecordItemSuccess(guidProvider.NewGuid()); // 1 success
run.RecordItemSuccess(guidProvider.NewGuid()); // 2 successes
run.RecordItemFailure(); // 1 failure
run.RecordItemSkipped(); // 1 skipped
run.RecordItemSuccess(Guid.NewGuid()); // 3 successes
run.RecordItemSuccess(guidProvider.NewGuid()); // 3 successes
run.RecordItemFailure(); // 2 failures
// Assert
@@ -183,12 +191,14 @@ public class SbomSourceRunTests
string details)
{
// Arrange & Act
var guidProvider = new SequentialGuidProvider();
var run = SbomSourceRun.Create(
sourceId: Guid.NewGuid(),
sourceId: new Guid("33333333-3333-3333-3333-333333333333"),
tenantId: "tenant-1",
trigger: trigger,
correlationId: Guid.NewGuid().ToString("N"),
correlationId: new Guid("44444444-4444-4444-4444-444444444444").ToString("N"),
timeProvider: TimeProvider,
guidProvider: guidProvider,
triggerDetails: details);
// Assert
@@ -200,9 +210,10 @@ public class SbomSourceRunTests
public void Complete_WithMixedResults_SetsPartialSuccessStatus()
{
// Arrange
var run = CreateTestRun();
var guidProvider = new SequentialGuidProvider();
var run = CreateTestRun(guidProvider);
run.SetDiscoveredItems(3);
run.RecordItemSuccess(Guid.NewGuid());
run.RecordItemSuccess(guidProvider.NewGuid());
run.RecordItemFailure();
// Act
@@ -216,7 +227,7 @@ public class SbomSourceRunTests
public void Complete_WithNoSuccesses_SetsSkippedStatus()
{
// Arrange
var run = CreateTestRun();
var run = CreateTestRun(new SequentialGuidProvider());
run.SetDiscoveredItems(0);
// Act
@@ -226,13 +237,14 @@ public class SbomSourceRunTests
run.Status.Should().Be(SbomSourceRunStatus.Skipped);
}
private static SbomSourceRun CreateTestRun()
private static SbomSourceRun CreateTestRun(IGuidProvider guidProvider)
{
return SbomSourceRun.Create(
sourceId: Guid.NewGuid(),
sourceId: new Guid("11111111-1111-1111-1111-111111111111"),
tenantId: "tenant-1",
trigger: SbomSourceRunTrigger.Manual,
correlationId: Guid.NewGuid().ToString("N"),
timeProvider: TimeProvider);
correlationId: "corr-1",
timeProvider: TimeProvider,
guidProvider: guidProvider);
}
}

View File

@@ -1,6 +1,7 @@
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Determinism;
using StellaOps.Scanner.Sources.Domain;
using Xunit;
@@ -9,10 +10,12 @@ namespace StellaOps.Scanner.Sources.Tests.Domain;
public class SbomSourceTests
{
private readonly FakeTimeProvider _timeProvider;
private readonly SequentialGuidProvider _guidProvider;
public SbomSourceTests()
{
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 15, 10, 0, 0, TimeSpan.Zero));
_guidProvider = new SequentialGuidProvider();
}
private static readonly JsonDocument SampleConfig = JsonDocument.Parse("""
@@ -32,10 +35,11 @@ public class SbomSourceTests
sourceType: SbomSourceType.Zastava,
configuration: SampleConfig,
createdBy: "user-1",
timeProvider: _timeProvider);
timeProvider: _timeProvider,
guidProvider: _guidProvider);
// Assert
source.SourceId.Should().NotBeEmpty();
source.SourceId.Should().Be(new Guid("00000000-0000-0000-0000-000000000001"));
source.TenantId.Should().Be("tenant-1");
source.Name.Should().Be("test-source");
source.SourceType.Should().Be(SbomSourceType.Zastava);
@@ -56,6 +60,7 @@ public class SbomSourceTests
configuration: SampleConfig,
createdBy: "user-1",
timeProvider: _timeProvider,
guidProvider: _guidProvider,
cronSchedule: "0 * * * *"); // Every hour
// Assert
@@ -74,7 +79,8 @@ public class SbomSourceTests
sourceType: SbomSourceType.Zastava,
configuration: SampleConfig,
createdBy: "user-1",
timeProvider: _timeProvider);
timeProvider: _timeProvider,
guidProvider: _guidProvider);
// Assert
source.WebhookEndpoint.Should().NotBeNullOrEmpty();
@@ -91,7 +97,8 @@ public class SbomSourceTests
sourceType: SbomSourceType.Docker,
configuration: SampleConfig,
createdBy: "user-1",
timeProvider: _timeProvider);
timeProvider: _timeProvider,
guidProvider: _guidProvider);
// Act
source.Activate("activator", _timeProvider);
@@ -111,7 +118,8 @@ public class SbomSourceTests
sourceType: SbomSourceType.Docker,
configuration: SampleConfig,
createdBy: "user-1",
timeProvider: _timeProvider);
timeProvider: _timeProvider,
guidProvider: _guidProvider);
source.Activate("activator", _timeProvider);
// Act
@@ -134,7 +142,8 @@ public class SbomSourceTests
sourceType: SbomSourceType.Docker,
configuration: SampleConfig,
createdBy: "user-1",
timeProvider: _timeProvider);
timeProvider: _timeProvider,
guidProvider: _guidProvider);
source.Activate("activator", _timeProvider);
source.Pause("Maintenance", null, "operator", _timeProvider);
@@ -157,7 +166,8 @@ public class SbomSourceTests
sourceType: SbomSourceType.Docker,
configuration: SampleConfig,
createdBy: "user-1",
timeProvider: _timeProvider);
timeProvider: _timeProvider,
guidProvider: _guidProvider);
source.Activate("activator", _timeProvider);
// Simulate some failures
@@ -185,7 +195,8 @@ public class SbomSourceTests
sourceType: SbomSourceType.Docker,
configuration: SampleConfig,
createdBy: "user-1",
timeProvider: _timeProvider);
timeProvider: _timeProvider,
guidProvider: _guidProvider);
source.Activate("activator", _timeProvider);
// Act - fail multiple times
@@ -210,7 +221,8 @@ public class SbomSourceTests
sourceType: SbomSourceType.Docker,
configuration: SampleConfig,
createdBy: "user-1",
timeProvider: _timeProvider);
timeProvider: _timeProvider,
guidProvider: _guidProvider);
source.MaxScansPerHour = 10;
source.Activate("activator", _timeProvider);
@@ -231,7 +243,8 @@ public class SbomSourceTests
sourceType: SbomSourceType.Docker,
configuration: SampleConfig,
createdBy: "user-1",
timeProvider: _timeProvider);
timeProvider: _timeProvider,
guidProvider: _guidProvider);
var newConfig = JsonDocument.Parse("""
{

View File

@@ -0,0 +1,41 @@
using FluentAssertions;
using StellaOps.Scanner.Sources.Handlers.Docker;
using Xunit;
namespace StellaOps.Scanner.Sources.Tests.Handlers.Docker;
public class DockerSourceHandlerTests
{
[Fact]
public void ParseReference_KeepsRegistryPort_WhenNoTagProvided()
{
var reference = "registry.example.com:5000/repo/app";
var result = DockerSourceHandler.ParseReference(reference);
result.Repository.Should().Be("registry.example.com:5000/repo/app");
result.Tag.Should().BeNull();
}
[Fact]
public void ParseReference_ParsesTag_WhenAfterSlash()
{
var reference = "registry.example.com:5000/repo/app:1.2.3";
var result = DockerSourceHandler.ParseReference(reference);
result.Repository.Should().Be("registry.example.com:5000/repo/app");
result.Tag.Should().Be("1.2.3");
}
[Fact]
public void BuildFullReference_IncludesRegistryPort()
{
var reference = DockerSourceHandler.BuildFullReference(
"https://registry.example.com:5000",
"repo/app",
"1.2.3");
reference.Should().Be("registry.example.com:5000/repo/app:1.2.3");
}
}

View File

@@ -0,0 +1,43 @@
using System.Globalization;
using System.Text;
using FluentAssertions;
using StellaOps.Scanner.Sources.Persistence;
using Xunit;
namespace StellaOps.Scanner.Sources.Tests.Persistence;
public class CursorEncodingTests
{
[Fact]
public void Encode_UsesInvariantDigits()
{
var originalCulture = CultureInfo.CurrentCulture;
var originalUiCulture = CultureInfo.CurrentUICulture;
try
{
var testCulture = new CultureInfo("ar-SA");
CultureInfo.CurrentCulture = testCulture;
CultureInfo.CurrentUICulture = testCulture;
var encoded = CursorEncoding.Encode(123);
var expected = Convert.ToBase64String(Encoding.UTF8.GetBytes("123"));
encoded.Should().Be(expected);
}
finally
{
CultureInfo.CurrentCulture = originalCulture;
CultureInfo.CurrentUICulture = originalUiCulture;
}
}
[Fact]
public void Decode_RoundTripsEncodedValue()
{
var encoded = CursorEncoding.Encode(456);
var decoded = CursorEncoding.Decode(encoded);
decoded.Should().Be(456);
}
}

View File

@@ -6,10 +6,12 @@
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Sources/StellaOps.Scanner.Sources.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Determinism.Abstractions/StellaOps.Determinism.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />

View File

@@ -0,0 +1,21 @@
using FluentAssertions;
using StellaOps.Determinism;
using StellaOps.Scanner.Sources.Triggers;
using Xunit;
namespace StellaOps.Scanner.Sources.Tests.Triggers;
public class TriggerContextTests
{
[Fact]
public void Manual_UsesGuidProvider_WhenCorrelationIdNotProvided()
{
var expected = new SequentialGuidProvider().NewGuid().ToString("N");
var provider = new SequentialGuidProvider();
var context = TriggerContext.Manual("tester", guidProvider: provider);
context.CorrelationId.Should().Be(expected);
context.Metadata.Should().ContainKey("triggeredBy").WhoseValue.Should().Be("tester");
}
}

View File

@@ -0,0 +1,165 @@
using System.Net.Http.Headers;
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Scanner.Storage.Oci.Tests;
/// <summary>
/// Integration tests for OciImageInspector using a local OCI registry container.
/// </summary>
[Trait("Category", TestCategories.Integration)]
public sealed class OciImageInspectorIntegrationTests : IAsyncLifetime
{
private IContainer? _registryContainer;
private string _registryHost = string.Empty;
private HttpClient? _httpClient;
public async ValueTask InitializeAsync()
{
_registryContainer = new ContainerBuilder()
.WithImage("registry:2")
.WithPortBinding(5000, true)
.WithWaitStrategy(Wait.ForUnixContainer()
.UntilHttpRequestIsSucceeded(r => r.ForPath("/v2/").ForPort(5000)))
.Build();
await _registryContainer.StartAsync();
var port = _registryContainer.GetMappedPublicPort(5000);
_registryHost = $"localhost:{port}";
_httpClient = new HttpClient();
}
public async ValueTask DisposeAsync()
{
_httpClient?.Dispose();
if (_registryContainer is not null)
{
await _registryContainer.DisposeAsync();
}
}
[Fact]
public async Task InspectAsync_LocalRegistry_ReturnsManifest()
{
var published = await PushBaseImageAsync();
var inspector = new OciImageInspector(
new TestHttpClientFactory(_httpClient!),
new OciRegistryOptions { DefaultRegistry = _registryHost, AllowInsecure = true },
NullLogger<OciImageInspector>.Instance,
new FixedTimeProvider(new DateTimeOffset(2026, 1, 13, 12, 0, 0, TimeSpan.Zero)));
var result = await inspector.InspectAsync($"http://{_registryHost}/test/app:latest");
Assert.NotNull(result);
Assert.False(result!.IsMultiArch);
Assert.Equal(published.ManifestDigest, result.ResolvedDigest);
Assert.Single(result.Platforms);
Assert.Equal(published.ConfigDigest, result.Platforms[0].ConfigDigest);
Assert.Single(result.Platforms[0].Layers);
Assert.Equal(published.LayerDigest, result.Platforms[0].Layers[0].Digest);
}
private async Task<PublishedImage> PushBaseImageAsync()
{
var config = """{"created":"2026-01-13T00:00:00Z","architecture":"amd64","os":"linux"}"""u8.ToArray();
var configDigest = ComputeSha256Digest(config);
await PushBlobAsync("test/app", configDigest, config);
var layer = new byte[] { 0x01, 0x02, 0x03, 0x04 };
var layerDigest = ComputeSha256Digest(layer);
await PushBlobAsync("test/app", layerDigest, layer);
var manifest = $$"""
{
"schemaVersion": 2,
"mediaType": "{{OciMediaTypes.ImageManifest}}",
"config": {
"mediaType": "{{OciMediaTypes.ImageConfig}}",
"digest": "{{configDigest}}",
"size": {{config.Length}}
},
"layers": [
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": "{{layerDigest}}",
"size": {{layer.Length}}
}
]
}
""";
var manifestBytes = System.Text.Encoding.UTF8.GetBytes(manifest);
var manifestDigest = ComputeSha256Digest(manifestBytes);
var manifestUrl = $"http://{_registryHost}/v2/test/app/manifests/latest";
var request = new HttpRequestMessage(HttpMethod.Put, manifestUrl)
{
Content = new ByteArrayContent(manifestBytes)
};
request.Content.Headers.ContentType = new MediaTypeHeaderValue(OciMediaTypes.ImageManifest);
var response = await _httpClient!.SendAsync(request);
response.EnsureSuccessStatusCode();
return new PublishedImage(manifestDigest, configDigest, layerDigest);
}
private async Task PushBlobAsync(string repository, string digest, byte[] content)
{
var initiateUrl = $"http://{_registryHost}/v2/{repository}/blobs/uploads/";
var initiateRequest = new HttpRequestMessage(HttpMethod.Post, initiateUrl);
var initiateResponse = await _httpClient!.SendAsync(initiateRequest);
initiateResponse.EnsureSuccessStatusCode();
var uploadLocation = initiateResponse.Headers.Location?.ToString();
Assert.NotNull(uploadLocation);
var separator = uploadLocation.Contains('?') ? "&" : "?";
var uploadUrl = $"{uploadLocation}{separator}digest={Uri.EscapeDataString(digest)}";
if (!uploadUrl.StartsWith("http", StringComparison.OrdinalIgnoreCase))
{
uploadUrl = $"http://{_registryHost}{uploadUrl}";
}
var uploadRequest = new HttpRequestMessage(HttpMethod.Put, uploadUrl)
{
Content = new ByteArrayContent(content)
};
uploadRequest.Content.Headers.ContentType = new MediaTypeHeaderValue(OciMediaTypes.OctetStream);
var uploadResponse = await _httpClient!.SendAsync(uploadRequest);
uploadResponse.EnsureSuccessStatusCode();
}
private static string ComputeSha256Digest(byte[] content)
{
using var sha256 = System.Security.Cryptography.SHA256.Create();
var hash = sha256.ComputeHash(content);
return $"sha256:{Convert.ToHexStringLower(hash)}";
}
private sealed record PublishedImage(string ManifestDigest, string ConfigDigest, string LayerDigest);
private sealed class TestHttpClientFactory : IHttpClientFactory
{
private readonly HttpClient _client;
public TestHttpClientFactory(HttpClient client) => _client = client;
public HttpClient CreateClient(string name) => _client;
}
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _now;
public FixedTimeProvider(DateTimeOffset now) => _now = now;
public override DateTimeOffset GetUtcNow() => _now;
}
}

View File

@@ -0,0 +1,529 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Scanner.Storage.Oci.Tests;
public sealed class OciImageInspectorTests
{
private static readonly DateTimeOffset FixedNow = new(2026, 1, 13, 12, 0, 0, TimeSpan.Zero);
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task InspectAsync_SingleManifest_ReturnsPlatform()
{
var manifestDigest = "sha256:manifest123";
var configDigest = "sha256:config123";
var manifestJson = $$"""
{
"schemaVersion": 2,
"mediaType": "{{OciMediaTypes.ImageManifest}}",
"config": {
"mediaType": "{{OciMediaTypes.ImageConfig}}",
"digest": "{{configDigest}}",
"size": 64
},
"layers": [
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": "sha256:layer-one",
"size": 11
},
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": "sha256:layer-two",
"size": 22,
"annotations": {
"org.opencontainers.image.title": "layer-two"
}
}
]
}
""";
var configJson = """
{
"os": "linux",
"architecture": "amd64"
}
""";
var handler = new ScenarioHandler(request =>
{
var path = request.RequestUri?.AbsolutePath ?? string.Empty;
if (request.Method == HttpMethod.Head && path.EndsWith("/manifests/1.0", StringComparison.Ordinal))
{
return CreateHeadResponse(HttpStatusCode.OK, OciMediaTypes.ImageManifest, manifestDigest);
}
if (request.Method == HttpMethod.Get && path.EndsWith("/manifests/1.0", StringComparison.Ordinal))
{
return CreateJsonResponse(manifestJson, OciMediaTypes.ImageManifest, manifestDigest);
}
if (request.Method == HttpMethod.Get && path.EndsWith($"/blobs/{configDigest}", StringComparison.Ordinal))
{
return CreateJsonResponse(configJson, OciMediaTypes.ImageConfig, null);
}
return new HttpResponseMessage(HttpStatusCode.NotFound);
});
var inspector = new OciImageInspector(
new TestHttpClientFactory(handler),
new OciRegistryOptions { DefaultRegistry = "registry.example" },
NullLogger<OciImageInspector>.Instance,
new FixedTimeProvider(FixedNow));
var result = await inspector.InspectAsync("registry.example/demo/app:1.0");
Assert.NotNull(result);
Assert.False(result!.IsMultiArch);
Assert.Equal("registry.example", result.Registry);
Assert.Equal("demo/app", result.Repository);
Assert.Equal(manifestDigest, result.ResolvedDigest);
Assert.Equal(FixedNow, result.InspectedAt);
Assert.Single(result.Platforms);
var platform = result.Platforms[0];
Assert.Equal("linux", platform.Os);
Assert.Equal("amd64", platform.Architecture);
Assert.Equal(manifestDigest, platform.ManifestDigest);
Assert.Equal(configDigest, platform.ConfigDigest);
Assert.Equal(2, platform.Layers.Length);
Assert.Equal(0, platform.Layers[0].Order);
Assert.Equal(1, platform.Layers[1].Order);
Assert.Equal(platform.Layers.Sum(layer => layer.Size), platform.TotalSize);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task InspectAsync_MultiArchIndex_ReturnsSortedPlatforms()
{
var indexDigest = "sha256:index123";
var manifestA = "sha256:manifest-a";
var manifestB = "sha256:manifest-b";
var configA = "sha256:config-a";
var configB = "sha256:config-b";
var indexJson = $$"""
{
"schemaVersion": 2,
"mediaType": "{{OciMediaTypes.ImageIndex}}",
"manifests": [
{
"mediaType": "{{OciMediaTypes.ImageManifest}}",
"digest": "{{manifestB}}",
"size": 100,
"platform": { "os": "linux", "architecture": "arm64", "variant": "v8" }
},
{
"mediaType": "{{OciMediaTypes.ImageManifest}}",
"digest": "{{manifestA}}",
"size": 90,
"platform": { "os": "linux", "architecture": "amd64" }
}
]
}
""";
var manifestJsonA = $$"""
{
"schemaVersion": 2,
"mediaType": "{{OciMediaTypes.ImageManifest}}",
"config": { "mediaType": "{{OciMediaTypes.ImageConfig}}", "digest": "{{configA}}", "size": 10 },
"layers": []
}
""";
var manifestJsonB = $$"""
{
"schemaVersion": 2,
"mediaType": "{{OciMediaTypes.ImageManifest}}",
"config": { "mediaType": "{{OciMediaTypes.ImageConfig}}", "digest": "{{configB}}", "size": 10 },
"layers": []
}
""";
var configJsonA = """{ "os": "linux", "architecture": "amd64" }""";
var configJsonB = """{ "os": "linux", "architecture": "arm64", "variant": "v8" }""";
var handler = new ScenarioHandler(request =>
{
var path = request.RequestUri?.AbsolutePath ?? string.Empty;
if (request.Method == HttpMethod.Head && path.EndsWith("/manifests/latest", StringComparison.Ordinal))
{
return CreateHeadResponse(HttpStatusCode.OK, OciMediaTypes.ImageIndex, indexDigest);
}
if (request.Method == HttpMethod.Get && path.EndsWith("/manifests/latest", StringComparison.Ordinal))
{
return CreateJsonResponse(indexJson, OciMediaTypes.ImageIndex, indexDigest);
}
if (request.Method == HttpMethod.Head && path.EndsWith($"/manifests/{manifestA}", StringComparison.Ordinal))
{
return CreateHeadResponse(HttpStatusCode.OK, OciMediaTypes.ImageManifest, manifestA);
}
if (request.Method == HttpMethod.Get && path.EndsWith($"/manifests/{manifestA}", StringComparison.Ordinal))
{
return CreateJsonResponse(manifestJsonA, OciMediaTypes.ImageManifest, manifestA);
}
if (request.Method == HttpMethod.Head && path.EndsWith($"/manifests/{manifestB}", StringComparison.Ordinal))
{
return CreateHeadResponse(HttpStatusCode.OK, OciMediaTypes.ImageManifest, manifestB);
}
if (request.Method == HttpMethod.Get && path.EndsWith($"/manifests/{manifestB}", StringComparison.Ordinal))
{
return CreateJsonResponse(manifestJsonB, OciMediaTypes.ImageManifest, manifestB);
}
if (request.Method == HttpMethod.Get && path.EndsWith($"/blobs/{configA}", StringComparison.Ordinal))
{
return CreateJsonResponse(configJsonA, OciMediaTypes.ImageConfig, null);
}
if (request.Method == HttpMethod.Get && path.EndsWith($"/blobs/{configB}", StringComparison.Ordinal))
{
return CreateJsonResponse(configJsonB, OciMediaTypes.ImageConfig, null);
}
return new HttpResponseMessage(HttpStatusCode.NotFound);
});
var inspector = new OciImageInspector(
new TestHttpClientFactory(handler),
new OciRegistryOptions { DefaultRegistry = "registry.example" },
NullLogger<OciImageInspector>.Instance,
new FixedTimeProvider(FixedNow));
var result = await inspector.InspectAsync("registry.example/demo/app:latest");
Assert.NotNull(result);
Assert.True(result!.IsMultiArch);
Assert.Equal(2, result.Platforms.Length);
Assert.Equal("amd64", result.Platforms[0].Architecture);
Assert.Equal("arm64", result.Platforms[1].Architecture);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task InspectAsync_PlatformFilter_ReturnsSingleMatch()
{
var indexDigest = "sha256:index-filter";
var manifestA = "sha256:manifest-a";
var manifestB = "sha256:manifest-b";
var configA = "sha256:config-a";
var configB = "sha256:config-b";
var indexJson = $$"""
{
"schemaVersion": 2,
"mediaType": "{{OciMediaTypes.DockerManifestList}}",
"manifests": [
{
"mediaType": "{{OciMediaTypes.ImageManifest}}",
"digest": "{{manifestB}}",
"size": 100,
"platform": { "os": "linux", "architecture": "arm64", "variant": "v8" }
},
{
"mediaType": "{{OciMediaTypes.ImageManifest}}",
"digest": "{{manifestA}}",
"size": 90,
"platform": { "os": "linux", "architecture": "amd64" }
}
]
}
""";
var manifestJson = $$"""
{
"schemaVersion": 2,
"mediaType": "{{OciMediaTypes.ImageManifest}}",
"config": { "mediaType": "{{OciMediaTypes.ImageConfig}}", "digest": "{{configA}}", "size": 10 },
"layers": []
}
""";
var configJson = """{ "os": "linux", "architecture": "amd64" }""";
var handler = new ScenarioHandler(request =>
{
var path = request.RequestUri?.AbsolutePath ?? string.Empty;
if (request.Method == HttpMethod.Head && path.EndsWith("/manifests/latest", StringComparison.Ordinal))
{
return CreateHeadResponse(HttpStatusCode.OK, OciMediaTypes.DockerManifestList, indexDigest);
}
if (request.Method == HttpMethod.Get && path.EndsWith("/manifests/latest", StringComparison.Ordinal))
{
return CreateJsonResponse(indexJson, OciMediaTypes.DockerManifestList, indexDigest);
}
if (request.Method == HttpMethod.Head && path.EndsWith($"/manifests/{manifestA}", StringComparison.Ordinal))
{
return CreateHeadResponse(HttpStatusCode.OK, OciMediaTypes.ImageManifest, manifestA);
}
if (request.Method == HttpMethod.Get && path.EndsWith($"/manifests/{manifestA}", StringComparison.Ordinal))
{
return CreateJsonResponse(manifestJson, OciMediaTypes.ImageManifest, manifestA);
}
if (request.Method == HttpMethod.Get && path.EndsWith($"/blobs/{configA}", StringComparison.Ordinal))
{
return CreateJsonResponse(configJson, OciMediaTypes.ImageConfig, null);
}
if (request.Method == HttpMethod.Head && path.EndsWith($"/manifests/{manifestB}", StringComparison.Ordinal))
{
return CreateHeadResponse(HttpStatusCode.OK, OciMediaTypes.ImageManifest, manifestB);
}
if (request.Method == HttpMethod.Get && path.EndsWith($"/manifests/{manifestB}", StringComparison.Ordinal))
{
return CreateJsonResponse(manifestJson, OciMediaTypes.ImageManifest, manifestB);
}
if (request.Method == HttpMethod.Get && path.EndsWith($"/blobs/{configB}", StringComparison.Ordinal))
{
return CreateJsonResponse(configJson, OciMediaTypes.ImageConfig, null);
}
return new HttpResponseMessage(HttpStatusCode.NotFound);
});
var inspector = new OciImageInspector(
new TestHttpClientFactory(handler),
new OciRegistryOptions { DefaultRegistry = "registry.example" },
NullLogger<OciImageInspector>.Instance,
new FixedTimeProvider(FixedNow));
var options = new ImageInspectionOptions
{
PlatformFilter = "linux/amd64"
};
var result = await inspector.InspectAsync("registry.example/demo/app:latest", options);
Assert.NotNull(result);
Assert.Single(result!.Platforms);
Assert.Equal("amd64", result.Platforms[0].Architecture);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task InspectAsync_NotFound_ReturnsNull()
{
var handler = new ScenarioHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound));
var inspector = new OciImageInspector(
new TestHttpClientFactory(handler),
new OciRegistryOptions { DefaultRegistry = "registry.example" },
NullLogger<OciImageInspector>.Instance,
new FixedTimeProvider(FixedNow));
var result = await inspector.InspectAsync("registry.example/missing/app:latest");
Assert.Null(result);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task InspectAsync_AuthChallenge_RequestsToken()
{
var manifestDigest = "sha256:manifest-auth";
var configDigest = "sha256:config-auth";
var manifestJson = $$"""
{
"schemaVersion": 2,
"mediaType": "{{OciMediaTypes.ImageManifest}}",
"config": { "mediaType": "{{OciMediaTypes.ImageConfig}}", "digest": "{{configDigest}}", "size": 5 },
"layers": []
}
""";
var configJson = """{ "os": "linux", "architecture": "amd64" }""";
var sawBasic = false;
var sawBearer = false;
var sawTokenRequest = false;
var handler = new ScenarioHandler(request =>
{
var uri = request.RequestUri;
var path = uri?.AbsolutePath ?? string.Empty;
if (uri is not null && uri.Host.Equals("auth.local", StringComparison.OrdinalIgnoreCase))
{
sawTokenRequest = true;
return CreateJsonResponse("""{ "token": "token-123" }""", "application/json", null);
}
if (request.Method == HttpMethod.Head && path.EndsWith("/manifests/1.0", StringComparison.Ordinal))
{
return CreateHeadResponse(HttpStatusCode.OK, OciMediaTypes.ImageManifest, manifestDigest);
}
if (request.Method == HttpMethod.Get && path.EndsWith("/manifests/1.0", StringComparison.Ordinal))
{
var auth = request.Headers.Authorization;
if (auth is not null && auth.Scheme.Equals("Bearer", StringComparison.OrdinalIgnoreCase))
{
sawBearer = true;
return CreateJsonResponse(manifestJson, OciMediaTypes.ImageManifest, manifestDigest);
}
if (auth is not null && auth.Scheme.Equals("Basic", StringComparison.OrdinalIgnoreCase))
{
sawBasic = true;
}
var unauthorized = new HttpResponseMessage(HttpStatusCode.Unauthorized);
unauthorized.Headers.WwwAuthenticate.Add(new AuthenticationHeaderValue(
"Bearer",
"realm=\"http://auth.local/token\",service=\"registry\",scope=\"repository:demo/app:pull\""));
return unauthorized;
}
if (request.Method == HttpMethod.Get && path.EndsWith($"/blobs/{configDigest}", StringComparison.Ordinal))
{
return CreateJsonResponse(configJson, OciMediaTypes.ImageConfig, null);
}
return new HttpResponseMessage(HttpStatusCode.NotFound);
});
var inspector = new OciImageInspector(
new TestHttpClientFactory(handler),
new OciRegistryOptions
{
DefaultRegistry = "registry.example",
Auth = new OciRegistryAuthOptions
{
Username = "user",
Password = "pass",
AllowAnonymousFallback = false
}
},
NullLogger<OciImageInspector>.Instance,
new FixedTimeProvider(FixedNow));
var result = await inspector.InspectAsync("registry.example/demo/app:1.0");
Assert.NotNull(result);
Assert.True(sawBasic);
Assert.True(sawTokenRequest);
Assert.True(sawBearer);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task InspectAsync_InvalidManifest_ReturnsWarning()
{
var manifestDigest = "sha256:manifest-bad";
var handler = new ScenarioHandler(request =>
{
var path = request.RequestUri?.AbsolutePath ?? string.Empty;
if (request.Method == HttpMethod.Head && path.EndsWith("/manifests/1.0", StringComparison.Ordinal))
{
return CreateHeadResponse(HttpStatusCode.OK, OciMediaTypes.ImageManifest, manifestDigest);
}
if (request.Method == HttpMethod.Get && path.EndsWith("/manifests/1.0", StringComparison.Ordinal))
{
return CreateJsonResponse("not-json", OciMediaTypes.ImageManifest, manifestDigest);
}
return new HttpResponseMessage(HttpStatusCode.NotFound);
});
var inspector = new OciImageInspector(
new TestHttpClientFactory(handler),
new OciRegistryOptions { DefaultRegistry = "registry.example" },
NullLogger<OciImageInspector>.Instance,
new FixedTimeProvider(FixedNow));
var result = await inspector.InspectAsync("registry.example/demo/app:1.0");
Assert.NotNull(result);
Assert.Empty(result!.Platforms);
Assert.Contains(result.Warnings, warning => warning.Contains("missing config", StringComparison.OrdinalIgnoreCase));
}
private static HttpResponseMessage CreateHeadResponse(HttpStatusCode statusCode, string? mediaType, string? digest)
{
var response = new HttpResponseMessage(statusCode);
if (!string.IsNullOrWhiteSpace(mediaType))
{
response.Content = new ByteArrayContent(Array.Empty<byte>());
response.Content.Headers.ContentType = new MediaTypeHeaderValue(mediaType);
}
if (!string.IsNullOrWhiteSpace(digest))
{
response.Headers.TryAddWithoutValidation("Docker-Content-Digest", digest);
}
return response;
}
private static HttpResponseMessage CreateJsonResponse(string json, string mediaType, string? digest)
{
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, mediaType)
};
if (!string.IsNullOrWhiteSpace(digest))
{
response.Headers.TryAddWithoutValidation("Docker-Content-Digest", digest);
}
return response;
}
private sealed class ScenarioHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responder;
public ScenarioHandler(Func<HttpRequestMessage, HttpResponseMessage> responder)
{
_responder = responder;
}
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
return Task.FromResult(_responder(request));
}
}
private sealed class TestHttpClientFactory : IHttpClientFactory
{
private readonly HttpClient _client;
public TestHttpClientFactory(HttpMessageHandler handler)
{
_client = new HttpClient(handler);
}
public HttpClient CreateClient(string name) => _client;
}
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _now;
public FixedTimeProvider(DateTimeOffset now) => _now = now;
public override DateTimeOffset GetUtcNow() => _now;
}
}

View File

@@ -16,6 +16,9 @@ namespace StellaOps.Scanner.Worker.Tests;
public sealed class BinaryVulnerabilityAnalyzerTests
{
private static readonly Guid FixedScanId = new("11111111-1111-1111-1111-111111111111");
private static readonly Guid AlternateScanId = new("22222222-2222-2222-2222-222222222222");
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task AnalyzeLayerAsync_WithNoBinaryPaths_ReturnsEmptyResult()
@@ -32,7 +35,7 @@ public sealed class BinaryVulnerabilityAnalyzerTests
var context = new BinaryLayerContext
{
ScanId = Guid.NewGuid(),
ScanId = FixedScanId,
LayerDigest = "sha256:test",
BinaryPaths = Array.Empty<string>(),
OpenFile = _ => null
@@ -51,7 +54,7 @@ public sealed class BinaryVulnerabilityAnalyzerTests
public async Task AnalyzeLayerAsync_WithBinaryPaths_ExtractsIdentitiesAndLooksUpVulnerabilities()
{
// Arrange
var scanId = Guid.NewGuid();
var scanId = AlternateScanId;
var layerDigest = "sha256:abc123";
var buildId = "0123456789abcdef0123456789abcdef01234567";
@@ -163,7 +166,7 @@ public sealed class BinaryVulnerabilityAnalyzerTests
var context = new BinaryLayerContext
{
ScanId = Guid.NewGuid(),
ScanId = FixedScanId,
LayerDigest = "sha256:test",
BinaryPaths = ["/usr/lib/bad.so", "/usr/lib/good.so"],
OpenFile = _ => new MemoryStream([0x7F, 0x45, 0x4C, 0x46])
@@ -194,7 +197,7 @@ public sealed class BinaryVulnerabilityAnalyzerTests
var context = new BinaryLayerContext
{
ScanId = Guid.NewGuid(),
ScanId = FixedScanId,
LayerDigest = "sha256:test",
BinaryPaths = ["/usr/lib/missing.so"],
OpenFile = _ => null // All files fail to open
@@ -215,7 +218,7 @@ public sealed class BinaryVulnerabilityAnalyzerTests
// Arrange
var finding = new BinaryVulnerabilityFinding
{
ScanId = Guid.NewGuid(),
ScanId = FixedScanId,
LayerDigest = "sha256:test",
BinaryKey = "testkey",
CveId = "CVE-2024-5678",
@@ -240,7 +243,7 @@ public sealed class BinaryVulnerabilityAnalyzerTests
public void BinaryAnalysisResult_Empty_ReturnsValidEmptyResult()
{
// Arrange
var scanId = Guid.NewGuid();
var scanId = FixedScanId;
var layerDigest = "sha256:empty";
// Act

View File

@@ -11,6 +11,7 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Scanner.Analyzers.Lang;
using StellaOps.Scanner.Analyzers.Lang.Plugin;
using StellaOps.Scanner.Analyzers.Native.Index;
@@ -35,12 +36,16 @@ namespace StellaOps.Scanner.Worker.Tests;
public sealed class CompositeScanAnalyzerDispatcherTests
{
private static readonly DateTimeOffset FixedNow = new(2025, 12, 24, 12, 0, 0, TimeSpan.Zero);
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ExecuteAsync_RunsLanguageAnalyzers_StoresResults()
{
using var workspace = new TempDirectory();
using var cacheRoot = new TempDirectory();
var timeProvider = new FakeTimeProvider();
timeProvider.SetUtcNow(FixedNow);
Environment.SetEnvironmentVariable("SCANNER_SURFACE_FS_ENDPOINT", "https://surface.test");
Environment.SetEnvironmentVariable("SCANNER_SURFACE_FS_BUCKET", "unit-test-bucket");
@@ -70,7 +75,7 @@ public sealed class CompositeScanAnalyzerDispatcherTests
var serviceCollection = new ServiceCollection();
serviceCollection.AddSingleton<IConfiguration>(new ConfigurationBuilder().Build());
serviceCollection.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Debug));
serviceCollection.AddSingleton(TimeProvider.System);
serviceCollection.AddSingleton<TimeProvider>(timeProvider);
serviceCollection.AddSurfaceEnvironment(options => options.ComponentName = "Scanner.Worker");
serviceCollection.AddSurfaceValidation();
serviceCollection.AddSingleton<ISurfaceValidatorRunner, NoopSurfaceValidatorRunner>();
@@ -121,14 +126,14 @@ public sealed class CompositeScanAnalyzerDispatcherTests
metrics,
new TestCryptoHash());
var lease = new TestJobLease(metadata);
var context = new ScanJobContext(lease, TimeProvider.System, TimeProvider.System.GetUtcNow(), TestContext.Current.CancellationToken);
var lease = new TestJobLease(metadata, timeProvider, jobId: "job-lang-1", scanId: "scan-lang-1");
var context = new ScanJobContext(lease, timeProvider, timeProvider.GetUtcNow(), TestContext.Current.CancellationToken);
await dispatcher.ExecuteAsync(context, TestContext.Current.CancellationToken);
// Re-run with a new context to exercise cache reuse.
var leaseSecond = new TestJobLease(metadata);
var contextSecond = new ScanJobContext(leaseSecond, TimeProvider.System, TimeProvider.System.GetUtcNow(), TestContext.Current.CancellationToken);
var leaseSecond = new TestJobLease(metadata, timeProvider, jobId: "job-lang-2", scanId: "scan-lang-2");
var contextSecond = new ScanJobContext(leaseSecond, timeProvider, timeProvider.GetUtcNow(), TestContext.Current.CancellationToken);
await dispatcher.ExecuteAsync(contextSecond, TestContext.Current.CancellationToken);
meterListener.RecordObservableInstruments();
@@ -161,6 +166,8 @@ public sealed class CompositeScanAnalyzerDispatcherTests
{
using var rootfs = new TempDirectory();
using var cacheRoot = new TempDirectory();
var timeProvider = new FakeTimeProvider();
timeProvider.SetUtcNow(FixedNow);
Environment.SetEnvironmentVariable("SCANNER_SURFACE_FS_ENDPOINT", "https://surface.test");
Environment.SetEnvironmentVariable("SCANNER_SURFACE_FS_BUCKET", "unit-test-bucket");
@@ -194,7 +201,7 @@ public sealed class CompositeScanAnalyzerDispatcherTests
var serviceCollection = new ServiceCollection();
serviceCollection.AddSingleton<IConfiguration>(new ConfigurationBuilder().Build());
serviceCollection.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Debug));
serviceCollection.AddSingleton(TimeProvider.System);
serviceCollection.AddSingleton<TimeProvider>(timeProvider);
serviceCollection.AddSurfaceEnvironment(options => options.ComponentName = "Scanner.Worker");
serviceCollection.AddSurfaceValidation();
serviceCollection.AddSingleton<ISurfaceValidatorRunner, NoopSurfaceValidatorRunner>();
@@ -245,13 +252,13 @@ public sealed class CompositeScanAnalyzerDispatcherTests
metrics,
new TestCryptoHash());
var lease = new TestJobLease(metadata);
var context = new ScanJobContext(lease, TimeProvider.System, TimeProvider.System.GetUtcNow(), TestContext.Current.CancellationToken);
var lease = new TestJobLease(metadata, timeProvider, jobId: "job-os-1", scanId: "scan-os-1");
var context = new ScanJobContext(lease, timeProvider, timeProvider.GetUtcNow(), TestContext.Current.CancellationToken);
await dispatcher.ExecuteAsync(context, TestContext.Current.CancellationToken);
var leaseSecond = new TestJobLease(metadata);
var contextSecond = new ScanJobContext(leaseSecond, TimeProvider.System, TimeProvider.System.GetUtcNow(), TestContext.Current.CancellationToken);
var leaseSecond = new TestJobLease(metadata, timeProvider, jobId: "job-os-2", scanId: "scan-os-2");
var contextSecond = new ScanJobContext(leaseSecond, timeProvider, timeProvider.GetUtcNow(), TestContext.Current.CancellationToken);
await dispatcher.ExecuteAsync(contextSecond, TestContext.Current.CancellationToken);
Assert.Equal(1, analyzer.InvocationCount);
@@ -281,6 +288,8 @@ public sealed class CompositeScanAnalyzerDispatcherTests
public async Task ExecuteAsync_RunsNativeAnalyzer_AppendsFileComponents()
{
using var rootfs = new TempDirectory();
var timeProvider = new FakeTimeProvider();
timeProvider.SetUtcNow(FixedNow);
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
@@ -300,7 +309,7 @@ public sealed class CompositeScanAnalyzerDispatcherTests
var serviceCollection = new ServiceCollection();
serviceCollection.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Debug));
serviceCollection.AddSingleton(TimeProvider.System);
serviceCollection.AddSingleton<TimeProvider>(timeProvider);
serviceCollection.AddSingleton<ScannerWorkerMetrics>();
serviceCollection.AddSingleton<IOptions<StellaOps.Scanner.Worker.Options.NativeAnalyzerOptions>>(
Microsoft.Extensions.Options.Options.Create(new StellaOps.Scanner.Worker.Options.NativeAnalyzerOptions
@@ -334,8 +343,8 @@ public sealed class CompositeScanAnalyzerDispatcherTests
metrics,
new TestCryptoHash());
var lease = new TestJobLease(metadata);
var context = new ScanJobContext(lease, TimeProvider.System, TimeProvider.System.GetUtcNow(), TestContext.Current.CancellationToken);
var lease = new TestJobLease(metadata, timeProvider, jobId: "job-native-1", scanId: "scan-native-1");
var context = new ScanJobContext(lease, timeProvider, timeProvider.GetUtcNow(), TestContext.Current.CancellationToken);
await dispatcher.ExecuteAsync(context, TestContext.Current.CancellationToken);
@@ -460,14 +469,15 @@ public sealed class CompositeScanAnalyzerDispatcherTests
{
private readonly Dictionary<string, string> _metadata;
public TestJobLease(Dictionary<string, string> metadata)
public TestJobLease(Dictionary<string, string> metadata, TimeProvider timeProvider, string jobId, string scanId)
{
_metadata = metadata;
JobId = Guid.NewGuid().ToString("n");
ScanId = $"scan-{Guid.NewGuid():n}";
JobId = jobId;
ScanId = scanId;
Attempt = 1;
EnqueuedAtUtc = DateTimeOffset.UtcNow.AddSeconds(-1);
LeasedAtUtc = DateTimeOffset.UtcNow;
var now = timeProvider.GetUtcNow();
EnqueuedAtUtc = now.AddSeconds(-1);
LeasedAtUtc = now;
LeaseDuration = TimeSpan.FromMinutes(5);
}
@@ -498,9 +508,12 @@ public sealed class CompositeScanAnalyzerDispatcherTests
private sealed class TempDirectory : IDisposable
{
private static int _counter;
public TempDirectory()
{
Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"stellaops-test-{Guid.NewGuid():n}");
var suffix = Interlocked.Increment(ref _counter).ToString("D4", System.Globalization.CultureInfo.InvariantCulture);
Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"stellaops-test-{suffix}");
Directory.CreateDirectory(Path);
}

View File

@@ -13,6 +13,8 @@ namespace StellaOps.Scanner.Worker.Tests.Determinism;
public sealed class FidelityMetricsIntegrationTests
{
private static readonly DateTimeOffset FixedNow = new(2025, 12, 24, 12, 0, 0, TimeSpan.Zero);
[Fact]
public void DeterminismReport_WithFidelityMetrics_IncludesAllThreeTiers()
{
@@ -108,7 +110,7 @@ public sealed class FidelityMetricsIntegrationTests
IdenticalOutputs = (int)(totalReplays * bitwiseFidelity),
SemanticMatches = (int)(totalReplays * semanticFidelity),
PolicyMatches = (int)(totalReplays * policyFidelity),
ComputedAt = DateTimeOffset.UtcNow
ComputedAt = FixedNow
};
}

View File

@@ -6,6 +6,7 @@ namespace StellaOps.Scanner.Worker.Tests.Determinism;
public sealed class FidelityMetricsServiceTests
{
private static readonly DateTimeOffset FixedNow = new(2025, 12, 24, 12, 0, 0, TimeSpan.Zero);
private readonly FidelityMetricsService _service = new();
[Fact]
@@ -97,7 +98,7 @@ public sealed class FidelityMetricsServiceTests
IdenticalOutputs = 10,
SemanticMatches = 10,
PolicyMatches = 10,
ComputedAt = DateTimeOffset.UtcNow
ComputedAt = FixedNow
};
var thresholds = FidelityThresholds.Default;
@@ -120,7 +121,7 @@ public sealed class FidelityMetricsServiceTests
IdenticalOutputs = 9,
SemanticMatches = 10,
PolicyMatches = 10,
ComputedAt = DateTimeOffset.UtcNow
ComputedAt = FixedNow
};
var thresholds = FidelityThresholds.Default;
@@ -143,7 +144,7 @@ public sealed class FidelityMetricsServiceTests
IdenticalOutputs = 8,
SemanticMatches = 10,
PolicyMatches = 10,
ComputedAt = DateTimeOffset.UtcNow
ComputedAt = FixedNow
};
var thresholds = FidelityThresholds.Default;
@@ -165,7 +166,7 @@ public sealed class FidelityMetricsServiceTests
IdenticalOutputs = 9,
SemanticMatches = 10,
PolicyMatches = 10,
ComputedAt = DateTimeOffset.UtcNow
ComputedAt = FixedNow
};
var thresholds = FidelityThresholds.Default;
@@ -188,7 +189,7 @@ public sealed class FidelityMetricsServiceTests
IdenticalOutputs = 9,
SemanticMatches = 8,
PolicyMatches = 7,
ComputedAt = DateTimeOffset.UtcNow
ComputedAt = FixedNow
};
var thresholds = FidelityThresholds.Default;

View File

@@ -59,7 +59,7 @@ public sealed class PolicyFidelityCalculatorTests
Assert.Equal(0, matchCount);
Assert.Single(mismatches);
Assert.Equal(FidelityMismatchType.PolicyDrift, mismatches[0].Type);
Assert.Contains("outcome:TrueFalse", mismatches[0].AffectedArtifacts!);
Assert.Contains("outcome:True->False", mismatches[0].AffectedArtifacts!);
}
[Fact]
@@ -101,7 +101,7 @@ public sealed class PolicyFidelityCalculatorTests
var (score, matchCount, mismatches) = _calculator.Calculate(baseline, replays);
Assert.Equal(0.0, score);
Assert.Contains("violations:05", mismatches[0].AffectedArtifacts!);
Assert.Contains("violations:0->5", mismatches[0].AffectedArtifacts!);
}
[Fact]
@@ -122,7 +122,7 @@ public sealed class PolicyFidelityCalculatorTests
var (score, matchCount, mismatches) = _calculator.Calculate(baseline, replays);
Assert.Equal(0.0, score);
Assert.Contains("block_level:nonewarn", mismatches[0].AffectedArtifacts!);
Assert.Contains("block_level:none->warn", mismatches[0].AffectedArtifacts!);
}
[Fact]

View File

@@ -4,6 +4,7 @@ using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Core.Entropy;
using StellaOps.Scanner.Worker.Processing;
@@ -15,45 +16,64 @@ namespace StellaOps.Scanner.Worker.Tests;
public class EntropyStageExecutorTests
{
private static readonly DateTimeOffset FixedNow = new(2025, 12, 24, 12, 0, 0, TimeSpan.Zero);
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ExecuteAsync_WritesEntropyReportAndSummary()
{
// Arrange: create a temp file with random bytes to yield high entropy.
var tmp = Path.GetTempFileName();
var rng = new Random(1234);
var bytes = new byte[64 * 1024];
rng.NextBytes(bytes);
File.WriteAllBytes(tmp, bytes);
var fileEntries = new List<ScanFileEntry>
var tmp = Path.Combine(Path.GetTempPath(), "stellaops-tests", "entropy-stage", "entropy.bin");
Directory.CreateDirectory(Path.GetDirectoryName(tmp)!);
if (File.Exists(tmp))
{
new ScanFileEntry(tmp, SizeBytes: bytes.LongLength, Kind: "blob", Metadata: new Dictionary<string, string>())
};
File.Delete(tmp);
}
try
{
var rng = new Random(1234);
var bytes = new byte[64 * 1024];
rng.NextBytes(bytes);
File.WriteAllBytes(tmp, bytes);
var lease = new StubLease("job-1", "scan-1", imageDigest: "sha256:test", layerDigest: "sha256:layer");
var context = new ScanJobContext(lease, TimeProvider.System, DateTimeOffset.UtcNow, TestContext.Current.CancellationToken);
context.Analysis.Set(ScanAnalysisKeys.FileEntries, (IReadOnlyList<ScanFileEntry>)fileEntries);
var fileEntries = new List<ScanFileEntry>
{
new ScanFileEntry(tmp, SizeBytes: bytes.LongLength, Kind: "blob", Metadata: new Dictionary<string, string>())
};
var executor = new EntropyStageExecutor(NullLogger<EntropyStageExecutor>.Instance);
var timeProvider = new FakeTimeProvider();
timeProvider.SetUtcNow(FixedNow);
var lease = new StubLease(timeProvider, "job-1", "scan-1", imageDigest: "sha256:test", layerDigest: "sha256:layer");
var context = new ScanJobContext(lease, timeProvider, timeProvider.GetUtcNow(), TestContext.Current.CancellationToken);
context.Analysis.Set(ScanAnalysisKeys.FileEntries, (IReadOnlyList<ScanFileEntry>)fileEntries);
// Act
await executor.ExecuteAsync(context, TestContext.Current.CancellationToken);
var executor = new EntropyStageExecutor(NullLogger<EntropyStageExecutor>.Instance);
// Assert
Assert.True(context.Analysis.TryGet<EntropyReport>(ScanAnalysisKeys.EntropyReport, out var report));
Assert.NotNull(report);
Assert.Equal("sha256:layer", report!.LayerDigest);
Assert.NotEmpty(report.Files);
// Act
await executor.ExecuteAsync(context, TestContext.Current.CancellationToken);
Assert.True(context.Analysis.TryGet<EntropyLayerSummary>(ScanAnalysisKeys.EntropyLayerSummary, out var summary));
Assert.NotNull(summary);
Assert.Equal("sha256:layer", summary!.LayerDigest);
// Assert
Assert.True(context.Analysis.TryGet<EntropyReport>(ScanAnalysisKeys.EntropyReport, out var report));
Assert.NotNull(report);
Assert.Equal("sha256:layer", report!.LayerDigest);
Assert.NotEmpty(report.Files);
Assert.True(context.Analysis.TryGet<EntropyLayerSummary>(ScanAnalysisKeys.EntropyLayerSummary, out var summary));
Assert.NotNull(summary);
Assert.Equal("sha256:layer", summary!.LayerDigest);
}
finally
{
if (File.Exists(tmp))
{
File.Delete(tmp);
}
}
}
private sealed class StubLease : IScanJobLease
{
public StubLease(string jobId, string scanId, string imageDigest, string layerDigest)
public StubLease(TimeProvider timeProvider, string jobId, string scanId, string imageDigest, string layerDigest)
{
JobId = jobId;
ScanId = scanId;
@@ -62,13 +82,16 @@ public class EntropyStageExecutorTests
["image.digest"] = imageDigest,
["layerDigest"] = layerDigest
};
var now = timeProvider.GetUtcNow();
EnqueuedAtUtc = now;
LeasedAtUtc = now;
}
public string JobId { get; }
public string ScanId { get; }
public int Attempt => 1;
public DateTimeOffset EnqueuedAtUtc => DateTimeOffset.UtcNow;
public DateTimeOffset LeasedAtUtc => DateTimeOffset.UtcNow;
public DateTimeOffset EnqueuedAtUtc { get; }
public DateTimeOffset LeasedAtUtc { get; }
public TimeSpan LeaseDuration => TimeSpan.FromMinutes(5);
public IReadOnlyDictionary<string, string> Metadata { get; }

View File

@@ -10,6 +10,7 @@ using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.EntryTrace;
using StellaOps.Scanner.EntryTrace.Runtime;
@@ -28,11 +29,15 @@ namespace StellaOps.Scanner.Worker.Tests;
public sealed class EntryTraceExecutionServiceTests : IDisposable
{
private static readonly DateTimeOffset FixedNow = new(2025, 12, 24, 12, 0, 0, TimeSpan.Zero);
private static int _instanceCounter;
private static int _tempCounter;
private readonly string _tempRoot;
public EntryTraceExecutionServiceTests()
{
_tempRoot = Path.Combine(Path.GetTempPath(), $"entrytrace-service-{Guid.NewGuid():n}");
var suffix = Interlocked.Increment(ref _instanceCounter).ToString("D4", System.Globalization.CultureInfo.InvariantCulture);
_tempRoot = Path.Combine(Path.GetTempPath(), $"entrytrace-service-{suffix}");
Directory.CreateDirectory(_tempRoot);
}
@@ -207,15 +212,21 @@ public sealed class EntryTraceExecutionServiceTests : IDisposable
hash);
}
private static ScanJobContext CreateContext(IReadOnlyDictionary<string, string> metadata)
private static ScanJobContext CreateContext(
IReadOnlyDictionary<string, string> metadata,
string jobId = "job-entrytrace",
string scanId = "scan-entrytrace")
{
var lease = new TestLease(metadata);
return new ScanJobContext(lease, TimeProvider.System, DateTimeOffset.UtcNow, TestContext.Current.CancellationToken);
var timeProvider = new FakeTimeProvider();
timeProvider.SetUtcNow(FixedNow);
var lease = new TestLease(metadata, timeProvider, jobId, scanId);
return new ScanJobContext(lease, timeProvider, timeProvider.GetUtcNow(), TestContext.Current.CancellationToken);
}
private Dictionary<string, string> CreateMetadata(params string[] environmentEntries)
{
var configPath = Path.Combine(_tempRoot, $"config-{Guid.NewGuid():n}.json");
var suffix = Interlocked.Increment(ref _tempCounter).ToString("D4", System.Globalization.CultureInfo.InvariantCulture);
var configPath = Path.Combine(_tempRoot, $"config-{suffix}.json");
var env = environmentEntries.Length == 0
? new[] { "PATH=/bin:/usr/bin" }
: environmentEntries;
@@ -232,7 +243,7 @@ public sealed class EntryTraceExecutionServiceTests : IDisposable
}
""");
var rootDirectory = Path.Combine(_tempRoot, $"root-{Guid.NewGuid():n}");
var rootDirectory = Path.Combine(_tempRoot, $"root-{suffix}");
Directory.CreateDirectory(rootDirectory);
File.WriteAllText(Path.Combine(rootDirectory, "entrypoint.sh"), "#!/bin/sh\necho hello\n");
@@ -320,16 +331,19 @@ public sealed class EntryTraceExecutionServiceTests : IDisposable
{
private readonly IReadOnlyDictionary<string, string> _metadata;
public TestLease(IReadOnlyDictionary<string, string> metadata)
public TestLease(IReadOnlyDictionary<string, string> metadata, TimeProvider timeProvider, string jobId, string scanId)
{
_metadata = metadata;
EnqueuedAtUtc = DateTimeOffset.UtcNow;
LeasedAtUtc = EnqueuedAtUtc;
JobId = jobId;
ScanId = scanId;
var now = timeProvider.GetUtcNow();
EnqueuedAtUtc = now;
LeasedAtUtc = now;
}
public string JobId { get; } = $"job-{Guid.NewGuid():n}";
public string JobId { get; }
public string ScanId { get; } = $"scan-{Guid.NewGuid():n}";
public string ScanId { get; }
public int Attempt => 1;
@@ -368,7 +382,7 @@ public sealed class EntryTraceExecutionServiceTests : IDisposable
new SurfaceSecretsConfiguration("inline", "tenant", null, null, null, AllowInline: true),
"tenant",
new SurfaceTlsConfiguration(null, null, null))
{ CreatedAtUtc = DateTimeOffset.UtcNow };
{ CreatedAtUtc = FixedNow };
RawVariables = new Dictionary<string, string>();
}

View File

@@ -1,5 +1,6 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using Moq;
using StellaOps.Scanner.Core.Epss;
using StellaOps.Scanner.Storage.Epss;
@@ -51,7 +52,7 @@ public sealed class EpssEnrichmentJobTests
score: 0.70,
percentile: 0.995,
modelDate: modelDate,
capturedAt: DateTimeOffset.Parse("2027-01-16T00:07:00Z"),
capturedAt: new DateTimeOffset(2027, 1, 16, 0, 7, 0, TimeSpan.Zero),
source: "test",
fromCache: false)
},
@@ -88,7 +89,7 @@ public sealed class EpssEnrichmentJobTests
CriticalPercentile = 0.995,
MediumPercentile = 0.90,
}),
TimeProvider.System,
CreateTimeProvider(),
NullLogger<EpssEnrichmentJob>.Instance);
await job.EnrichAsync();
@@ -98,4 +99,11 @@ public sealed class EpssEnrichmentJobTests
Assert.Equal(EpssPriorityBand.Medium.ToString(), published[0].oldBand);
Assert.Equal(EpssPriorityBand.Critical.ToString(), published[0].newBand);
}
private static TimeProvider CreateTimeProvider()
{
var timeProvider = new FakeTimeProvider();
timeProvider.SetUtcNow(new DateTimeOffset(2027, 1, 16, 0, 8, 0, TimeSpan.Zero));
return timeProvider;
}
}

View File

@@ -1,5 +1,6 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using Npgsql;
using StellaOps.Scanner.Core.Epss;
using StellaOps.Scanner.Storage;
@@ -60,11 +61,15 @@ public sealed class EpssSignalFlowIntegrationTests : IAsyncLifetime
var observedCveRepository = new PostgresObservedCveRepository(_dataSource);
var day1 = new DateOnly(2027, 1, 15);
var run1 = await epssRepository.BeginImportAsync(day1, "bundle://day1.csv.gz", DateTimeOffset.Parse("2027-01-15T00:05:00Z"), "sha256:day1");
var run1 = await epssRepository.BeginImportAsync(
day1,
"bundle://day1.csv.gz",
new DateTimeOffset(2027, 1, 15, 0, 5, 0, TimeSpan.Zero),
"sha256:day1");
var write1 = await epssRepository.WriteSnapshotAsync(
run1.ImportRunId,
day1,
DateTimeOffset.Parse("2027-01-15T00:06:00Z"),
new DateTimeOffset(2027, 1, 15, 0, 6, 0, TimeSpan.Zero),
ToAsync(new[]
{
new EpssScoreRow("CVE-2024-0001", 0.40, 0.90),
@@ -73,11 +78,15 @@ public sealed class EpssSignalFlowIntegrationTests : IAsyncLifetime
await epssRepository.MarkImportSucceededAsync(run1.ImportRunId, write1.RowCount, "sha256:decompressed1", "v2027.01.15", day1);
var day2 = new DateOnly(2027, 1, 16);
var run2 = await epssRepository.BeginImportAsync(day2, "bundle://day2.csv.gz", DateTimeOffset.Parse("2027-01-16T00:05:00Z"), "sha256:day2");
var run2 = await epssRepository.BeginImportAsync(
day2,
"bundle://day2.csv.gz",
new DateTimeOffset(2027, 1, 16, 0, 5, 0, TimeSpan.Zero),
"sha256:day2");
var write2 = await epssRepository.WriteSnapshotAsync(
run2.ImportRunId,
day2,
DateTimeOffset.Parse("2027-01-16T00:06:00Z"),
new DateTimeOffset(2027, 1, 16, 0, 6, 0, TimeSpan.Zero),
ToAsync(new[]
{
new EpssScoreRow("CVE-2024-0001", 0.55, 0.95),
@@ -107,7 +116,7 @@ public sealed class EpssSignalFlowIntegrationTests : IAsyncLifetime
Enabled = true,
BatchSize = 500
}),
TimeProvider.System,
CreateTimeProvider(),
NullLogger<EpssSignalJob>.Instance);
await job.GenerateSignalsAsync();
@@ -167,6 +176,13 @@ public sealed class EpssSignalFlowIntegrationTests : IAsyncLifetime
public Task<bool> IsAvailableAsync(CancellationToken cancellationToken = default) => Task.FromResult(true);
}
private static TimeProvider CreateTimeProvider()
{
var timeProvider = new FakeTimeProvider();
timeProvider.SetUtcNow(new DateTimeOffset(2027, 1, 16, 0, 8, 0, TimeSpan.Zero));
return timeProvider;
}
private sealed class RecordingEpssSignalPublisher : IEpssSignalPublisher
{
public List<EpssSignal> Published { get; } = new();

View File

@@ -1,5 +1,6 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using Moq;
using StellaOps.Scanner.Core.Epss;
using StellaOps.Scanner.Storage.Epss;
@@ -26,7 +27,7 @@ public sealed class EpssSignalJobTests
ImportRunId: Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
ModelDate: modelDate,
SourceUri: "bundle://test.csv.gz",
RetrievedAtUtc: DateTimeOffset.Parse("2027-01-16T00:05:00Z"),
RetrievedAtUtc: new DateTimeOffset(2027, 1, 16, 0, 5, 0, TimeSpan.Zero),
FileSha256: "sha256:test",
DecompressedSha256: "sha256:decompressed",
RowCount: 3,
@@ -34,7 +35,7 @@ public sealed class EpssSignalJobTests
PublishedDate: modelDate,
Status: "SUCCEEDED",
Error: null,
CreatedAtUtc: DateTimeOffset.Parse("2027-01-16T00:06:00Z")));
CreatedAtUtc: new DateTimeOffset(2027, 1, 16, 0, 6, 0, TimeSpan.Zero)));
var changes = new List<EpssChangeRecord>
{
@@ -123,7 +124,7 @@ public sealed class EpssSignalJobTests
Enabled = true,
BatchSize = 500
}),
TimeProvider.System,
CreateTimeProvider(),
NullLogger<EpssSignalJob>.Instance);
await job.GenerateSignalsAsync();
@@ -159,7 +160,7 @@ public sealed class EpssSignalJobTests
ImportRunId: Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"),
ModelDate: modelDate,
SourceUri: "bundle://test.csv.gz",
RetrievedAtUtc: DateTimeOffset.Parse("2027-01-16T00:05:00Z"),
RetrievedAtUtc: new DateTimeOffset(2027, 1, 16, 0, 5, 0, TimeSpan.Zero),
FileSha256: "sha256:test",
DecompressedSha256: "sha256:decompressed",
RowCount: 1,
@@ -167,12 +168,12 @@ public sealed class EpssSignalJobTests
PublishedDate: modelDate,
Status: "SUCCEEDED",
Error: null,
CreatedAtUtc: DateTimeOffset.Parse("2027-01-16T00:06:00Z")))
CreatedAtUtc: new DateTimeOffset(2027, 1, 16, 0, 6, 0, TimeSpan.Zero)))
.ReturnsAsync(new EpssImportRun(
ImportRunId: Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"),
ModelDate: modelDate,
SourceUri: "bundle://test.csv.gz",
RetrievedAtUtc: DateTimeOffset.Parse("2027-01-16T00:05:00Z"),
RetrievedAtUtc: new DateTimeOffset(2027, 1, 16, 0, 5, 0, TimeSpan.Zero),
FileSha256: "sha256:test",
DecompressedSha256: "sha256:decompressed",
RowCount: 1,
@@ -180,7 +181,7 @@ public sealed class EpssSignalJobTests
PublishedDate: modelDate,
Status: "SUCCEEDED",
Error: null,
CreatedAtUtc: DateTimeOffset.Parse("2027-01-16T00:06:00Z")));
CreatedAtUtc: new DateTimeOffset(2027, 1, 16, 0, 6, 0, TimeSpan.Zero)));
var changes = new List<EpssChangeRecord>
{
@@ -262,7 +263,7 @@ public sealed class EpssSignalJobTests
Enabled = true,
BatchSize = 500
}),
TimeProvider.System,
CreateTimeProvider(),
NullLogger<EpssSignalJob>.Instance);
await job.GenerateSignalsAsync(); // establishes _lastModelVersion
@@ -291,4 +292,11 @@ public sealed class EpssSignalJobTests
public Task<DateOnly?> GetLatestModelDateAsync(CancellationToken cancellationToken = default) => Task.FromResult(_latestModelDate);
public Task<bool> IsAvailableAsync(CancellationToken cancellationToken = default) => Task.FromResult(true);
}
private static TimeProvider CreateTimeProvider()
{
var timeProvider = new FakeTimeProvider();
timeProvider.SetUtcNow(new DateTimeOffset(2027, 1, 16, 0, 8, 0, TimeSpan.Zero));
return timeProvider;
}
}

View File

@@ -1,11 +1,11 @@
using System;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Attestation;
using StellaOps.Cryptography;
using StellaOps.Scanner.Worker.Options;
using StellaOps.Scanner.Worker.Processing.Surface;
@@ -86,42 +86,11 @@ public sealed class HmacDsseEnvelopeSignerTests
{
var secret = Convert.FromBase64String(base64Secret);
using var hmac = new System.Security.Cryptography.HMACSHA256(secret);
var pae = BuildPae(payloadType, payload);
var pae = DsseHelper.PreAuthenticationEncoding(payloadType, payload);
var signature = hmac.ComputeHash(pae);
return Base64UrlEncode(signature);
}
private static byte[] BuildPae(string payloadType, byte[] payload)
{
const string prefix = "DSSEv1";
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
var typeLen = Encoding.UTF8.GetBytes(typeBytes.Length.ToString());
var payloadLen = Encoding.UTF8.GetBytes(payload.Length.ToString());
var total = prefix.Length + 1 + typeLen.Length + 1 + typeBytes.Length + 1 + payloadLen.Length + 1 + payload.Length;
var buffer = new byte[total];
var offset = 0;
Encoding.UTF8.GetBytes(prefix, buffer.AsSpan(offset));
offset += prefix.Length;
buffer[offset++] = 0x20;
typeLen.CopyTo(buffer.AsSpan(offset));
offset += typeLen.Length;
buffer[offset++] = 0x20;
typeBytes.CopyTo(buffer.AsSpan(offset));
offset += typeBytes.Length;
buffer[offset++] = 0x20;
payloadLen.CopyTo(buffer.AsSpan(offset));
offset += payloadLen.Length;
buffer[offset++] = 0x20;
payload.CopyTo(buffer.AsSpan(offset));
return buffer;
}
private static string Base64UrlEncode(ReadOnlySpan<byte> data)
{
var base64 = Convert.ToBase64String(data);

View File

@@ -1,7 +1,7 @@
// -----------------------------------------------------------------------------
// WorkerEndToEndJobTests.cs
// Sprint: SPRINT_5100_0009_0001 - Scanner Module Test Implementation
// Task: SCANNER-5100-020 - Add end-to-end job test: enqueue worker runs stored evidence exists events emitted
// Task: SCANNER-5100-020 - Add end-to-end job test: enqueue -> worker runs -> stored evidence exists -> events emitted
// Description: Tests the complete job lifecycle from enqueue to evidence storage
// -----------------------------------------------------------------------------
@@ -28,7 +28,7 @@ namespace StellaOps.Scanner.Worker.Tests.Integration;
/// <summary>
/// End-to-end integration tests for Scanner Worker job lifecycle.
/// Validates: job acquisition processing storage event emission.
/// Validates: job acquisition -> processing -> storage -> event emission.
/// </summary>
[Trait("Category", "Integration")]
[Trait("Category", "WK1")]
@@ -422,7 +422,7 @@ public sealed class WorkerEndToEndJobTests
{
Type = "scan.completed",
ScanId = context.Lease.ScanId,
Timestamp = DateTimeOffset.UtcNow
Timestamp = context.TimeProvider.GetUtcNow()
});
}
}

View File

@@ -4,6 +4,7 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Scanner.Worker.Options;
using StellaOps.Scanner.Worker.Determinism;
using StellaOps.Scanner.Worker.Processing;
@@ -15,6 +16,8 @@ namespace StellaOps.Scanner.Worker.Tests;
public sealed class LeaseHeartbeatServiceTests
{
private static readonly DateTimeOffset FixedNow = new(2025, 12, 24, 12, 0, 0, TimeSpan.Zero);
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task RunAsync_RespectsSafetyFactorBudget()
@@ -32,11 +35,13 @@ public sealed class LeaseHeartbeatServiceTests
var optionsMonitor = new StaticOptionsMonitor<ScannerWorkerOptions>(options);
using var cts = new CancellationTokenSource();
var scheduler = new RecordingDelayScheduler(cts);
var lease = new TestJobLease(TimeSpan.FromSeconds(90));
var timeProvider = new FakeTimeProvider();
timeProvider.SetUtcNow(FixedNow);
var lease = new TestJobLease(timeProvider, "job-lease-1", "scan-lease-1", TimeSpan.FromSeconds(90));
var randomProvider = new DeterministicRandomProvider(seed: 1337);
var service = new LeaseHeartbeatService(
TimeProvider.System,
timeProvider,
scheduler,
optionsMonitor,
randomProvider,
@@ -71,16 +76,19 @@ public sealed class LeaseHeartbeatServiceTests
private sealed class TestJobLease : IScanJobLease
{
public TestJobLease(TimeSpan leaseDuration)
public TestJobLease(TimeProvider timeProvider, string jobId, string scanId, TimeSpan leaseDuration)
{
LeaseDuration = leaseDuration;
EnqueuedAtUtc = DateTimeOffset.UtcNow - leaseDuration;
LeasedAtUtc = DateTimeOffset.UtcNow;
JobId = jobId;
ScanId = scanId;
var now = timeProvider.GetUtcNow();
EnqueuedAtUtc = now - leaseDuration;
LeasedAtUtc = now;
}
public string JobId { get; } = Guid.NewGuid().ToString("n");
public string JobId { get; }
public string ScanId { get; } = $"scan-{Guid.NewGuid():n}";
public string ScanId { get; }
public int Attempt { get; } = 1;

View File

@@ -16,6 +16,13 @@ namespace StellaOps.Scanner.Worker.Tests.Metrics;
public sealed class ScanCompletionMetricsIntegrationTests
{
private static readonly DateTimeOffset FixedNow = new(2025, 12, 24, 12, 0, 0, TimeSpan.Zero);
private static readonly Guid ScanIdPrimary = new("11111111-1111-1111-1111-111111111111");
private static readonly Guid TenantIdPrimary = new("22222222-2222-2222-2222-222222222222");
private static readonly Guid ScanIdSecondary = new("33333333-3333-3333-3333-333333333333");
private static readonly Guid ScanIdTertiary = new("44444444-4444-4444-4444-444444444444");
private static readonly Guid ScanIdQuaternary = new("55555555-5555-5555-5555-555555555555");
[Fact]
public async Task CaptureAsync_PersistsMetricsOnScanCompletion()
{
@@ -40,8 +47,8 @@ public sealed class ScanCompletionMetricsIntegrationTests
var context = new ScanCompletionContext
{
ScanId = Guid.NewGuid(),
TenantId = Guid.NewGuid(),
ScanId = ScanIdPrimary,
TenantId = TenantIdPrimary,
ArtifactDigest = "sha256:abc123",
ArtifactType = "oci_image",
FindingsSha256 = "sha256:def456",
@@ -53,15 +60,15 @@ public sealed class ScanCompletionMetricsIntegrationTests
new PhaseCompletionInfo
{
PhaseName = "pull",
StartedAt = DateTimeOffset.UtcNow.AddSeconds(-10),
FinishedAt = DateTimeOffset.UtcNow.AddSeconds(-5),
StartedAt = FixedNow.AddSeconds(-10),
FinishedAt = FixedNow.AddSeconds(-5),
Success = true
},
new PhaseCompletionInfo
{
PhaseName = "analyze",
StartedAt = DateTimeOffset.UtcNow.AddSeconds(-5),
FinishedAt = DateTimeOffset.UtcNow,
StartedAt = FixedNow.AddSeconds(-5),
FinishedAt = FixedNow,
Success = true
}
}
@@ -97,8 +104,8 @@ public sealed class ScanCompletionMetricsIntegrationTests
var context = new ScanCompletionContext
{
ScanId = Guid.NewGuid(),
TenantId = Guid.NewGuid(),
ScanId = ScanIdSecondary,
TenantId = TenantIdPrimary,
ArtifactDigest = "sha256:abc123",
ArtifactType = "oci_image",
FindingsSha256 = "sha256:def456"
@@ -130,8 +137,8 @@ public sealed class ScanCompletionMetricsIntegrationTests
var context = new ScanCompletionContext
{
ScanId = Guid.NewGuid(),
TenantId = Guid.NewGuid(),
ScanId = ScanIdTertiary,
TenantId = TenantIdPrimary,
ArtifactDigest = "sha256:abc123",
ArtifactType = "oci_image",
FindingsSha256 = "sha256:findings",
@@ -172,8 +179,8 @@ public sealed class ScanCompletionMetricsIntegrationTests
var context = new ScanCompletionContext
{
ScanId = Guid.NewGuid(),
TenantId = Guid.NewGuid(),
ScanId = ScanIdQuaternary,
TenantId = TenantIdPrimary,
ArtifactDigest = "sha256:abc123",
ArtifactType = "oci_image",
FindingsSha256 = "sha256:findings",

View File

@@ -7,6 +7,7 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using Moq;
using StellaOps.Attestor;
using StellaOps.Scanner.Core.Configuration;
@@ -22,6 +23,9 @@ namespace StellaOps.Scanner.Worker.Tests.PoE;
public class PoEGenerationStageExecutorTests : IDisposable
{
private static readonly DateTimeOffset FixedNow = new(2025, 12, 24, 12, 0, 0, TimeSpan.Zero);
private static int _tempCounter;
private readonly string _tempCasRoot;
private readonly Mock<IReachabilityResolver> _resolverMock;
private readonly Mock<IProofEmitter> _emitterMock;
@@ -32,7 +36,8 @@ public class PoEGenerationStageExecutorTests : IDisposable
public PoEGenerationStageExecutorTests()
{
_tempCasRoot = Path.Combine(Path.GetTempPath(), $"poe-stage-test-{Guid.NewGuid()}");
var suffix = Interlocked.Increment(ref _tempCounter).ToString("D4", System.Globalization.CultureInfo.InvariantCulture);
_tempCasRoot = Path.Combine(Path.GetTempPath(), $"poe-stage-test-{suffix}");
Directory.CreateDirectory(_tempCasRoot);
_resolverMock = new Mock<IReachabilityResolver>();
@@ -249,9 +254,14 @@ public class PoEGenerationStageExecutorTests : IDisposable
It.IsAny<CancellationToken>()))
.ReturnsAsync(poeBytes);
var hashCounter = 0;
_emitterMock
.Setup(x => x.ComputePoEHash(poeBytes))
.Returns((byte[] data) => $"blake3:{Guid.NewGuid():N}");
.Returns(() =>
{
hashCounter++;
return $"blake3:{hashCounter:D2}";
});
_emitterMock
.Setup(x => x.SignPoEAsync(poeBytes, It.IsAny<string>(), It.IsAny<CancellationToken>()))
@@ -325,10 +335,12 @@ public class PoEGenerationStageExecutorTests : IDisposable
leaseMock.Setup(l => l.JobId).Returns("job-123");
leaseMock.Setup(l => l.ScanId).Returns("scan-abc123");
var timeProvider = new FakeTimeProvider();
timeProvider.SetUtcNow(FixedNow);
return new ScanJobContext(
leaseMock.Object,
TimeProvider.System,
DateTimeOffset.UtcNow,
timeProvider,
timeProvider.GetUtcNow(),
TestContext.Current.CancellationToken
);
}

View File

@@ -21,6 +21,8 @@ namespace StellaOps.Scanner.Worker.Tests.PoE;
/// </summary>
public class PoEOrchestratorDirectTests : IDisposable
{
private static int _tempCounter;
private readonly ITestOutputHelper _output;
private readonly string _tempCasRoot;
private readonly Mock<IReachabilityResolver> _resolverMock;
@@ -31,7 +33,8 @@ public class PoEOrchestratorDirectTests : IDisposable
public PoEOrchestratorDirectTests(ITestOutputHelper output)
{
_output = output;
_tempCasRoot = Path.Combine(Path.GetTempPath(), $"poe-direct-test-{Guid.NewGuid()}");
var suffix = Interlocked.Increment(ref _tempCounter).ToString("D4", System.Globalization.CultureInfo.InvariantCulture);
_tempCasRoot = Path.Combine(Path.GetTempPath(), $"poe-direct-test-{suffix}");
Directory.CreateDirectory(_tempCasRoot);
_resolverMock = new Mock<IReachabilityResolver>();

View File

@@ -7,6 +7,7 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Scanner.Queue;
using StellaOps.Scanner.Worker.Diagnostics;
using StellaOps.Scanner.Worker.Hosting;
@@ -21,6 +22,10 @@ namespace StellaOps.Scanner.Worker.Tests;
public sealed class RedisWorkerSmokeTests
{
private static readonly DateTimeOffset FixedNow = new(2025, 12, 24, 12, 0, 0, TimeSpan.Zero);
private static readonly string RunId =
Environment.GetEnvironmentVariable("STELLAOPS_REDIS_SMOKE_RUN_ID") ?? "default";
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Worker_CompletesJob_ViaRedisQueue()
@@ -32,8 +37,8 @@ public sealed class RedisWorkerSmokeTests
}
var redisConnection = Environment.GetEnvironmentVariable("STELLAOPS_REDIS_CONNECTION") ?? "localhost:6379";
var streamName = $"scanner:jobs:{Guid.NewGuid():n}";
var consumerGroup = $"worker-smoke-{Guid.NewGuid():n}";
var streamName = $"scanner:jobs:{RunId}";
var consumerGroup = $"worker-smoke-{RunId}";
var configuration = BuildQueueConfiguration(redisConnection, streamName, consumerGroup);
var queueOptions = new ScannerQueueOptions();
@@ -59,7 +64,9 @@ public sealed class RedisWorkerSmokeTests
builder.SetMinimumLevel(LogLevel.Debug);
builder.AddConsole();
});
services.AddSingleton(TimeProvider.System);
var timeProvider = CreateTimeProvider();
services.AddSingleton(timeProvider);
services.AddSingleton<TimeProvider>(timeProvider);
services.AddScannerQueue(configuration, "scanner:queue");
services.AddSingleton<IScanJobSource, QueueBackedScanJobSource>();
services.AddSingleton<QueueBackedScanJobSourceDependencies>();
@@ -77,8 +84,8 @@ public sealed class RedisWorkerSmokeTests
using var provider = services.BuildServiceProvider();
var queue = provider.GetRequiredService<IScanQueue>();
var jobId = $"job-{Guid.NewGuid():n}";
var scanId = $"scan-{Guid.NewGuid():n}";
var jobId = $"job-{RunId}";
var scanId = $"scan-{RunId}";
await queue.EnqueueAsync(new ScanQueueMessage(jobId, Encoding.UTF8.GetBytes("smoke"))
{
Attributes = new Dictionary<string, string>(StringComparer.Ordinal)
@@ -139,7 +146,7 @@ public sealed class RedisWorkerSmokeTests
private readonly ScannerQueueOptions _queueOptions;
private readonly QueueBackedScanJobSourceDependencies _deps;
private readonly TimeProvider _timeProvider;
private readonly string _consumerName = $"worker-smoke-{Guid.NewGuid():n}";
private readonly string _consumerName = $"worker-smoke-{RunId}";
public QueueBackedScanJobSource(
IScanQueue queue,
@@ -150,7 +157,7 @@ public sealed class RedisWorkerSmokeTests
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
_queueOptions = queueOptions ?? throw new ArgumentNullException(nameof(queueOptions));
_deps = deps ?? throw new ArgumentNullException(nameof(deps));
_timeProvider = timeProvider ?? TimeProvider.System;
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public async Task<IScanJobLease?> TryAcquireAsync(CancellationToken cancellationToken)
@@ -245,4 +252,11 @@ public sealed class RedisWorkerSmokeTests
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
private static TimeProvider CreateTimeProvider()
{
var timeProvider = new FakeTimeProvider();
timeProvider.SetUtcNow(FixedNow);
return timeProvider;
}
}

View File

@@ -7,6 +7,7 @@ using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Surface.Env;
using StellaOps.Scanner.Surface.Secrets;
@@ -20,6 +21,8 @@ namespace StellaOps.Scanner.Worker.Tests;
public sealed class RegistrySecretStageExecutorTests
{
private static readonly DateTimeOffset FixedNow = new(2025, 12, 24, 12, 0, 0, TimeSpan.Zero);
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ExecuteAsync_WithSecret_StoresCredentialsAndEmitsMetrics()
@@ -41,7 +44,8 @@ public sealed class RegistrySecretStageExecutorTests
var provider = new StubSecretProvider(secretJson);
var environment = new StubSurfaceEnvironment("tenant-eu");
var metrics = new ScannerWorkerMetrics();
var timeProvider = TimeProvider.System;
var timeProvider = new FakeTimeProvider();
timeProvider.SetUtcNow(FixedNow);
var executor = new RegistrySecretStageExecutor(
provider,
environment,
@@ -53,7 +57,7 @@ public sealed class RegistrySecretStageExecutorTests
{
["surface.registry.secret"] = "primary"
};
var lease = new StubLease("job-1", "scan-1", metadata);
var lease = new StubLease(timeProvider, "job-1", "scan-1", metadata);
using var contextCancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
var context = new ScanJobContext(lease, timeProvider, timeProvider.GetUtcNow(), contextCancellation.Token);
@@ -81,15 +85,17 @@ public sealed class RegistrySecretStageExecutorTests
var provider = new MissingSecretProvider();
var environment = new StubSurfaceEnvironment("tenant-eu");
var metrics = new ScannerWorkerMetrics();
var timeProvider = new FakeTimeProvider();
timeProvider.SetUtcNow(FixedNow);
var executor = new RegistrySecretStageExecutor(
provider,
environment,
metrics,
TimeProvider.System,
timeProvider,
NullLogger<RegistrySecretStageExecutor>.Instance);
var lease = new StubLease("job-2", "scan-2", new Dictionary<string, string>());
var context = new ScanJobContext(lease, TimeProvider.System, TimeProvider.System.GetUtcNow(), TestContext.Current.CancellationToken);
var lease = new StubLease(timeProvider, "job-2", "scan-2", new Dictionary<string, string>());
var context = new ScanJobContext(lease, timeProvider, timeProvider.GetUtcNow(), TestContext.Current.CancellationToken);
var measurements = new List<(long Value, KeyValuePair<string, object?>[] Tags)>();
using var listener = CreateCounterListener("scanner_worker_registry_secret_requests_total", measurements);
@@ -183,7 +189,7 @@ public sealed class RegistrySecretStageExecutorTests
tenant,
new SurfaceTlsConfiguration(null, null, null))
{
CreatedAtUtc = DateTimeOffset.UtcNow
CreatedAtUtc = FixedNow
};
RawVariables = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
@@ -198,13 +204,14 @@ public sealed class RegistrySecretStageExecutorTests
{
private readonly IReadOnlyDictionary<string, string> _metadata;
public StubLease(string jobId, string scanId, IReadOnlyDictionary<string, string> metadata)
public StubLease(TimeProvider timeProvider, string jobId, string scanId, IReadOnlyDictionary<string, string> metadata)
{
JobId = jobId;
ScanId = scanId;
_metadata = metadata;
EnqueuedAtUtc = DateTimeOffset.UtcNow.AddMinutes(-1);
LeasedAtUtc = DateTimeOffset.UtcNow;
var now = timeProvider.GetUtcNow();
EnqueuedAtUtc = now.AddMinutes(-1);
LeasedAtUtc = now;
}
public string JobId { get; }

View File

@@ -2,6 +2,7 @@ using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Worker.Processing;
using StellaOps.Scanner.Worker.Processing.Replay;
@@ -45,20 +46,31 @@ public sealed class ReplaySealedBundleStageExecutorTests
internal static class TestContexts
{
private static readonly DateTimeOffset FixedNow = new(2025, 12, 24, 12, 0, 0, TimeSpan.Zero);
public static ScanJobContext Create(out Dictionary<string, string> metadata)
{
var lease = new TestScanJobLease();
var timeProvider = new FakeTimeProvider();
timeProvider.SetUtcNow(FixedNow);
var lease = new TestScanJobLease(timeProvider);
metadata = lease.MutableMetadata;
return new ScanJobContext(lease, TimeProvider.System, TimeProvider.System.GetUtcNow(), TestContext.Current.CancellationToken);
return new ScanJobContext(lease, timeProvider, timeProvider.GetUtcNow(), TestContext.Current.CancellationToken);
}
private sealed class TestScanJobLease : IScanJobLease
{
private readonly DateTimeOffset _now;
public TestScanJobLease(TimeProvider timeProvider)
{
_now = timeProvider.GetUtcNow();
}
public string JobId => "job-1";
public string ScanId => "scan-1";
public int Attempt => 1;
public DateTimeOffset EnqueuedAtUtc => DateTimeOffset.UtcNow;
public DateTimeOffset LeasedAtUtc => DateTimeOffset.UtcNow;
public DateTimeOffset EnqueuedAtUtc => _now;
public DateTimeOffset LeasedAtUtc => _now;
public TimeSpan LeaseDuration => TimeSpan.FromMinutes(5);
public Dictionary<string, string> MutableMetadata { get; } = new();
public IReadOnlyDictionary<string, string> Metadata => MutableMetadata;

View File

@@ -16,6 +16,8 @@ namespace StellaOps.Scanner.Worker.Tests;
public sealed class ScannerStorageSurfaceSecretConfiguratorTests
{
private static readonly DateTimeOffset FixedNow = new(2025, 12, 24, 12, 0, 0, TimeSpan.Zero);
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Configure_WithCasAccessSecret_AppliesSettings()
@@ -85,7 +87,7 @@ public sealed class ScannerStorageSurfaceSecretConfiguratorTests
tenant,
new SurfaceTlsConfiguration(null, null, null))
{
CreatedAtUtc = DateTimeOffset.UtcNow
CreatedAtUtc = FixedNow
};
RawVariables = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}

View File

@@ -5,16 +5,18 @@
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../StellaOps.Scanner.Worker/StellaOps.Scanner.Worker.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Queue/StellaOps.Scanner.Queue.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/StellaOps.Scanner.Analyzers.Lang.Ruby.csproj" />
<ProjectReference Include="../../Attestor/StellaOps.Attestation/StellaOps.Attestation.csproj" />
<ProjectReference Include="..\..\..\__Tests\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Moq" />
</ItemGroup>
</Project>
</Project>

View File

@@ -11,11 +11,13 @@ namespace StellaOps.Scanner.Worker.Tests;
public sealed class SurfaceCacheOptionsConfiguratorTests
{
private static readonly DateTimeOffset FixedNow = new(2025, 12, 24, 12, 0, 0, TimeSpan.Zero);
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Configure_UsesSurfaceEnvironmentCacheRoot()
{
var cacheRoot = new DirectoryInfo(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")));
var cacheRoot = new DirectoryInfo(Path.Combine(Path.GetTempPath(), "stellaops-tests", "surface-cache-options"));
var settings = new SurfaceEnvironmentSettings(
new Uri("https://surface.example"),
"surface-cache",
@@ -27,7 +29,7 @@ public sealed class SurfaceCacheOptionsConfiguratorTests
new SurfaceSecretsConfiguration("file", "tenant-a", "/etc/secrets", null, null, false),
"tenant-a",
new SurfaceTlsConfiguration(null, null, new X509Certificate2Collection()))
{ CreatedAtUtc = DateTimeOffset.UtcNow };
{ CreatedAtUtc = FixedNow };
var environment = new StubSurfaceEnvironment(settings);
var configurator = new SurfaceCacheOptionsConfigurator(environment);

View File

@@ -12,6 +12,7 @@ using System.Threading.Tasks;
using System.Security.Cryptography;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Scanner.Analyzers.Lang;
using StellaOps.Scanner.Analyzers.Lang.Ruby;
using StellaOps.Scanner.Core.Contracts;
@@ -33,6 +34,8 @@ namespace StellaOps.Scanner.Worker.Tests;
public sealed class SurfaceManifestStageExecutorTests
{
private static readonly DateTimeOffset FixedNow = new(2025, 12, 24, 12, 0, 0, TimeSpan.Zero);
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ExecuteAsync_WhenNoPayloads_SkipsPublishAndRecordsSkipMetric()
@@ -261,8 +264,10 @@ public sealed class SurfaceManifestStageExecutorTests
private static ScanJobContext CreateContext(Dictionary<string, string>? metadata = null)
{
var lease = new FakeJobLease(metadata);
return new ScanJobContext(lease, TimeProvider.System, DateTimeOffset.UtcNow, TestContext.Current.CancellationToken);
var timeProvider = new FakeTimeProvider();
timeProvider.SetUtcNow(FixedNow);
var lease = new FakeJobLease(timeProvider, metadata);
return new ScanJobContext(lease, timeProvider, timeProvider.GetUtcNow(), TestContext.Current.CancellationToken);
}
private static void PopulateAnalysis(ScanJobContext context)
@@ -418,7 +423,7 @@ public sealed class SurfaceManifestStageExecutorTests
var engine = new LanguageAnalyzerEngine(new ILanguageAnalyzer[] { analyzer });
var analyzerContext = new LanguageAnalyzerContext(
fixturePath,
TimeProvider.System,
CreateTimeProvider(),
usageHints: null,
services: null,
analysisStore: context.Analysis);
@@ -690,7 +695,7 @@ public sealed class SurfaceManifestStageExecutorTests
Tenant = _tenant,
ImageDigest = request.ImageDigest,
ScanId = request.ScanId,
GeneratedAt = DateTimeOffset.UtcNow,
GeneratedAt = FixedNow,
Source = new SurfaceManifestSource
{
Component = request.Component,
@@ -740,7 +745,7 @@ public sealed class SurfaceManifestStageExecutorTests
Secrets: new SurfaceSecretsConfiguration("none", tenant, null, null, null, false),
Tenant: tenant,
Tls: new SurfaceTlsConfiguration(null, null, null))
{ CreatedAtUtc = DateTimeOffset.UtcNow };
{ CreatedAtUtc = FixedNow };
}
public SurfaceEnvironmentSettings Settings { get; }
@@ -785,13 +790,18 @@ public sealed class SurfaceManifestStageExecutorTests
{
private readonly Dictionary<string, string> _metadata;
public FakeJobLease(Dictionary<string, string>? extraMetadata = null)
public FakeJobLease(TimeProvider timeProvider, Dictionary<string, string>? extraMetadata = null)
{
_metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["queue"] = "tests",
["job.kind"] = "unit"
};
JobId = "job-surface-manifest";
ScanId = "scan-surface-manifest";
var now = timeProvider.GetUtcNow();
EnqueuedAtUtc = now.AddMinutes(-1);
LeasedAtUtc = now;
if (extraMetadata is not null)
{
@@ -802,15 +812,15 @@ public sealed class SurfaceManifestStageExecutorTests
}
}
public string JobId { get; } = Guid.NewGuid().ToString("n");
public string JobId { get; }
public string ScanId { get; } = $"scan-{Guid.NewGuid():n}";
public string ScanId { get; }
public int Attempt { get; } = 1;
public DateTimeOffset EnqueuedAtUtc { get; } = DateTimeOffset.UtcNow.AddMinutes(-1);
public DateTimeOffset EnqueuedAtUtc { get; }
public DateTimeOffset LeasedAtUtc { get; } = DateTimeOffset.UtcNow;
public DateTimeOffset LeasedAtUtc { get; }
public TimeSpan LeaseDuration { get; } = TimeSpan.FromMinutes(5);
@@ -826,4 +836,11 @@ public sealed class SurfaceManifestStageExecutorTests
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
private static TimeProvider CreateTimeProvider()
{
var timeProvider = new FakeTimeProvider();
timeProvider.SetUtcNow(FixedNow);
return timeProvider;
}
}

View File

@@ -12,11 +12,13 @@ namespace StellaOps.Scanner.Worker.Tests;
public sealed class SurfaceManifestStoreOptionsConfiguratorTests
{
private static readonly DateTimeOffset FixedNow = new(2025, 12, 24, 12, 0, 0, TimeSpan.Zero);
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Configure_UsesSurfaceEnvironmentEndpointAndBucket()
{
var cacheRoot = new DirectoryInfo(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")));
var cacheRoot = new DirectoryInfo(Path.Combine(Path.GetTempPath(), "stellaops-tests", "surface-manifest-store"));
var settings = new SurfaceEnvironmentSettings(
new Uri("https://surface.example"),
"surface-bucket",
@@ -28,7 +30,7 @@ public sealed class SurfaceManifestStoreOptionsConfiguratorTests
new SurfaceSecretsConfiguration("file", "tenant-a", "/etc/secrets", null, null, false),
"tenant-a",
new SurfaceTlsConfiguration(null, null, new X509Certificate2Collection()))
{ CreatedAtUtc = DateTimeOffset.UtcNow };
{ CreatedAtUtc = FixedNow };
var environment = new StubSurfaceEnvironment(settings);
var cacheOptions = Microsoft.Extensions.Options.Options.Create(new SurfaceCacheOptions { RootDirectory = cacheRoot.FullName });

View File

@@ -8,6 +8,7 @@ using FluentAssertions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using Moq;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Gate;
@@ -25,6 +26,7 @@ namespace StellaOps.Scanner.Worker.Tests;
[Trait("Category", TestCategories.Unit)]
public sealed class VexGateStageExecutorTests
{
private static readonly DateTimeOffset FixedNow = new(2025, 12, 24, 12, 0, 0, TimeSpan.Zero);
private readonly Mock<IVexGateService> _mockGateService;
private readonly Mock<IScanMetricsCollector> _mockMetrics;
private readonly ILogger<VexGateStageExecutor> _logger;
@@ -49,8 +51,8 @@ public sealed class VexGateStageExecutorTests
Dictionary<string, object>? analysisData = null,
TimeProvider? timeProvider = null)
{
var tp = timeProvider ?? TimeProvider.System;
var lease = new TestJobLease();
var tp = timeProvider ?? CreateTimeProvider();
var lease = new TestJobLease(tp);
var context = new ScanJobContext(lease, tp, tp.GetUtcNow(), TestContext.Current.CancellationToken);
if (analysisData is not null)
@@ -108,7 +110,7 @@ public sealed class VexGateStageExecutorTests
ConfidenceScore = 0.9,
BackportHints = []
},
EvaluatedAt = DateTimeOffset.UtcNow
EvaluatedAt = FixedNow
}
};
}
@@ -549,11 +551,20 @@ public sealed class VexGateStageExecutorTests
/// </summary>
private sealed class TestJobLease : IScanJobLease
{
public string JobId { get; } = $"job-{Guid.NewGuid():N}";
public string ScanId { get; } = $"scan-{Guid.NewGuid():N}";
public TestJobLease(TimeProvider timeProvider)
{
JobId = "job-vex-gate";
ScanId = "scan-vex-gate";
var now = timeProvider.GetUtcNow();
EnqueuedAtUtc = now.AddMinutes(-1);
LeasedAtUtc = now;
}
public string JobId { get; }
public string ScanId { get; }
public int Attempt => 1;
public DateTimeOffset EnqueuedAtUtc { get; } = DateTimeOffset.UtcNow.AddMinutes(-1);
public DateTimeOffset LeasedAtUtc { get; } = DateTimeOffset.UtcNow;
public DateTimeOffset EnqueuedAtUtc { get; }
public DateTimeOffset LeasedAtUtc { get; }
public TimeSpan LeaseDuration => TimeSpan.FromMinutes(5);
public IReadOnlyDictionary<string, string> Metadata { get; } = new Dictionary<string, string>
{
@@ -568,5 +579,12 @@ public sealed class VexGateStageExecutorTests
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
private static TimeProvider CreateTimeProvider()
{
var timeProvider = new FakeTimeProvider();
timeProvider.SetUtcNow(FixedNow);
return timeProvider;
}
#endregion
}

View File

@@ -29,6 +29,8 @@ namespace StellaOps.Scanner.Worker.Tests;
public sealed class WorkerBasicScanScenarioTests
{
private static readonly DateTimeOffset FixedNow = new(2025, 12, 24, 12, 0, 0, TimeSpan.Zero);
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DelayAsync_CompletesAfterTimeAdvance()
@@ -44,7 +46,7 @@ public sealed class WorkerBasicScanScenarioTests
public async Task Worker_CompletesJob_RecordsTelemetry_And_Heartbeats()
{
var fakeTime = new FakeTimeProvider();
fakeTime.SetUtcNow(DateTimeOffset.UtcNow);
fakeTime.SetUtcNow(FixedNow);
var options = new ScannerWorkerOptions
{
@@ -114,11 +116,11 @@ public sealed class WorkerBasicScanScenarioTests
}
catch (TimeoutException ex)
{
var stageLogs = string.Join(Environment.NewLine, testLoggerProvider
var stageLogs = string.Join("\n", testLoggerProvider
.GetEntriesForCategory(typeof(ScanProgressReporter).FullName!)
.Select(entry => entry.ToFormattedString()));
throw new TimeoutException($"Worker did not complete within timeout. Logs:{Environment.NewLine}{stageLogs}", ex);
throw new TimeoutException($"Worker did not complete within timeout. Logs:\n{stageLogs}", ex);
}
await worker.StopAsync(TestContext.Current.CancellationToken);
@@ -193,13 +195,15 @@ public sealed class WorkerBasicScanScenarioTests
public TestJobLease(FakeTimeProvider timeProvider)
{
_timeProvider = timeProvider;
JobId = "job-basic";
ScanId = "scan-basic";
EnqueuedAtUtc = _timeProvider.GetUtcNow() - TimeSpan.FromSeconds(5);
LeasedAtUtc = _timeProvider.GetUtcNow();
}
public string JobId { get; } = Guid.NewGuid().ToString("n");
public string JobId { get; }
public string ScanId { get; } = $"scan-{Guid.NewGuid():n}";
public string ScanId { get; }
public int Attempt { get; } = 1;

View File

@@ -0,0 +1,9 @@
# ELF Section Hash Fixtures
These fixtures are synthetic ELF binaries with known section contents.
They are used to validate deterministic section hashing.
- standard-amd64.elf: includes .text, .rodata, .data, .symtab, .dynsym
- stripped-amd64.elf: omits .symtab
- minimal-amd64.elf: .data is empty (hash of empty)
- corrupt.bin: non-ELF input

View File

@@ -0,0 +1 @@
not an elf

View File

@@ -0,0 +1,13 @@
{
"fileHash": "ea5d1058e09e8cdc904dc089aa9a69dfa3bdfc93d0a0b02da6b9251b4ed1e871",
"sections": {
".data": {
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"size": 0
},
".text": {
"sha256": "10a8e3dafed6c8938e72779d3652bca45f67d8cf7f662389ec01295cde46d254",
"size": 8
}
}
}

View File

@@ -0,0 +1,25 @@
{
"fileHash": "e8b34dc1fbf85d99d7a94918d8982d042a3a9bf297edb6c89740eb72e8910758",
"sections": {
".symtab": {
"sha256": "044a28a44a08c9e5ab42ac90e2bbefcd498f4682d066467b449ab44e3b0c8e48",
"size": 17
},
".rodata": {
"sha256": "9d11c1c7eac0cd03133b258e45e2010003ff0c554d6f0a3c6b9518129386a9d1",
"size": 6
},
".text": {
"sha256": "10a8e3dafed6c8938e72779d3652bca45f67d8cf7f662389ec01295cde46d254",
"size": 8
},
".dynsym": {
"sha256": "f21e7fa140a6a132d85b0b2fa47a9b353adeca0a9e9a229fb521990597ad3431",
"size": 17
},
".data": {
"sha256": "c97c29c7a71b392b437ee03fd17f09bb10b75e879466fc0eb757b2c4a78ac938",
"size": 4
}
}
}

View File

@@ -0,0 +1,21 @@
{
"fileHash": "6bd7e3f22f8bc923d626991ac89422b740e3aee0b2f25c8104f640769c511d5a",
"sections": {
".rodata": {
"sha256": "9d11c1c7eac0cd03133b258e45e2010003ff0c554d6f0a3c6b9518129386a9d1",
"size": 6
},
".text": {
"sha256": "10a8e3dafed6c8938e72779d3652bca45f67d8cf7f662389ec01295cde46d254",
"size": 8
},
".dynsym": {
"sha256": "f21e7fa140a6a132d85b0b2fa47a9b353adeca0a9e9a229fb521990597ad3431",
"size": 17
},
".data": {
"sha256": "c97c29c7a71b392b437ee03fd17f09bb10b75e879466fc0eb757b2c4a78ac938",
"size": 4
}
}
}