Resolve Concelier/Excititor merge conflicts
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
|
||||
namespace StellaOps.Scanner.Core.Tests.Contracts;
|
||||
|
||||
public sealed class ComponentGraphBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Build_AggregatesComponentsAcrossLayers()
|
||||
{
|
||||
var layer1 = LayerComponentFragment.Create("sha256:layer1", new[]
|
||||
{
|
||||
new ComponentRecord
|
||||
{
|
||||
Identity = ComponentIdentity.Create("pkg:npm/a", "a", "1.0.0"),
|
||||
LayerDigest = "sha256:layer1",
|
||||
Evidence = ImmutableArray.Create(ComponentEvidence.FromPath("/app/node_modules/a/package.json")),
|
||||
Dependencies = ImmutableArray.Create("pkg:npm/x"),
|
||||
Usage = ComponentUsage.Create(false),
|
||||
Metadata = new ComponentMetadata
|
||||
{
|
||||
Scope = "runtime",
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
var layer2 = LayerComponentFragment.Create("sha256:layer2", new[]
|
||||
{
|
||||
new ComponentRecord
|
||||
{
|
||||
Identity = ComponentIdentity.Create("pkg:npm/a", "a", "1.0.0"),
|
||||
LayerDigest = "sha256:layer2",
|
||||
Evidence = ImmutableArray.Create(ComponentEvidence.FromPath("/app/node_modules/a/index.js")),
|
||||
Dependencies = ImmutableArray.Create("pkg:npm/y"),
|
||||
Usage = ComponentUsage.Create(true, new[] { "/app/start.sh" }),
|
||||
},
|
||||
new ComponentRecord
|
||||
{
|
||||
Identity = ComponentIdentity.Create("pkg:npm/b", "b", "2.0.0"),
|
||||
LayerDigest = "sha256:layer2",
|
||||
Evidence = ImmutableArray.Create(ComponentEvidence.FromPath("/app/node_modules/b/package.json")),
|
||||
}
|
||||
});
|
||||
|
||||
var graph = ComponentGraphBuilder.Build(new[] { layer1, layer2 });
|
||||
|
||||
Assert.Equal(new[] { "sha256:layer1", "sha256:layer2" }, graph.Layers.Select(layer => layer.LayerDigest));
|
||||
Assert.Equal(new[] { "pkg:npm/a", "pkg:npm/b" }, graph.Components.Select(component => component.Identity.Key));
|
||||
|
||||
var componentA = graph.ComponentMap["pkg:npm/a"];
|
||||
Assert.Equal("sha256:layer1", componentA.FirstLayerDigest);
|
||||
Assert.Equal("sha256:layer2", componentA.LastLayerDigest);
|
||||
Assert.Equal(new[] { "sha256:layer1", "sha256:layer2" }, componentA.LayerDigests);
|
||||
Assert.True(componentA.Usage.UsedByEntrypoint);
|
||||
Assert.Contains("/app/start.sh", componentA.Usage.Entrypoints);
|
||||
Assert.Equal(new[] { "pkg:npm/x", "pkg:npm/y" }, componentA.Dependencies);
|
||||
Assert.Equal("runtime", componentA.Metadata?.Scope);
|
||||
Assert.Equal(2, componentA.Evidence.Length);
|
||||
|
||||
var componentB = graph.ComponentMap["pkg:npm/b"];
|
||||
Assert.Equal("sha256:layer2", componentB.FirstLayerDigest);
|
||||
Assert.Null(componentB.LastLayerDigest);
|
||||
Assert.Single(componentB.LayerDigests, "sha256:layer2");
|
||||
Assert.False(componentB.Usage.UsedByEntrypoint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_DeterministicOrdering()
|
||||
{
|
||||
var fragments = new[]
|
||||
{
|
||||
LayerComponentFragment.Create("sha256:layer1", new[]
|
||||
{
|
||||
new ComponentRecord
|
||||
{
|
||||
Identity = ComponentIdentity.Create("pkg:npm/c", "c"),
|
||||
LayerDigest = "sha256:layer1",
|
||||
},
|
||||
new ComponentRecord
|
||||
{
|
||||
Identity = ComponentIdentity.Create("pkg:npm/a", "a"),
|
||||
LayerDigest = "sha256:layer1",
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
var graph1 = ComponentGraphBuilder.Build(fragments);
|
||||
var graph2 = ComponentGraphBuilder.Build(fragments);
|
||||
|
||||
Assert.Equal(graph1.Components.Select(c => c.Identity.Key), graph2.Components.Select(c => c.Identity.Key));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
using StellaOps.Scanner.Core.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.Core.Tests.Contracts;
|
||||
|
||||
public sealed class ComponentModelsTests
|
||||
{
|
||||
[Fact]
|
||||
public void ComponentIdentity_Create_Trimmed()
|
||||
{
|
||||
var identity = ComponentIdentity.Create(" pkg:npm/foo ", " Foo ", " 1.0.0 ", " pkg:npm/foo@1.0.0 ", " library ", " group ");
|
||||
|
||||
Assert.Equal("pkg:npm/foo", identity.Key);
|
||||
Assert.Equal("Foo", identity.Name);
|
||||
Assert.Equal("1.0.0", identity.Version);
|
||||
Assert.Equal("pkg:npm/foo@1.0.0", identity.Purl);
|
||||
Assert.Equal("library", identity.ComponentType);
|
||||
Assert.Equal("group", identity.Group);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComponentUsage_Create_SortsEntrypoints()
|
||||
{
|
||||
var usage = ComponentUsage.Create(true, new[] { "/app/start.sh", "/app/start.sh", "/bin/init", " ", null! });
|
||||
|
||||
Assert.True(usage.UsedByEntrypoint);
|
||||
Assert.Equal(new[] { "/app/start.sh", "/bin/init" }, usage.Entrypoints);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LayerComponentFragment_Create_SortsComponents()
|
||||
{
|
||||
var compB = new ComponentRecord
|
||||
{
|
||||
Identity = ComponentIdentity.Create("pkg:npm/b", "b"),
|
||||
LayerDigest = "sha256:layer2",
|
||||
};
|
||||
|
||||
var compA = new ComponentRecord
|
||||
{
|
||||
Identity = ComponentIdentity.Create("pkg:npm/a", "a"),
|
||||
LayerDigest = "sha256:layer2",
|
||||
};
|
||||
|
||||
var fragment = LayerComponentFragment.Create("sha256:layer2", new[] { compB, compA });
|
||||
|
||||
Assert.Equal("sha256:layer2", fragment.LayerDigest);
|
||||
Assert.Equal(new[] { compA.Identity.Key, compB.Identity.Key }, fragment.Components.Select(c => c.Identity.Key));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComponentRecord_Serializes_WithScannerDefaults()
|
||||
{
|
||||
var record = new ComponentRecord
|
||||
{
|
||||
Identity = ComponentIdentity.Create("pkg:npm/test", "test", "1.0.0"),
|
||||
LayerDigest = "sha256:layer",
|
||||
Evidence = ImmutableArray.Create(ComponentEvidence.FromPath("/app/package.json")),
|
||||
Dependencies = ImmutableArray.Create("pkg:npm/dep"),
|
||||
Usage = ComponentUsage.Create(true, new[] { "/app/start.sh" }),
|
||||
Metadata = new ComponentMetadata
|
||||
{
|
||||
Scope = "runtime",
|
||||
Licenses = new[] { "MIT" },
|
||||
Properties = new Dictionary<string, string>
|
||||
{
|
||||
["source"] = "package-lock.json",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(record, ScannerJsonOptions.Default);
|
||||
var deserialized = JsonSerializer.Deserialize<ComponentRecord>(json, ScannerJsonOptions.Default);
|
||||
|
||||
Assert.NotNull(deserialized);
|
||||
Assert.Equal(record.Identity.Key, deserialized!.Identity.Key);
|
||||
Assert.Equal(record.Metadata?.Scope, deserialized.Metadata?.Scope);
|
||||
Assert.True(deserialized.Usage.UsedByEntrypoint);
|
||||
Assert.Equal(record.Usage.Entrypoints.AsSpan(), deserialized.Usage.Entrypoints.AsSpan());
|
||||
}
|
||||
}
|
||||
81
src/StellaOps.Scanner.Core.Tests/Contracts/ScanJobTests.cs
Normal file
81
src/StellaOps.Scanner.Core.Tests/Contracts/ScanJobTests.cs
Normal file
@@ -0,0 +1,81 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
using StellaOps.Scanner.Core.Serialization;
|
||||
using StellaOps.Scanner.Core.Utility;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Core.Tests.Contracts;
|
||||
|
||||
public sealed class ScanJobTests
|
||||
{
|
||||
[Fact]
|
||||
public void SerializeAndDeserialize_RoundTripsDeterministically()
|
||||
{
|
||||
var createdAt = new DateTimeOffset(2025, 10, 18, 14, 30, 15, TimeSpan.Zero);
|
||||
var jobId = ScannerIdentifiers.CreateJobId("registry.example.com/stellaops/scanner:1.2.3", "sha256:ABCDEF", "tenant-a", "request-1");
|
||||
var correlationId = ScannerIdentifiers.CreateCorrelationId(jobId, "enqueue");
|
||||
var error = new ScannerError(
|
||||
ScannerErrorCode.AnalyzerFailure,
|
||||
ScannerErrorSeverity.Error,
|
||||
"Analyzer crashed for layer sha256:abc",
|
||||
createdAt,
|
||||
retryable: false,
|
||||
details: new Dictionary<string, string>
|
||||
{
|
||||
["stage"] = "analyze-os",
|
||||
["layer"] = "sha256:abc"
|
||||
});
|
||||
|
||||
var job = new ScanJob(
|
||||
jobId,
|
||||
ScanJobStatus.Running,
|
||||
"registry.example.com/stellaops/scanner:1.2.3",
|
||||
"SHA256:ABCDEF",
|
||||
createdAt,
|
||||
createdAt,
|
||||
correlationId,
|
||||
"tenant-a",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["requestId"] = "request-1"
|
||||
},
|
||||
error);
|
||||
|
||||
var json = JsonSerializer.Serialize(job, ScannerJsonOptions.CreateDefault());
|
||||
var deserialized = JsonSerializer.Deserialize<ScanJob>(json, ScannerJsonOptions.CreateDefault());
|
||||
|
||||
Assert.NotNull(deserialized);
|
||||
Assert.Equal(job.Id, deserialized!.Id);
|
||||
Assert.Equal(job.ImageDigest, deserialized.ImageDigest);
|
||||
Assert.Equal(job.CorrelationId, deserialized.CorrelationId);
|
||||
Assert.Equal(job.Metadata["requestId"], deserialized.Metadata["requestId"]);
|
||||
|
||||
var secondJson = JsonSerializer.Serialize(deserialized, ScannerJsonOptions.CreateDefault());
|
||||
Assert.Equal(json, secondJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithStatus_UpdatesTimestampDeterministically()
|
||||
{
|
||||
var createdAt = new DateTimeOffset(2025, 10, 18, 14, 30, 15, 123, TimeSpan.Zero);
|
||||
var jobId = ScannerIdentifiers.CreateJobId("example/scanner:latest", "sha256:def", null, null);
|
||||
var correlationId = ScannerIdentifiers.CreateCorrelationId(jobId, "enqueue");
|
||||
|
||||
var job = new ScanJob(
|
||||
jobId,
|
||||
ScanJobStatus.Pending,
|
||||
"example/scanner:latest",
|
||||
"sha256:def",
|
||||
createdAt,
|
||||
null,
|
||||
correlationId,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
|
||||
var updated = job.WithStatus(ScanJobStatus.Running, createdAt.AddSeconds(5));
|
||||
|
||||
Assert.Equal(ScanJobStatus.Running, updated.Status);
|
||||
Assert.Equal(ScannerTimestamps.Normalize(createdAt.AddSeconds(5)), updated.UpdatedAt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
using StellaOps.Scanner.Core.Serialization;
|
||||
using StellaOps.Scanner.Core.Utility;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Core.Tests.Contracts;
|
||||
|
||||
public sealed class ScannerCoreContractsTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = ScannerJsonOptions.CreateDefault();
|
||||
private static readonly ScanJobId SampleJobId = ScanJobId.From(Guid.Parse("8f4cc9c5-8245-4b9d-9b4f-5ae049631b7d"));
|
||||
private static readonly DateTimeOffset SampleCreatedAt = new DateTimeOffset(2025, 10, 18, 14, 30, 15, TimeSpan.Zero).AddTicks(1_234_560);
|
||||
|
||||
[Fact]
|
||||
public void ScanJob_RoundTripMatchesGoldenFixture()
|
||||
{
|
||||
var job = CreateSampleJob();
|
||||
|
||||
var json = JsonSerializer.Serialize(job, Options);
|
||||
var expected = LoadFixture("scan-job.json");
|
||||
Assert.Equal(expected, json);
|
||||
|
||||
var deserialized = JsonSerializer.Deserialize<ScanJob>(expected, Options);
|
||||
Assert.NotNull(deserialized);
|
||||
Assert.Equal(job.Id, deserialized!.Id);
|
||||
Assert.Equal(job.ImageDigest, deserialized.ImageDigest);
|
||||
Assert.Equal(job.CorrelationId, deserialized.CorrelationId);
|
||||
Assert.Equal(job.Metadata, deserialized.Metadata);
|
||||
Assert.Equal(job.Failure?.Message, deserialized.Failure?.Message);
|
||||
Assert.Equal(job.Failure?.Details, deserialized.Failure?.Details);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScanProgressEvent_RoundTripMatchesGoldenFixture()
|
||||
{
|
||||
var progress = CreateSampleProgressEvent();
|
||||
|
||||
var json = JsonSerializer.Serialize(progress, Options);
|
||||
var expected = LoadFixture("scan-progress-event.json");
|
||||
Assert.Equal(expected, json);
|
||||
|
||||
var deserialized = JsonSerializer.Deserialize<ScanProgressEvent>(expected, Options);
|
||||
Assert.NotNull(deserialized);
|
||||
Assert.Equal(progress.JobId, deserialized!.JobId);
|
||||
Assert.Equal(progress.Stage, deserialized.Stage);
|
||||
Assert.Equal(progress.Kind, deserialized.Kind);
|
||||
Assert.Equal(progress.Sequence, deserialized.Sequence);
|
||||
Assert.Equal(progress.Error?.Details, deserialized.Error?.Details);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScannerError_RoundTripMatchesGoldenFixture()
|
||||
{
|
||||
var error = CreateSampleError();
|
||||
|
||||
var json = JsonSerializer.Serialize(error, Options);
|
||||
var expected = LoadFixture("scanner-error.json");
|
||||
Assert.Equal(expected, json);
|
||||
|
||||
var deserialized = JsonSerializer.Deserialize<ScannerError>(expected, Options);
|
||||
Assert.NotNull(deserialized);
|
||||
Assert.Equal(error.Code, deserialized!.Code);
|
||||
Assert.Equal(error.Severity, deserialized.Severity);
|
||||
Assert.Equal(error.Details, deserialized.Details);
|
||||
}
|
||||
|
||||
private static ScanJob CreateSampleJob()
|
||||
{
|
||||
var updatedAt = SampleCreatedAt.AddSeconds(5);
|
||||
var correlationId = ScannerIdentifiers.CreateCorrelationId(SampleJobId, nameof(ScanStage.AnalyzeOperatingSystem));
|
||||
|
||||
return new ScanJob(
|
||||
SampleJobId,
|
||||
ScanJobStatus.Running,
|
||||
"registry.example.com/stellaops/scanner:1.2.3",
|
||||
"SHA256:ABCDEF",
|
||||
SampleCreatedAt,
|
||||
updatedAt,
|
||||
correlationId,
|
||||
"tenant-a",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["requestId"] = "req-1234",
|
||||
["source"] = "ci"
|
||||
},
|
||||
CreateSampleError());
|
||||
}
|
||||
|
||||
private static ScanProgressEvent CreateSampleProgressEvent()
|
||||
{
|
||||
return new ScanProgressEvent(
|
||||
SampleJobId,
|
||||
ScanStage.AnalyzeOperatingSystem,
|
||||
ScanProgressEventKind.Warning,
|
||||
sequence: 3,
|
||||
timestamp: SampleCreatedAt.AddSeconds(1),
|
||||
percentComplete: 42.5,
|
||||
message: "OS analyzer reported missing packages",
|
||||
attributes: new Dictionary<string, string>
|
||||
{
|
||||
["package"] = "openssl",
|
||||
["version"] = "1.1.1w"
|
||||
},
|
||||
error: CreateSampleError());
|
||||
}
|
||||
|
||||
private static ScannerError CreateSampleError()
|
||||
{
|
||||
return new ScannerError(
|
||||
ScannerErrorCode.AnalyzerFailure,
|
||||
ScannerErrorSeverity.Error,
|
||||
"Analyzer failed to parse layer",
|
||||
SampleCreatedAt,
|
||||
retryable: false,
|
||||
details: new Dictionary<string, string>
|
||||
{
|
||||
["layerDigest"] = "sha256:deadbeef",
|
||||
["attempt"] = "1"
|
||||
},
|
||||
stage: nameof(ScanStage.AnalyzeOperatingSystem),
|
||||
component: "os-analyzer");
|
||||
}
|
||||
|
||||
private static string LoadFixture(string fileName)
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, "Fixtures", fileName);
|
||||
return File.ReadAllText(path).Trim();
|
||||
}
|
||||
}
|
||||
1
src/StellaOps.Scanner.Core.Tests/Fixtures/scan-job.json
Normal file
1
src/StellaOps.Scanner.Core.Tests/Fixtures/scan-job.json
Normal file
@@ -0,0 +1 @@
|
||||
{"id":"8f4cc9c582454b9d9b4f5ae049631b7d","status":"running","imageReference":"registry.example.com/stellaops/scanner:1.2.3","imageDigest":"sha256:abcdef","createdAt":"2025-10-18T14:30:15.123456+00:00","updatedAt":"2025-10-18T14:30:20.123456+00:00","correlationId":"scan-analyzeoperatingsystem-8f4cc9c582454b9d9b4f5ae049631b7d","tenantId":"tenant-a","metadata":{"requestId":"req-1234","source":"ci"},"failure":{"code":"analyzerFailure","severity":"error","message":"Analyzer failed to parse layer","timestamp":"2025-10-18T14:30:15.123456+00:00","retryable":false,"stage":"AnalyzeOperatingSystem","component":"os-analyzer","details":{"layerDigest":"sha256:deadbeef","attempt":"1"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"jobId":"8f4cc9c582454b9d9b4f5ae049631b7d","stage":"analyzeOperatingSystem","kind":"warning","sequence":3,"timestamp":"2025-10-18T14:30:16.123456+00:00","percentComplete":42.5,"message":"OS analyzer reported missing packages","attributes":{"package":"openssl","version":"1.1.1w"},"error":{"code":"analyzerFailure","severity":"error","message":"Analyzer failed to parse layer","timestamp":"2025-10-18T14:30:15.123456+00:00","retryable":false,"stage":"AnalyzeOperatingSystem","component":"os-analyzer","details":{"layerDigest":"sha256:deadbeef","attempt":"1"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"code":"analyzerFailure","severity":"error","message":"Analyzer failed to parse layer","timestamp":"2025-10-18T14:30:15.123456+00:00","retryable":false,"stage":"AnalyzeOperatingSystem","component":"os-analyzer","details":{"layerDigest":"sha256:deadbeef","attempt":"1"}}
|
||||
@@ -0,0 +1,103 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
using StellaOps.Scanner.Core.Observability;
|
||||
using StellaOps.Scanner.Core.Utility;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Core.Tests.Observability;
|
||||
|
||||
public sealed class ScannerLogExtensionsPerformanceTests
|
||||
{
|
||||
private const double ThresholdMicroseconds = 5.0;
|
||||
private const int WarmupIterations = 5_000;
|
||||
private const int MeasuredIterations = 200_000;
|
||||
private static readonly DateTimeOffset Timestamp = ScannerTimestamps.Normalize(new DateTimeOffset(2025, 10, 19, 12, 0, 0, TimeSpan.Zero));
|
||||
private static readonly string Stage = nameof(ScanStage.AnalyzeOperatingSystem);
|
||||
private static readonly string Component = "os-analyzer";
|
||||
|
||||
[Fact]
|
||||
public void BeginScanScope_CompletesWithinThreshold()
|
||||
{
|
||||
using var factory = LoggerFactory.Create(builder => builder.AddFilter(static _ => false));
|
||||
var logger = factory.CreateLogger("ScannerPerformance");
|
||||
var job = CreateScanJob();
|
||||
|
||||
var microseconds = Measure(() => logger.BeginScanScope(job, Stage, Component));
|
||||
|
||||
Assert.True(microseconds <= ThresholdMicroseconds, $"Expected BeginScanScope to stay ≤ {ThresholdMicroseconds} µs but measured {microseconds:F3} µs.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BeginProgressScope_CompletesWithinThreshold()
|
||||
{
|
||||
using var factory = LoggerFactory.Create(builder => builder.AddFilter(static _ => false));
|
||||
var logger = factory.CreateLogger("ScannerPerformance");
|
||||
var progress = CreateProgressEvent();
|
||||
|
||||
var microseconds = Measure(() => logger.BeginProgressScope(progress, Component));
|
||||
|
||||
Assert.True(microseconds <= ThresholdMicroseconds, $"Expected BeginProgressScope to stay ≤ {ThresholdMicroseconds} µs but measured {microseconds:F3} µs.");
|
||||
}
|
||||
|
||||
private static double Measure(Func<IDisposable> scopeFactory)
|
||||
{
|
||||
for (var i = 0; i < WarmupIterations; i++)
|
||||
{
|
||||
using var scope = scopeFactory();
|
||||
}
|
||||
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
GC.Collect();
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
for (var i = 0; i < MeasuredIterations; i++)
|
||||
{
|
||||
using var scope = scopeFactory();
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
return stopwatch.Elapsed.TotalSeconds * 1_000_000 / MeasuredIterations;
|
||||
}
|
||||
|
||||
private static ScanJob CreateScanJob()
|
||||
{
|
||||
var jobId = ScannerIdentifiers.CreateJobId("registry.example.com/stellaops/scanner:1.2.3", "sha256:abcdef", "tenant-a", "perf");
|
||||
var correlationId = ScannerIdentifiers.CreateCorrelationId(jobId, Stage, Component);
|
||||
|
||||
return new ScanJob(
|
||||
jobId,
|
||||
ScanJobStatus.Running,
|
||||
"registry.example.com/stellaops/scanner:1.2.3",
|
||||
"sha256:abcdef",
|
||||
Timestamp,
|
||||
Timestamp,
|
||||
correlationId,
|
||||
"tenant-a",
|
||||
new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["requestId"] = "req-perf"
|
||||
});
|
||||
}
|
||||
|
||||
private static ScanProgressEvent CreateProgressEvent()
|
||||
{
|
||||
var jobId = ScannerIdentifiers.CreateJobId("registry.example.com/stellaops/scanner:1.2.3", "sha256:abcdef", "tenant-a", "perf");
|
||||
|
||||
return new ScanProgressEvent(
|
||||
jobId,
|
||||
ScanStage.AnalyzeOperatingSystem,
|
||||
ScanProgressEventKind.Progress,
|
||||
sequence: 42,
|
||||
Timestamp,
|
||||
percentComplete: 10.5,
|
||||
message: "performance check",
|
||||
attributes: new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["sample"] = "true"
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
using StellaOps.Scanner.Core.Observability;
|
||||
using StellaOps.Scanner.Core.Utility;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Core.Tests.Observability;
|
||||
|
||||
public sealed class ScannerLogExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void BeginScanScope_PopulatesCorrelationContext()
|
||||
{
|
||||
using var factory = LoggerFactory.Create(builder => builder.AddFilter(_ => true));
|
||||
var logger = factory.CreateLogger("test");
|
||||
|
||||
var jobId = ScannerIdentifiers.CreateJobId("example/scanner:1.0", "sha256:abc", null, null);
|
||||
var correlationId = ScannerIdentifiers.CreateCorrelationId(jobId, "enqueue");
|
||||
var job = new ScanJob(
|
||||
jobId,
|
||||
ScanJobStatus.Pending,
|
||||
"example/scanner:1.0",
|
||||
"sha256:abc",
|
||||
DateTimeOffset.UtcNow,
|
||||
null,
|
||||
correlationId,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
|
||||
using (logger.BeginScanScope(job, "enqueue"))
|
||||
{
|
||||
Assert.True(ScannerCorrelationContextAccessor.TryGetCorrelationId(out var current));
|
||||
Assert.Equal(correlationId, current);
|
||||
}
|
||||
|
||||
Assert.False(ScannerCorrelationContextAccessor.TryGetCorrelationId(out _));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Scanner.Core.Security;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Core.Tests.Security;
|
||||
|
||||
public sealed class AuthorityTokenSourceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetAsync_ReusesCachedTokenUntilRefreshSkew()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 18, 12, 0, 0, TimeSpan.Zero));
|
||||
var client = new FakeTokenClient(timeProvider);
|
||||
var source = new AuthorityTokenSource(client, TimeSpan.FromSeconds(30), timeProvider, NullLogger<AuthorityTokenSource>.Instance);
|
||||
|
||||
var token1 = await source.GetAsync("scanner", new[] { "scanner.read" });
|
||||
Assert.Equal(1, client.RequestCount);
|
||||
|
||||
var token2 = await source.GetAsync("scanner", new[] { "scanner.read" });
|
||||
Assert.Equal(1, client.RequestCount);
|
||||
Assert.Equal(token1.AccessToken, token2.AccessToken);
|
||||
|
||||
timeProvider.Advance(TimeSpan.FromMinutes(3));
|
||||
var token3 = await source.GetAsync("scanner", new[] { "scanner.read" });
|
||||
Assert.Equal(2, client.RequestCount);
|
||||
Assert.NotEqual(token1.AccessToken, token3.AccessToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvalidateAsync_RemovesCachedToken()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 18, 12, 0, 0, TimeSpan.Zero));
|
||||
var client = new FakeTokenClient(timeProvider);
|
||||
var source = new AuthorityTokenSource(client, TimeSpan.FromSeconds(30), timeProvider, NullLogger<AuthorityTokenSource>.Instance);
|
||||
|
||||
_ = await source.GetAsync("scanner", new[] { "scanner.read" });
|
||||
Assert.Equal(1, client.RequestCount);
|
||||
|
||||
await source.InvalidateAsync("scanner", new[] { "scanner.read" });
|
||||
_ = await source.GetAsync("scanner", new[] { "scanner.read" });
|
||||
|
||||
Assert.Equal(2, client.RequestCount);
|
||||
}
|
||||
|
||||
private sealed class FakeTokenClient : IStellaOpsTokenClient
|
||||
{
|
||||
private readonly FakeTimeProvider timeProvider;
|
||||
private int counter;
|
||||
|
||||
public FakeTokenClient(FakeTimeProvider timeProvider)
|
||||
{
|
||||
this.timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public int RequestCount => counter;
|
||||
|
||||
public Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var access = $"token-{Interlocked.Increment(ref counter)}";
|
||||
var expires = timeProvider.GetUtcNow().AddMinutes(2);
|
||||
var scopes = scope is null
|
||||
? Array.Empty<string>()
|
||||
: scope.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
return Task.FromResult(new StellaOpsTokenResult(access, "Bearer", expires, scopes));
|
||||
}
|
||||
|
||||
public Task<StellaOpsTokenResult> RequestPasswordTokenAsync(string username, string password, string? scope = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<JsonWebKeySet> GetJsonWebKeySetAsync(CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public ValueTask<StellaOpsTokenCacheEntry?> GetCachedTokenAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.FromResult<StellaOpsTokenCacheEntry?>(null);
|
||||
|
||||
public ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask ClearCachedTokenAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.Security.Dpop;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Core.Tests.Security;
|
||||
|
||||
public sealed class DpopProofValidatorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ValidateAsync_ReturnsSuccess_ForValidProof()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 18, 12, 0, 0, TimeSpan.Zero));
|
||||
var validator = new DpopProofValidator(Options.Create(new DpopValidationOptions()), new InMemoryDpopReplayCache(timeProvider), timeProvider);
|
||||
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var securityKey = new ECDsaSecurityKey(key) { KeyId = Guid.NewGuid().ToString("N") };
|
||||
|
||||
var proof = CreateProof(timeProvider, securityKey, "GET", new Uri("https://scanner.example.com/api/v1/scans"));
|
||||
var result = await validator.ValidateAsync(proof, "GET", new Uri("https://scanner.example.com/api/v1/scans"));
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.NotNull(result.PublicKey);
|
||||
Assert.NotNull(result.JwtId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_Fails_OnNonceMismatch()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 18, 12, 0, 0, TimeSpan.Zero));
|
||||
var validator = new DpopProofValidator(Options.Create(new DpopValidationOptions()), new InMemoryDpopReplayCache(timeProvider), timeProvider);
|
||||
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var securityKey = new ECDsaSecurityKey(key) { KeyId = Guid.NewGuid().ToString("N") };
|
||||
|
||||
var proof = CreateProof(timeProvider, securityKey, "POST", new Uri("https://scanner.example.com/api/v1/scans"), nonce: "expected");
|
||||
var result = await validator.ValidateAsync(proof, "POST", new Uri("https://scanner.example.com/api/v1/scans"), nonce: "different");
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("invalid_token", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_Fails_OnReplay()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 18, 12, 0, 0, TimeSpan.Zero));
|
||||
var cache = new InMemoryDpopReplayCache(timeProvider);
|
||||
var validator = new DpopProofValidator(Options.Create(new DpopValidationOptions()), cache, timeProvider);
|
||||
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var securityKey = new ECDsaSecurityKey(key) { KeyId = Guid.NewGuid().ToString("N") };
|
||||
var jti = Guid.NewGuid().ToString();
|
||||
|
||||
var proof = CreateProof(timeProvider, securityKey, "GET", new Uri("https://scanner.example.com/api/v1/scans"), jti: jti);
|
||||
|
||||
var first = await validator.ValidateAsync(proof, "GET", new Uri("https://scanner.example.com/api/v1/scans"));
|
||||
Assert.True(first.IsValid);
|
||||
|
||||
var second = await validator.ValidateAsync(proof, "GET", new Uri("https://scanner.example.com/api/v1/scans"));
|
||||
Assert.False(second.IsValid);
|
||||
Assert.Equal("replay", second.ErrorCode);
|
||||
}
|
||||
|
||||
private static string CreateProof(FakeTimeProvider timeProvider, ECDsaSecurityKey key, string method, Uri uri, string? nonce = null, string? jti = null)
|
||||
{
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
var signingCredentials = new SigningCredentials(key, SecurityAlgorithms.EcdsaSha256);
|
||||
var jwk = JsonWebKeyConverter.ConvertFromECDsaSecurityKey(key);
|
||||
|
||||
var header = new JwtHeader(signingCredentials)
|
||||
{
|
||||
["typ"] = "dpop+jwt",
|
||||
["jwk"] = new Dictionary<string, object?>
|
||||
{
|
||||
["kty"] = jwk.Kty,
|
||||
["crv"] = jwk.Crv,
|
||||
["x"] = jwk.X,
|
||||
["y"] = jwk.Y
|
||||
}
|
||||
};
|
||||
|
||||
var payload = new JwtPayload
|
||||
{
|
||||
["htm"] = method.ToUpperInvariant(),
|
||||
["htu"] = Normalize(uri),
|
||||
["iat"] = timeProvider.GetUtcNow().ToUnixTimeSeconds(),
|
||||
["jti"] = jti ?? Guid.NewGuid().ToString()
|
||||
};
|
||||
|
||||
if (nonce is not null)
|
||||
{
|
||||
payload["nonce"] = nonce;
|
||||
}
|
||||
|
||||
var token = new JwtSecurityToken(header, payload);
|
||||
return handler.WriteToken(token);
|
||||
}
|
||||
|
||||
private static string Normalize(Uri uri)
|
||||
{
|
||||
var builder = new UriBuilder(uri)
|
||||
{
|
||||
Fragment = string.Empty
|
||||
};
|
||||
|
||||
builder.Host = builder.Host.ToLowerInvariant();
|
||||
builder.Scheme = builder.Scheme.ToLowerInvariant();
|
||||
|
||||
if ((builder.Scheme == "http" && builder.Port == 80) || (builder.Scheme == "https" && builder.Port == 443))
|
||||
{
|
||||
builder.Port = -1;
|
||||
}
|
||||
|
||||
return builder.Uri.GetComponents(UriComponents.SchemeAndServer | UriComponents.PathAndQuery, UriFormat.UriEscaped);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System;
|
||||
using StellaOps.Scanner.Core.Security;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Core.Tests.Security;
|
||||
|
||||
public sealed class RestartOnlyPluginGuardTests
|
||||
{
|
||||
[Fact]
|
||||
public void EnsureRegistrationAllowed_AllowsNewPluginsBeforeSeal()
|
||||
{
|
||||
var guard = new RestartOnlyPluginGuard();
|
||||
guard.EnsureRegistrationAllowed("./plugins/analyzer.dll");
|
||||
|
||||
Assert.Contains(guard.KnownPlugins, path => path.EndsWith("analyzer.dll", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureRegistrationAllowed_ThrowsAfterSeal()
|
||||
{
|
||||
var guard = new RestartOnlyPluginGuard(new[] { "./plugins/a.dll" });
|
||||
guard.Seal();
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => guard.EnsureRegistrationAllowed("./plugins/new.dll"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../StellaOps.Scanner.Core/StellaOps.Scanner.Core.csproj" />
|
||||
<ProjectReference Include="../StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="../StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Update="Fixtures\*.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,33 @@
|
||||
using StellaOps.Scanner.Core.Utility;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Core.Tests.Utility;
|
||||
|
||||
public sealed class ScannerIdentifiersTests
|
||||
{
|
||||
[Fact]
|
||||
public void CreateJobId_IsDeterministicAndCaseInsensitive()
|
||||
{
|
||||
var first = ScannerIdentifiers.CreateJobId("registry.example.com/repo:latest", "SHA256:ABC", "Tenant-A", "salt");
|
||||
var second = ScannerIdentifiers.CreateJobId("REGISTRY.EXAMPLE.COM/REPO:latest", "sha256:abc", "tenant-a", "salt");
|
||||
|
||||
Assert.Equal(first, second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateDeterministicHash_ProducesLowercaseHex()
|
||||
{
|
||||
var hash = ScannerIdentifiers.CreateDeterministicHash("scan", "abc", "123");
|
||||
|
||||
Assert.Matches("^[0-9a-f]{64}$", hash);
|
||||
Assert.Equal(hash, hash.ToLowerInvariant());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizeImageReference_LowercasesRegistryAndRepository()
|
||||
{
|
||||
var normalized = ScannerIdentifiers.NormalizeImageReference("Registry.Example.com/StellaOps/Scanner:1.0");
|
||||
|
||||
Assert.Equal("registry.example.com/stellaops/scanner:1.0", normalized);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using StellaOps.Scanner.Core.Utility;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Core.Tests.Utility;
|
||||
|
||||
public sealed class ScannerTimestampsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Normalize_TrimsToMicroseconds()
|
||||
{
|
||||
var value = new DateTimeOffset(2025, 10, 18, 14, 30, 15, TimeSpan.Zero).AddTicks(7);
|
||||
var normalized = ScannerTimestamps.Normalize(value);
|
||||
|
||||
var expectedTicks = value.UtcTicks - (value.UtcTicks % 10);
|
||||
Assert.Equal(expectedTicks, normalized.UtcTicks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToIso8601_ProducesUtcString()
|
||||
{
|
||||
var value = new DateTimeOffset(2025, 10, 18, 14, 30, 15, TimeSpan.FromHours(-4));
|
||||
var iso = ScannerTimestamps.ToIso8601(value);
|
||||
|
||||
Assert.Equal("2025-10-18T18:30:15.000000Z", iso);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user