Files
git.stella-ops.org/src/EvidenceLocker/StellaOps.EvidenceLocker/StellaOps.EvidenceLocker.Tests/EvidenceSignatureServiceTests.cs
master 2eb6852d34
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Add unit tests for SBOM ingestion and transformation
- Implement `SbomIngestServiceCollectionExtensionsTests` to verify the SBOM ingestion pipeline exports snapshots correctly.
- Create `SbomIngestTransformerTests` to ensure the transformation produces expected nodes and edges, including deduplication of license nodes and normalization of timestamps.
- Add `SbomSnapshotExporterTests` to test the export functionality for manifest, adjacency, nodes, and edges.
- Introduce `VexOverlayTransformerTests` to validate the transformation of VEX nodes and edges.
- Set up project file for the test project with necessary dependencies and configurations.
- Include JSON fixture files for testing purposes.
2025-11-04 07:49:39 +02:00

270 lines
9.6 KiB
C#

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography;
using StellaOps.EvidenceLocker.Core.Builders;
using StellaOps.EvidenceLocker.Core.Configuration;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Core.Signing;
using StellaOps.EvidenceLocker.Infrastructure.Signing;
using Xunit;
namespace StellaOps.EvidenceLocker.Tests;
public sealed class EvidenceSignatureServiceTests
{
private static readonly SigningKeyMaterialOptions TestKeyMaterial = CreateKeyMaterial();
[Fact]
public async Task SignManifestAsync_SignsManifestWithoutTimestamp_WhenTimestampingDisabled()
{
var timestampClient = new FakeTimestampAuthorityClient();
var timeProvider = new TestTimeProvider(new DateTimeOffset(2025, 11, 3, 10, 0, 0, TimeSpan.Zero));
var service = CreateService(timestampClient, timeProvider);
var manifest = CreateManifest();
var signature = await service.SignManifestAsync(
manifest.BundleId,
manifest.TenantId,
manifest,
CancellationToken.None);
Assert.NotNull(signature);
Assert.Equal("application/vnd.stella.test+json", signature.PayloadType);
Assert.NotNull(signature.Payload);
Assert.NotEmpty(signature.Signature);
Assert.Null(signature.TimestampedAt);
Assert.Null(signature.TimestampAuthority);
Assert.Null(signature.TimestampToken);
Assert.Equal(0, timestampClient.CallCount);
}
[Fact]
public async Task SignManifestAsync_AttachesTimestamp_WhenAuthorityClientSucceeds()
{
var timestampClient = new FakeTimestampAuthorityClient
{
Result = new TimestampResult(
new DateTimeOffset(2025, 11, 3, 10, 0, 5, TimeSpan.Zero),
"CN=Test TSA",
new byte[] { 1, 2, 3 })
};
var timeProvider = new TestTimeProvider(new DateTimeOffset(2025, 11, 3, 10, 0, 0, TimeSpan.Zero));
var signingOptions = CreateSigningOptions(timestamping: new TimestampingOptions
{
Enabled = true,
Endpoint = "https://tsa.example",
HashAlgorithm = "SHA256"
});
var service = CreateService(timestampClient, timeProvider, signingOptions);
var manifest = CreateManifest();
var signature = await service.SignManifestAsync(
manifest.BundleId,
manifest.TenantId,
manifest,
CancellationToken.None);
Assert.NotNull(signature);
Assert.Equal(timestampClient.Result!.Timestamp, signature.TimestampedAt);
Assert.Equal(timestampClient.Result.Authority, signature.TimestampAuthority);
Assert.Equal(timestampClient.Result.Token, signature.TimestampToken);
Assert.Equal(1, timestampClient.CallCount);
}
[Fact]
public async Task SignManifestAsync_Throws_WhenTimestampRequiredAndClientFails()
{
var timestampClient = new FakeTimestampAuthorityClient
{
Exception = new InvalidOperationException("TSA offline")
};
var signingOptions = CreateSigningOptions(timestamping: new TimestampingOptions
{
Enabled = true,
Endpoint = "https://tsa.example",
HashAlgorithm = "SHA256",
RequireTimestamp = true
});
var service = CreateService(timestampClient, new TestTimeProvider(DateTimeOffset.UtcNow), signingOptions);
var manifest = CreateManifest();
await Assert.ThrowsAsync<InvalidOperationException>(() => service.SignManifestAsync(
manifest.BundleId,
manifest.TenantId,
manifest,
CancellationToken.None));
}
[Fact]
public async Task SignManifestAsync_ProducesDeterministicPayload()
{
var timestampClient = new FakeTimestampAuthorityClient();
var service = CreateService(timestampClient, new TestTimeProvider(DateTimeOffset.UtcNow));
var sharedBundleId = EvidenceBundleId.FromGuid(Guid.NewGuid());
var sharedTenantId = TenantId.FromGuid(Guid.NewGuid());
var manifestA = CreateManifest(
metadataOrder: new[] { ("zeta", "1"), ("alpha", "2") },
bundleId: sharedBundleId,
tenantId: sharedTenantId);
var manifestB = CreateManifest(
metadataOrder: new[] { ("alpha", "2"), ("zeta", "1") },
bundleId: sharedBundleId,
tenantId: sharedTenantId);
var signatureA = await service.SignManifestAsync(
manifestA.BundleId,
manifestA.TenantId,
manifestA,
CancellationToken.None);
var signatureB = await service.SignManifestAsync(
manifestB.BundleId,
manifestB.TenantId,
manifestB,
CancellationToken.None);
Assert.NotNull(signatureA);
Assert.NotNull(signatureB);
var payloadA = Encoding.UTF8.GetString(Convert.FromBase64String(signatureA!.Payload));
var payloadB = Encoding.UTF8.GetString(Convert.FromBase64String(signatureB!.Payload));
Assert.Equal(payloadA, payloadB);
using var document = JsonDocument.Parse(payloadA);
var metadataElement = document.RootElement.GetProperty("metadata");
using var enumerator = metadataElement.EnumerateObject();
Assert.True(enumerator.MoveNext());
Assert.Equal("alpha", enumerator.Current.Name);
Assert.True(enumerator.MoveNext());
Assert.Equal("zeta", enumerator.Current.Name);
}
private static EvidenceSignatureService CreateService(
ITimestampAuthorityClient timestampAuthorityClient,
TimeProvider timeProvider,
SigningOptions? signingOptions = null)
{
var registry = new CryptoProviderRegistry(new ICryptoProvider[] { new DefaultCryptoProvider() });
var options = Options.Create(new EvidenceLockerOptions
{
Database = new DatabaseOptions { ConnectionString = "Host=localhost" },
ObjectStore = new ObjectStoreOptions
{
Kind = ObjectStoreKind.FileSystem,
FileSystem = new FileSystemStoreOptions { RootPath = "." }
},
Quotas = new QuotaOptions(),
Signing = signingOptions ?? CreateSigningOptions()
});
return new EvidenceSignatureService(
registry,
timestampAuthorityClient,
options,
timeProvider,
NullLogger<EvidenceSignatureService>.Instance);
}
private static SigningOptions CreateSigningOptions(TimestampingOptions? timestamping = null)
=> new()
{
Enabled = true,
Algorithm = SignatureAlgorithms.Es256,
KeyId = "test-key",
PayloadType = "application/vnd.stella.test+json",
KeyMaterial = TestKeyMaterial,
Timestamping = timestamping
};
private static SigningKeyMaterialOptions CreateKeyMaterial()
{
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var privatePem = ecdsa.ExportECPrivateKeyPem();
var publicPem = ecdsa.ExportSubjectPublicKeyInfoPem();
return new SigningKeyMaterialOptions
{
EcPrivateKeyPem = privatePem,
EcPublicKeyPem = publicPem
};
}
private static EvidenceBundleManifest CreateManifest(
(string key, string value)[]? metadataOrder = null,
EvidenceBundleId? bundleId = null,
TenantId? tenantId = null)
{
metadataOrder ??= new[] { ("alpha", "1"), ("beta", "2") };
var metadataDictionary = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var (key, value) in metadataOrder)
{
metadataDictionary[key] = value;
}
var metadata = new ReadOnlyDictionary<string, string>(metadataDictionary);
var attributesDictionary = new Dictionary<string, string>(StringComparer.Ordinal)
{
["scope"] = "inputs",
["priority"] = "high"
};
var attributes = new ReadOnlyDictionary<string, string>(attributesDictionary);
var manifestEntry = new EvidenceManifestEntry(
"inputs",
"inputs/config.json",
new string('a', 64),
128,
"application/json",
attributes);
return new EvidenceBundleManifest(
bundleId ?? EvidenceBundleId.FromGuid(Guid.NewGuid()),
tenantId ?? TenantId.FromGuid(Guid.NewGuid()),
EvidenceBundleKind.Evaluation,
new DateTimeOffset(2025, 11, 3, 9, 30, 0, TimeSpan.Zero),
metadata,
new List<EvidenceManifestEntry> { manifestEntry });
}
private sealed class FakeTimestampAuthorityClient : ITimestampAuthorityClient
{
public TimestampResult? Result { get; set; }
public Exception? Exception { get; set; }
public int CallCount { get; private set; }
public Task<TimestampResult?> RequestTimestampAsync(
ReadOnlyMemory<byte> signature,
string hashAlgorithm,
CancellationToken cancellationToken)
{
CallCount++;
if (Exception is not null)
{
throw Exception;
}
return Task.FromResult(Result);
}
}
private sealed class TestTimeProvider(DateTimeOffset fixedUtcNow) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => fixedUtcNow;
}
}