audit, advisories and doctors/setup work
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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}"
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="Microsoft.Extensions.Time.Testing" />
|
||||
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("""
|
||||
{
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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:True→False", 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:0→5", 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:none→warn", mismatches[0].AffectedArtifacts!);
|
||||
Assert.Contains("block_level:none->warn", mismatches[0].AffectedArtifacts!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
not an elf
|
||||
Binary file not shown.
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"fileHash": "ea5d1058e09e8cdc904dc089aa9a69dfa3bdfc93d0a0b02da6b9251b4ed1e871",
|
||||
"sections": {
|
||||
".data": {
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"size": 0
|
||||
},
|
||||
".text": {
|
||||
"sha256": "10a8e3dafed6c8938e72779d3652bca45f67d8cf7f662389ec01295cde46d254",
|
||||
"size": 8
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user