audit work, fixed StellaOps.sln warnings/errors, fixed tests, sprints work, new advisories

This commit is contained in:
master
2026-01-07 18:49:59 +02:00
parent 04ec098046
commit 608a7f85c0
866 changed files with 56323 additions and 6231 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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.

View File

@@ -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
}
}

View File

@@ -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();
}
}

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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.

View 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);
}
}

View 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();
}
}

View File

@@ -0,0 +1,5 @@
{
"invalid": "json",
"no_context": true,
"spdxVersion": "SPDX-2.3"
}

View File

@@ -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"
}
]
}

View File

@@ -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"
}
]
}

View File

@@ -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"
}
]
}

View File

@@ -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."
}
]
}

View File

@@ -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>

View 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]);
}
}

View File

@@ -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);
}
}