more audit work
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
# Attestor SPDX3 Build Profile Tests Charter
|
||||
|
||||
## Purpose & Scope
|
||||
- Working directory: `src/Attestor/__Libraries/__Tests/StellaOps.Attestor.Spdx3.Tests/`.
|
||||
- Roles: QA automation, backend engineer.
|
||||
- Focus: deterministic unit tests for SPDX3 build mapping and validation.
|
||||
|
||||
## Required Reading
|
||||
- `docs/README.md`
|
||||
- `docs/07_HIGH_LEVEL_ARCHITECTURE.md`
|
||||
- `docs/modules/attestor/architecture.md`
|
||||
- `docs/modules/platform/architecture-overview.md`
|
||||
- `docs/implplan/permament/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`
|
||||
|
||||
## Working Agreement
|
||||
- Use fixed timestamps and IDs in fixtures.
|
||||
- Avoid Random, Guid.NewGuid, DateTime.UtcNow in tests.
|
||||
- Cover error paths and deterministic ID generation.
|
||||
- Update `TASKS.md` and sprint tracker as statuses change.
|
||||
@@ -0,0 +1,176 @@
|
||||
// <copyright file="BuildAttestationMapperTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Spdx3.Model.Build;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Spdx3.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="BuildAttestationMapper"/>.
|
||||
/// Sprint: SPRINT_20260107_004_003 Task BP-009
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class BuildAttestationMapperTests
|
||||
{
|
||||
private readonly BuildAttestationMapper _mapper = new();
|
||||
private const string SpdxIdPrefix = "https://stellaops.io/spdx/test";
|
||||
|
||||
[Fact]
|
||||
public void MapToSpdx3_WithFullAttestation_MapsAllFields()
|
||||
{
|
||||
// Arrange
|
||||
var attestation = new BuildAttestationPayload
|
||||
{
|
||||
BuildType = "https://slsa.dev/provenance/v1",
|
||||
Builder = new BuilderInfo { Id = "https://github.com/actions/runner", Version = "2.300.0" },
|
||||
Invocation = new BuildInvocation
|
||||
{
|
||||
ConfigSource = new ConfigSource
|
||||
{
|
||||
Uri = "https://github.com/stellaops/app",
|
||||
Digest = new Dictionary<string, string> { ["sha256"] = "abc123" },
|
||||
EntryPoint = ".github/workflows/build.yml"
|
||||
},
|
||||
Environment = new Dictionary<string, string> { ["CI"] = "true" },
|
||||
Parameters = new Dictionary<string, string> { ["target"] = "release" }
|
||||
},
|
||||
Metadata = new BuildMetadata
|
||||
{
|
||||
BuildInvocationId = "run-12345",
|
||||
BuildStartedOn = new DateTimeOffset(2026, 1, 7, 12, 0, 0, TimeSpan.Zero),
|
||||
BuildFinishedOn = new DateTimeOffset(2026, 1, 7, 12, 5, 0, TimeSpan.Zero)
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var build = _mapper.MapToSpdx3(attestation, SpdxIdPrefix);
|
||||
|
||||
// Assert
|
||||
build.Should().NotBeNull();
|
||||
build.BuildType.Should().Be("https://slsa.dev/provenance/v1");
|
||||
build.BuildId.Should().Be("run-12345");
|
||||
build.BuildStartTime.Should().Be(attestation.Metadata.BuildStartedOn);
|
||||
build.BuildEndTime.Should().Be(attestation.Metadata.BuildFinishedOn);
|
||||
build.ConfigSourceUri.Should().ContainSingle().Which.Should().Be("https://github.com/stellaops/app");
|
||||
build.ConfigSourceDigest.Should().ContainSingle().Which.Algorithm.Should().Be("sha256");
|
||||
build.ConfigSourceEntrypoint.Should().ContainSingle().Which.Should().Be(".github/workflows/build.yml");
|
||||
build.Environment.Should().ContainKey("CI").WhoseValue.Should().Be("true");
|
||||
build.Parameter.Should().ContainKey("target").WhoseValue.Should().Be("release");
|
||||
build.SpdxId.Should().StartWith(SpdxIdPrefix);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapToSpdx3_WithMinimalAttestation_MapsRequiredFields()
|
||||
{
|
||||
// Arrange
|
||||
var attestation = new BuildAttestationPayload
|
||||
{
|
||||
BuildType = "https://stellaops.org/build/scan/v1"
|
||||
};
|
||||
|
||||
// Act
|
||||
var build = _mapper.MapToSpdx3(attestation, SpdxIdPrefix);
|
||||
|
||||
// Assert
|
||||
build.Should().NotBeNull();
|
||||
build.BuildType.Should().Be("https://stellaops.org/build/scan/v1");
|
||||
build.SpdxId.Should().StartWith(SpdxIdPrefix);
|
||||
build.ConfigSourceUri.Should().BeEmpty();
|
||||
build.Environment.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapFromSpdx3_WithFullBuild_MapsToAttestation()
|
||||
{
|
||||
// Arrange
|
||||
var build = new Spdx3Build
|
||||
{
|
||||
SpdxId = "https://stellaops.io/spdx/test/build/123",
|
||||
BuildType = "https://slsa.dev/provenance/v1",
|
||||
BuildId = "build-123",
|
||||
BuildStartTime = new DateTimeOffset(2026, 1, 7, 12, 0, 0, TimeSpan.Zero),
|
||||
BuildEndTime = new DateTimeOffset(2026, 1, 7, 12, 5, 0, TimeSpan.Zero),
|
||||
ConfigSourceUri = ImmutableArray.Create("https://github.com/stellaops/app"),
|
||||
ConfigSourceDigest = ImmutableArray.Create(Spdx3Hash.Sha256("abc123")),
|
||||
ConfigSourceEntrypoint = ImmutableArray.Create("Dockerfile"),
|
||||
Environment = ImmutableDictionary<string, string>.Empty.Add("CI", "true"),
|
||||
Parameter = ImmutableDictionary<string, string>.Empty.Add("target", "release")
|
||||
};
|
||||
|
||||
// Act
|
||||
var attestation = _mapper.MapFromSpdx3(build);
|
||||
|
||||
// Assert
|
||||
attestation.Should().NotBeNull();
|
||||
attestation.BuildType.Should().Be("https://slsa.dev/provenance/v1");
|
||||
attestation.Metadata!.BuildInvocationId.Should().Be("build-123");
|
||||
attestation.Metadata!.BuildStartedOn.Should().Be(build.BuildStartTime);
|
||||
attestation.Metadata!.BuildFinishedOn.Should().Be(build.BuildEndTime);
|
||||
attestation.Invocation!.ConfigSource!.Uri.Should().Be("https://github.com/stellaops/app");
|
||||
attestation.Invocation!.Environment.Should().ContainKey("CI");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanMapToSpdx3_WithValidAttestation_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var attestation = new BuildAttestationPayload
|
||||
{
|
||||
BuildType = "https://slsa.dev/provenance/v1"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _mapper.CanMapToSpdx3(attestation);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanMapToSpdx3_WithEmptyBuildType_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var attestation = new BuildAttestationPayload
|
||||
{
|
||||
BuildType = ""
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _mapper.CanMapToSpdx3(attestation);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanMapToSpdx3_WithNull_ReturnsFalse()
|
||||
{
|
||||
// Act
|
||||
var result = _mapper.CanMapToSpdx3(null!);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapToSpdx3_GeneratesDeterministicSpdxId()
|
||||
{
|
||||
// Arrange
|
||||
var attestation = new BuildAttestationPayload
|
||||
{
|
||||
BuildType = "https://slsa.dev/provenance/v1",
|
||||
Metadata = new BuildMetadata { BuildInvocationId = "fixed-id-123" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var build1 = _mapper.MapToSpdx3(attestation, SpdxIdPrefix);
|
||||
var build2 = _mapper.MapToSpdx3(attestation, SpdxIdPrefix);
|
||||
|
||||
// Assert
|
||||
build1.SpdxId.Should().Be(build2.SpdxId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
// <copyright file="BuildProfileValidatorTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Spdx3.Model.Build;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Spdx3.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="BuildProfileValidator"/>.
|
||||
/// Sprint: SPRINT_20260107_004_003 Task BP-009
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class BuildProfileValidatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Validate_WithValidBuild_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
var build = new Spdx3Build
|
||||
{
|
||||
SpdxId = "https://stellaops.io/spdx/test/build/123",
|
||||
BuildType = "https://slsa.dev/provenance/v1",
|
||||
BuildId = "build-123",
|
||||
BuildStartTime = new DateTimeOffset(2026, 1, 7, 12, 0, 0, TimeSpan.Zero),
|
||||
BuildEndTime = new DateTimeOffset(2026, 1, 7, 12, 5, 0, TimeSpan.Zero)
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = BuildProfileValidator.Validate(build);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.ErrorsOnly.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithMissingBuildType_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var build = new Spdx3Build
|
||||
{
|
||||
SpdxId = "https://stellaops.io/spdx/test/build/123",
|
||||
BuildType = "",
|
||||
BuildId = "build-123"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = BuildProfileValidator.Validate(build);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.ErrorsOnly.Should().ContainSingle()
|
||||
.Which.Field.Should().Be("buildType");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithInvalidBuildTypeUri_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var build = new Spdx3Build
|
||||
{
|
||||
SpdxId = "https://stellaops.io/spdx/test/build/123",
|
||||
BuildType = "not-a-uri",
|
||||
BuildId = "build-123"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = BuildProfileValidator.Validate(build);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.ErrorsOnly.Should().ContainSingle()
|
||||
.Which.Message.Should().Contain("valid URI");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithEndTimeBeforeStartTime_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var build = new Spdx3Build
|
||||
{
|
||||
SpdxId = "https://stellaops.io/spdx/test/build/123",
|
||||
BuildType = "https://slsa.dev/provenance/v1",
|
||||
BuildId = "build-123",
|
||||
BuildStartTime = new DateTimeOffset(2026, 1, 7, 12, 5, 0, TimeSpan.Zero),
|
||||
BuildEndTime = new DateTimeOffset(2026, 1, 7, 12, 0, 0, TimeSpan.Zero) // Before start
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = BuildProfileValidator.Validate(build);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.ErrorsOnly.Should().ContainSingle()
|
||||
.Which.Field.Should().Be("buildEndTime");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithMissingBuildId_ReturnsWarning()
|
||||
{
|
||||
// Arrange
|
||||
var build = new Spdx3Build
|
||||
{
|
||||
SpdxId = "https://stellaops.io/spdx/test/build/123",
|
||||
BuildType = "https://slsa.dev/provenance/v1"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = BuildProfileValidator.Validate(build);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue(); // Warnings don't fail validation
|
||||
result.WarningsOnly.Should().ContainSingle()
|
||||
.Which.Field.Should().Be("buildId");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithDigestWithoutUri_ReturnsWarning()
|
||||
{
|
||||
// Arrange
|
||||
var build = new Spdx3Build
|
||||
{
|
||||
SpdxId = "https://stellaops.io/spdx/test/build/123",
|
||||
BuildType = "https://slsa.dev/provenance/v1",
|
||||
BuildId = "build-123",
|
||||
ConfigSourceDigest = ImmutableArray.Create(Spdx3Hash.Sha256("abc123"))
|
||||
// Note: ConfigSourceUri is empty
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = BuildProfileValidator.Validate(build);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.WarningsOnly.Should().Contain(w => w.Field == "configSourceDigest");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithUnknownHashAlgorithm_ReturnsWarning()
|
||||
{
|
||||
// Arrange
|
||||
var build = new Spdx3Build
|
||||
{
|
||||
SpdxId = "https://stellaops.io/spdx/test/build/123",
|
||||
BuildType = "https://slsa.dev/provenance/v1",
|
||||
BuildId = "build-123",
|
||||
ConfigSourceUri = ImmutableArray.Create("https://github.com/test/repo"),
|
||||
ConfigSourceDigest = ImmutableArray.Create(new Spdx3Hash
|
||||
{
|
||||
Algorithm = "unknown-algo",
|
||||
HashValue = "abc123"
|
||||
})
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = BuildProfileValidator.Validate(build);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.WarningsOnly.Should().Contain(w => w.Field == "configSourceDigest.algorithm");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithMissingSpdxId_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var build = new Spdx3Build
|
||||
{
|
||||
SpdxId = "",
|
||||
BuildType = "https://slsa.dev/provenance/v1",
|
||||
BuildId = "build-123"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = BuildProfileValidator.Validate(build);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.ErrorsOnly.Should().Contain(e => e.Field == "spdxId");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
// <copyright file="CombinedDocumentBuilderTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Spdx3.Model;
|
||||
using StellaOps.Spdx3.Model.Build;
|
||||
using StellaOps.Spdx3.Model.Software;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Spdx3.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="CombinedDocumentBuilder"/>.
|
||||
/// Sprint: SPRINT_20260107_004_003 Task BP-008
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CombinedDocumentBuilderTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private static readonly DateTimeOffset FixedTimestamp =
|
||||
new(2026, 1, 8, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
public CombinedDocumentBuilderTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(FixedTimestamp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithSoftwareAndBuildProfiles_CreatesCombinedDocument()
|
||||
{
|
||||
// Arrange
|
||||
var sbom = CreateTestSbom();
|
||||
var build = CreateTestBuild();
|
||||
|
||||
// Act
|
||||
var document = CombinedDocumentBuilder.Create(_timeProvider)
|
||||
.WithDocumentId("https://stellaops.io/spdx/combined/12345")
|
||||
.WithName("Combined SBOM and Build")
|
||||
.WithSoftwareProfile(sbom)
|
||||
.WithBuildProfile(build)
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
document.Should().NotBeNull();
|
||||
document.Profiles.Should().Contain(Spdx3ProfileIdentifier.Core);
|
||||
document.Profiles.Should().Contain(Spdx3ProfileIdentifier.Software);
|
||||
document.Profiles.Should().Contain(Spdx3ProfileIdentifier.Build);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithBuildProfile_CreatesGeneratesRelationship()
|
||||
{
|
||||
// Arrange
|
||||
var sbom = CreateTestSbom();
|
||||
var build = CreateTestBuild();
|
||||
|
||||
// Act
|
||||
var document = CombinedDocumentBuilder.Create(_timeProvider)
|
||||
.WithDocumentId("https://stellaops.io/spdx/combined/12345")
|
||||
.WithSoftwareProfile(sbom)
|
||||
.WithBuildProfile(build)
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
var relationships = document.Relationships.ToList();
|
||||
relationships.Should().Contain(r =>
|
||||
r.RelationshipType == Spdx3RelationshipType.Generates &&
|
||||
r.From == build.SpdxId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithBuildAttestation_MapsBuildFromAttestation()
|
||||
{
|
||||
// Arrange
|
||||
var sbom = CreateTestSbom();
|
||||
var attestation = new BuildAttestationPayload
|
||||
{
|
||||
BuildType = "https://slsa.dev/provenance/v1",
|
||||
Metadata = new BuildMetadata
|
||||
{
|
||||
BuildInvocationId = "run-12345",
|
||||
BuildStartedOn = FixedTimestamp
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var document = CombinedDocumentBuilder.Create(_timeProvider)
|
||||
.WithDocumentId("https://stellaops.io/spdx/combined/12345")
|
||||
.WithSoftwareProfile(sbom)
|
||||
.WithBuildAttestation(attestation, "https://stellaops.io/spdx")
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
document.Elements.Should().Contain(e => e is Spdx3Build);
|
||||
var buildElement = document.Elements.OfType<Spdx3Build>().First();
|
||||
buildElement.BuildType.Should().Be("https://slsa.dev/provenance/v1");
|
||||
buildElement.BuildId.Should().Be("run-12345");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithoutDocumentId_ThrowsInvalidOperationException()
|
||||
{
|
||||
// Arrange
|
||||
var sbom = CreateTestSbom();
|
||||
|
||||
// Act
|
||||
var act = () => CombinedDocumentBuilder.Create(_timeProvider)
|
||||
.WithSoftwareProfile(sbom)
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<InvalidOperationException>()
|
||||
.WithMessage("*Document SPDX ID is required*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_CreatesDefaultCreationInfo()
|
||||
{
|
||||
// Arrange
|
||||
var sbom = CreateTestSbom();
|
||||
|
||||
// Act
|
||||
var document = CombinedDocumentBuilder.Create(_timeProvider)
|
||||
.WithDocumentId("https://stellaops.io/spdx/doc/12345")
|
||||
.WithSoftwareProfile(sbom)
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
document.CreationInfos.Should().HaveCount(1);
|
||||
var creationInfo = document.CreationInfos.First();
|
||||
creationInfo.SpecVersion.Should().Be(Spdx3CreationInfo.Spdx301Version);
|
||||
creationInfo.Created.Should().Be(FixedTimestamp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithCustomCreationInfo_UsesProvidedInfo()
|
||||
{
|
||||
// Arrange
|
||||
var sbom = CreateTestSbom();
|
||||
var customCreationInfo = new Spdx3CreationInfo
|
||||
{
|
||||
Id = "custom-creation-info",
|
||||
SpecVersion = Spdx3CreationInfo.Spdx301Version,
|
||||
Created = FixedTimestamp.AddHours(-1),
|
||||
CreatedBy = ImmutableArray.Create("custom-author"),
|
||||
Profile = ImmutableArray.Create(Spdx3ProfileIdentifier.Core)
|
||||
};
|
||||
|
||||
// Act
|
||||
var document = CombinedDocumentBuilder.Create(_timeProvider)
|
||||
.WithDocumentId("https://stellaops.io/spdx/doc/12345")
|
||||
.WithSoftwareProfile(sbom)
|
||||
.WithCreationInfo(customCreationInfo)
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
document.CreationInfos.Should().Contain(customCreationInfo);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithBuildProvenance_ExtensionMethod_CreatesCombinedDocument()
|
||||
{
|
||||
// Arrange
|
||||
var sbom = CreateTestSbom();
|
||||
var attestation = new BuildAttestationPayload
|
||||
{
|
||||
BuildType = "https://stellaops.org/build/scan/v1"
|
||||
};
|
||||
|
||||
// Act
|
||||
var combined = sbom.WithBuildProvenance(
|
||||
attestation,
|
||||
documentId: "https://stellaops.io/spdx/combined/ext-12345",
|
||||
spdxIdPrefix: "https://stellaops.io/spdx",
|
||||
timeProvider: _timeProvider);
|
||||
|
||||
// Assert
|
||||
combined.Should().NotBeNull();
|
||||
combined.Profiles.Should().Contain(Spdx3ProfileIdentifier.Build);
|
||||
combined.Elements.Should().Contain(e => e is Spdx3Build);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_PreservesAllSbomElements()
|
||||
{
|
||||
// Arrange
|
||||
var sbom = CreateTestSbomWithMultiplePackages();
|
||||
|
||||
// Act
|
||||
var document = CombinedDocumentBuilder.Create(_timeProvider)
|
||||
.WithDocumentId("https://stellaops.io/spdx/doc/12345")
|
||||
.WithSoftwareProfile(sbom)
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
var packages = document.Packages.ToList();
|
||||
packages.Should().HaveCount(3);
|
||||
}
|
||||
|
||||
private static Spdx3Document CreateTestSbom()
|
||||
{
|
||||
var creationInfo = new Spdx3CreationInfo
|
||||
{
|
||||
SpecVersion = Spdx3CreationInfo.Spdx301Version,
|
||||
Created = FixedTimestamp.AddDays(-1),
|
||||
CreatedBy = ImmutableArray<string>.Empty,
|
||||
Profile = ImmutableArray.Create(Spdx3ProfileIdentifier.Core, Spdx3ProfileIdentifier.Software)
|
||||
};
|
||||
|
||||
var rootPackage = new Spdx3Package
|
||||
{
|
||||
SpdxId = "https://stellaops.io/spdx/pkg/root",
|
||||
Type = "software_Package",
|
||||
Name = "test-root-package",
|
||||
PackageVersion = "1.0.0"
|
||||
};
|
||||
|
||||
return new Spdx3Document(
|
||||
elements: new Spdx3Element[] { rootPackage },
|
||||
creationInfos: new[] { creationInfo },
|
||||
profiles: new[] { Spdx3ProfileIdentifier.Core, Spdx3ProfileIdentifier.Software });
|
||||
}
|
||||
|
||||
private static Spdx3Document CreateTestSbomWithMultiplePackages()
|
||||
{
|
||||
var creationInfo = new Spdx3CreationInfo
|
||||
{
|
||||
SpecVersion = Spdx3CreationInfo.Spdx301Version,
|
||||
Created = FixedTimestamp.AddDays(-1),
|
||||
CreatedBy = ImmutableArray<string>.Empty,
|
||||
Profile = ImmutableArray.Create(Spdx3ProfileIdentifier.Core, Spdx3ProfileIdentifier.Software)
|
||||
};
|
||||
|
||||
var packages = new Spdx3Package[]
|
||||
{
|
||||
new()
|
||||
{
|
||||
SpdxId = "https://stellaops.io/spdx/pkg/root",
|
||||
Type = "software_Package",
|
||||
Name = "root-package",
|
||||
PackageVersion = "1.0.0"
|
||||
},
|
||||
new()
|
||||
{
|
||||
SpdxId = "https://stellaops.io/spdx/pkg/dep1",
|
||||
Type = "software_Package",
|
||||
Name = "dependency-1",
|
||||
PackageVersion = "2.0.0"
|
||||
},
|
||||
new()
|
||||
{
|
||||
SpdxId = "https://stellaops.io/spdx/pkg/dep2",
|
||||
Type = "software_Package",
|
||||
Name = "dependency-2",
|
||||
PackageVersion = "3.0.0"
|
||||
}
|
||||
};
|
||||
|
||||
return new Spdx3Document(
|
||||
elements: packages,
|
||||
creationInfos: new[] { creationInfo },
|
||||
profiles: new[] { Spdx3ProfileIdentifier.Core, Spdx3ProfileIdentifier.Software });
|
||||
}
|
||||
|
||||
private static Spdx3Build CreateTestBuild()
|
||||
{
|
||||
return new Spdx3Build
|
||||
{
|
||||
SpdxId = "https://stellaops.io/spdx/build/12345",
|
||||
Type = Spdx3Build.TypeName,
|
||||
BuildType = "https://slsa.dev/provenance/v1",
|
||||
BuildId = "build-12345",
|
||||
BuildStartTime = FixedTimestamp.AddMinutes(-5),
|
||||
BuildEndTime = FixedTimestamp
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
// <copyright file="DsseSpdx3SignerTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Moq;
|
||||
using StellaOps.Spdx3.Model;
|
||||
using StellaOps.Spdx3.Model.Build;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Spdx3.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="DsseSpdx3Signer"/>.
|
||||
/// Sprint: SPRINT_20260107_004_003 Task BP-005
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DsseSpdx3SignerTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly Mock<ISpdx3Serializer> _serializerMock;
|
||||
private readonly Mock<IDsseSigningProvider> _signingProviderMock;
|
||||
private readonly DsseSpdx3Signer _signer;
|
||||
|
||||
private static readonly DateTimeOffset FixedTimestamp =
|
||||
new(2026, 1, 7, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
public DsseSpdx3SignerTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(FixedTimestamp);
|
||||
_serializerMock = new Mock<ISpdx3Serializer>();
|
||||
_signingProviderMock = new Mock<IDsseSigningProvider>();
|
||||
|
||||
_signer = new DsseSpdx3Signer(
|
||||
_serializerMock.Object,
|
||||
_signingProviderMock.Object,
|
||||
_timeProvider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_WithValidDocument_ReturnsEnvelope()
|
||||
{
|
||||
// Arrange
|
||||
var document = CreateTestDocument();
|
||||
var options = new DsseSpdx3SigningOptions { PrimaryKeyId = "key-123" };
|
||||
var payloadBytes = Encoding.UTF8.GetBytes("{\"test\":\"document\"}");
|
||||
|
||||
_serializerMock
|
||||
.Setup(s => s.SerializeToBytes(document))
|
||||
.Returns(payloadBytes);
|
||||
|
||||
_signingProviderMock
|
||||
.Setup(s => s.SignAsync(
|
||||
It.IsAny<byte[]>(),
|
||||
"key-123",
|
||||
null,
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new DsseSignatureResult
|
||||
{
|
||||
KeyId = "key-123",
|
||||
SignatureBytes = new byte[] { 0x01, 0x02, 0x03 }
|
||||
});
|
||||
|
||||
// Act
|
||||
var envelope = await _signer.SignAsync(document, options);
|
||||
|
||||
// Assert
|
||||
envelope.Should().NotBeNull();
|
||||
envelope.PayloadType.Should().Be(DsseSpdx3Signer.Spdx3PayloadType);
|
||||
envelope.Payload.Should().NotBeNullOrEmpty();
|
||||
envelope.Signatures.Should().HaveCount(1);
|
||||
envelope.Signatures[0].KeyId.Should().Be("key-123");
|
||||
envelope.SignedAt.Should().Be(FixedTimestamp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_WithSecondaryKey_ReturnsTwoSignatures()
|
||||
{
|
||||
// Arrange
|
||||
var document = CreateTestDocument();
|
||||
var options = new DsseSpdx3SigningOptions
|
||||
{
|
||||
PrimaryKeyId = "key-123",
|
||||
PrimaryAlgorithm = "ES256",
|
||||
SecondaryKeyId = "pq-key-456",
|
||||
SecondaryAlgorithm = "ML-DSA-65"
|
||||
};
|
||||
var payloadBytes = Encoding.UTF8.GetBytes("{\"test\":\"document\"}");
|
||||
|
||||
_serializerMock
|
||||
.Setup(s => s.SerializeToBytes(document))
|
||||
.Returns(payloadBytes);
|
||||
|
||||
_signingProviderMock
|
||||
.Setup(s => s.SignAsync(
|
||||
It.IsAny<byte[]>(),
|
||||
"key-123",
|
||||
"ES256",
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new DsseSignatureResult
|
||||
{
|
||||
KeyId = "key-123",
|
||||
SignatureBytes = new byte[] { 0x01, 0x02, 0x03 },
|
||||
Algorithm = "ES256"
|
||||
});
|
||||
|
||||
_signingProviderMock
|
||||
.Setup(s => s.SignAsync(
|
||||
It.IsAny<byte[]>(),
|
||||
"pq-key-456",
|
||||
"ML-DSA-65",
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new DsseSignatureResult
|
||||
{
|
||||
KeyId = "pq-key-456",
|
||||
SignatureBytes = new byte[] { 0x04, 0x05, 0x06 },
|
||||
Algorithm = "ML-DSA-65"
|
||||
});
|
||||
|
||||
// Act
|
||||
var envelope = await _signer.SignAsync(document, options);
|
||||
|
||||
// Assert
|
||||
envelope.Signatures.Should().HaveCount(2);
|
||||
envelope.Signatures[0].KeyId.Should().Be("key-123");
|
||||
envelope.Signatures[1].KeyId.Should().Be("pq-key-456");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignBuildProfileAsync_CreatesBuildDocument()
|
||||
{
|
||||
// Arrange
|
||||
var build = new Spdx3Build
|
||||
{
|
||||
SpdxId = "https://stellaops.io/spdx/build/12345",
|
||||
Type = Spdx3Build.TypeName,
|
||||
BuildType = "https://slsa.dev/provenance/v1",
|
||||
BuildId = "build-12345"
|
||||
};
|
||||
|
||||
var options = new DsseSpdx3SigningOptions { PrimaryKeyId = "key-123" };
|
||||
|
||||
_serializerMock
|
||||
.Setup(s => s.SerializeToBytes(It.IsAny<Spdx3Document>()))
|
||||
.Returns(Encoding.UTF8.GetBytes("{\"build\":\"test\"}"));
|
||||
|
||||
_signingProviderMock
|
||||
.Setup(s => s.SignAsync(
|
||||
It.IsAny<byte[]>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new DsseSignatureResult
|
||||
{
|
||||
KeyId = "key-123",
|
||||
SignatureBytes = new byte[] { 0x01, 0x02, 0x03 }
|
||||
});
|
||||
|
||||
// Act
|
||||
var envelope = await _signer.SignBuildProfileAsync(build, null, options);
|
||||
|
||||
// Assert
|
||||
envelope.Should().NotBeNull();
|
||||
envelope.PayloadType.Should().Be(DsseSpdx3Signer.Spdx3PayloadType);
|
||||
|
||||
_serializerMock.Verify(
|
||||
s => s.SerializeToBytes(It.Is<Spdx3Document>(d =>
|
||||
d.Elements.Any(e => e is Spdx3Build))),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_WithValidSignature_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var envelope = new DsseSpdx3Envelope
|
||||
{
|
||||
PayloadType = DsseSpdx3Signer.Spdx3PayloadType,
|
||||
Payload = "eyJ0ZXN0IjoiZG9jdW1lbnQifQ", // base64url of {"test":"document"}
|
||||
Signatures = ImmutableArray.Create(new DsseSpdx3Signature
|
||||
{
|
||||
KeyId = "key-123",
|
||||
Sig = "AQID" // base64url of [0x01, 0x02, 0x03]
|
||||
})
|
||||
};
|
||||
|
||||
var trustedKeys = new List<DsseVerificationKey>
|
||||
{
|
||||
new() { KeyId = "key-123", PublicKey = new byte[] { 0x10, 0x20 } }
|
||||
};
|
||||
|
||||
_signingProviderMock
|
||||
.Setup(s => s.VerifyAsync(
|
||||
It.IsAny<byte[]>(),
|
||||
It.IsAny<byte[]>(),
|
||||
It.Is<DsseVerificationKey>(k => k.KeyId == "key-123"),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
// Act
|
||||
var result = await _signer.VerifyAsync(envelope, trustedKeys);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_WithUntrustedKey_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var envelope = new DsseSpdx3Envelope
|
||||
{
|
||||
PayloadType = DsseSpdx3Signer.Spdx3PayloadType,
|
||||
Payload = "eyJ0ZXN0IjoiZG9jdW1lbnQifQ",
|
||||
Signatures = ImmutableArray.Create(new DsseSpdx3Signature
|
||||
{
|
||||
KeyId = "untrusted-key",
|
||||
Sig = "AQID"
|
||||
})
|
||||
};
|
||||
|
||||
var trustedKeys = new List<DsseVerificationKey>
|
||||
{
|
||||
new() { KeyId = "key-123", PublicKey = new byte[] { 0x10, 0x20 } }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _signer.VerifyAsync(envelope, trustedKeys);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractDocument_WithValidEnvelope_ReturnsDocument()
|
||||
{
|
||||
// Arrange
|
||||
var originalDocument = CreateTestDocument();
|
||||
var payloadBytes = Encoding.UTF8.GetBytes("{\"test\":\"document\"}");
|
||||
var payload = Convert.ToBase64String(payloadBytes)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
|
||||
var envelope = new DsseSpdx3Envelope
|
||||
{
|
||||
PayloadType = DsseSpdx3Signer.Spdx3PayloadType,
|
||||
Payload = payload,
|
||||
Signatures = ImmutableArray<DsseSpdx3Signature>.Empty
|
||||
};
|
||||
|
||||
_serializerMock
|
||||
.Setup(s => s.Deserialize(It.IsAny<byte[]>()))
|
||||
.Returns(originalDocument);
|
||||
|
||||
// Act
|
||||
var extracted = _signer.ExtractDocument(envelope);
|
||||
|
||||
// Assert
|
||||
extracted.Should().NotBeNull();
|
||||
extracted.Should().Be(originalDocument);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractDocument_WithWrongPayloadType_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var envelope = new DsseSpdx3Envelope
|
||||
{
|
||||
PayloadType = "application/vnd.in-toto+json",
|
||||
Payload = "eyJ0ZXN0IjoiZG9jdW1lbnQifQ",
|
||||
Signatures = ImmutableArray<DsseSpdx3Signature>.Empty
|
||||
};
|
||||
|
||||
// Act
|
||||
var extracted = _signer.ExtractDocument(envelope);
|
||||
|
||||
// Assert
|
||||
extracted.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PayloadType_IsCorrectSpdxMediaType()
|
||||
{
|
||||
// Assert
|
||||
DsseSpdx3Signer.Spdx3PayloadType.Should().Be("application/spdx+json");
|
||||
}
|
||||
|
||||
private static Spdx3Document CreateTestDocument()
|
||||
{
|
||||
var creationInfo = new Spdx3CreationInfo
|
||||
{
|
||||
SpecVersion = Spdx3CreationInfo.Spdx301Version,
|
||||
Created = FixedTimestamp,
|
||||
CreatedBy = ImmutableArray<string>.Empty,
|
||||
Profile = ImmutableArray.Create(Spdx3ProfileIdentifier.Core, Spdx3ProfileIdentifier.Build)
|
||||
};
|
||||
|
||||
return new Spdx3Document(
|
||||
elements: Array.Empty<Spdx3Element>(),
|
||||
creationInfos: new[] { creationInfo },
|
||||
profiles: new[] { Spdx3ProfileIdentifier.Core, Spdx3ProfileIdentifier.Build });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="7.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="xunit" Version="2.9.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Attestor.Spdx3\StellaOps.Attestor.Spdx3.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,10 @@
|
||||
# Attestor SPDX3 Build Profile Tests Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/permament/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0849-M | DONE | Revalidated 2026-01-08. |
|
||||
| AUDIT-0849-T | DONE | Revalidated 2026-01-08. |
|
||||
| AUDIT-0849-A | DONE | Waived (test project; revalidated 2026-01-08). |
|
||||
Reference in New Issue
Block a user