audit work, fixed StellaOps.sln warnings/errors, fixed tests, sprints work, new advisories
This commit is contained in:
@@ -5,10 +5,6 @@
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.runner.visualstudio" >
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" >
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
|
||||
@@ -8,10 +8,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" >
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -10,10 +10,6 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" >
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
22
src/__Libraries/__Tests/StellaOps.Eventing.Tests/AGENTS.md
Normal file
22
src/__Libraries/__Tests/StellaOps.Eventing.Tests/AGENTS.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Eventing Tests Charter
|
||||
|
||||
## Mission
|
||||
- Verify eventing envelope contracts and deterministic serialization.
|
||||
|
||||
## Responsibilities
|
||||
- Cover schema validation, parsing, and round-trip serialization.
|
||||
- Exercise edge cases and determinism behavior.
|
||||
|
||||
## Required Reading
|
||||
- docs/README.md
|
||||
- docs/07_HIGH_LEVEL_ARCHITECTURE.md
|
||||
- docs/modules/platform/architecture-overview.md
|
||||
- docs/modules/eventing/event-envelope-schema.md
|
||||
|
||||
## Working Agreement
|
||||
- Use fixed times and IDs for deterministic tests.
|
||||
- Avoid network dependencies in tests.
|
||||
|
||||
## Testing Strategy
|
||||
- Unit tests for schema and serialization.
|
||||
- Regression tests for envelope compatibility.
|
||||
@@ -0,0 +1,154 @@
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.Eventing.Internal;
|
||||
using StellaOps.HybridLogicalClock;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Eventing.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class EventIdGeneratorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Generate_SameInputs_ProducesSameId()
|
||||
{
|
||||
// Arrange
|
||||
var correlationId = "scan-abc123";
|
||||
var tHlc = new HlcTimestamp(1704585600000, 0, "node1");
|
||||
var service = "Scheduler";
|
||||
var kind = "ENQUEUE";
|
||||
|
||||
// Act
|
||||
var id1 = EventIdGenerator.Generate(correlationId, tHlc, service, kind);
|
||||
var id2 = EventIdGenerator.Generate(correlationId, tHlc, service, kind);
|
||||
|
||||
// Assert
|
||||
id1.Should().Be(id2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_DifferentCorrelationId_ProducesDifferentId()
|
||||
{
|
||||
// Arrange
|
||||
var tHlc = new HlcTimestamp(1704585600000, 0, "node1");
|
||||
var service = "Scheduler";
|
||||
var kind = "ENQUEUE";
|
||||
|
||||
// Act
|
||||
var id1 = EventIdGenerator.Generate("scan-abc123", tHlc, service, kind);
|
||||
var id2 = EventIdGenerator.Generate("scan-xyz789", tHlc, service, kind);
|
||||
|
||||
// Assert
|
||||
id1.Should().NotBe(id2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_DifferentHlc_ProducesDifferentId()
|
||||
{
|
||||
// Arrange
|
||||
var correlationId = "scan-abc123";
|
||||
var tHlc1 = new HlcTimestamp(1704585600000, 0, "node1");
|
||||
var tHlc2 = new HlcTimestamp(1704585600000, 1, "node1");
|
||||
var service = "Scheduler";
|
||||
var kind = "ENQUEUE";
|
||||
|
||||
// Act
|
||||
var id1 = EventIdGenerator.Generate(correlationId, tHlc1, service, kind);
|
||||
var id2 = EventIdGenerator.Generate(correlationId, tHlc2, service, kind);
|
||||
|
||||
// Assert
|
||||
id1.Should().NotBe(id2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_DifferentService_ProducesDifferentId()
|
||||
{
|
||||
// Arrange
|
||||
var correlationId = "scan-abc123";
|
||||
var tHlc = new HlcTimestamp(1704585600000, 0, "node1");
|
||||
var kind = "ENQUEUE";
|
||||
|
||||
// Act
|
||||
var id1 = EventIdGenerator.Generate(correlationId, tHlc, "Scheduler", kind);
|
||||
var id2 = EventIdGenerator.Generate(correlationId, tHlc, "AirGap", kind);
|
||||
|
||||
// Assert
|
||||
id1.Should().NotBe(id2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_DifferentKind_ProducesDifferentId()
|
||||
{
|
||||
// Arrange
|
||||
var correlationId = "scan-abc123";
|
||||
var tHlc = new HlcTimestamp(1704585600000, 0, "node1");
|
||||
var service = "Scheduler";
|
||||
|
||||
// Act
|
||||
var id1 = EventIdGenerator.Generate(correlationId, tHlc, service, "ENQUEUE");
|
||||
var id2 = EventIdGenerator.Generate(correlationId, tHlc, service, "EXECUTE");
|
||||
|
||||
// Assert
|
||||
id1.Should().NotBe(id2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_ReturnsLowercaseHex32Chars()
|
||||
{
|
||||
// Arrange
|
||||
var correlationId = "scan-abc123";
|
||||
var tHlc = new HlcTimestamp(1704585600000, 0, "node1");
|
||||
var service = "Scheduler";
|
||||
var kind = "ENQUEUE";
|
||||
|
||||
// Act
|
||||
var id = EventIdGenerator.Generate(correlationId, tHlc, service, kind);
|
||||
|
||||
// Assert
|
||||
id.Should().HaveLength(32);
|
||||
id.Should().MatchRegex("^[a-f0-9]{32}$");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputePayloadDigest_SamePayload_ProducesSameDigest()
|
||||
{
|
||||
// Arrange
|
||||
var payload = """{"key":"value"}""";
|
||||
|
||||
// Act
|
||||
var digest1 = EventIdGenerator.ComputePayloadDigest(payload);
|
||||
var digest2 = EventIdGenerator.ComputePayloadDigest(payload);
|
||||
|
||||
// Assert
|
||||
digest1.Should().BeEquivalentTo(digest2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputePayloadDigest_DifferentPayload_ProducesDifferentDigest()
|
||||
{
|
||||
// Arrange
|
||||
var payload1 = """{"key":"value1"}""";
|
||||
var payload2 = """{"key":"value2"}""";
|
||||
|
||||
// Act
|
||||
var digest1 = EventIdGenerator.ComputePayloadDigest(payload1);
|
||||
var digest2 = EventIdGenerator.ComputePayloadDigest(payload2);
|
||||
|
||||
// Assert
|
||||
digest1.Should().NotBeEquivalentTo(digest2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputePayloadDigest_Returns32Bytes()
|
||||
{
|
||||
// Arrange
|
||||
var payload = """{"key":"value"}""";
|
||||
|
||||
// Act
|
||||
var digest = EventIdGenerator.ComputePayloadDigest(payload);
|
||||
|
||||
// Assert
|
||||
digest.Should().HaveCount(32); // SHA-256 = 256 bits = 32 bytes
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.Eventing.Models;
|
||||
using StellaOps.Eventing.Storage;
|
||||
using StellaOps.HybridLogicalClock;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Eventing.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class InMemoryTimelineEventStoreTests
|
||||
{
|
||||
private readonly InMemoryTimelineEventStore _store;
|
||||
|
||||
public InMemoryTimelineEventStoreTests()
|
||||
{
|
||||
_store = new InMemoryTimelineEventStore();
|
||||
}
|
||||
|
||||
private static TimelineEvent CreateEvent(
|
||||
string correlationId,
|
||||
string kind,
|
||||
HlcTimestamp hlc,
|
||||
string service = "TestService")
|
||||
{
|
||||
return new TimelineEvent
|
||||
{
|
||||
EventId = $"{correlationId}-{kind}-{hlc.LogicalCounter}",
|
||||
CorrelationId = correlationId,
|
||||
Kind = kind,
|
||||
THlc = hlc,
|
||||
TsWall = DateTimeOffset.UtcNow,
|
||||
Service = service,
|
||||
Payload = "{}",
|
||||
PayloadDigest = new byte[32],
|
||||
EngineVersion = new EngineVersionRef("Test", "1.0.0", "test"),
|
||||
SchemaVersion = 1
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AppendAsync_StoresEvent()
|
||||
{
|
||||
// Arrange
|
||||
var e = CreateEvent("corr-1", "ENQUEUE", new HlcTimestamp(1000, 0, "n1"));
|
||||
|
||||
// Act
|
||||
await _store.AppendAsync(e);
|
||||
|
||||
// Assert
|
||||
var retrieved = await _store.GetByIdAsync(e.EventId);
|
||||
retrieved.Should().NotBeNull();
|
||||
retrieved!.EventId.Should().Be(e.EventId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AppendAsync_Idempotent_DoesNotDuplicate()
|
||||
{
|
||||
// Arrange
|
||||
var e = CreateEvent("corr-1", "ENQUEUE", new HlcTimestamp(1000, 0, "n1"));
|
||||
|
||||
// Act
|
||||
await _store.AppendAsync(e);
|
||||
await _store.AppendAsync(e); // Duplicate
|
||||
|
||||
// Assert
|
||||
var count = await _store.CountByCorrelationIdAsync("corr-1");
|
||||
count.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByCorrelationIdAsync_ReturnsOrderedByHlc()
|
||||
{
|
||||
// Arrange
|
||||
var hlc1 = new HlcTimestamp(1000, 0, "n1");
|
||||
var hlc2 = new HlcTimestamp(1000, 1, "n1");
|
||||
var hlc3 = new HlcTimestamp(2000, 0, "n1");
|
||||
|
||||
// Insert out of order
|
||||
await _store.AppendAsync(CreateEvent("corr-1", "C", hlc3));
|
||||
await _store.AppendAsync(CreateEvent("corr-1", "A", hlc1));
|
||||
await _store.AppendAsync(CreateEvent("corr-1", "B", hlc2));
|
||||
|
||||
// Act
|
||||
var events = await _store.GetByCorrelationIdAsync("corr-1");
|
||||
|
||||
// Assert
|
||||
events.Should().HaveCount(3);
|
||||
events[0].Kind.Should().Be("A");
|
||||
events[1].Kind.Should().Be("B");
|
||||
events[2].Kind.Should().Be("C");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByCorrelationIdAsync_Pagination_Works()
|
||||
{
|
||||
// Arrange
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
await _store.AppendAsync(CreateEvent("corr-1", $"E{i}", new HlcTimestamp(1000 + i, 0, "n1")));
|
||||
}
|
||||
|
||||
// Act
|
||||
var page1 = await _store.GetByCorrelationIdAsync("corr-1", limit: 3, offset: 0);
|
||||
var page2 = await _store.GetByCorrelationIdAsync("corr-1", limit: 3, offset: 3);
|
||||
|
||||
// Assert
|
||||
page1.Should().HaveCount(3);
|
||||
page2.Should().HaveCount(3);
|
||||
page1[0].Kind.Should().Be("E0");
|
||||
page2[0].Kind.Should().Be("E3");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByHlcRangeAsync_FiltersCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
await _store.AppendAsync(CreateEvent("corr-1", "A", new HlcTimestamp(1000, 0, "n1")));
|
||||
await _store.AppendAsync(CreateEvent("corr-1", "B", new HlcTimestamp(2000, 0, "n1")));
|
||||
await _store.AppendAsync(CreateEvent("corr-1", "C", new HlcTimestamp(3000, 0, "n1")));
|
||||
await _store.AppendAsync(CreateEvent("corr-1", "D", new HlcTimestamp(4000, 0, "n1")));
|
||||
|
||||
// Act
|
||||
var events = await _store.GetByHlcRangeAsync(
|
||||
"corr-1",
|
||||
new HlcTimestamp(2000, 0, "n1"),
|
||||
new HlcTimestamp(3000, 0, "n1"));
|
||||
|
||||
// Assert
|
||||
events.Should().HaveCount(2);
|
||||
events[0].Kind.Should().Be("B");
|
||||
events[1].Kind.Should().Be("C");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByServiceAsync_FiltersCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
await _store.AppendAsync(CreateEvent("corr-1", "A", new HlcTimestamp(1000, 0, "n1"), "Scheduler"));
|
||||
await _store.AppendAsync(CreateEvent("corr-2", "B", new HlcTimestamp(2000, 0, "n1"), "AirGap"));
|
||||
await _store.AppendAsync(CreateEvent("corr-3", "C", new HlcTimestamp(3000, 0, "n1"), "Scheduler"));
|
||||
|
||||
// Act
|
||||
var events = await _store.GetByServiceAsync("Scheduler");
|
||||
|
||||
// Assert
|
||||
events.Should().HaveCount(2);
|
||||
events.All(e => e.Service == "Scheduler").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_NotFound_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var result = await _store.GetByIdAsync("nonexistent");
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CountByCorrelationIdAsync_ReturnsCorrectCount()
|
||||
{
|
||||
// Arrange
|
||||
await _store.AppendAsync(CreateEvent("corr-1", "A", new HlcTimestamp(1000, 0, "n1")));
|
||||
await _store.AppendAsync(CreateEvent("corr-1", "B", new HlcTimestamp(2000, 0, "n1")));
|
||||
await _store.AppendAsync(CreateEvent("corr-2", "C", new HlcTimestamp(3000, 0, "n1")));
|
||||
|
||||
// Act
|
||||
var count1 = await _store.CountByCorrelationIdAsync("corr-1");
|
||||
var count2 = await _store.CountByCorrelationIdAsync("corr-2");
|
||||
var count3 = await _store.CountByCorrelationIdAsync("corr-3");
|
||||
|
||||
// Assert
|
||||
count1.Should().Be(2);
|
||||
count2.Should().Be(1);
|
||||
count3.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AppendBatchAsync_StoresAllEvents()
|
||||
{
|
||||
// Arrange
|
||||
var events = new[]
|
||||
{
|
||||
CreateEvent("corr-1", "A", new HlcTimestamp(1000, 0, "n1")),
|
||||
CreateEvent("corr-1", "B", new HlcTimestamp(2000, 0, "n1")),
|
||||
CreateEvent("corr-1", "C", new HlcTimestamp(3000, 0, "n1"))
|
||||
};
|
||||
|
||||
// Act
|
||||
await _store.AppendBatchAsync(events);
|
||||
|
||||
// Assert
|
||||
var count = await _store.CountByCorrelationIdAsync("corr-1");
|
||||
count.Should().Be(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clear_RemovesAllEvents()
|
||||
{
|
||||
// Arrange
|
||||
_store.AppendAsync(CreateEvent("corr-1", "A", new HlcTimestamp(1000, 0, "n1"))).Wait();
|
||||
|
||||
// Act
|
||||
_store.Clear();
|
||||
|
||||
// Assert
|
||||
_store.GetAll().Should().BeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.Eventing.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Eventing\StellaOps.Eventing.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.HybridLogicalClock\StellaOps.HybridLogicalClock.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,183 @@
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Moq;
|
||||
using StellaOps.Eventing.Models;
|
||||
using StellaOps.Eventing.Storage;
|
||||
using StellaOps.HybridLogicalClock;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Eventing.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TimelineEventEmitterTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly Mock<IHybridLogicalClock> _hlcMock;
|
||||
private readonly InMemoryTimelineEventStore _eventStore;
|
||||
private readonly IOptions<EventingOptions> _options;
|
||||
private readonly TimelineEventEmitter _emitter;
|
||||
|
||||
public TimelineEventEmitterTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 7, 12, 0, 0, TimeSpan.Zero));
|
||||
_hlcMock = new Mock<IHybridLogicalClock>();
|
||||
_eventStore = new InMemoryTimelineEventStore();
|
||||
_options = Options.Create(new EventingOptions
|
||||
{
|
||||
ServiceName = "TestService",
|
||||
EngineVersion = new EngineVersionRef("TestEngine", "1.0.0", "sha256:test")
|
||||
});
|
||||
|
||||
_emitter = new TimelineEventEmitter(
|
||||
_hlcMock.Object,
|
||||
_timeProvider,
|
||||
_eventStore,
|
||||
_options,
|
||||
NullLogger<TimelineEventEmitter>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitAsync_StoresEventWithCorrectFields()
|
||||
{
|
||||
// Arrange
|
||||
var correlationId = "scan-abc123";
|
||||
var kind = EventKinds.Enqueue;
|
||||
var payload = new { JobId = "job-1", Status = "pending" };
|
||||
var expectedHlc = new HlcTimestamp(1704585600000, 0, "node1");
|
||||
|
||||
_hlcMock.Setup(h => h.Tick()).Returns(expectedHlc);
|
||||
|
||||
// Act
|
||||
var result = await _emitter.EmitAsync(correlationId, kind, payload);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.CorrelationId.Should().Be(correlationId);
|
||||
result.Kind.Should().Be(kind);
|
||||
result.Service.Should().Be("TestService");
|
||||
result.THlc.Should().Be(expectedHlc);
|
||||
result.TsWall.Should().Be(_timeProvider.GetUtcNow());
|
||||
result.SchemaVersion.Should().Be(1);
|
||||
result.EngineVersion.EngineName.Should().Be("TestEngine");
|
||||
result.EngineVersion.Version.Should().Be("1.0.0");
|
||||
result.PayloadDigest.Should().NotBeEmpty();
|
||||
result.EventId.Should().HaveLength(32);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitAsync_GeneratesDeterministicEventId()
|
||||
{
|
||||
// Arrange
|
||||
var correlationId = "scan-abc123";
|
||||
var kind = EventKinds.Execute;
|
||||
var payload = new { Step = 1 };
|
||||
var hlc = new HlcTimestamp(1704585600000, 0, "node1");
|
||||
|
||||
_hlcMock.Setup(h => h.Tick()).Returns(hlc);
|
||||
|
||||
// Act
|
||||
var result1 = await _emitter.EmitAsync(correlationId, kind, payload);
|
||||
|
||||
// Create a second emitter with same config
|
||||
var emitter2 = new TimelineEventEmitter(
|
||||
_hlcMock.Object,
|
||||
_timeProvider,
|
||||
new InMemoryTimelineEventStore(),
|
||||
_options,
|
||||
NullLogger<TimelineEventEmitter>.Instance);
|
||||
|
||||
var result2 = await emitter2.EmitAsync(correlationId, kind, payload);
|
||||
|
||||
// Assert - Same inputs should produce same EventId
|
||||
result1.EventId.Should().Be(result2.EventId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitAsync_StoresEventInStore()
|
||||
{
|
||||
// Arrange
|
||||
var correlationId = "scan-abc123";
|
||||
var hlc = new HlcTimestamp(1704585600000, 0, "node1");
|
||||
_hlcMock.Setup(h => h.Tick()).Returns(hlc);
|
||||
|
||||
// Act
|
||||
var emitted = await _emitter.EmitAsync(correlationId, EventKinds.Enqueue, new { Test = true });
|
||||
|
||||
// Assert
|
||||
var stored = await _eventStore.GetByIdAsync(emitted.EventId);
|
||||
stored.Should().NotBeNull();
|
||||
stored!.EventId.Should().Be(emitted.EventId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitBatchAsync_StoresAllEvents()
|
||||
{
|
||||
// Arrange
|
||||
var hlcCounter = 0L;
|
||||
_hlcMock.Setup(h => h.Tick())
|
||||
.Returns(() => new HlcTimestamp(1704585600000, hlcCounter++, "node1"));
|
||||
|
||||
var pendingEvents = new[]
|
||||
{
|
||||
new PendingEvent("scan-1", EventKinds.Enqueue, new { Step = 1 }),
|
||||
new PendingEvent("scan-1", EventKinds.Execute, new { Step = 2 }),
|
||||
new PendingEvent("scan-1", EventKinds.Complete, new { Step = 3 })
|
||||
};
|
||||
|
||||
// Act
|
||||
var results = await _emitter.EmitBatchAsync(pendingEvents);
|
||||
|
||||
// Assert
|
||||
results.Should().HaveCount(3);
|
||||
results.Select(r => r.Kind).Should().BeEquivalentTo(
|
||||
new[] { EventKinds.Enqueue, EventKinds.Execute, EventKinds.Complete });
|
||||
|
||||
var stored = _eventStore.GetAll();
|
||||
stored.Should().HaveCount(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitBatchAsync_EmptyBatch_ReturnsEmptyList()
|
||||
{
|
||||
// Act
|
||||
var results = await _emitter.EmitBatchAsync(Array.Empty<PendingEvent>());
|
||||
|
||||
// Assert
|
||||
results.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitAsync_IncludesPayloadDigest()
|
||||
{
|
||||
// Arrange
|
||||
var hlc = new HlcTimestamp(1704585600000, 0, "node1");
|
||||
_hlcMock.Setup(h => h.Tick()).Returns(hlc);
|
||||
|
||||
// Act
|
||||
var result = await _emitter.EmitAsync("corr-1", EventKinds.Emit, new { Data = "test" });
|
||||
|
||||
// Assert
|
||||
result.PayloadDigest.Should().NotBeNull();
|
||||
result.PayloadDigest.Should().HaveCount(32); // SHA-256
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitAsync_DifferentPayloads_DifferentDigests()
|
||||
{
|
||||
// Arrange
|
||||
var hlcCounter = 0L;
|
||||
_hlcMock.Setup(h => h.Tick())
|
||||
.Returns(() => new HlcTimestamp(1704585600000, hlcCounter++, "node1"));
|
||||
|
||||
// Act
|
||||
var result1 = await _emitter.EmitAsync("corr-1", EventKinds.Emit, new { Value = 1 });
|
||||
var result2 = await _emitter.EmitAsync("corr-1", EventKinds.Emit, new { Value = 2 });
|
||||
|
||||
// Assert
|
||||
result1.PayloadDigest.Should().NotBeEquivalentTo(result2.PayloadDigest);
|
||||
}
|
||||
}
|
||||
@@ -15,10 +15,6 @@
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" >
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -10,11 +10,8 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" />
|
||||
<PackageReference Include="xunit.v3.assert" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" PrivateAssets="all" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="Moq" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -16,10 +16,6 @@
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" >
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" >
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
|
||||
@@ -11,10 +11,6 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.runner.visualstudio" >
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" >
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
|
||||
|
||||
24
src/__Libraries/__Tests/StellaOps.Spdx3.Tests/AGENTS.md
Normal file
24
src/__Libraries/__Tests/StellaOps.Spdx3.Tests/AGENTS.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# SPDX3 Tests Charter
|
||||
|
||||
## Mission
|
||||
- Validate SPDX 3.0.1 parsing, validation, and context resolution.
|
||||
|
||||
## Responsibilities
|
||||
- Cover parser, validator, and version detection behaviors.
|
||||
- Exercise offline and embedded context resolution paths.
|
||||
- Guard determinism and error handling.
|
||||
|
||||
## Required Reading
|
||||
- docs/README.md
|
||||
- docs/07_HIGH_LEVEL_ARCHITECTURE.md
|
||||
- docs/modules/platform/architecture-overview.md
|
||||
- docs/modules/sbom-service/architecture.md
|
||||
- docs/modules/sbom-service/spdx3-profile-support.md
|
||||
|
||||
## Working Agreement
|
||||
- Use fixed times and IDs in fixtures.
|
||||
- Avoid network access in tests.
|
||||
|
||||
## Testing Strategy
|
||||
- Unit tests for parser, validator, and version detection.
|
||||
- Determinism tests for ordering and serialized output.
|
||||
272
src/__Libraries/__Tests/StellaOps.Spdx3.Tests/ModelTests.cs
Normal file
272
src/__Libraries/__Tests/StellaOps.Spdx3.Tests/ModelTests.cs
Normal file
@@ -0,0 +1,272 @@
|
||||
// <copyright file="ModelTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Spdx3.Model;
|
||||
using StellaOps.Spdx3.Model.Software;
|
||||
|
||||
namespace StellaOps.Spdx3.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for SPDX 3.0.1 model classes.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModelTests
|
||||
{
|
||||
[Fact]
|
||||
public void Spdx3Package_Equality_Works()
|
||||
{
|
||||
// Arrange
|
||||
var pkg1 = new Spdx3Package
|
||||
{
|
||||
SpdxId = "urn:test:pkg1",
|
||||
Name = "test-package",
|
||||
PackageVersion = "1.0.0"
|
||||
};
|
||||
|
||||
var pkg2 = new Spdx3Package
|
||||
{
|
||||
SpdxId = "urn:test:pkg1",
|
||||
Name = "test-package",
|
||||
PackageVersion = "1.0.0"
|
||||
};
|
||||
|
||||
var pkg3 = new Spdx3Package
|
||||
{
|
||||
SpdxId = "urn:test:pkg2",
|
||||
Name = "other-package",
|
||||
PackageVersion = "2.0.0"
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.Equal(pkg1, pkg2);
|
||||
Assert.NotEqual(pkg1, pkg3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Spdx3Relationship_TypeMapping_Works()
|
||||
{
|
||||
// Arrange
|
||||
var relationship = new Spdx3Relationship
|
||||
{
|
||||
SpdxId = "urn:test:rel1",
|
||||
From = "urn:test:pkg1",
|
||||
To = ["urn:test:pkg2"],
|
||||
RelationshipType = Spdx3RelationshipType.DependsOn
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.Equal(Spdx3RelationshipType.DependsOn, relationship.RelationshipType);
|
||||
Assert.Single(relationship.To);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Spdx3Hash_NormalizesValue()
|
||||
{
|
||||
// Arrange
|
||||
var hash = new Spdx3Hash
|
||||
{
|
||||
Algorithm = Spdx3HashAlgorithm.Sha256,
|
||||
HashValue = "ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890"
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.Equal(
|
||||
"abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
|
||||
hash.NormalizedHashValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Spdx3Hash_ValidatesHex()
|
||||
{
|
||||
// Arrange
|
||||
var validHash = new Spdx3Hash
|
||||
{
|
||||
Algorithm = Spdx3HashAlgorithm.Sha256,
|
||||
HashValue = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
|
||||
};
|
||||
|
||||
var invalidHash = new Spdx3Hash
|
||||
{
|
||||
Algorithm = Spdx3HashAlgorithm.Sha256,
|
||||
HashValue = "xyz-not-hex!"
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.True(validHash.IsValidHex());
|
||||
Assert.False(invalidHash.IsValidHex());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Spdx3Hash_ValidatesLength()
|
||||
{
|
||||
// Arrange
|
||||
var validSha256 = new Spdx3Hash
|
||||
{
|
||||
Algorithm = Spdx3HashAlgorithm.Sha256,
|
||||
HashValue = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" // 64 chars
|
||||
};
|
||||
|
||||
var invalidSha256 = new Spdx3Hash
|
||||
{
|
||||
Algorithm = Spdx3HashAlgorithm.Sha256,
|
||||
HashValue = "abcdef" // too short
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.True(validSha256.IsValidLength());
|
||||
Assert.False(invalidSha256.IsValidLength());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(Spdx3HashAlgorithm.Sha256, 64)]
|
||||
[InlineData(Spdx3HashAlgorithm.Sha512, 128)]
|
||||
[InlineData(Spdx3HashAlgorithm.Sha3_256, 64)]
|
||||
[InlineData(Spdx3HashAlgorithm.Blake2b256, 64)]
|
||||
[InlineData(Spdx3HashAlgorithm.Md5, 32)]
|
||||
public void Spdx3Hash_GetExpectedLength_ReturnsCorrectLength(Spdx3HashAlgorithm algorithm, int expected)
|
||||
{
|
||||
// Arrange
|
||||
var hash = new Spdx3Hash
|
||||
{
|
||||
Algorithm = algorithm,
|
||||
HashValue = new string('a', expected)
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expected, hash.GetExpectedLength());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(Spdx3HashAlgorithm.Sha256, true)]
|
||||
[InlineData(Spdx3HashAlgorithm.Sha512, true)]
|
||||
[InlineData(Spdx3HashAlgorithm.Blake2b256, true)]
|
||||
[InlineData(Spdx3HashAlgorithm.Md5, false)]
|
||||
[InlineData(Spdx3HashAlgorithm.Sha1, false)]
|
||||
public void HashAlgorithm_IsRecommended_ReturnsCorrectValue(Spdx3HashAlgorithm algorithm, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, algorithm.IsRecommended());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(Spdx3HashAlgorithm.Md5, true)]
|
||||
[InlineData(Spdx3HashAlgorithm.Sha1, true)]
|
||||
[InlineData(Spdx3HashAlgorithm.Sha256, false)]
|
||||
public void HashAlgorithm_IsDeprecated_ReturnsCorrectValue(Spdx3HashAlgorithm algorithm, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, algorithm.IsDeprecated());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Spdx3ProfileIdentifier_ParseUri_Works()
|
||||
{
|
||||
// Assert
|
||||
Assert.Equal(Spdx3ProfileIdentifier.Software, Spdx3ProfileUris.ParseUri(Spdx3ProfileUris.Software));
|
||||
Assert.Equal(Spdx3ProfileIdentifier.Core, Spdx3ProfileUris.ParseUri(Spdx3ProfileUris.Core));
|
||||
Assert.Equal(Spdx3ProfileIdentifier.Build, Spdx3ProfileUris.ParseUri(Spdx3ProfileUris.Build));
|
||||
Assert.Null(Spdx3ProfileUris.ParseUri("https://unknown.example.com"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Spdx3ProfileIdentifier_Parse_WorksWithNames()
|
||||
{
|
||||
// Assert
|
||||
Assert.Equal(Spdx3ProfileIdentifier.Software, Spdx3ProfileUris.Parse("Software"));
|
||||
Assert.Equal(Spdx3ProfileIdentifier.Software, Spdx3ProfileUris.Parse("software"));
|
||||
Assert.Equal(Spdx3ProfileIdentifier.Build, Spdx3ProfileUris.Parse("BUILD"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Spdx3ProfileIdentifier_GetUri_Works()
|
||||
{
|
||||
// Assert
|
||||
Assert.Equal(Spdx3ProfileUris.Software, Spdx3ProfileUris.GetUri(Spdx3ProfileIdentifier.Software));
|
||||
Assert.Equal(Spdx3ProfileUris.Core, Spdx3ProfileUris.GetUri(Spdx3ProfileIdentifier.Core));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExternalIdentifierExtensions_GetPurl_Works()
|
||||
{
|
||||
// Arrange
|
||||
var identifiers = new[]
|
||||
{
|
||||
new Spdx3ExternalIdentifier
|
||||
{
|
||||
ExternalIdentifierType = Spdx3ExternalIdentifierType.PackageUrl,
|
||||
Identifier = "pkg:npm/lodash@4.17.21"
|
||||
},
|
||||
new Spdx3ExternalIdentifier
|
||||
{
|
||||
ExternalIdentifierType = Spdx3ExternalIdentifierType.Cpe23,
|
||||
Identifier = "cpe:2.3:a:lodash:lodash:4.17.21:*:*:*:*:*:*:*"
|
||||
}
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.Equal("pkg:npm/lodash@4.17.21", identifiers.GetPurl());
|
||||
Assert.Equal("cpe:2.3:a:lodash:lodash:4.17.21:*:*:*:*:*:*:*", identifiers.GetCpe23());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Spdx3CreationInfo_IsValidSpecVersion_Works()
|
||||
{
|
||||
// Arrange
|
||||
var valid = new Spdx3CreationInfo
|
||||
{
|
||||
SpecVersion = "3.0.1",
|
||||
Created = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
var invalid = new Spdx3CreationInfo
|
||||
{
|
||||
SpecVersion = "2.3",
|
||||
Created = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.True(valid.IsValidSpecVersion());
|
||||
Assert.False(invalid.IsValidSpecVersion());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Spdx3Document_ConformsTo_Works()
|
||||
{
|
||||
// Arrange
|
||||
var packages = new[] { new Spdx3Package { SpdxId = "urn:test:pkg1", Name = "test" } };
|
||||
var profiles = new[] { Spdx3ProfileIdentifier.Software, Spdx3ProfileIdentifier.Core };
|
||||
var doc = new Spdx3Document(packages, [], profiles);
|
||||
|
||||
// Assert
|
||||
Assert.True(doc.ConformsTo(Spdx3ProfileIdentifier.Software));
|
||||
Assert.True(doc.ConformsTo(Spdx3ProfileIdentifier.Core));
|
||||
Assert.False(doc.ConformsTo(Spdx3ProfileIdentifier.Build));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Spdx3Document_GetRootPackage_Works()
|
||||
{
|
||||
// Arrange
|
||||
var pkg1 = new Spdx3Package { SpdxId = "urn:test:pkg1", Name = "root" };
|
||||
var pkg2 = new Spdx3Package { SpdxId = "urn:test:pkg2", Name = "dep" };
|
||||
var relationship = new Spdx3Relationship
|
||||
{
|
||||
SpdxId = "urn:test:rel1",
|
||||
From = "urn:test:pkg1",
|
||||
To = ["urn:test:pkg2"],
|
||||
RelationshipType = Spdx3RelationshipType.Contains
|
||||
};
|
||||
|
||||
var doc = new Spdx3Document(
|
||||
[pkg1, pkg2, relationship],
|
||||
[],
|
||||
[Spdx3ProfileIdentifier.Software]);
|
||||
|
||||
// Act
|
||||
var root = doc.GetRootPackage();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(root);
|
||||
Assert.Equal("root", root.Name);
|
||||
}
|
||||
}
|
||||
265
src/__Libraries/__Tests/StellaOps.Spdx3.Tests/ParserTests.cs
Normal file
265
src/__Libraries/__Tests/StellaOps.Spdx3.Tests/ParserTests.cs
Normal file
@@ -0,0 +1,265 @@
|
||||
// <copyright file="ParserTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Spdx3.JsonLd;
|
||||
using StellaOps.Spdx3.Model;
|
||||
|
||||
namespace StellaOps.Spdx3.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for SPDX 3.0.1 parser.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ParserTests : IDisposable
|
||||
{
|
||||
private readonly Spdx3Parser _parser;
|
||||
private readonly MemoryCache _cache;
|
||||
|
||||
public ParserTests()
|
||||
{
|
||||
_cache = new MemoryCache(new MemoryCacheOptions { SizeLimit = 100 });
|
||||
var httpClientFactory = new Mock<IHttpClientFactory>();
|
||||
var options = Options.Create(new Spdx3ContextResolverOptions { AllowRemoteContexts = false });
|
||||
var resolver = new Spdx3ContextResolver(
|
||||
httpClientFactory.Object,
|
||||
_cache,
|
||||
NullLogger<Spdx3ContextResolver>.Instance,
|
||||
options,
|
||||
TimeProvider.System);
|
||||
|
||||
_parser = new Spdx3Parser(resolver, NullLogger<Spdx3Parser>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_ValidSoftwareProfile_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
var samplePath = Path.Combine("Samples", "valid-software-profile.json");
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
// Act
|
||||
var result = await _parser.ParseAsync(samplePath, ct);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success, string.Join(", ", result.Errors.Select(e => e.Message)));
|
||||
Assert.NotNull(result.Document);
|
||||
Assert.True(result.Document.Packages.Length > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_ValidLiteProfile_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
var samplePath = Path.Combine("Samples", "valid-lite-profile.json");
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
// Act
|
||||
var result = await _parser.ParseAsync(samplePath, ct);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.Document);
|
||||
Assert.Contains(Spdx3ProfileIdentifier.Lite, result.Document.Profiles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_ValidBuildProfile_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
var samplePath = Path.Combine("Samples", "valid-build-profile.json");
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
// Act
|
||||
var result = await _parser.ParseAsync(samplePath, ct);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.Document);
|
||||
Assert.Contains(Spdx3ProfileIdentifier.Build, result.Document.Profiles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_InvalidNoContext_ReturnsFail()
|
||||
{
|
||||
// Arrange
|
||||
var samplePath = Path.Combine("Samples", "invalid-no-context.json");
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
// Act
|
||||
var result = await _parser.ParseAsync(samplePath, ct);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains(result.Errors, e => e.Code == "MISSING_CONTEXT");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_ExtractsPackages()
|
||||
{
|
||||
// Arrange
|
||||
var samplePath = Path.Combine("Samples", "valid-software-profile.json");
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
// Act
|
||||
var result = await _parser.ParseAsync(samplePath, ct);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.Document);
|
||||
|
||||
var packages = result.Document.Packages;
|
||||
Assert.Equal(2, packages.Length);
|
||||
|
||||
var mainPackage = packages.FirstOrDefault(p => p.Name == "example-app");
|
||||
Assert.NotNull(mainPackage);
|
||||
Assert.Equal("1.0.0", mainPackage.PackageVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_ExtractsRelationships()
|
||||
{
|
||||
// Arrange
|
||||
var samplePath = Path.Combine("Samples", "valid-software-profile.json");
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
// Act
|
||||
var result = await _parser.ParseAsync(samplePath, ct);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.Document);
|
||||
|
||||
var relationships = result.Document.Relationships;
|
||||
Assert.Equal(2, relationships.Length);
|
||||
|
||||
var dependsOn = relationships.FirstOrDefault(r => r.RelationshipType == Spdx3RelationshipType.DependsOn);
|
||||
Assert.NotNull(dependsOn);
|
||||
Assert.Equal("urn:spdx:example:package-1", dependsOn.From);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_ExtractsCreationInfo()
|
||||
{
|
||||
// Arrange
|
||||
var samplePath = Path.Combine("Samples", "valid-software-profile.json");
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
// Act
|
||||
var result = await _parser.ParseAsync(samplePath, ct);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.Document);
|
||||
Assert.Contains(Spdx3ProfileIdentifier.Software, result.Document.Profiles);
|
||||
Assert.Contains(Spdx3ProfileIdentifier.Core, result.Document.Profiles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_ExtractsPurl()
|
||||
{
|
||||
// Arrange
|
||||
var samplePath = Path.Combine("Samples", "valid-software-profile.json");
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
// Act
|
||||
var result = await _parser.ParseAsync(samplePath, ct);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.Document);
|
||||
|
||||
var purls = result.Document.GetAllPurls().ToList();
|
||||
Assert.Contains("pkg:npm/example-app@1.0.0", purls);
|
||||
Assert.Contains("pkg:npm/lodash@4.17.21", purls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_NonexistentFile_ReturnsFail()
|
||||
{
|
||||
// Arrange
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
// Act
|
||||
var result = await _parser.ParseAsync("nonexistent-file.json", ct);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains(result.Errors, e => e.Code == "FILE_NOT_FOUND");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseFromJsonAsync_ValidJson_Parses()
|
||||
{
|
||||
// Arrange
|
||||
var json = """
|
||||
{
|
||||
"@context": "https://spdx.org/rdf/3.0.1/spdx-context.jsonld",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "software_Package",
|
||||
"spdxId": "urn:test:pkg1",
|
||||
"name": "test-package",
|
||||
"packageVersion": "1.0.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
// Act
|
||||
var result = await _parser.ParseFromJsonAsync(json, ct);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.Document);
|
||||
Assert.Single(result.Document.Packages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_DocumentGetById_ReturnsElement()
|
||||
{
|
||||
// Arrange
|
||||
var samplePath = Path.Combine("Samples", "valid-software-profile.json");
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
// Act
|
||||
var result = await _parser.ParseAsync(samplePath, ct);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.Document);
|
||||
|
||||
var pkg = result.Document.GetById<Model.Software.Spdx3Package>("urn:spdx:example:package-1");
|
||||
Assert.NotNull(pkg);
|
||||
Assert.Equal("example-app", pkg.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_DocumentGetDependencies_ReturnsDeps()
|
||||
{
|
||||
// Arrange
|
||||
var samplePath = Path.Combine("Samples", "valid-software-profile.json");
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
// Act
|
||||
var result = await _parser.ParseAsync(samplePath, ct);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.Document);
|
||||
|
||||
var deps = result.Document.GetDependencies("urn:spdx:example:package-1").ToList();
|
||||
Assert.Single(deps);
|
||||
Assert.Equal("lodash", deps[0].Name);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cache.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"invalid": "json",
|
||||
"no_context": true,
|
||||
"spdxVersion": "SPDX-2.3"
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"@context": "https://spdx.org/rdf/3.0.1/spdx-context.jsonld",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "software_SpdxDocument",
|
||||
"spdxId": "urn:spdx:build:document-1",
|
||||
"name": "Build Profile SBOM with Attestation",
|
||||
"creationInfo": {
|
||||
"@id": "_:creationInfoBuild",
|
||||
"@type": "CreationInfo",
|
||||
"specVersion": "3.0.1",
|
||||
"created": "2026-01-07T14:00:00Z",
|
||||
"createdBy": ["urn:spdx:build:ci-system"],
|
||||
"createdUsing": ["urn:spdx:build:stellaops-attestor"],
|
||||
"profile": ["Build", "Software", "Core"],
|
||||
"dataLicense": "CC0-1.0"
|
||||
},
|
||||
"rootElement": ["urn:spdx:build:package-artifact"]
|
||||
},
|
||||
{
|
||||
"@type": "Tool",
|
||||
"spdxId": "urn:spdx:build:ci-system",
|
||||
"name": "GitHub Actions"
|
||||
},
|
||||
{
|
||||
"@type": "Tool",
|
||||
"spdxId": "urn:spdx:build:stellaops-attestor",
|
||||
"name": "StellaOps Attestor v1.2.0"
|
||||
},
|
||||
{
|
||||
"@type": "software_Package",
|
||||
"spdxId": "urn:spdx:build:package-artifact",
|
||||
"creationInfo": "_:creationInfoBuild",
|
||||
"name": "stellaops-scanner",
|
||||
"packageVersion": "1.5.0",
|
||||
"primaryPurpose": "Application",
|
||||
"downloadLocation": "https://github.com/stellaops/scanner/releases/download/v1.5.0/scanner-linux-amd64",
|
||||
"buildTime": "2026-01-07T13:45:00Z",
|
||||
"externalIdentifier": [
|
||||
{
|
||||
"@type": "ExternalIdentifier",
|
||||
"externalIdentifierType": "PackageUrl",
|
||||
"identifier": "pkg:github/stellaops/scanner@v1.5.0"
|
||||
},
|
||||
{
|
||||
"@type": "ExternalIdentifier",
|
||||
"externalIdentifierType": "GitOid",
|
||||
"identifier": "abc123def456789012345678901234567890abcd"
|
||||
}
|
||||
],
|
||||
"verifiedUsing": [
|
||||
{
|
||||
"@type": "Hash",
|
||||
"algorithm": "sha256",
|
||||
"hashValue": "fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321"
|
||||
},
|
||||
{
|
||||
"@type": "Hash",
|
||||
"algorithm": "sha512",
|
||||
"hashValue": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
}
|
||||
],
|
||||
"externalRef": [
|
||||
{
|
||||
"@type": "ExternalRef",
|
||||
"externalRefType": "SecurityAdvisory",
|
||||
"locator": ["https://github.com/stellaops/scanner/security/advisories"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"@type": "software_Package",
|
||||
"spdxId": "urn:spdx:build:package-source",
|
||||
"creationInfo": "_:creationInfoBuild",
|
||||
"name": "stellaops-scanner-source",
|
||||
"packageVersion": "1.5.0",
|
||||
"primaryPurpose": "Source",
|
||||
"externalIdentifier": [
|
||||
{
|
||||
"@type": "ExternalIdentifier",
|
||||
"externalIdentifierType": "Swhid",
|
||||
"identifier": "swh:1:cnt:abc123456789abcdef0123456789abcdef01234567"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"@type": "Relationship",
|
||||
"spdxId": "urn:spdx:build:rel-generated",
|
||||
"creationInfo": "_:creationInfoBuild",
|
||||
"from": "urn:spdx:build:package-artifact",
|
||||
"to": ["urn:spdx:build:package-source"],
|
||||
"relationshipType": "GeneratedFrom"
|
||||
},
|
||||
{
|
||||
"@type": "Relationship",
|
||||
"spdxId": "urn:spdx:build:rel-tool",
|
||||
"creationInfo": "_:creationInfoBuild",
|
||||
"from": "urn:spdx:build:package-artifact",
|
||||
"to": ["urn:spdx:build:ci-system"],
|
||||
"relationshipType": "BuildToolOf"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"@context": "https://spdx.org/rdf/3.0.1/spdx-context.jsonld",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "software_SpdxDocument",
|
||||
"spdxId": "urn:spdx:lite:document-1",
|
||||
"name": "Lite Profile SBOM",
|
||||
"creationInfo": {
|
||||
"@id": "_:creationInfoLite",
|
||||
"@type": "CreationInfo",
|
||||
"specVersion": "3.0.1",
|
||||
"created": "2026-01-07T12:00:00Z",
|
||||
"createdBy": ["urn:spdx:lite:scanner"],
|
||||
"profile": ["Lite", "Core"],
|
||||
"dataLicense": "CC0-1.0"
|
||||
},
|
||||
"rootElement": ["urn:spdx:lite:package-main"]
|
||||
},
|
||||
{
|
||||
"@type": "Tool",
|
||||
"spdxId": "urn:spdx:lite:scanner",
|
||||
"name": "StellaOps CI Scanner"
|
||||
},
|
||||
{
|
||||
"@type": "software_Package",
|
||||
"spdxId": "urn:spdx:lite:package-main",
|
||||
"creationInfo": "_:creationInfoLite",
|
||||
"name": "my-service",
|
||||
"packageVersion": "2.1.0",
|
||||
"externalIdentifier": [
|
||||
{
|
||||
"@type": "ExternalIdentifier",
|
||||
"externalIdentifierType": "PackageUrl",
|
||||
"identifier": "pkg:docker/my-service@2.1.0"
|
||||
}
|
||||
],
|
||||
"verifiedUsing": [
|
||||
{
|
||||
"@type": "Hash",
|
||||
"algorithm": "sha256",
|
||||
"hashValue": "sha256:aabbccdd1122334455667788990011223344556677889900aabbccdd11223344"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"@type": "software_Package",
|
||||
"spdxId": "urn:spdx:lite:package-dep1",
|
||||
"creationInfo": "_:creationInfoLite",
|
||||
"name": "express",
|
||||
"packageVersion": "4.18.2",
|
||||
"externalIdentifier": [
|
||||
{
|
||||
"@type": "ExternalIdentifier",
|
||||
"externalIdentifierType": "PackageUrl",
|
||||
"identifier": "pkg:npm/express@4.18.2"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"@type": "software_Package",
|
||||
"spdxId": "urn:spdx:lite:package-dep2",
|
||||
"creationInfo": "_:creationInfoLite",
|
||||
"name": "typescript",
|
||||
"packageVersion": "5.3.3",
|
||||
"externalIdentifier": [
|
||||
{
|
||||
"@type": "ExternalIdentifier",
|
||||
"externalIdentifierType": "PackageUrl",
|
||||
"identifier": "pkg:npm/typescript@5.3.3"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"@type": "Relationship",
|
||||
"spdxId": "urn:spdx:lite:rel-1",
|
||||
"creationInfo": "_:creationInfoLite",
|
||||
"from": "urn:spdx:lite:package-main",
|
||||
"to": ["urn:spdx:lite:package-dep1"],
|
||||
"relationshipType": "DependsOn"
|
||||
},
|
||||
{
|
||||
"@type": "Relationship",
|
||||
"spdxId": "urn:spdx:lite:rel-2",
|
||||
"creationInfo": "_:creationInfoLite",
|
||||
"from": "urn:spdx:lite:package-main",
|
||||
"to": ["urn:spdx:lite:package-dep2"],
|
||||
"relationshipType": "DependsOn"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
{
|
||||
"@context": "https://spdx.org/rdf/3.0.1/spdx-context.jsonld",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "software_SpdxDocument",
|
||||
"spdxId": "urn:spdx:security:document-1",
|
||||
"name": "Security Profile SBOM with Vulnerability Data",
|
||||
"creationInfo": {
|
||||
"@id": "_:creationInfoSec",
|
||||
"@type": "CreationInfo",
|
||||
"specVersion": "3.0.1",
|
||||
"created": "2026-01-07T16:00:00Z",
|
||||
"createdBy": ["urn:spdx:security:scanner"],
|
||||
"profile": ["Security", "Software", "Core"],
|
||||
"dataLicense": "CC0-1.0"
|
||||
},
|
||||
"rootElement": ["urn:spdx:security:package-main"]
|
||||
},
|
||||
{
|
||||
"@type": "Tool",
|
||||
"spdxId": "urn:spdx:security:scanner",
|
||||
"name": "StellaOps Vulnerability Scanner v2.0.0"
|
||||
},
|
||||
{
|
||||
"@type": "software_Package",
|
||||
"spdxId": "urn:spdx:security:package-main",
|
||||
"creationInfo": "_:creationInfoSec",
|
||||
"name": "vulnerable-app",
|
||||
"packageVersion": "1.0.0",
|
||||
"primaryPurpose": "Application",
|
||||
"externalIdentifier": [
|
||||
{
|
||||
"@type": "ExternalIdentifier",
|
||||
"externalIdentifierType": "PackageUrl",
|
||||
"identifier": "pkg:npm/vulnerable-app@1.0.0"
|
||||
}
|
||||
],
|
||||
"verifiedUsing": [
|
||||
{
|
||||
"@type": "Hash",
|
||||
"algorithm": "sha256",
|
||||
"hashValue": "abc123def456789012345678901234567890abcdef123456789012345678901234"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"@type": "software_Package",
|
||||
"spdxId": "urn:spdx:security:package-vulnerable-dep",
|
||||
"creationInfo": "_:creationInfoSec",
|
||||
"name": "lodash",
|
||||
"packageVersion": "4.17.15",
|
||||
"primaryPurpose": "Library",
|
||||
"externalIdentifier": [
|
||||
{
|
||||
"@type": "ExternalIdentifier",
|
||||
"externalIdentifierType": "PackageUrl",
|
||||
"identifier": "pkg:npm/lodash@4.17.15"
|
||||
},
|
||||
{
|
||||
"@type": "ExternalIdentifier",
|
||||
"externalIdentifierType": "Cpe23",
|
||||
"identifier": "cpe:2.3:a:lodash:lodash:4.17.15:*:*:*:*:*:*:*"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"@type": "security_Vulnerability",
|
||||
"spdxId": "urn:spdx:security:vuln-cve-2020-8203",
|
||||
"creationInfo": "_:creationInfoSec",
|
||||
"name": "CVE-2020-8203",
|
||||
"summary": "Prototype Pollution in lodash",
|
||||
"description": "Prototype pollution in zipObjectDeep in lodash before 4.17.20 allows an attacker to modify the prototype of Object.prototype.",
|
||||
"externalIdentifier": [
|
||||
{
|
||||
"@type": "ExternalIdentifier",
|
||||
"externalIdentifierType": "Cve",
|
||||
"identifier": "CVE-2020-8203"
|
||||
}
|
||||
],
|
||||
"externalRef": [
|
||||
{
|
||||
"@type": "ExternalRef",
|
||||
"externalRefType": "SecurityAdvisory",
|
||||
"locator": ["https://nvd.nist.gov/vuln/detail/CVE-2020-8203"]
|
||||
}
|
||||
],
|
||||
"publishedTime": "2020-07-15T00:00:00Z",
|
||||
"modifiedTime": "2023-01-20T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"@type": "security_VulnAssessmentRelationship",
|
||||
"spdxId": "urn:spdx:security:assessment-1",
|
||||
"creationInfo": "_:creationInfoSec",
|
||||
"from": "urn:spdx:security:vuln-cve-2020-8203",
|
||||
"to": ["urn:spdx:security:package-vulnerable-dep"],
|
||||
"relationshipType": "AffectsElement",
|
||||
"assessedElement": "urn:spdx:security:package-vulnerable-dep",
|
||||
"suppliedBy": "urn:spdx:security:scanner",
|
||||
"publishedTime": "2026-01-07T16:00:00Z"
|
||||
},
|
||||
{
|
||||
"@type": "security_VexVulnAssessmentRelationship",
|
||||
"spdxId": "urn:spdx:security:vex-1",
|
||||
"creationInfo": "_:creationInfoSec",
|
||||
"from": "urn:spdx:security:vuln-cve-2020-8203",
|
||||
"to": ["urn:spdx:security:package-main"],
|
||||
"relationshipType": "HasAssessmentFor",
|
||||
"vexVersion": "1.0.0",
|
||||
"statusNotes": "The vulnerable function is not called in this application",
|
||||
"status": "not_affected",
|
||||
"justification": "vulnerable_code_not_in_execute_path"
|
||||
},
|
||||
{
|
||||
"@type": "security_CvssV3VulnAssessmentRelationship",
|
||||
"spdxId": "urn:spdx:security:cvss-1",
|
||||
"creationInfo": "_:creationInfoSec",
|
||||
"from": "urn:spdx:security:vuln-cve-2020-8203",
|
||||
"to": ["urn:spdx:security:package-vulnerable-dep"],
|
||||
"relationshipType": "HasAssessmentFor",
|
||||
"score": 7.4,
|
||||
"severity": "High",
|
||||
"vectorString": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:H/A:H"
|
||||
},
|
||||
{
|
||||
"@type": "Relationship",
|
||||
"spdxId": "urn:spdx:security:rel-depends",
|
||||
"creationInfo": "_:creationInfoSec",
|
||||
"from": "urn:spdx:security:package-main",
|
||||
"to": ["urn:spdx:security:package-vulnerable-dep"],
|
||||
"relationshipType": "DependsOn"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
{
|
||||
"@context": "https://spdx.org/rdf/3.0.1/spdx-context.jsonld",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "software_SpdxDocument",
|
||||
"spdxId": "urn:spdx:example:document-1",
|
||||
"name": "Example SPDX 3.0.1 Document",
|
||||
"creationInfo": {
|
||||
"@id": "_:creationInfo1",
|
||||
"@type": "CreationInfo",
|
||||
"specVersion": "3.0.1",
|
||||
"created": "2026-01-07T10:00:00Z",
|
||||
"createdBy": ["urn:spdx:example:stellaops-tool"],
|
||||
"profile": ["Software", "Core"],
|
||||
"dataLicense": "CC0-1.0"
|
||||
},
|
||||
"rootElement": ["urn:spdx:example:package-1"],
|
||||
"element": [
|
||||
"urn:spdx:example:package-1",
|
||||
"urn:spdx:example:package-2",
|
||||
"urn:spdx:example:file-1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"@type": "Tool",
|
||||
"spdxId": "urn:spdx:example:stellaops-tool",
|
||||
"name": "StellaOps Scanner v1.0.0"
|
||||
},
|
||||
{
|
||||
"@type": "software_Package",
|
||||
"spdxId": "urn:spdx:example:package-1",
|
||||
"creationInfo": "_:creationInfo1",
|
||||
"name": "example-app",
|
||||
"packageVersion": "1.0.0",
|
||||
"downloadLocation": "https://example.com/example-app-1.0.0.tar.gz",
|
||||
"homePage": "https://example.com",
|
||||
"primaryPurpose": "Application",
|
||||
"copyrightText": "Copyright 2026 Example Inc.",
|
||||
"suppliedBy": "urn:spdx:example:org-1",
|
||||
"externalIdentifier": [
|
||||
{
|
||||
"@type": "ExternalIdentifier",
|
||||
"externalIdentifierType": "PackageUrl",
|
||||
"identifier": "pkg:npm/example-app@1.0.0"
|
||||
}
|
||||
],
|
||||
"verifiedUsing": [
|
||||
{
|
||||
"@type": "Hash",
|
||||
"algorithm": "sha256",
|
||||
"hashValue": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"@type": "software_Package",
|
||||
"spdxId": "urn:spdx:example:package-2",
|
||||
"creationInfo": "_:creationInfo1",
|
||||
"name": "lodash",
|
||||
"packageVersion": "4.17.21",
|
||||
"primaryPurpose": "Library",
|
||||
"externalIdentifier": [
|
||||
{
|
||||
"@type": "ExternalIdentifier",
|
||||
"externalIdentifierType": "PackageUrl",
|
||||
"identifier": "pkg:npm/lodash@4.17.21"
|
||||
}
|
||||
],
|
||||
"verifiedUsing": [
|
||||
{
|
||||
"@type": "Hash",
|
||||
"algorithm": "sha256",
|
||||
"hashValue": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"@type": "software_File",
|
||||
"spdxId": "urn:spdx:example:file-1",
|
||||
"creationInfo": "_:creationInfo1",
|
||||
"name": "index.js",
|
||||
"contentType": "application/javascript",
|
||||
"verifiedUsing": [
|
||||
{
|
||||
"@type": "Hash",
|
||||
"algorithm": "sha256",
|
||||
"hashValue": "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"@type": "Relationship",
|
||||
"spdxId": "urn:spdx:example:rel-1",
|
||||
"creationInfo": "_:creationInfo1",
|
||||
"from": "urn:spdx:example:package-1",
|
||||
"to": ["urn:spdx:example:package-2"],
|
||||
"relationshipType": "DependsOn"
|
||||
},
|
||||
{
|
||||
"@type": "Relationship",
|
||||
"spdxId": "urn:spdx:example:rel-2",
|
||||
"creationInfo": "_:creationInfo1",
|
||||
"from": "urn:spdx:example:package-1",
|
||||
"to": ["urn:spdx:example:file-1"],
|
||||
"relationshipType": "Contains"
|
||||
},
|
||||
{
|
||||
"@type": "Organization",
|
||||
"spdxId": "urn:spdx:example:org-1",
|
||||
"name": "Example Inc."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="coverlet.collector">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Spdx3\StellaOps.Spdx3.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="Samples\*.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
292
src/__Libraries/__Tests/StellaOps.Spdx3.Tests/ValidatorTests.cs
Normal file
292
src/__Libraries/__Tests/StellaOps.Spdx3.Tests/ValidatorTests.cs
Normal file
@@ -0,0 +1,292 @@
|
||||
// <copyright file="ValidatorTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Spdx3.Model;
|
||||
using StellaOps.Spdx3.Model.Software;
|
||||
using StellaOps.Spdx3.Validation;
|
||||
|
||||
namespace StellaOps.Spdx3.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for SPDX 3.0.1 validator.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ValidatorTests
|
||||
{
|
||||
private readonly Spdx3Validator _validator = new();
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidDocument_ReturnsValid()
|
||||
{
|
||||
// Arrange
|
||||
var doc = CreateValidDocument();
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(doc);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_EmptyDocument_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var doc = new Spdx3Document([], [], []);
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(doc);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Code == "EMPTY_DOCUMENT");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_DuplicateSpdxId_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var pkg1 = new Spdx3Package { SpdxId = "urn:test:dup", Name = "pkg1" };
|
||||
var pkg2 = new Spdx3Package { SpdxId = "urn:test:dup", Name = "pkg2" };
|
||||
var doc = new Spdx3Document([pkg1, pkg2], [], [Spdx3ProfileIdentifier.Software]);
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(doc);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Code == "DUPLICATE_SPDX_ID");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_DanglingRelationship_ReturnsWarning()
|
||||
{
|
||||
// Arrange
|
||||
var pkg = new Spdx3Package { SpdxId = "urn:test:pkg1", Name = "pkg1" };
|
||||
var rel = new Spdx3Relationship
|
||||
{
|
||||
SpdxId = "urn:test:rel1",
|
||||
From = "urn:test:pkg1",
|
||||
To = ["urn:test:nonexistent"],
|
||||
RelationshipType = Spdx3RelationshipType.DependsOn
|
||||
};
|
||||
var doc = new Spdx3Document([pkg, rel], [], [Spdx3ProfileIdentifier.Software]);
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(doc);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid); // Warnings don't fail validation by default
|
||||
Assert.Contains(result.Warnings, w => w.Code == "DANGLING_RELATIONSHIP_TO");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_EmptyRelationshipTo_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var pkg = new Spdx3Package { SpdxId = "urn:test:pkg1", Name = "pkg1" };
|
||||
var rel = new Spdx3Relationship
|
||||
{
|
||||
SpdxId = "urn:test:rel1",
|
||||
From = "urn:test:pkg1",
|
||||
To = [],
|
||||
RelationshipType = Spdx3RelationshipType.DependsOn
|
||||
};
|
||||
var doc = new Spdx3Document([pkg, rel], [], [Spdx3ProfileIdentifier.Software]);
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(doc);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Code == "EMPTY_RELATIONSHIP_TO");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_InvalidPurl_ReturnsWarning()
|
||||
{
|
||||
// Arrange
|
||||
var pkg = new Spdx3Package
|
||||
{
|
||||
SpdxId = "urn:test:pkg1",
|
||||
Name = "pkg1",
|
||||
ExternalIdentifier =
|
||||
[
|
||||
new Spdx3ExternalIdentifier
|
||||
{
|
||||
ExternalIdentifierType = Spdx3ExternalIdentifierType.PackageUrl,
|
||||
Identifier = "not-a-valid-purl"
|
||||
}
|
||||
]
|
||||
};
|
||||
var doc = new Spdx3Document([pkg], [], [Spdx3ProfileIdentifier.Software]);
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(doc);
|
||||
|
||||
// Assert
|
||||
Assert.Contains(result.Warnings, w => w.Code == "INVALID_PURL_FORMAT");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_InvalidHashFormat_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var pkg = new Spdx3Package
|
||||
{
|
||||
SpdxId = "urn:test:pkg1",
|
||||
Name = "pkg1",
|
||||
VerifiedUsing =
|
||||
[
|
||||
new Spdx3Hash
|
||||
{
|
||||
Algorithm = Spdx3HashAlgorithm.Sha256,
|
||||
HashValue = "not-hex-value!"
|
||||
}
|
||||
]
|
||||
};
|
||||
var doc = new Spdx3Document([pkg], [], [Spdx3ProfileIdentifier.Software]);
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(doc);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Code == "INVALID_HASH_FORMAT");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_DeprecatedHashAlgorithm_ReturnsWarning()
|
||||
{
|
||||
// Arrange
|
||||
var pkg = new Spdx3Package
|
||||
{
|
||||
SpdxId = "urn:test:pkg1",
|
||||
Name = "pkg1",
|
||||
VerifiedUsing =
|
||||
[
|
||||
new Spdx3Hash
|
||||
{
|
||||
Algorithm = Spdx3HashAlgorithm.Md5,
|
||||
HashValue = "d41d8cd98f00b204e9800998ecf8427e"
|
||||
}
|
||||
]
|
||||
};
|
||||
var doc = new Spdx3Document([pkg], [], [Spdx3ProfileIdentifier.Software]);
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(doc);
|
||||
|
||||
// Assert
|
||||
Assert.Contains(result.Warnings, w => w.Code == "DEPRECATED_HASH_ALGORITHM");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RequiredProfileMissing_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var doc = CreateValidDocument();
|
||||
var options = new Spdx3ValidationOptions
|
||||
{
|
||||
RequiredProfiles = [Spdx3ProfileIdentifier.Build]
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(doc, options);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Code == "MISSING_REQUIRED_PROFILE");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_TreatWarningsAsErrors_ConvertsWarnings()
|
||||
{
|
||||
// Arrange
|
||||
var pkg = new Spdx3Package
|
||||
{
|
||||
SpdxId = "urn:test:pkg1",
|
||||
Name = "pkg1",
|
||||
VerifiedUsing =
|
||||
[
|
||||
new Spdx3Hash
|
||||
{
|
||||
Algorithm = Spdx3HashAlgorithm.Md5,
|
||||
HashValue = "d41d8cd98f00b204e9800998ecf8427e"
|
||||
}
|
||||
]
|
||||
};
|
||||
var doc = new Spdx3Document([pkg], [], [Spdx3ProfileIdentifier.Software]);
|
||||
var options = new Spdx3ValidationOptions { TreatWarningsAsErrors = true };
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(doc, options);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Code == "DEPRECATED_HASH_ALGORITHM");
|
||||
Assert.Empty(result.Warnings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Info_ContainsStats()
|
||||
{
|
||||
// Arrange
|
||||
var doc = CreateValidDocument();
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(doc);
|
||||
|
||||
// Assert
|
||||
Assert.Contains(result.Info, i => i.Code == "DOCUMENT_STATS");
|
||||
}
|
||||
|
||||
private static Spdx3Document CreateValidDocument()
|
||||
{
|
||||
var pkg1 = new Spdx3Package
|
||||
{
|
||||
SpdxId = "urn:test:pkg1",
|
||||
Name = "root-package",
|
||||
PackageVersion = "1.0.0",
|
||||
VerifiedUsing =
|
||||
[
|
||||
new Spdx3Hash
|
||||
{
|
||||
Algorithm = Spdx3HashAlgorithm.Sha256,
|
||||
HashValue = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var pkg2 = new Spdx3Package
|
||||
{
|
||||
SpdxId = "urn:test:pkg2",
|
||||
Name = "dep-package",
|
||||
PackageVersion = "2.0.0",
|
||||
ExternalIdentifier =
|
||||
[
|
||||
new Spdx3ExternalIdentifier
|
||||
{
|
||||
ExternalIdentifierType = Spdx3ExternalIdentifierType.PackageUrl,
|
||||
Identifier = "pkg:npm/dep-package@2.0.0"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var rel = new Spdx3Relationship
|
||||
{
|
||||
SpdxId = "urn:test:rel1",
|
||||
From = "urn:test:pkg1",
|
||||
To = ["urn:test:pkg2"],
|
||||
RelationshipType = Spdx3RelationshipType.DependsOn
|
||||
};
|
||||
|
||||
return new Spdx3Document(
|
||||
[pkg1, pkg2, rel],
|
||||
[],
|
||||
[Spdx3ProfileIdentifier.Software, Spdx3ProfileIdentifier.Core]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// <copyright file="VersionDetectorTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.Spdx3.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for SPDX version detection.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class VersionDetectorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Detect_Spdx301JsonLd_ReturnsCorrectVersion()
|
||||
{
|
||||
// Arrange
|
||||
var json = """
|
||||
{
|
||||
"@context": "https://spdx.org/rdf/3.0.1/spdx-context.jsonld",
|
||||
"@graph": []
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = Spdx3VersionDetector.Detect(json);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(Spdx3VersionDetector.SpdxVersion.Spdx301, result.Version);
|
||||
Assert.True(result.IsJsonLd);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Detect_Spdx23_ReturnsCorrectVersion()
|
||||
{
|
||||
// Arrange
|
||||
var json = """
|
||||
{
|
||||
"spdxVersion": "SPDX-2.3",
|
||||
"dataLicense": "CC0-1.0",
|
||||
"SPDXID": "SPDXRef-DOCUMENT"
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = Spdx3VersionDetector.Detect(json);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(Spdx3VersionDetector.SpdxVersion.Spdx23, result.Version);
|
||||
Assert.False(result.IsJsonLd);
|
||||
Assert.Equal("SPDX-2.3", result.VersionString);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Detect_Spdx22_ReturnsCorrectVersion()
|
||||
{
|
||||
// Arrange
|
||||
var json = """
|
||||
{
|
||||
"spdxVersion": "SPDX-2.2",
|
||||
"dataLicense": "CC0-1.0",
|
||||
"SPDXID": "SPDXRef-DOCUMENT"
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = Spdx3VersionDetector.Detect(json);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(Spdx3VersionDetector.SpdxVersion.Spdx22, result.Version);
|
||||
Assert.False(result.IsJsonLd);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Detect_Unknown_ReturnsUnknown()
|
||||
{
|
||||
// Arrange
|
||||
var json = """
|
||||
{
|
||||
"random": "data",
|
||||
"notSpdx": true
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = Spdx3VersionDetector.Detect(json);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(Spdx3VersionDetector.SpdxVersion.Unknown, result.Version);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Detect_ContextWithArray_DetectsVersion()
|
||||
{
|
||||
// Arrange
|
||||
var json = """
|
||||
{
|
||||
"@context": [
|
||||
"https://spdx.org/rdf/3.0.1/spdx-context.jsonld",
|
||||
{ "custom": "http://example.org/custom" }
|
||||
],
|
||||
"@graph": []
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = Spdx3VersionDetector.Detect(json);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(Spdx3VersionDetector.SpdxVersion.Spdx301, result.Version);
|
||||
Assert.True(result.IsJsonLd);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Detect_SpecVersionInGraph_DetectsVersion()
|
||||
{
|
||||
// Arrange
|
||||
var json = """
|
||||
{
|
||||
"@context": "https://example.com/context",
|
||||
"@graph": [
|
||||
{
|
||||
"creationInfo": {
|
||||
"specVersion": "3.0.1"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = Spdx3VersionDetector.Detect(json);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(Spdx3VersionDetector.SpdxVersion.Spdx301, result.Version);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(Spdx3VersionDetector.SpdxVersion.Spdx22, "Use SpdxParser (SPDX 2.x parser)")]
|
||||
[InlineData(Spdx3VersionDetector.SpdxVersion.Spdx23, "Use SpdxParser (SPDX 2.x parser)")]
|
||||
[InlineData(Spdx3VersionDetector.SpdxVersion.Spdx301, "Use Spdx3Parser (SPDX 3.0.1 parser)")]
|
||||
[InlineData(Spdx3VersionDetector.SpdxVersion.Unknown, "Unknown format - manual inspection required")]
|
||||
public void GetParserRecommendation_ReturnsCorrectRecommendation(
|
||||
Spdx3VersionDetector.SpdxVersion version,
|
||||
string expected)
|
||||
{
|
||||
// Act
|
||||
var recommendation = Spdx3VersionDetector.GetParserRecommendation(version);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expected, recommendation);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user