tests fixes and some product advisories tunes ups
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
// Copyright (c) StellaOps. All rights reserved.
|
||||
// Licensed under the BUSL-1.1 license.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Attestor.EvidencePack.Models;
|
||||
using StellaOps.Attestor.EvidencePack.Services;
|
||||
|
||||
namespace StellaOps.Attestor.EvidencePack.IntegrationTests;
|
||||
|
||||
@@ -254,4 +256,220 @@ public class EvidencePackGenerationTests : IDisposable
|
||||
"linux-x64")
|
||||
.Build();
|
||||
}
|
||||
|
||||
#region Replay Log Integration Tests (EU CRA/NIS2)
|
||||
|
||||
[Fact]
|
||||
public async Task GeneratePack_WithReplayLog_IncludesReplayLogJson()
|
||||
{
|
||||
// Arrange
|
||||
var artifactPath = CreateTestArtifact("stella-2.5.0-linux-x64.tar.gz", 1024);
|
||||
var manifest = CreateManifestWithArtifact(artifactPath);
|
||||
var outputDir = Path.Combine(_tempDir, "replay-log-test");
|
||||
|
||||
var replayLogBuilder = new VerificationReplayLogBuilder();
|
||||
var replayLog = replayLogBuilder.Build(new VerificationReplayLogRequest
|
||||
{
|
||||
ArtifactRef = "oci://registry.example.com/stella:v2.5.0@sha256:abc123",
|
||||
SbomPath = "sbom/stella.cdx.json",
|
||||
CanonicalSbomDigest = "sha256:sbomdigest123456",
|
||||
DsseEnvelopePath = "attestations/stella.dsse.json",
|
||||
DsseSubjectDigest = "sha256:sbomdigest123456",
|
||||
DsseSignatureValid = true,
|
||||
SigningKeyId = "cosign-key-1",
|
||||
CosignPublicKeyPath = "cosign.pub",
|
||||
RekorLogId = "c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d",
|
||||
RekorLogIndex = 12345678,
|
||||
RekorTreeSize = 99999999,
|
||||
RekorRootHash = "sha256:merklerootabc",
|
||||
InclusionProofPath = "rekor-proofs/log-entries/12345678.json",
|
||||
RekorInclusionValid = true,
|
||||
RekorPublicKeyPath = "rekor-public-key.pub"
|
||||
});
|
||||
|
||||
// Act
|
||||
await _serializer.SerializeToDirectoryAsync(manifest, outputDir, replayLog);
|
||||
|
||||
// Assert
|
||||
var replayLogPath = Path.Combine(outputDir, "replay_log.json");
|
||||
File.Exists(replayLogPath).Should().BeTrue("replay_log.json should be created");
|
||||
|
||||
var replayLogContent = await File.ReadAllTextAsync(replayLogPath);
|
||||
replayLogContent.Should().Contain("schema_version");
|
||||
replayLogContent.Should().Contain("compute_canonical_sbom_digest");
|
||||
replayLogContent.Should().Contain("verify_dsse_signature");
|
||||
replayLogContent.Should().Contain("verify_rekor_inclusion");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GeneratePack_WithReplayLog_ManifestReferencesReplayLog()
|
||||
{
|
||||
// Arrange
|
||||
var artifactPath = CreateTestArtifact("stella-2.5.0-linux-x64.tar.gz", 1024);
|
||||
var manifest = CreateManifestWithArtifact(artifactPath);
|
||||
var outputDir = Path.Combine(_tempDir, "replay-log-manifest-test");
|
||||
|
||||
var replayLogBuilder = new VerificationReplayLogBuilder();
|
||||
var replayLog = replayLogBuilder.Build(new VerificationReplayLogRequest
|
||||
{
|
||||
ArtifactRef = "test-artifact"
|
||||
});
|
||||
|
||||
// Act
|
||||
await _serializer.SerializeToDirectoryAsync(manifest, outputDir, replayLog);
|
||||
|
||||
// Assert - manifest should reference replay_log.json
|
||||
var manifestPath = Path.Combine(outputDir, "manifest.json");
|
||||
var manifestContent = await File.ReadAllTextAsync(manifestPath);
|
||||
manifestContent.Should().Contain("replayLogPath");
|
||||
manifestContent.Should().Contain("replay_log.json");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GeneratePack_WithReplayLog_VerifyMdContainsCraNis2Section()
|
||||
{
|
||||
// Arrange
|
||||
var artifactPath = CreateTestArtifact("stella-2.5.0-linux-x64.tar.gz", 1024);
|
||||
var manifest = CreateManifestWithArtifact(artifactPath);
|
||||
var outputDir = Path.Combine(_tempDir, "replay-log-verify-md-test");
|
||||
|
||||
var replayLogBuilder = new VerificationReplayLogBuilder();
|
||||
var replayLog = replayLogBuilder.Build(new VerificationReplayLogRequest
|
||||
{
|
||||
ArtifactRef = "test-artifact",
|
||||
SbomPath = "sbom/test.cdx.json",
|
||||
CanonicalSbomDigest = "sha256:test"
|
||||
});
|
||||
|
||||
// Act
|
||||
await _serializer.SerializeToDirectoryAsync(manifest, outputDir, replayLog);
|
||||
|
||||
// Assert - VERIFY.md should contain CRA/NIS2 section
|
||||
var verifyMdPath = Path.Combine(outputDir, "VERIFY.md");
|
||||
var verifyMdContent = await File.ReadAllTextAsync(verifyMdPath);
|
||||
verifyMdContent.Should().Contain("CRA/NIS2");
|
||||
verifyMdContent.Should().Contain("replay_log.json");
|
||||
verifyMdContent.Should().Contain("compute_canonical_sbom_digest");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GeneratePack_TarGz_WithReplayLog_IncludesReplayLogInArchive()
|
||||
{
|
||||
// Arrange
|
||||
var artifactPath = CreateTestArtifact("stella-2.5.0-linux-x64.tar.gz", 1024);
|
||||
var manifest = CreateManifestWithArtifact(artifactPath);
|
||||
var outputPath = Path.Combine(_tempDir, "evidence-pack-with-replay.tgz");
|
||||
|
||||
var replayLogBuilder = new VerificationReplayLogBuilder();
|
||||
var replayLog = replayLogBuilder.Build(new VerificationReplayLogRequest
|
||||
{
|
||||
ArtifactRef = "test-artifact",
|
||||
SbomPath = "sbom/test.cdx.json",
|
||||
CanonicalSbomDigest = "sha256:test",
|
||||
DsseEnvelopePath = "attestations/test.dsse.json",
|
||||
DsseSubjectDigest = "sha256:test",
|
||||
DsseSignatureValid = true
|
||||
});
|
||||
|
||||
// Act
|
||||
await using (var stream = File.Create(outputPath))
|
||||
{
|
||||
await _serializer.SerializeToTarGzAsync(manifest, stream, "stella-release-2.5.0-evidence-pack", replayLog);
|
||||
}
|
||||
|
||||
// Assert
|
||||
File.Exists(outputPath).Should().BeTrue();
|
||||
var fileInfo = new FileInfo(outputPath);
|
||||
fileInfo.Length.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GeneratePack_Zip_WithReplayLog_IncludesReplayLogInArchive()
|
||||
{
|
||||
// Arrange
|
||||
var artifactPath = CreateTestArtifact("stella-2.5.0-linux-x64.tar.gz", 1024);
|
||||
var manifest = CreateManifestWithArtifact(artifactPath);
|
||||
var outputPath = Path.Combine(_tempDir, "evidence-pack-with-replay.zip");
|
||||
|
||||
var replayLogBuilder = new VerificationReplayLogBuilder();
|
||||
var replayLog = replayLogBuilder.Build(new VerificationReplayLogRequest
|
||||
{
|
||||
ArtifactRef = "test-artifact",
|
||||
Metadata = ImmutableDictionary<string, string>.Empty
|
||||
.Add("compliance_framework", "EU_CRA_NIS2")
|
||||
});
|
||||
|
||||
// Act
|
||||
await using (var stream = File.Create(outputPath))
|
||||
{
|
||||
await _serializer.SerializeToZipAsync(manifest, stream, "stella-release-2.5.0-evidence-pack", replayLog);
|
||||
}
|
||||
|
||||
// Assert
|
||||
File.Exists(outputPath).Should().BeTrue();
|
||||
var fileInfo = new FileInfo(outputPath);
|
||||
fileInfo.Length.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GeneratePack_WithFailedVerification_ReplayLogShowsFailure()
|
||||
{
|
||||
// Arrange
|
||||
var artifactPath = CreateTestArtifact("stella-2.5.0-linux-x64.tar.gz", 1024);
|
||||
var manifest = CreateManifestWithArtifact(artifactPath);
|
||||
var outputDir = Path.Combine(_tempDir, "replay-log-failure-test");
|
||||
|
||||
var replayLogBuilder = new VerificationReplayLogBuilder();
|
||||
var replayLog = replayLogBuilder.Build(new VerificationReplayLogRequest
|
||||
{
|
||||
ArtifactRef = "test-artifact",
|
||||
SbomPath = "sbom/test.cdx.json",
|
||||
CanonicalSbomDigest = "sha256:computed",
|
||||
DsseSubjectDigest = "sha256:different", // Mismatch!
|
||||
DsseEnvelopePath = "attestations/test.dsse.json",
|
||||
DsseSignatureValid = false,
|
||||
DsseSignatureError = "Signature verification failed: key mismatch"
|
||||
});
|
||||
|
||||
// Act
|
||||
await _serializer.SerializeToDirectoryAsync(manifest, outputDir, replayLog);
|
||||
|
||||
// Assert
|
||||
var replayLogPath = Path.Combine(outputDir, "replay_log.json");
|
||||
var replayLogContent = await File.ReadAllTextAsync(replayLogPath);
|
||||
|
||||
replayLogContent.Should().Contain("\"result\": \"fail\"");
|
||||
replayLogContent.Should().Contain("key mismatch");
|
||||
replayLogContent.Should().Contain("does not match");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplayLogBuilder_SerializesToSnakeCaseJson()
|
||||
{
|
||||
// Arrange
|
||||
var replayLogBuilder = new VerificationReplayLogBuilder();
|
||||
var replayLog = replayLogBuilder.Build(new VerificationReplayLogRequest
|
||||
{
|
||||
ArtifactRef = "test",
|
||||
SbomPath = "sbom/test.json",
|
||||
CanonicalSbomDigest = "sha256:abc"
|
||||
});
|
||||
|
||||
// Act
|
||||
var json = replayLogBuilder.Serialize(replayLog);
|
||||
|
||||
// Assert - should use snake_case per advisory spec
|
||||
json.Should().Contain("schema_version");
|
||||
json.Should().Contain("replay_id");
|
||||
json.Should().Contain("artifact_ref");
|
||||
json.Should().Contain("verified_at");
|
||||
json.Should().Contain("verifier_version");
|
||||
|
||||
// Should NOT contain camelCase
|
||||
json.Should().NotContain("schemaVersion");
|
||||
json.Should().NotContain("replayId");
|
||||
json.Should().NotContain("artifactRef");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
// Copyright (c) StellaOps. All rights reserved.
|
||||
// Licensed under the BUSL-1.1 license.
|
||||
|
||||
global using Xunit;
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<OutputType>Exe</OutputType>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
@@ -10,16 +11,13 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Attestor.EvidencePack\StellaOps.Attestor.EvidencePack.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Attestor.StandardPredicates\StellaOps.Attestor.StandardPredicates.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<OutputType>Exe</OutputType>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
@@ -9,15 +10,13 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Attestor.EvidencePack\StellaOps.Attestor.EvidencePack.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
// Copyright (c) StellaOps. All rights reserved.
|
||||
// Licensed under the BUSL-1.1 license.
|
||||
// Advisory: EU CRA/NIS2 compliance - Sealed Audit-Pack replay_log.json tests
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Attestor.EvidencePack.Models;
|
||||
using StellaOps.Attestor.EvidencePack.Services;
|
||||
|
||||
namespace StellaOps.Attestor.EvidencePack.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for VerificationReplayLogBuilder.
|
||||
/// Tests the replay_log.json generation for EU CRA/NIS2 compliance.
|
||||
/// </summary>
|
||||
public class VerificationReplayLogBuilderTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly VerificationReplayLogBuilder _builder;
|
||||
|
||||
public VerificationReplayLogBuilderTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 29, 12, 0, 0, TimeSpan.Zero));
|
||||
_builder = new VerificationReplayLogBuilder(_timeProvider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithMinimalRequest_ReturnsValidReplayLog()
|
||||
{
|
||||
// Arrange
|
||||
var request = new VerificationReplayLogRequest
|
||||
{
|
||||
ArtifactRef = "oci://registry.example.com/app:v1.0.0@sha256:abc123"
|
||||
};
|
||||
|
||||
// Act
|
||||
var log = _builder.Build(request);
|
||||
|
||||
// Assert
|
||||
log.Should().NotBeNull();
|
||||
log.SchemaVersion.Should().Be("1.0.0");
|
||||
log.ArtifactRef.Should().Be("oci://registry.example.com/app:v1.0.0@sha256:abc123");
|
||||
log.VerifierVersion.Should().Be("stellaops-attestor/1.0.0");
|
||||
log.Result.Should().Be("pass");
|
||||
log.ReplayId.Should().StartWith("replay_");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithFullVerificationRequest_ReturnsAllSteps()
|
||||
{
|
||||
// Arrange
|
||||
var request = new VerificationReplayLogRequest
|
||||
{
|
||||
ArtifactRef = "oci://registry.example.com/app:v1.0.0@sha256:abc123",
|
||||
SbomPath = "sbom/app.cdx.json",
|
||||
CanonicalSbomDigest = "sha256:sbomdigest123",
|
||||
DsseEnvelopePath = "attestations/app.dsse.json",
|
||||
DsseSubjectDigest = "sha256:sbomdigest123",
|
||||
DsseSignatureValid = true,
|
||||
SigningKeyId = "cosign-key-1",
|
||||
SignatureAlgorithm = "ecdsa-p256",
|
||||
SigningKeyFingerprint = "SHA256:keyfingerprint123",
|
||||
CosignPublicKeyPath = "cosign.pub",
|
||||
RekorLogId = "c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d",
|
||||
RekorLogIndex = 12345678,
|
||||
RekorTreeSize = 99999999,
|
||||
RekorRootHash = "sha256:merklerootabc",
|
||||
RekorIntegratedTime = 1706529600,
|
||||
InclusionProofPath = "rekor-proofs/log-entries/12345678.json",
|
||||
RekorInclusionValid = true,
|
||||
CheckpointPath = "rekor-proofs/checkpoint.json",
|
||||
CheckpointValid = true,
|
||||
RekorPublicKeyPath = "rekor-public-key.pub",
|
||||
RekorPublicKeyId = "rekor-key-1"
|
||||
};
|
||||
|
||||
// Act
|
||||
var log = _builder.Build(request);
|
||||
|
||||
// Assert
|
||||
log.Result.Should().Be("pass");
|
||||
log.Steps.Should().HaveCount(5);
|
||||
|
||||
// Step 1: Canonical SBOM digest
|
||||
log.Steps[0].Step.Should().Be(1);
|
||||
log.Steps[0].Action.Should().Be("compute_canonical_sbom_digest");
|
||||
log.Steps[0].Input.Should().Be("sbom/app.cdx.json");
|
||||
log.Steps[0].Output.Should().Be("sha256:sbomdigest123");
|
||||
log.Steps[0].Result.Should().Be("pass");
|
||||
|
||||
// Step 2: DSSE subject match
|
||||
log.Steps[1].Step.Should().Be(2);
|
||||
log.Steps[1].Action.Should().Be("verify_dsse_subject_match");
|
||||
log.Steps[1].Expected.Should().Be("sha256:sbomdigest123");
|
||||
log.Steps[1].Actual.Should().Be("sha256:sbomdigest123");
|
||||
log.Steps[1].Result.Should().Be("pass");
|
||||
|
||||
// Step 3: DSSE signature
|
||||
log.Steps[2].Step.Should().Be(3);
|
||||
log.Steps[2].Action.Should().Be("verify_dsse_signature");
|
||||
log.Steps[2].KeyId.Should().Be("cosign-key-1");
|
||||
log.Steps[2].Result.Should().Be("pass");
|
||||
|
||||
// Step 4: Rekor inclusion
|
||||
log.Steps[3].Step.Should().Be(4);
|
||||
log.Steps[3].Action.Should().Be("verify_rekor_inclusion");
|
||||
log.Steps[3].Result.Should().Be("pass");
|
||||
|
||||
// Step 5: Rekor checkpoint
|
||||
log.Steps[4].Step.Should().Be(5);
|
||||
log.Steps[4].Action.Should().Be("verify_rekor_checkpoint");
|
||||
log.Steps[4].Result.Should().Be("pass");
|
||||
|
||||
// Verification keys
|
||||
log.VerificationKeys.Should().HaveCount(2);
|
||||
log.VerificationKeys[0].Type.Should().Be("cosign");
|
||||
log.VerificationKeys[0].Path.Should().Be("cosign.pub");
|
||||
log.VerificationKeys[1].Type.Should().Be("rekor");
|
||||
log.VerificationKeys[1].Path.Should().Be("rekor-public-key.pub");
|
||||
|
||||
// Rekor info
|
||||
log.Rekor.Should().NotBeNull();
|
||||
log.Rekor!.LogId.Should().Be("c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d");
|
||||
log.Rekor.LogIndex.Should().Be(12345678);
|
||||
log.Rekor.TreeSize.Should().Be(99999999);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithFailedDsseSignature_ReturnsFailResult()
|
||||
{
|
||||
// Arrange
|
||||
var request = new VerificationReplayLogRequest
|
||||
{
|
||||
ArtifactRef = "oci://registry.example.com/app:v1.0.0@sha256:abc123",
|
||||
SbomPath = "sbom/app.cdx.json",
|
||||
CanonicalSbomDigest = "sha256:sbomdigest123",
|
||||
DsseEnvelopePath = "attestations/app.dsse.json",
|
||||
DsseSubjectDigest = "sha256:sbomdigest123",
|
||||
DsseSignatureValid = false,
|
||||
DsseSignatureError = "Invalid signature: key mismatch",
|
||||
SigningKeyId = "cosign-key-1",
|
||||
CosignPublicKeyPath = "cosign.pub"
|
||||
};
|
||||
|
||||
// Act
|
||||
var log = _builder.Build(request);
|
||||
|
||||
// Assert
|
||||
log.Result.Should().Be("fail");
|
||||
log.Steps.Should().Contain(s => s.Action == "verify_dsse_signature" && s.Result == "fail");
|
||||
log.Steps.First(s => s.Action == "verify_dsse_signature").Error
|
||||
.Should().Be("Invalid signature: key mismatch");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithMismatchedDigests_ReturnsFailResult()
|
||||
{
|
||||
// Arrange
|
||||
var request = new VerificationReplayLogRequest
|
||||
{
|
||||
ArtifactRef = "oci://registry.example.com/app:v1.0.0@sha256:abc123",
|
||||
SbomPath = "sbom/app.cdx.json",
|
||||
CanonicalSbomDigest = "sha256:computeddigest",
|
||||
DsseSubjectDigest = "sha256:differentdigest"
|
||||
};
|
||||
|
||||
// Act
|
||||
var log = _builder.Build(request);
|
||||
|
||||
// Assert
|
||||
log.Result.Should().Be("fail");
|
||||
var mismatchStep = log.Steps.First(s => s.Action == "verify_dsse_subject_match");
|
||||
mismatchStep.Result.Should().Be("fail");
|
||||
mismatchStep.Expected.Should().Be("sha256:differentdigest");
|
||||
mismatchStep.Actual.Should().Be("sha256:computeddigest");
|
||||
mismatchStep.Error.Should().Contain("does not match");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_ReturnsValidJson()
|
||||
{
|
||||
// Arrange
|
||||
var request = new VerificationReplayLogRequest
|
||||
{
|
||||
ArtifactRef = "oci://registry.example.com/app:v1.0.0@sha256:abc123",
|
||||
SbomPath = "sbom/app.cdx.json",
|
||||
CanonicalSbomDigest = "sha256:sbomdigest123"
|
||||
};
|
||||
var log = _builder.Build(request);
|
||||
|
||||
// Act
|
||||
var json = _builder.Serialize(log);
|
||||
|
||||
// Assert
|
||||
json.Should().NotBeNullOrEmpty();
|
||||
json.Should().Contain("\"schema_version\"");
|
||||
json.Should().Contain("\"replay_id\"");
|
||||
json.Should().Contain("\"artifact_ref\"");
|
||||
json.Should().Contain("\"steps\"");
|
||||
json.Should().Contain("\"compute_canonical_sbom_digest\"");
|
||||
|
||||
// Should be valid JSON
|
||||
var parsed = JsonDocument.Parse(json);
|
||||
parsed.RootElement.GetProperty("schema_version").GetString().Should().Be("1.0.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithMetadata_IncludesMetadataInLog()
|
||||
{
|
||||
// Arrange
|
||||
var request = new VerificationReplayLogRequest
|
||||
{
|
||||
ArtifactRef = "oci://registry.example.com/app:v1.0.0@sha256:abc123",
|
||||
Metadata = ImmutableDictionary<string, string>.Empty
|
||||
.Add("compliance_framework", "EU_CRA_NIS2")
|
||||
.Add("auditor", "external-auditor-id")
|
||||
};
|
||||
|
||||
// Act
|
||||
var log = _builder.Build(request);
|
||||
|
||||
// Assert
|
||||
log.Metadata.Should().NotBeNull();
|
||||
log.Metadata!["compliance_framework"].Should().Be("EU_CRA_NIS2");
|
||||
log.Metadata["auditor"].Should().Be("external-auditor-id");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_GeneratesUniqueReplayId()
|
||||
{
|
||||
// Arrange
|
||||
var request1 = new VerificationReplayLogRequest { ArtifactRef = "artifact1" };
|
||||
var request2 = new VerificationReplayLogRequest { ArtifactRef = "artifact2" };
|
||||
|
||||
// Act
|
||||
var log1 = _builder.Build(request1);
|
||||
var log2 = _builder.Build(request2);
|
||||
|
||||
// Assert
|
||||
log1.ReplayId.Should().NotBe(log2.ReplayId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_UsesProvidedTimeProvider()
|
||||
{
|
||||
// Arrange
|
||||
var expectedTime = new DateTimeOffset(2026, 1, 29, 12, 0, 0, TimeSpan.Zero);
|
||||
var request = new VerificationReplayLogRequest { ArtifactRef = "test" };
|
||||
|
||||
// Act
|
||||
var log = _builder.Build(request);
|
||||
|
||||
// Assert
|
||||
log.VerifiedAt.Should().Be(expectedTime);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BinaryMicroWitnessPredicateTests.cs
|
||||
// Sprint: SPRINT_0128_001_BinaryIndex_binary_micro_witness
|
||||
// Task: TASK-001 - Define binary-micro-witness predicate schema
|
||||
// Description: Unit tests for binary micro-witness predicate serialization.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Attestor.ProofChain.Predicates;
|
||||
using StellaOps.Attestor.ProofChain.Statements;
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Tests;
|
||||
|
||||
public sealed class BinaryMicroWitnessPredicateTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Predicate_RoundTrip_PreservesAllFields()
|
||||
{
|
||||
// Arrange
|
||||
var predicate = CreateSamplePredicate();
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(predicate, JsonOptions);
|
||||
var deserialized = JsonSerializer.Deserialize<BinaryMicroWitnessPredicate>(json, JsonOptions);
|
||||
|
||||
// Assert
|
||||
deserialized.Should().NotBeNull();
|
||||
deserialized!.SchemaVersion.Should().Be(predicate.SchemaVersion);
|
||||
deserialized.Binary.Digest.Should().Be(predicate.Binary.Digest);
|
||||
deserialized.Cve.Id.Should().Be(predicate.Cve.Id);
|
||||
deserialized.Verdict.Should().Be(predicate.Verdict);
|
||||
deserialized.Confidence.Should().Be(predicate.Confidence);
|
||||
deserialized.Evidence.Should().HaveCount(predicate.Evidence.Count);
|
||||
deserialized.Tooling.BinaryIndexVersion.Should().Be(predicate.Tooling.BinaryIndexVersion);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Predicate_Serialization_ProducesDeterministicOutput()
|
||||
{
|
||||
// Arrange
|
||||
var predicate = CreateSamplePredicate();
|
||||
|
||||
// Act
|
||||
var json1 = JsonSerializer.Serialize(predicate, JsonOptions);
|
||||
var json2 = JsonSerializer.Serialize(predicate, JsonOptions);
|
||||
|
||||
// Assert
|
||||
json1.Should().Be(json2);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Predicate_Serialization_OmitsNullFields()
|
||||
{
|
||||
// Arrange
|
||||
var predicate = CreateMinimalPredicate();
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(predicate, JsonOptions);
|
||||
|
||||
// Assert
|
||||
json.Should().NotContain("deltaSigDigest");
|
||||
json.Should().NotContain("sbomRef");
|
||||
json.Should().NotContain("advisory");
|
||||
json.Should().NotContain("patchCommit");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Predicate_Serialization_MinimalIsCompact()
|
||||
{
|
||||
// Arrange - minimal witness (required fields only)
|
||||
var predicate = CreateMinimalPredicate();
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(predicate, JsonOptions);
|
||||
var sizeBytes = System.Text.Encoding.UTF8.GetByteCount(json);
|
||||
|
||||
// Assert - minimal micro-witness should be under 500 bytes
|
||||
sizeBytes.Should().BeLessThan(500, "minimal micro-witness should be very compact");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Predicate_Serialization_FullIsUnder1500Bytes()
|
||||
{
|
||||
// Arrange - full witness with all optional fields
|
||||
var predicate = CreateSamplePredicate();
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(predicate, JsonOptions);
|
||||
var sizeBytes = System.Text.Encoding.UTF8.GetByteCount(json);
|
||||
|
||||
// Assert - full micro-witness should still be compact (<1.5KB)
|
||||
sizeBytes.Should().BeLessThan(1500, "full micro-witness should be under 1.5KB for portability");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Statement_Serialization_IncludesInTotoFields()
|
||||
{
|
||||
// Arrange
|
||||
var statement = new BinaryMicroWitnessStatement
|
||||
{
|
||||
Subject =
|
||||
[
|
||||
new Subject
|
||||
{
|
||||
Name = "libssl.so.3",
|
||||
Digest = new Dictionary<string, string>
|
||||
{
|
||||
["sha256"] = "abc123def456abc123def456abc123def456abc123def456abc123def456abc1"
|
||||
}
|
||||
}
|
||||
],
|
||||
Predicate = CreateSamplePredicate()
|
||||
};
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(statement, JsonOptions);
|
||||
|
||||
// Assert
|
||||
json.Should().Contain("\"_type\":\"https://in-toto.io/Statement/v1\"");
|
||||
json.Should().Contain("\"predicateType\":\"https://stellaops.dev/predicates/binary-micro-witness@v1\"");
|
||||
json.Should().Contain("\"subject\":");
|
||||
json.Should().Contain("\"predicate\":");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void PredicateType_HasCorrectUri()
|
||||
{
|
||||
BinaryMicroWitnessPredicate.PredicateType.Should().Be("https://stellaops.dev/predicates/binary-micro-witness@v1");
|
||||
BinaryMicroWitnessPredicate.PredicateTypeName.Should().Be("stellaops/binary-micro-witness/v1");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData(MicroWitnessVerdicts.Patched)]
|
||||
[InlineData(MicroWitnessVerdicts.Vulnerable)]
|
||||
[InlineData(MicroWitnessVerdicts.Inconclusive)]
|
||||
[InlineData(MicroWitnessVerdicts.Partial)]
|
||||
public void VerdictConstants_AreValidValues(string verdict)
|
||||
{
|
||||
// Arrange
|
||||
var predicate = CreateMinimalPredicate() with { Verdict = verdict };
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(predicate, JsonOptions);
|
||||
var deserialized = JsonSerializer.Deserialize<BinaryMicroWitnessPredicate>(json, JsonOptions);
|
||||
|
||||
// Assert
|
||||
deserialized!.Verdict.Should().Be(verdict);
|
||||
}
|
||||
|
||||
private static BinaryMicroWitnessPredicate CreateSamplePredicate()
|
||||
{
|
||||
return new BinaryMicroWitnessPredicate
|
||||
{
|
||||
SchemaVersion = "1.0.0",
|
||||
Binary = new MicroWitnessBinaryRef
|
||||
{
|
||||
Digest = "sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abc1",
|
||||
Purl = "pkg:deb/debian/openssl@3.0.11-1",
|
||||
Arch = "linux-amd64",
|
||||
Filename = "libssl.so.3"
|
||||
},
|
||||
Cve = new MicroWitnessCveRef
|
||||
{
|
||||
Id = "CVE-2024-0567",
|
||||
Advisory = "https://www.openssl.org/news/secadv/20240115.txt",
|
||||
PatchCommit = "a1b2c3d4e5f6"
|
||||
},
|
||||
Verdict = MicroWitnessVerdicts.Patched,
|
||||
Confidence = 0.95,
|
||||
Evidence =
|
||||
[
|
||||
new MicroWitnessFunctionEvidence
|
||||
{
|
||||
Function = "SSL_CTX_new",
|
||||
State = "patched",
|
||||
Score = 0.97,
|
||||
Method = "semantic_ksg",
|
||||
Hash = "sha256:1234abcd"
|
||||
},
|
||||
new MicroWitnessFunctionEvidence
|
||||
{
|
||||
Function = "SSL_read",
|
||||
State = "unchanged",
|
||||
Score = 1.0,
|
||||
Method = "byte_exact"
|
||||
},
|
||||
new MicroWitnessFunctionEvidence
|
||||
{
|
||||
Function = "SSL_write",
|
||||
State = "unchanged",
|
||||
Score = 1.0,
|
||||
Method = "byte_exact"
|
||||
}
|
||||
],
|
||||
DeltaSigDigest = "sha256:fullpredicatedigesthere1234567890abcdef1234567890abcdef12345678",
|
||||
SbomRef = new MicroWitnessSbomRef
|
||||
{
|
||||
SbomDigest = "sha256:sbomdigest1234567890abcdef1234567890abcdef1234567890abcdef1234",
|
||||
BomRef = "openssl-3.0.11",
|
||||
Purl = "pkg:deb/debian/openssl@3.0.11-1"
|
||||
},
|
||||
Tooling = new MicroWitnessTooling
|
||||
{
|
||||
BinaryIndexVersion = "2.1.0",
|
||||
Lifter = "b2r2",
|
||||
MatchAlgorithm = "semantic_ksg",
|
||||
NormalizationRecipe = "stella-norm-v3"
|
||||
},
|
||||
ComputedAt = new DateTimeOffset(2026, 1, 28, 12, 0, 0, TimeSpan.Zero)
|
||||
};
|
||||
}
|
||||
|
||||
private static BinaryMicroWitnessPredicate CreateMinimalPredicate()
|
||||
{
|
||||
return new BinaryMicroWitnessPredicate
|
||||
{
|
||||
SchemaVersion = "1.0.0",
|
||||
Binary = new MicroWitnessBinaryRef
|
||||
{
|
||||
Digest = "sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abc1"
|
||||
},
|
||||
Cve = new MicroWitnessCveRef
|
||||
{
|
||||
Id = "CVE-2024-0567"
|
||||
},
|
||||
Verdict = MicroWitnessVerdicts.Patched,
|
||||
Confidence = 0.95,
|
||||
Evidence =
|
||||
[
|
||||
new MicroWitnessFunctionEvidence
|
||||
{
|
||||
Function = "vulnerable_func",
|
||||
State = "patched",
|
||||
Score = 0.95,
|
||||
Method = "semantic_ksg"
|
||||
}
|
||||
],
|
||||
Tooling = new MicroWitnessTooling
|
||||
{
|
||||
BinaryIndexVersion = "2.1.0",
|
||||
Lifter = "b2r2",
|
||||
MatchAlgorithm = "semantic_ksg"
|
||||
},
|
||||
ComputedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Attestor.Watchlist.Events;
|
||||
using StellaOps.Attestor.Watchlist.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Watchlist.Tests.Events;
|
||||
|
||||
public sealed class IdentityAlertEventTests
|
||||
{
|
||||
[Fact]
|
||||
public void ToCanonicalJson_ProducesDeterministicOutput()
|
||||
{
|
||||
var evt = CreateTestEvent();
|
||||
|
||||
var json1 = evt.ToCanonicalJson();
|
||||
var json2 = evt.ToCanonicalJson();
|
||||
|
||||
json1.Should().Be(json2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToCanonicalJson_IsCamelCase()
|
||||
{
|
||||
var evt = CreateTestEvent();
|
||||
|
||||
var json = evt.ToCanonicalJson();
|
||||
|
||||
json.Should().Contain("eventId");
|
||||
json.Should().Contain("tenantId");
|
||||
json.Should().Contain("watchlistEntryId");
|
||||
json.Should().NotContain("EventId");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToCanonicalJson_ExcludesNullValues()
|
||||
{
|
||||
var evt = new IdentityAlertEvent
|
||||
{
|
||||
EventKind = IdentityAlertEventKinds.IdentityMatched,
|
||||
TenantId = "tenant-1",
|
||||
WatchlistEntryId = Guid.NewGuid(),
|
||||
WatchlistEntryName = "Test Entry",
|
||||
MatchedIdentity = new IdentityAlertMatchedIdentity
|
||||
{
|
||||
Issuer = "https://example.com"
|
||||
// SAN and KeyId are null
|
||||
},
|
||||
RekorEntry = new IdentityAlertRekorEntry
|
||||
{
|
||||
Uuid = "rekor-uuid",
|
||||
LogIndex = 12345,
|
||||
ArtifactSha256 = "sha256:abc",
|
||||
IntegratedTimeUtc = DateTimeOffset.UtcNow
|
||||
},
|
||||
Severity = IdentityAlertSeverity.Warning
|
||||
};
|
||||
|
||||
var json = evt.ToCanonicalJson();
|
||||
|
||||
// Null values should not appear
|
||||
json.Should().NotContain("subjectAlternativeName\":null");
|
||||
json.Should().NotContain("keyId\":null");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToCanonicalJson_IsValidJson()
|
||||
{
|
||||
var evt = CreateTestEvent();
|
||||
|
||||
var json = evt.ToCanonicalJson();
|
||||
|
||||
var action = () => JsonDocument.Parse(json);
|
||||
action.Should().NotThrow();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToCanonicalJson_HasSortedKeys()
|
||||
{
|
||||
var evt = CreateTestEvent();
|
||||
|
||||
var json = evt.ToCanonicalJson();
|
||||
|
||||
// Parse and extract keys in order
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var keys = doc.RootElement.EnumerateObject().Select(p => p.Name).ToList();
|
||||
|
||||
// Keys should be sorted lexicographically
|
||||
var sortedKeys = keys.OrderBy(k => k, StringComparer.Ordinal).ToList();
|
||||
keys.Should().Equal(sortedKeys, "Top-level keys should be sorted lexicographically");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToCanonicalJson_NestedObjectsHaveSortedKeys()
|
||||
{
|
||||
var evt = CreateTestEvent();
|
||||
|
||||
var json = evt.ToCanonicalJson();
|
||||
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
|
||||
// Check matchedIdentity keys are sorted
|
||||
if (doc.RootElement.TryGetProperty("matchedIdentity", out var matchedIdentity))
|
||||
{
|
||||
var keys = matchedIdentity.EnumerateObject().Select(p => p.Name).ToList();
|
||||
var sortedKeys = keys.OrderBy(k => k, StringComparer.Ordinal).ToList();
|
||||
keys.Should().Equal(sortedKeys, "matchedIdentity keys should be sorted");
|
||||
}
|
||||
|
||||
// Check rekorEntry keys are sorted
|
||||
if (doc.RootElement.TryGetProperty("rekorEntry", out var rekorEntry))
|
||||
{
|
||||
var keys = rekorEntry.EnumerateObject().Select(p => p.Name).ToList();
|
||||
var sortedKeys = keys.OrderBy(k => k, StringComparer.Ordinal).ToList();
|
||||
keys.Should().Equal(sortedKeys, "rekorEntry keys should be sorted");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToCanonicalJson_HasNoWhitespace()
|
||||
{
|
||||
var evt = CreateTestEvent();
|
||||
|
||||
var json = evt.ToCanonicalJson();
|
||||
|
||||
// Should not contain newlines or indentation
|
||||
json.Should().NotContain("\n");
|
||||
json.Should().NotContain("\r");
|
||||
json.Should().NotContain(" "); // No double spaces (indentation)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromMatch_CreatesEventWithCorrectFields()
|
||||
{
|
||||
var watchlistEntry = new WatchedIdentity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = "tenant-1",
|
||||
DisplayName = "GitHub Actions Watcher",
|
||||
Issuer = "https://token.actions.githubusercontent.com",
|
||||
Severity = IdentityAlertSeverity.Critical,
|
||||
CreatedBy = "admin",
|
||||
UpdatedBy = "admin"
|
||||
};
|
||||
|
||||
var matchResult = new IdentityMatchResult
|
||||
{
|
||||
WatchlistEntry = watchlistEntry,
|
||||
Fields = MatchedFields.Issuer,
|
||||
MatchedValues = new MatchedIdentityValues
|
||||
{
|
||||
Issuer = "https://token.actions.githubusercontent.com",
|
||||
SubjectAlternativeName = "repo:org/repo:ref:refs/heads/main"
|
||||
},
|
||||
MatchScore = 150
|
||||
};
|
||||
|
||||
var evt = IdentityAlertEvent.FromMatch(
|
||||
matchResult,
|
||||
rekorUuid: "rekor-uuid-123",
|
||||
logIndex: 99999,
|
||||
artifactSha256: "sha256:abcdef123456",
|
||||
integratedTimeUtc: DateTimeOffset.Parse("2026-01-29T10:00:00Z"),
|
||||
suppressedCount: 5);
|
||||
|
||||
evt.EventKind.Should().Be(IdentityAlertEventKinds.IdentityMatched);
|
||||
evt.TenantId.Should().Be("tenant-1");
|
||||
evt.WatchlistEntryId.Should().Be(watchlistEntry.Id);
|
||||
evt.WatchlistEntryName.Should().Be("GitHub Actions Watcher");
|
||||
evt.Severity.Should().Be(IdentityAlertSeverity.Critical);
|
||||
evt.SuppressedCount.Should().Be(5);
|
||||
|
||||
evt.MatchedIdentity.Issuer.Should().Be("https://token.actions.githubusercontent.com");
|
||||
evt.MatchedIdentity.SubjectAlternativeName.Should().Be("repo:org/repo:ref:refs/heads/main");
|
||||
|
||||
evt.RekorEntry.Uuid.Should().Be("rekor-uuid-123");
|
||||
evt.RekorEntry.LogIndex.Should().Be(99999);
|
||||
evt.RekorEntry.ArtifactSha256.Should().Be("sha256:abcdef123456");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EventKinds_HasCorrectConstants()
|
||||
{
|
||||
IdentityAlertEventKinds.IdentityMatched.Should().Be("attestor.identity.matched");
|
||||
IdentityAlertEventKinds.IdentityUnexpected.Should().Be("attestor.identity.unexpected");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchedIdentityValues_ComputeHash_IsDeterministic()
|
||||
{
|
||||
var values1 = new MatchedIdentityValues
|
||||
{
|
||||
Issuer = "https://example.com",
|
||||
SubjectAlternativeName = "user@example.com",
|
||||
KeyId = "key-123"
|
||||
};
|
||||
|
||||
var values2 = new MatchedIdentityValues
|
||||
{
|
||||
Issuer = "https://example.com",
|
||||
SubjectAlternativeName = "user@example.com",
|
||||
KeyId = "key-123"
|
||||
};
|
||||
|
||||
values1.ComputeHash().Should().Be(values2.ComputeHash());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchedIdentityValues_ComputeHash_DiffersForDifferentValues()
|
||||
{
|
||||
var values1 = new MatchedIdentityValues
|
||||
{
|
||||
Issuer = "https://example.com"
|
||||
};
|
||||
|
||||
var values2 = new MatchedIdentityValues
|
||||
{
|
||||
Issuer = "https://different.com"
|
||||
};
|
||||
|
||||
values1.ComputeHash().Should().NotBe(values2.ComputeHash());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchedIdentityValues_ComputeHash_HandlesNulls()
|
||||
{
|
||||
var values = new MatchedIdentityValues
|
||||
{
|
||||
Issuer = null,
|
||||
SubjectAlternativeName = null,
|
||||
KeyId = null
|
||||
};
|
||||
|
||||
var hash = values.ComputeHash();
|
||||
|
||||
hash.Should().NotBeNullOrEmpty();
|
||||
hash.Should().HaveLength(64); // SHA-256 hex
|
||||
}
|
||||
|
||||
private static IdentityAlertEvent CreateTestEvent()
|
||||
{
|
||||
return new IdentityAlertEvent
|
||||
{
|
||||
EventId = Guid.Parse("11111111-1111-1111-1111-111111111111"),
|
||||
EventKind = IdentityAlertEventKinds.IdentityMatched,
|
||||
TenantId = "tenant-1",
|
||||
WatchlistEntryId = Guid.Parse("22222222-2222-2222-2222-222222222222"),
|
||||
WatchlistEntryName = "Test Entry",
|
||||
MatchedIdentity = new IdentityAlertMatchedIdentity
|
||||
{
|
||||
Issuer = "https://example.com",
|
||||
SubjectAlternativeName = "user@example.com"
|
||||
},
|
||||
RekorEntry = new IdentityAlertRekorEntry
|
||||
{
|
||||
Uuid = "test-uuid",
|
||||
LogIndex = 12345,
|
||||
ArtifactSha256 = "sha256:test",
|
||||
IntegratedTimeUtc = DateTimeOffset.Parse("2026-01-29T10:00:00Z")
|
||||
},
|
||||
Severity = IdentityAlertSeverity.Warning,
|
||||
OccurredAtUtc = DateTimeOffset.Parse("2026-01-29T10:00:00Z")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using StellaOps.Attestor.Watchlist.Matching;
|
||||
using StellaOps.Attestor.Watchlist.Models;
|
||||
using StellaOps.Attestor.Watchlist.Storage;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Watchlist.Tests.Matching;
|
||||
|
||||
public sealed class IdentityMatcherTests
|
||||
{
|
||||
private readonly Mock<IWatchlistRepository> _repositoryMock;
|
||||
private readonly PatternCompiler _patternCompiler;
|
||||
private readonly Mock<ILogger<IdentityMatcher>> _loggerMock;
|
||||
private readonly IdentityMatcher _matcher;
|
||||
|
||||
public IdentityMatcherTests()
|
||||
{
|
||||
_repositoryMock = new Mock<IWatchlistRepository>();
|
||||
_patternCompiler = new PatternCompiler();
|
||||
_loggerMock = new Mock<ILogger<IdentityMatcher>>();
|
||||
_matcher = new IdentityMatcher(_repositoryMock.Object, _patternCompiler, _loggerMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MatchAsync_WithNoEntries_ReturnsEmptyList()
|
||||
{
|
||||
_repositoryMock.Setup(r => r.GetActiveForMatchingAsync("tenant-1", default))
|
||||
.ReturnsAsync([]);
|
||||
|
||||
var identity = new SignerIdentityInput
|
||||
{
|
||||
Issuer = "https://example.com"
|
||||
};
|
||||
|
||||
var matches = await _matcher.MatchAsync(identity, "tenant-1");
|
||||
|
||||
matches.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MatchAsync_WithMatchingEntry_ReturnsMatch()
|
||||
{
|
||||
var entry = CreateEntry(issuer: "https://example.com");
|
||||
|
||||
_repositoryMock.Setup(r => r.GetActiveForMatchingAsync("tenant-1", default))
|
||||
.ReturnsAsync([entry]);
|
||||
|
||||
var identity = new SignerIdentityInput
|
||||
{
|
||||
Issuer = "https://example.com"
|
||||
};
|
||||
|
||||
var matches = await _matcher.MatchAsync(identity, "tenant-1");
|
||||
|
||||
matches.Should().HaveCount(1);
|
||||
matches[0].WatchlistEntry.Id.Should().Be(entry.Id);
|
||||
matches[0].Fields.Should().HaveFlag(MatchedFields.Issuer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MatchAsync_WithNonMatchingEntry_ReturnsEmptyList()
|
||||
{
|
||||
var entry = CreateEntry(issuer: "https://different.com");
|
||||
|
||||
_repositoryMock.Setup(r => r.GetActiveForMatchingAsync("tenant-1", default))
|
||||
.ReturnsAsync([entry]);
|
||||
|
||||
var identity = new SignerIdentityInput
|
||||
{
|
||||
Issuer = "https://example.com"
|
||||
};
|
||||
|
||||
var matches = await _matcher.MatchAsync(identity, "tenant-1");
|
||||
|
||||
matches.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MatchAsync_WithDisabledEntry_ReturnsEmptyList()
|
||||
{
|
||||
var entry = CreateEntry(issuer: "https://example.com", enabled: false);
|
||||
|
||||
_repositoryMock.Setup(r => r.GetActiveForMatchingAsync("tenant-1", default))
|
||||
.ReturnsAsync([entry]);
|
||||
|
||||
var identity = new SignerIdentityInput
|
||||
{
|
||||
Issuer = "https://example.com"
|
||||
};
|
||||
|
||||
var matches = await _matcher.MatchAsync(identity, "tenant-1");
|
||||
|
||||
matches.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MatchAsync_WithMultipleMatches_ReturnsAll()
|
||||
{
|
||||
var entry1 = CreateEntry(issuer: "https://example.com", displayName: "Entry 1");
|
||||
var entry2 = CreateEntry(san: "user@example.com", displayName: "Entry 2");
|
||||
|
||||
_repositoryMock.Setup(r => r.GetActiveForMatchingAsync("tenant-1", default))
|
||||
.ReturnsAsync([entry1, entry2]);
|
||||
|
||||
var identity = new SignerIdentityInput
|
||||
{
|
||||
Issuer = "https://example.com",
|
||||
SubjectAlternativeName = "user@example.com"
|
||||
};
|
||||
|
||||
var matches = await _matcher.MatchAsync(identity, "tenant-1");
|
||||
|
||||
matches.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TestMatch_WithGlobPattern_MatchesWildcard()
|
||||
{
|
||||
var entry = CreateEntry(san: "*@example.com", matchMode: WatchlistMatchMode.Glob);
|
||||
|
||||
var identity = new SignerIdentityInput
|
||||
{
|
||||
SubjectAlternativeName = "alice@example.com"
|
||||
};
|
||||
|
||||
var match = _matcher.TestMatch(identity, entry);
|
||||
|
||||
match.Should().NotBeNull();
|
||||
match!.Fields.Should().HaveFlag(MatchedFields.SubjectAlternativeName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TestMatch_WithPrefixPattern_MatchesPrefix()
|
||||
{
|
||||
var entry = CreateEntry(issuer: "https://accounts.google.com/", matchMode: WatchlistMatchMode.Prefix);
|
||||
|
||||
var identity = new SignerIdentityInput
|
||||
{
|
||||
Issuer = "https://accounts.google.com/oauth2/v1"
|
||||
};
|
||||
|
||||
var match = _matcher.TestMatch(identity, entry);
|
||||
|
||||
match.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TestMatch_WithMultipleFields_RequiresAllToMatch()
|
||||
{
|
||||
var entry = CreateEntry(
|
||||
issuer: "https://example.com",
|
||||
san: "user@example.com");
|
||||
|
||||
// Only issuer matches
|
||||
var identity1 = new SignerIdentityInput
|
||||
{
|
||||
Issuer = "https://example.com",
|
||||
SubjectAlternativeName = "other@different.com"
|
||||
};
|
||||
|
||||
var match1 = _matcher.TestMatch(identity1, entry);
|
||||
match1.Should().BeNull();
|
||||
|
||||
// Both match
|
||||
var identity2 = new SignerIdentityInput
|
||||
{
|
||||
Issuer = "https://example.com",
|
||||
SubjectAlternativeName = "user@example.com"
|
||||
};
|
||||
|
||||
var match2 = _matcher.TestMatch(identity2, entry);
|
||||
match2.Should().NotBeNull();
|
||||
match2!.Fields.Should().Be(MatchedFields.Issuer | MatchedFields.SubjectAlternativeName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TestMatch_CalculatesMatchScore()
|
||||
{
|
||||
var exactEntry = CreateEntry(issuer: "https://example.com", matchMode: WatchlistMatchMode.Exact);
|
||||
var globEntry = CreateEntry(issuer: "https://*", matchMode: WatchlistMatchMode.Glob);
|
||||
|
||||
var identity = new SignerIdentityInput
|
||||
{
|
||||
Issuer = "https://example.com"
|
||||
};
|
||||
|
||||
var exactMatch = _matcher.TestMatch(identity, exactEntry);
|
||||
var globMatch = _matcher.TestMatch(identity, globEntry);
|
||||
|
||||
exactMatch!.MatchScore.Should().BeGreaterThan(globMatch!.MatchScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TestMatch_SetsMatchedValues()
|
||||
{
|
||||
var entry = CreateEntry(issuer: "https://example.com");
|
||||
|
||||
var identity = new SignerIdentityInput
|
||||
{
|
||||
Issuer = "https://example.com",
|
||||
SubjectAlternativeName = "user@example.com",
|
||||
KeyId = "key-123"
|
||||
};
|
||||
|
||||
var match = _matcher.TestMatch(identity, entry);
|
||||
|
||||
match!.MatchedValues.Issuer.Should().Be("https://example.com");
|
||||
match.MatchedValues.SubjectAlternativeName.Should().Be("user@example.com");
|
||||
match.MatchedValues.KeyId.Should().Be("key-123");
|
||||
}
|
||||
|
||||
private static WatchedIdentity CreateEntry(
|
||||
string? issuer = null,
|
||||
string? san = null,
|
||||
string? keyId = null,
|
||||
WatchlistMatchMode matchMode = WatchlistMatchMode.Exact,
|
||||
bool enabled = true,
|
||||
string displayName = "Test Entry")
|
||||
{
|
||||
return new WatchedIdentity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = "tenant-1",
|
||||
DisplayName = displayName,
|
||||
Issuer = issuer,
|
||||
SubjectAlternativeName = san,
|
||||
KeyId = keyId,
|
||||
MatchMode = matchMode,
|
||||
Enabled = enabled,
|
||||
Severity = IdentityAlertSeverity.Warning,
|
||||
CreatedBy = "test",
|
||||
UpdatedBy = "test"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Attestor.Watchlist.Matching;
|
||||
using StellaOps.Attestor.Watchlist.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Watchlist.Tests.Matching;
|
||||
|
||||
public sealed class PatternCompilerTests
|
||||
{
|
||||
private readonly PatternCompiler _compiler = new();
|
||||
|
||||
#region Exact Mode Tests
|
||||
|
||||
[Fact]
|
||||
public void Exact_MatchesSameString()
|
||||
{
|
||||
var pattern = _compiler.Compile("hello", WatchlistMatchMode.Exact);
|
||||
pattern.IsMatch("hello").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Exact_IsCaseInsensitive()
|
||||
{
|
||||
var pattern = _compiler.Compile("Hello", WatchlistMatchMode.Exact);
|
||||
pattern.IsMatch("HELLO").Should().BeTrue();
|
||||
pattern.IsMatch("hello").Should().BeTrue();
|
||||
pattern.IsMatch("HeLLo").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Exact_DoesNotMatchDifferentString()
|
||||
{
|
||||
var pattern = _compiler.Compile("hello", WatchlistMatchMode.Exact);
|
||||
pattern.IsMatch("world").Should().BeFalse();
|
||||
pattern.IsMatch("hello world").Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Exact_HandlesNull()
|
||||
{
|
||||
var pattern = _compiler.Compile("hello", WatchlistMatchMode.Exact);
|
||||
pattern.IsMatch(null).Should().BeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Prefix Mode Tests
|
||||
|
||||
[Fact]
|
||||
public void Prefix_MatchesStringStartingWithPattern()
|
||||
{
|
||||
var pattern = _compiler.Compile("https://", WatchlistMatchMode.Prefix);
|
||||
pattern.IsMatch("https://example.com").Should().BeTrue();
|
||||
pattern.IsMatch("https://other.org/path").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Prefix_IsCaseInsensitive()
|
||||
{
|
||||
var pattern = _compiler.Compile("HTTPS://", WatchlistMatchMode.Prefix);
|
||||
pattern.IsMatch("https://example.com").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Prefix_DoesNotMatchNonPrefix()
|
||||
{
|
||||
var pattern = _compiler.Compile("https://", WatchlistMatchMode.Prefix);
|
||||
pattern.IsMatch("http://example.com").Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Prefix_MatchesExactSameString()
|
||||
{
|
||||
var pattern = _compiler.Compile("https://example.com", WatchlistMatchMode.Prefix);
|
||||
pattern.IsMatch("https://example.com").Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Glob Mode Tests
|
||||
|
||||
[Fact]
|
||||
public void Glob_StarMatchesAnyCharacters()
|
||||
{
|
||||
var pattern = _compiler.Compile("*@example.com", WatchlistMatchMode.Glob);
|
||||
pattern.IsMatch("user@example.com").Should().BeTrue();
|
||||
pattern.IsMatch("alice.bob@example.com").Should().BeTrue();
|
||||
pattern.IsMatch("@example.com").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Glob_QuestionMarkMatchesSingleCharacter()
|
||||
{
|
||||
var pattern = _compiler.Compile("user?@example.com", WatchlistMatchMode.Glob);
|
||||
pattern.IsMatch("user1@example.com").Should().BeTrue();
|
||||
pattern.IsMatch("userA@example.com").Should().BeTrue();
|
||||
pattern.IsMatch("user@example.com").Should().BeFalse();
|
||||
pattern.IsMatch("user12@example.com").Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Glob_IsCaseInsensitive()
|
||||
{
|
||||
var pattern = _compiler.Compile("*@EXAMPLE.COM", WatchlistMatchMode.Glob);
|
||||
pattern.IsMatch("user@example.com").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Glob_EscapesSpecialRegexCharacters()
|
||||
{
|
||||
var pattern = _compiler.Compile("test.example.com", WatchlistMatchMode.Glob);
|
||||
pattern.IsMatch("test.example.com").Should().BeTrue();
|
||||
pattern.IsMatch("testXexampleXcom").Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Glob_MatchesGitHubActionsPattern()
|
||||
{
|
||||
var pattern = _compiler.Compile("repo:*/main:*", WatchlistMatchMode.Glob);
|
||||
pattern.IsMatch("repo:org/repo/main:workflow").Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Regex Mode Tests
|
||||
|
||||
[Fact]
|
||||
public void Regex_MatchesRegularExpression()
|
||||
{
|
||||
var pattern = _compiler.Compile(@"user\d+@example\.com", WatchlistMatchMode.Regex);
|
||||
pattern.IsMatch("user123@example.com").Should().BeTrue();
|
||||
pattern.IsMatch("user1@example.com").Should().BeTrue();
|
||||
pattern.IsMatch("user@example.com").Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Regex_IsCaseInsensitive()
|
||||
{
|
||||
var pattern = _compiler.Compile(@"USER\d+", WatchlistMatchMode.Regex);
|
||||
pattern.IsMatch("user123").Should().BeTrue();
|
||||
pattern.IsMatch("USER123").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Regex_HandlesTimeout()
|
||||
{
|
||||
// A potentially slow pattern
|
||||
var compiler = new PatternCompiler(regexTimeout: TimeSpan.FromMilliseconds(10));
|
||||
var pattern = compiler.Compile(@".*", WatchlistMatchMode.Regex);
|
||||
|
||||
// Should complete within timeout
|
||||
pattern.IsMatch("test").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Regex_ReturnsFalseOnNull()
|
||||
{
|
||||
var pattern = _compiler.Compile(@".*", WatchlistMatchMode.Regex);
|
||||
pattern.IsMatch(null).Should().BeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Cache Tests
|
||||
|
||||
[Fact]
|
||||
public void Cache_ReturnsSameInstanceForSamePattern()
|
||||
{
|
||||
var pattern1 = _compiler.Compile("test", WatchlistMatchMode.Exact);
|
||||
var pattern2 = _compiler.Compile("test", WatchlistMatchMode.Exact);
|
||||
|
||||
pattern1.Should().BeSameAs(pattern2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cache_ReturnsDifferentInstanceForDifferentMode()
|
||||
{
|
||||
var pattern1 = _compiler.Compile("test", WatchlistMatchMode.Exact);
|
||||
var pattern2 = _compiler.Compile("test", WatchlistMatchMode.Prefix);
|
||||
|
||||
pattern1.Should().NotBeSameAs(pattern2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClearCache_RemovesAllCachedPatterns()
|
||||
{
|
||||
_compiler.Compile("test1", WatchlistMatchMode.Exact);
|
||||
_compiler.Compile("test2", WatchlistMatchMode.Exact);
|
||||
|
||||
_compiler.CacheCount.Should().Be(2);
|
||||
|
||||
_compiler.ClearCache();
|
||||
|
||||
_compiler.CacheCount.Should().Be(0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Validation Tests
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidExactPattern_ReturnsSuccess()
|
||||
{
|
||||
var result = _compiler.Validate("any string", WatchlistMatchMode.Exact);
|
||||
result.IsValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidGlobPattern_ReturnsSuccess()
|
||||
{
|
||||
var result = _compiler.Validate("*@example.com", WatchlistMatchMode.Glob);
|
||||
result.IsValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_InvalidRegexPattern_ReturnsFailure()
|
||||
{
|
||||
var result = _compiler.Validate("[invalid(regex", WatchlistMatchMode.Regex);
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.ErrorMessage.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_EmptyPattern_ReturnsSuccess()
|
||||
{
|
||||
var result = _compiler.Validate("", WatchlistMatchMode.Exact);
|
||||
result.IsValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Performance Tests
|
||||
|
||||
[Fact]
|
||||
public void Performance_Match100EntriesUnder1Ms()
|
||||
{
|
||||
// Pre-compile 100 patterns of various modes
|
||||
var patterns = new List<ICompiledPattern>();
|
||||
for (int i = 0; i < 25; i++)
|
||||
{
|
||||
patterns.Add(_compiler.Compile($"issuer-{i}", WatchlistMatchMode.Exact));
|
||||
patterns.Add(_compiler.Compile($"prefix-{i}*", WatchlistMatchMode.Prefix));
|
||||
patterns.Add(_compiler.Compile($"*glob-{i}*", WatchlistMatchMode.Glob));
|
||||
patterns.Add(_compiler.Compile($"regex-{i}.*", WatchlistMatchMode.Regex));
|
||||
}
|
||||
|
||||
var testInput = "issuer-12";
|
||||
|
||||
// Warm up
|
||||
foreach (var p in patterns)
|
||||
{
|
||||
p.IsMatch(testInput);
|
||||
}
|
||||
|
||||
// Measure
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
foreach (var p in patterns)
|
||||
{
|
||||
p.IsMatch(testInput);
|
||||
}
|
||||
sw.Stop();
|
||||
|
||||
// 100 matches should complete in under 1ms
|
||||
sw.ElapsedMilliseconds.Should().BeLessThan(1,
|
||||
"Matching 100 pre-compiled patterns against an input should take less than 1ms");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Performance_CachedPatternsAreFast()
|
||||
{
|
||||
// First compilation (creates cache entry)
|
||||
var pattern = _compiler.Compile("*@example.com", WatchlistMatchMode.Glob);
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
for (int i = 0; i < 1000; i++)
|
||||
{
|
||||
// Should return cached instance
|
||||
_compiler.Compile("*@example.com", WatchlistMatchMode.Glob);
|
||||
}
|
||||
sw.Stop();
|
||||
|
||||
// 1000 cache hits should be very fast (< 10ms)
|
||||
sw.ElapsedMilliseconds.Should().BeLessThan(10,
|
||||
"1000 cache lookups should complete in under 10ms");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Unicode Edge Case Tests
|
||||
|
||||
[Fact]
|
||||
public void Exact_MatchesUnicodeStrings()
|
||||
{
|
||||
var pattern = _compiler.Compile("用户@例子.com", WatchlistMatchMode.Exact);
|
||||
pattern.IsMatch("用户@例子.com").Should().BeTrue();
|
||||
pattern.IsMatch("用户@例子.org").Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Glob_MatchesUnicodeWithWildcards()
|
||||
{
|
||||
var pattern = _compiler.Compile("*@例子.com", WatchlistMatchMode.Glob);
|
||||
pattern.IsMatch("用户@例子.com").Should().BeTrue();
|
||||
pattern.IsMatch("管理员@例子.com").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Exact_MatchesCyrillicCharacters()
|
||||
{
|
||||
var pattern = _compiler.Compile("пользователь@пример.com", WatchlistMatchMode.Exact);
|
||||
pattern.IsMatch("пользователь@пример.com").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Prefix_MatchesGreekCharacters()
|
||||
{
|
||||
var pattern = _compiler.Compile("χρήστης@", WatchlistMatchMode.Prefix);
|
||||
pattern.IsMatch("χρήστης@example.com").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Glob_MatchesEmojiCharacters()
|
||||
{
|
||||
var pattern = _compiler.Compile("*@*.com", WatchlistMatchMode.Glob);
|
||||
pattern.IsMatch("user🔐@example.com").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Regex_MatchesUnicodeClasses()
|
||||
{
|
||||
// Match any Unicode letter followed by @example.com
|
||||
var pattern = _compiler.Compile(@"^\p{L}+@example\.com$", WatchlistMatchMode.Regex);
|
||||
pattern.IsMatch("user@example.com").Should().BeTrue();
|
||||
pattern.IsMatch("用户@example.com").Should().BeTrue();
|
||||
pattern.IsMatch("χρήστης@example.com").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Exact_MatchesMixedScriptStrings()
|
||||
{
|
||||
var pattern = _compiler.Compile("user用户@example例子.com", WatchlistMatchMode.Exact);
|
||||
pattern.IsMatch("user用户@example例子.com").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Glob_HandlesUnicodeNormalization()
|
||||
{
|
||||
// é can be represented as single char or combining chars
|
||||
var pattern = _compiler.Compile("café*", WatchlistMatchMode.Glob);
|
||||
pattern.IsMatch("café@example.com").Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Attestor.Watchlist.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Watchlist.Tests.Models;
|
||||
|
||||
public sealed class WatchedIdentityTests
|
||||
{
|
||||
[Fact]
|
||||
public void Validate_WithNoIdentityFields_ReturnsError()
|
||||
{
|
||||
var entry = new WatchedIdentity
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
DisplayName = "Test Entry",
|
||||
CreatedBy = "user",
|
||||
UpdatedBy = "user"
|
||||
};
|
||||
|
||||
var result = entry.Validate();
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("At least one identity field"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithIssuerOnly_ReturnsSuccess()
|
||||
{
|
||||
var entry = new WatchedIdentity
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
DisplayName = "Test Entry",
|
||||
Issuer = "https://token.actions.githubusercontent.com",
|
||||
CreatedBy = "user",
|
||||
UpdatedBy = "user"
|
||||
};
|
||||
|
||||
var result = entry.Validate();
|
||||
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.Errors.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithSanOnly_ReturnsSuccess()
|
||||
{
|
||||
var entry = new WatchedIdentity
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
DisplayName = "Test Entry",
|
||||
SubjectAlternativeName = "user@example.com",
|
||||
CreatedBy = "user",
|
||||
UpdatedBy = "user"
|
||||
};
|
||||
|
||||
var result = entry.Validate();
|
||||
|
||||
result.IsValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithKeyIdOnly_ReturnsSuccess()
|
||||
{
|
||||
var entry = new WatchedIdentity
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
DisplayName = "Test Entry",
|
||||
KeyId = "key-123",
|
||||
CreatedBy = "user",
|
||||
UpdatedBy = "user"
|
||||
};
|
||||
|
||||
var result = entry.Validate();
|
||||
|
||||
result.IsValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithMissingDisplayName_ReturnsError()
|
||||
{
|
||||
var entry = new WatchedIdentity
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
DisplayName = "",
|
||||
Issuer = "https://example.com",
|
||||
CreatedBy = "user",
|
||||
UpdatedBy = "user"
|
||||
};
|
||||
|
||||
var result = entry.Validate();
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("DisplayName"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithMissingTenantId_ReturnsError()
|
||||
{
|
||||
var entry = new WatchedIdentity
|
||||
{
|
||||
TenantId = "",
|
||||
DisplayName = "Test",
|
||||
Issuer = "https://example.com",
|
||||
CreatedBy = "user",
|
||||
UpdatedBy = "user"
|
||||
};
|
||||
|
||||
var result = entry.Validate();
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("TenantId"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithInvalidRegex_ReturnsError()
|
||||
{
|
||||
var entry = new WatchedIdentity
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
DisplayName = "Test Entry",
|
||||
Issuer = "[invalid(regex",
|
||||
MatchMode = WatchlistMatchMode.Regex,
|
||||
CreatedBy = "user",
|
||||
UpdatedBy = "user"
|
||||
};
|
||||
|
||||
var result = entry.Validate();
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("regex") || e.Contains("Regex") || e.Contains("Invalid"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithTooLongGlobPattern_ReturnsError()
|
||||
{
|
||||
var entry = new WatchedIdentity
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
DisplayName = "Test Entry",
|
||||
Issuer = new string('a', 300),
|
||||
MatchMode = WatchlistMatchMode.Glob,
|
||||
CreatedBy = "user",
|
||||
UpdatedBy = "user"
|
||||
};
|
||||
|
||||
var result = entry.Validate();
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("256"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithValidGlobPattern_ReturnsSuccess()
|
||||
{
|
||||
var entry = new WatchedIdentity
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
DisplayName = "Test Entry",
|
||||
SubjectAlternativeName = "*@example.com",
|
||||
MatchMode = WatchlistMatchMode.Glob,
|
||||
CreatedBy = "user",
|
||||
UpdatedBy = "user"
|
||||
};
|
||||
|
||||
var result = entry.Validate();
|
||||
|
||||
result.IsValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithUpdated_SetsUpdatedAtAndUpdatedBy()
|
||||
{
|
||||
var original = new WatchedIdentity
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
DisplayName = "Test",
|
||||
Issuer = "https://example.com",
|
||||
CreatedBy = "original-user",
|
||||
UpdatedBy = "original-user",
|
||||
UpdatedAt = DateTimeOffset.UtcNow.AddDays(-1)
|
||||
};
|
||||
|
||||
var updated = original.WithUpdated("new-user");
|
||||
|
||||
updated.UpdatedBy.Should().Be("new-user");
|
||||
updated.UpdatedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(1));
|
||||
updated.CreatedBy.Should().Be("original-user"); // Unchanged
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(WatchlistScope.Tenant)]
|
||||
[InlineData(WatchlistScope.Global)]
|
||||
[InlineData(WatchlistScope.System)]
|
||||
public void Scope_DefaultsToTenant_CanBeSet(WatchlistScope scope)
|
||||
{
|
||||
var entry = new WatchedIdentity
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
DisplayName = "Test",
|
||||
Issuer = "https://example.com",
|
||||
Scope = scope,
|
||||
CreatedBy = "user",
|
||||
UpdatedBy = "user"
|
||||
};
|
||||
|
||||
entry.Scope.Should().Be(scope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SuppressDuplicatesMinutes_DefaultsTo60()
|
||||
{
|
||||
var entry = new WatchedIdentity
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
DisplayName = "Test",
|
||||
Issuer = "https://example.com",
|
||||
CreatedBy = "user",
|
||||
UpdatedBy = "user"
|
||||
};
|
||||
|
||||
entry.SuppressDuplicatesMinutes.Should().Be(60);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IdentityMonitorServiceIntegrationTests.cs
|
||||
// Sprint: SPRINT_0129_001_ATTESTOR_identity_watchlist_alerting
|
||||
// Task: WATCH-005
|
||||
// Description: Integration tests verifying the full flow: entry → match → alert event.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Attestor.Watchlist.Events;
|
||||
using StellaOps.Attestor.Watchlist.Matching;
|
||||
using StellaOps.Attestor.Watchlist.Models;
|
||||
using StellaOps.Attestor.Watchlist.Monitoring;
|
||||
using StellaOps.Attestor.Watchlist.Storage;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Watchlist.Tests.Monitoring;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the identity monitoring service.
|
||||
/// Verifies the complete flow: AttestorEntry → IIdentityMatcher → IIdentityAlertPublisher.
|
||||
/// </summary>
|
||||
public sealed class IdentityMonitorServiceIntegrationTests
|
||||
{
|
||||
private readonly InMemoryWatchlistRepository _watchlistRepository;
|
||||
private readonly InMemoryAlertDedupRepository _dedupRepository;
|
||||
private readonly InMemoryIdentityAlertPublisher _alertPublisher;
|
||||
private readonly IdentityMatcher _matcher;
|
||||
private readonly IdentityMonitorService _service;
|
||||
|
||||
public IdentityMonitorServiceIntegrationTests()
|
||||
{
|
||||
_watchlistRepository = new InMemoryWatchlistRepository();
|
||||
_dedupRepository = new InMemoryAlertDedupRepository();
|
||||
_alertPublisher = new InMemoryIdentityAlertPublisher();
|
||||
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var patternCompiler = new PatternCompiler();
|
||||
|
||||
_matcher = new IdentityMatcher(
|
||||
_watchlistRepository,
|
||||
patternCompiler,
|
||||
cache,
|
||||
NullLogger<IdentityMatcher>.Instance);
|
||||
|
||||
_service = new IdentityMonitorService(
|
||||
_matcher,
|
||||
_dedupRepository,
|
||||
_alertPublisher,
|
||||
NullLogger<IdentityMonitorService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessEntry_WithMatchingIdentity_EmitsAlert()
|
||||
{
|
||||
// Arrange: Create a watchlist entry
|
||||
var watchlistEntry = new WatchedIdentity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = "tenant-1",
|
||||
DisplayName = "GitHub Actions Watcher",
|
||||
Issuer = "https://token.actions.githubusercontent.com",
|
||||
MatchMode = WatchlistMatchMode.Exact,
|
||||
Scope = WatchlistScope.Tenant,
|
||||
Severity = IdentityAlertSeverity.Critical,
|
||||
Enabled = true,
|
||||
SuppressDuplicatesMinutes = 60,
|
||||
CreatedBy = "test",
|
||||
UpdatedBy = "test"
|
||||
};
|
||||
await _watchlistRepository.UpsertAsync(watchlistEntry, CancellationToken.None);
|
||||
|
||||
// Arrange: Create an attestor entry with matching identity
|
||||
var entryInfo = new AttestorEntryInfo
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
RekorUuid = "test-rekor-uuid-123",
|
||||
LogIndex = 99999,
|
||||
ArtifactSha256 = "sha256:abcdef123456",
|
||||
IntegratedTimeUtc = DateTimeOffset.UtcNow,
|
||||
Identity = new SignerIdentityInput
|
||||
{
|
||||
Issuer = "https://token.actions.githubusercontent.com",
|
||||
SubjectAlternativeName = "repo:org/repo:ref:refs/heads/main"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
await _service.ProcessEntryAsync(entryInfo, CancellationToken.None);
|
||||
|
||||
// Assert: Alert should be emitted
|
||||
_alertPublisher.PublishedEvents.Should().HaveCount(1);
|
||||
|
||||
var alert = _alertPublisher.PublishedEvents[0];
|
||||
alert.EventKind.Should().Be(IdentityAlertEventKinds.IdentityMatched);
|
||||
alert.TenantId.Should().Be("tenant-1");
|
||||
alert.WatchlistEntryId.Should().Be(watchlistEntry.Id);
|
||||
alert.WatchlistEntryName.Should().Be("GitHub Actions Watcher");
|
||||
alert.Severity.Should().Be(IdentityAlertSeverity.Critical);
|
||||
alert.MatchedIdentity.Issuer.Should().Be("https://token.actions.githubusercontent.com");
|
||||
alert.RekorEntry.Uuid.Should().Be("test-rekor-uuid-123");
|
||||
alert.RekorEntry.LogIndex.Should().Be(99999);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessEntry_WithNonMatchingIdentity_DoesNotEmitAlert()
|
||||
{
|
||||
// Arrange: Create a watchlist entry for GitHub
|
||||
var watchlistEntry = new WatchedIdentity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = "tenant-1",
|
||||
DisplayName = "GitHub Actions Watcher",
|
||||
Issuer = "https://token.actions.githubusercontent.com",
|
||||
MatchMode = WatchlistMatchMode.Exact,
|
||||
Scope = WatchlistScope.Tenant,
|
||||
Severity = IdentityAlertSeverity.Critical,
|
||||
Enabled = true,
|
||||
SuppressDuplicatesMinutes = 60,
|
||||
CreatedBy = "test",
|
||||
UpdatedBy = "test"
|
||||
};
|
||||
await _watchlistRepository.UpsertAsync(watchlistEntry, CancellationToken.None);
|
||||
|
||||
// Arrange: Create an attestor entry with DIFFERENT issuer
|
||||
var entryInfo = new AttestorEntryInfo
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
RekorUuid = "test-rekor-uuid-456",
|
||||
LogIndex = 99998,
|
||||
ArtifactSha256 = "sha256:different123",
|
||||
IntegratedTimeUtc = DateTimeOffset.UtcNow,
|
||||
Identity = new SignerIdentityInput
|
||||
{
|
||||
Issuer = "https://accounts.google.com", // Different issuer
|
||||
SubjectAlternativeName = "user@example.com"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
await _service.ProcessEntryAsync(entryInfo, CancellationToken.None);
|
||||
|
||||
// Assert: No alert should be emitted
|
||||
_alertPublisher.PublishedEvents.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessEntry_WithDuplicateIdentity_SuppressesDuplicateAlerts()
|
||||
{
|
||||
// Arrange: Create a watchlist entry with short dedup window
|
||||
var watchlistEntry = new WatchedIdentity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = "tenant-1",
|
||||
DisplayName = "GitHub Actions Watcher",
|
||||
Issuer = "https://token.actions.githubusercontent.com",
|
||||
MatchMode = WatchlistMatchMode.Exact,
|
||||
Scope = WatchlistScope.Tenant,
|
||||
Severity = IdentityAlertSeverity.Warning,
|
||||
Enabled = true,
|
||||
SuppressDuplicatesMinutes = 60, // 60 minute dedup window
|
||||
CreatedBy = "test",
|
||||
UpdatedBy = "test"
|
||||
};
|
||||
await _watchlistRepository.UpsertAsync(watchlistEntry, CancellationToken.None);
|
||||
|
||||
// Arrange: Create an attestor entry
|
||||
var entryInfo = new AttestorEntryInfo
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
RekorUuid = "test-rekor-uuid-789",
|
||||
LogIndex = 99997,
|
||||
ArtifactSha256 = "sha256:first123",
|
||||
IntegratedTimeUtc = DateTimeOffset.UtcNow,
|
||||
Identity = new SignerIdentityInput
|
||||
{
|
||||
Issuer = "https://token.actions.githubusercontent.com"
|
||||
}
|
||||
};
|
||||
|
||||
// Act: Process the same identity twice
|
||||
await _service.ProcessEntryAsync(entryInfo, CancellationToken.None);
|
||||
|
||||
// Second entry with same identity (should be deduplicated)
|
||||
var entryInfo2 = new AttestorEntryInfo
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
RekorUuid = "test-rekor-uuid-790",
|
||||
LogIndex = 99996,
|
||||
ArtifactSha256 = "sha256:second123",
|
||||
IntegratedTimeUtc = DateTimeOffset.UtcNow,
|
||||
Identity = new SignerIdentityInput
|
||||
{
|
||||
Issuer = "https://token.actions.githubusercontent.com"
|
||||
}
|
||||
};
|
||||
await _service.ProcessEntryAsync(entryInfo2, CancellationToken.None);
|
||||
|
||||
// Assert: Only first alert should be emitted (second is suppressed)
|
||||
_alertPublisher.PublishedEvents.Should().HaveCount(1);
|
||||
_alertPublisher.PublishedEvents[0].RekorEntry.Uuid.Should().Be("test-rekor-uuid-789");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessEntry_WithGlobPattern_MatchesWildcard()
|
||||
{
|
||||
// Arrange: Create a watchlist entry with glob pattern
|
||||
var watchlistEntry = new WatchedIdentity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = "tenant-1",
|
||||
DisplayName = "All GitHub Repos",
|
||||
Issuer = "https://token.actions.githubusercontent.com",
|
||||
SubjectAlternativeName = "repo:org/*",
|
||||
MatchMode = WatchlistMatchMode.Glob,
|
||||
Scope = WatchlistScope.Tenant,
|
||||
Severity = IdentityAlertSeverity.Info,
|
||||
Enabled = true,
|
||||
SuppressDuplicatesMinutes = 1,
|
||||
CreatedBy = "test",
|
||||
UpdatedBy = "test"
|
||||
};
|
||||
await _watchlistRepository.UpsertAsync(watchlistEntry, CancellationToken.None);
|
||||
|
||||
// Arrange: Create an entry matching the glob pattern
|
||||
var entryInfo = new AttestorEntryInfo
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
RekorUuid = "glob-test-uuid",
|
||||
LogIndex = 12345,
|
||||
ArtifactSha256 = "sha256:glob123",
|
||||
IntegratedTimeUtc = DateTimeOffset.UtcNow,
|
||||
Identity = new SignerIdentityInput
|
||||
{
|
||||
Issuer = "https://token.actions.githubusercontent.com",
|
||||
SubjectAlternativeName = "repo:org/my-repo:ref:refs/heads/main"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
await _service.ProcessEntryAsync(entryInfo, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
_alertPublisher.PublishedEvents.Should().HaveCount(1);
|
||||
_alertPublisher.PublishedEvents[0].MatchedIdentity.SubjectAlternativeName
|
||||
.Should().Be("repo:org/my-repo:ref:refs/heads/main");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessEntry_WithDisabledEntry_DoesNotMatch()
|
||||
{
|
||||
// Arrange: Create a DISABLED watchlist entry
|
||||
var watchlistEntry = new WatchedIdentity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = "tenant-1",
|
||||
DisplayName = "Disabled Watcher",
|
||||
Issuer = "https://token.actions.githubusercontent.com",
|
||||
MatchMode = WatchlistMatchMode.Exact,
|
||||
Scope = WatchlistScope.Tenant,
|
||||
Severity = IdentityAlertSeverity.Critical,
|
||||
Enabled = false, // Disabled
|
||||
SuppressDuplicatesMinutes = 60,
|
||||
CreatedBy = "test",
|
||||
UpdatedBy = "test"
|
||||
};
|
||||
await _watchlistRepository.UpsertAsync(watchlistEntry, CancellationToken.None);
|
||||
|
||||
var entryInfo = new AttestorEntryInfo
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
RekorUuid = "disabled-test-uuid",
|
||||
LogIndex = 11111,
|
||||
ArtifactSha256 = "sha256:disabled123",
|
||||
IntegratedTimeUtc = DateTimeOffset.UtcNow,
|
||||
Identity = new SignerIdentityInput
|
||||
{
|
||||
Issuer = "https://token.actions.githubusercontent.com"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
await _service.ProcessEntryAsync(entryInfo, CancellationToken.None);
|
||||
|
||||
// Assert: No alert (entry is disabled)
|
||||
_alertPublisher.PublishedEvents.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessEntry_WithGlobalScope_MatchesAcrossTenants()
|
||||
{
|
||||
// Arrange: Create a GLOBAL scope watchlist entry owned by tenant-admin
|
||||
var watchlistEntry = new WatchedIdentity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = "tenant-admin",
|
||||
DisplayName = "Global GitHub Watcher",
|
||||
Issuer = "https://token.actions.githubusercontent.com",
|
||||
MatchMode = WatchlistMatchMode.Exact,
|
||||
Scope = WatchlistScope.Global, // Global scope
|
||||
Severity = IdentityAlertSeverity.Warning,
|
||||
Enabled = true,
|
||||
SuppressDuplicatesMinutes = 60,
|
||||
CreatedBy = "admin",
|
||||
UpdatedBy = "admin"
|
||||
};
|
||||
await _watchlistRepository.UpsertAsync(watchlistEntry, CancellationToken.None);
|
||||
|
||||
// Arrange: Entry from different tenant
|
||||
var entryInfo = new AttestorEntryInfo
|
||||
{
|
||||
TenantId = "tenant-other", // Different tenant
|
||||
RekorUuid = "global-test-uuid",
|
||||
LogIndex = 22222,
|
||||
ArtifactSha256 = "sha256:global123",
|
||||
IntegratedTimeUtc = DateTimeOffset.UtcNow,
|
||||
Identity = new SignerIdentityInput
|
||||
{
|
||||
Issuer = "https://token.actions.githubusercontent.com"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
await _service.ProcessEntryAsync(entryInfo, CancellationToken.None);
|
||||
|
||||
// Assert: Global entry should match across tenants
|
||||
_alertPublisher.PublishedEvents.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessEntry_WithMultipleMatches_EmitsMultipleAlerts()
|
||||
{
|
||||
// Arrange: Create TWO matching watchlist entries
|
||||
var entry1 = new WatchedIdentity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = "tenant-1",
|
||||
DisplayName = "GitHub Watcher 1",
|
||||
Issuer = "https://token.actions.githubusercontent.com",
|
||||
MatchMode = WatchlistMatchMode.Exact,
|
||||
Scope = WatchlistScope.Tenant,
|
||||
Severity = IdentityAlertSeverity.Critical,
|
||||
Enabled = true,
|
||||
SuppressDuplicatesMinutes = 1,
|
||||
CreatedBy = "test",
|
||||
UpdatedBy = "test"
|
||||
};
|
||||
var entry2 = new WatchedIdentity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = "tenant-1",
|
||||
DisplayName = "GitHub Watcher 2",
|
||||
Issuer = "https://token.actions.githubusercontent.com",
|
||||
MatchMode = WatchlistMatchMode.Prefix,
|
||||
Scope = WatchlistScope.Tenant,
|
||||
Severity = IdentityAlertSeverity.Warning,
|
||||
Enabled = true,
|
||||
SuppressDuplicatesMinutes = 1,
|
||||
CreatedBy = "test",
|
||||
UpdatedBy = "test"
|
||||
};
|
||||
await _watchlistRepository.UpsertAsync(entry1, CancellationToken.None);
|
||||
await _watchlistRepository.UpsertAsync(entry2, CancellationToken.None);
|
||||
|
||||
var entryInfo = new AttestorEntryInfo
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
RekorUuid = "multi-match-uuid",
|
||||
LogIndex = 33333,
|
||||
ArtifactSha256 = "sha256:multi123",
|
||||
IntegratedTimeUtc = DateTimeOffset.UtcNow,
|
||||
Identity = new SignerIdentityInput
|
||||
{
|
||||
Issuer = "https://token.actions.githubusercontent.com"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
await _service.ProcessEntryAsync(entryInfo, CancellationToken.None);
|
||||
|
||||
// Assert: Both entries should match and emit alerts
|
||||
_alertPublisher.PublishedEvents.Should().HaveCount(2);
|
||||
_alertPublisher.PublishedEvents.Should().Contain(e => e.WatchlistEntryName == "GitHub Watcher 1");
|
||||
_alertPublisher.PublishedEvents.Should().Contain(e => e.WatchlistEntryName == "GitHub Watcher 2");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test helper: Attestor entry information for processing.
|
||||
/// </summary>
|
||||
public sealed record AttestorEntryInfo
|
||||
{
|
||||
public required string TenantId { get; init; }
|
||||
public required string RekorUuid { get; init; }
|
||||
public required long LogIndex { get; init; }
|
||||
public required string ArtifactSha256 { get; init; }
|
||||
public required DateTimeOffset IntegratedTimeUtc { get; init; }
|
||||
public required SignerIdentityInput Identity { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
<PackageReference Include="Testcontainers.PostgreSql" />
|
||||
<PackageReference Include="coverlet.collector">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Attestor.Watchlist\StellaOps.Attestor.Watchlist.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Attestor\StellaOps.Attestor.Infrastructure\StellaOps.Attestor.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,256 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PostgresWatchlistRepositoryTests.cs
|
||||
// Sprint: SPRINT_0129_001_ATTESTOR_identity_watchlist_alerting
|
||||
// Task: WATCH-004
|
||||
// Description: Integration tests for PostgreSQL watchlist repository using Testcontainers.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Npgsql;
|
||||
using StellaOps.Attestor.Infrastructure.Watchlist;
|
||||
using StellaOps.Attestor.Watchlist.Models;
|
||||
using StellaOps.Attestor.Watchlist.Storage;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Watchlist.Tests.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for PostgresWatchlistRepository.
|
||||
/// These tests verify CRUD operations against a real PostgreSQL database via Testcontainers.
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
[Collection(WatchlistPostgresCollection.Name)]
|
||||
public sealed class PostgresWatchlistRepositoryTests : IAsyncLifetime
|
||||
{
|
||||
private readonly WatchlistPostgresFixture _fixture;
|
||||
private PostgresWatchlistRepository _repository = null!;
|
||||
|
||||
public PostgresWatchlistRepositoryTests(WatchlistPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
var dataSource = NpgsqlDataSource.Create(_fixture.ConnectionString);
|
||||
_repository = new PostgresWatchlistRepository(
|
||||
dataSource,
|
||||
NullLogger<PostgresWatchlistRepository>.Instance);
|
||||
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_CreatesNewEntry_ReturnsEntry()
|
||||
{
|
||||
var entry = CreateTestEntry();
|
||||
|
||||
var result = await _repository.UpsertAsync(entry, CancellationToken.None);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result.Id.Should().NotBe(Guid.Empty);
|
||||
result.DisplayName.Should().Be(entry.DisplayName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_ExistingEntry_ReturnsEntry()
|
||||
{
|
||||
var entry = CreateTestEntry();
|
||||
var created = await _repository.UpsertAsync(entry, CancellationToken.None);
|
||||
|
||||
var result = await _repository.GetAsync(created.Id, CancellationToken.None);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Id.Should().Be(created.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_NonExistentEntry_ReturnsNull()
|
||||
{
|
||||
var result = await _repository.GetAsync(Guid.NewGuid(), CancellationToken.None);
|
||||
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_WithTenantFilter_ReturnsOnlyTenantEntries()
|
||||
{
|
||||
var entry1 = CreateTestEntry("tenant-1");
|
||||
var entry2 = CreateTestEntry("tenant-2");
|
||||
await _repository.UpsertAsync(entry1, CancellationToken.None);
|
||||
await _repository.UpsertAsync(entry2, CancellationToken.None);
|
||||
|
||||
var results = await _repository.ListAsync("tenant-1", includeGlobal: false, CancellationToken.None);
|
||||
|
||||
results.Should().AllSatisfy(e => e.TenantId.Should().Be("tenant-1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_IncludeGlobal_ReturnsGlobalEntries()
|
||||
{
|
||||
var tenantEntry = CreateTestEntry("tenant-1", WatchlistScope.Tenant);
|
||||
var globalEntry = CreateTestEntry("admin", WatchlistScope.Global);
|
||||
await _repository.UpsertAsync(tenantEntry, CancellationToken.None);
|
||||
await _repository.UpsertAsync(globalEntry, CancellationToken.None);
|
||||
|
||||
var results = await _repository.ListAsync("tenant-1", includeGlobal: true, CancellationToken.None);
|
||||
|
||||
results.Should().Contain(e => e.Scope == WatchlistScope.Global);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_ExistingEntry_RemovesEntry()
|
||||
{
|
||||
var entry = CreateTestEntry();
|
||||
var created = await _repository.UpsertAsync(entry, CancellationToken.None);
|
||||
|
||||
await _repository.DeleteAsync(created.Id, entry.TenantId, CancellationToken.None);
|
||||
|
||||
var result = await _repository.GetAsync(created.Id, CancellationToken.None);
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_TenantIsolation_CannotDeleteOtherTenantEntry()
|
||||
{
|
||||
var entry = CreateTestEntry("tenant-1");
|
||||
var created = await _repository.UpsertAsync(entry, CancellationToken.None);
|
||||
|
||||
// Try to delete with different tenant
|
||||
await _repository.DeleteAsync(created.Id, "tenant-2", CancellationToken.None);
|
||||
|
||||
// Entry should still exist
|
||||
var result = await _repository.GetAsync(created.Id, CancellationToken.None);
|
||||
result.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetActiveForMatchingAsync_ReturnsOnlyEnabledEntries()
|
||||
{
|
||||
var enabledEntry = CreateTestEntry("tenant-1", enabled: true);
|
||||
var disabledEntry = CreateTestEntry("tenant-1", enabled: false);
|
||||
await _repository.UpsertAsync(enabledEntry, CancellationToken.None);
|
||||
await _repository.UpsertAsync(disabledEntry, CancellationToken.None);
|
||||
|
||||
var results = await _repository.GetActiveForMatchingAsync("tenant-1", CancellationToken.None);
|
||||
|
||||
results.Should().AllSatisfy(e => e.Enabled.Should().BeTrue());
|
||||
}
|
||||
|
||||
private static WatchedIdentity CreateTestEntry(
|
||||
string tenantId = "test-tenant",
|
||||
WatchlistScope scope = WatchlistScope.Tenant,
|
||||
bool enabled = true)
|
||||
{
|
||||
return new WatchedIdentity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
DisplayName = $"Test Entry {Guid.NewGuid():N}",
|
||||
Issuer = "https://example.com",
|
||||
MatchMode = WatchlistMatchMode.Exact,
|
||||
Scope = scope,
|
||||
Severity = IdentityAlertSeverity.Warning,
|
||||
Enabled = enabled,
|
||||
SuppressDuplicatesMinutes = 60,
|
||||
CreatedBy = "test",
|
||||
UpdatedBy = "test"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for PostgresAlertDedupRepository.
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
[Collection(WatchlistPostgresCollection.Name)]
|
||||
public sealed class PostgresAlertDedupRepositoryTests : IAsyncLifetime
|
||||
{
|
||||
private readonly WatchlistPostgresFixture _fixture;
|
||||
private PostgresAlertDedupRepository _repository = null!;
|
||||
private PostgresWatchlistRepository _watchlistRepo = null!;
|
||||
|
||||
public PostgresAlertDedupRepositoryTests(WatchlistPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
var dataSource = NpgsqlDataSource.Create(_fixture.ConnectionString);
|
||||
_repository = new PostgresAlertDedupRepository(dataSource);
|
||||
_watchlistRepo = new PostgresWatchlistRepository(
|
||||
dataSource,
|
||||
NullLogger<PostgresWatchlistRepository>.Instance);
|
||||
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAndUpdateAsync_FirstAlert_AllowsAlert()
|
||||
{
|
||||
var watchlistEntry = CreateWatchlistEntry();
|
||||
var created = await _watchlistRepo.UpsertAsync(watchlistEntry, CancellationToken.None);
|
||||
|
||||
var result = await _repository.CheckAndUpdateAsync(
|
||||
created.Id, "test-identity-hash", 60, CancellationToken.None);
|
||||
|
||||
result.ShouldSend.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAndUpdateAsync_DuplicateWithinWindow_SuppressesAlert()
|
||||
{
|
||||
var watchlistEntry = CreateWatchlistEntry();
|
||||
var created = await _watchlistRepo.UpsertAsync(watchlistEntry, CancellationToken.None);
|
||||
|
||||
// First alert
|
||||
await _repository.CheckAndUpdateAsync(
|
||||
created.Id, "test-identity-hash", 60, CancellationToken.None);
|
||||
|
||||
// Second alert within window
|
||||
var result = await _repository.CheckAndUpdateAsync(
|
||||
created.Id, "test-identity-hash", 60, CancellationToken.None);
|
||||
|
||||
// The dedup logic should track the duplicate
|
||||
result.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CleanupExpiredAsync_RemovesOldRecords()
|
||||
{
|
||||
// Insert a dedup record, then clean up (all recent records will survive)
|
||||
var watchlistEntry = CreateWatchlistEntry();
|
||||
var created = await _watchlistRepo.UpsertAsync(watchlistEntry, CancellationToken.None);
|
||||
|
||||
await _repository.CheckAndUpdateAsync(
|
||||
created.Id, "test-hash", 60, CancellationToken.None);
|
||||
|
||||
// Cleanup should not remove recent records (< 7 days old)
|
||||
var removed = await _repository.CleanupExpiredAsync(CancellationToken.None);
|
||||
removed.Should().Be(0);
|
||||
}
|
||||
|
||||
private static WatchedIdentity CreateWatchlistEntry()
|
||||
{
|
||||
return new WatchedIdentity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = "test-tenant",
|
||||
DisplayName = $"Test Entry {Guid.NewGuid():N}",
|
||||
Issuer = "https://example.com",
|
||||
MatchMode = WatchlistMatchMode.Exact,
|
||||
Scope = WatchlistScope.Tenant,
|
||||
Severity = IdentityAlertSeverity.Warning,
|
||||
Enabled = true,
|
||||
SuppressDuplicatesMinutes = 60,
|
||||
CreatedBy = "test",
|
||||
UpdatedBy = "test"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using Npgsql;
|
||||
using Testcontainers.PostgreSql;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Watchlist.Tests.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL integration test fixture for watchlist repository tests.
|
||||
/// Starts a Testcontainers PostgreSQL instance and applies the watchlist migration.
|
||||
/// </summary>
|
||||
public sealed class WatchlistPostgresFixture : IAsyncLifetime
|
||||
{
|
||||
private const string PostgresImage = "postgres:16-alpine";
|
||||
|
||||
private PostgreSqlContainer? _container;
|
||||
|
||||
public string ConnectionString => _container?.GetConnectionString()
|
||||
?? throw new InvalidOperationException("Container not initialized");
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_container = new PostgreSqlBuilder()
|
||||
.WithImage(PostgresImage)
|
||||
.Build();
|
||||
|
||||
await _container.StartAsync();
|
||||
}
|
||||
catch (ArgumentException ex) when (
|
||||
string.Equals(ex.ParamName, "DockerEndpointAuthConfig", StringComparison.Ordinal) ||
|
||||
ex.Message.Contains("Docker is either not running", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (_container is not null)
|
||||
{
|
||||
try { await _container.DisposeAsync(); } catch { /* ignore */ }
|
||||
}
|
||||
_container = null;
|
||||
throw SkipException.ForSkip(
|
||||
$"Watchlist integration tests require Docker/Testcontainers. Skipping: {ex.Message}");
|
||||
}
|
||||
|
||||
// Create the attestor schema and apply the migration
|
||||
await using var conn = new NpgsqlConnection(ConnectionString);
|
||||
await conn.OpenAsync();
|
||||
|
||||
await using var schemaCmd = new NpgsqlCommand("CREATE SCHEMA IF NOT EXISTS attestor;", conn);
|
||||
await schemaCmd.ExecuteNonQueryAsync();
|
||||
|
||||
var migrationSql = await LoadMigrationSqlAsync();
|
||||
await using var migrationCmd = new NpgsqlCommand(migrationSql, conn);
|
||||
migrationCmd.CommandTimeout = 60;
|
||||
await migrationCmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_container is not null)
|
||||
{
|
||||
await _container.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Truncates all watchlist tables for test isolation.
|
||||
/// </summary>
|
||||
public async Task TruncateAllTablesAsync()
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(ConnectionString);
|
||||
await conn.OpenAsync();
|
||||
|
||||
await using var cmd = new NpgsqlCommand(
|
||||
"""
|
||||
TRUNCATE TABLE attestor.identity_alert_dedup CASCADE;
|
||||
TRUNCATE TABLE attestor.identity_watchlist CASCADE;
|
||||
""", conn);
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
private static async Task<string> LoadMigrationSqlAsync()
|
||||
{
|
||||
var directory = AppContext.BaseDirectory;
|
||||
while (directory is not null)
|
||||
{
|
||||
var migrationPath = Path.Combine(directory, "src", "Attestor",
|
||||
"StellaOps.Attestor", "StellaOps.Attestor.Infrastructure",
|
||||
"Migrations", "20260129_001_create_identity_watchlist.sql");
|
||||
|
||||
if (File.Exists(migrationPath))
|
||||
{
|
||||
return await File.ReadAllTextAsync(migrationPath);
|
||||
}
|
||||
|
||||
directory = Directory.GetParent(directory)?.FullName;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"Cannot find watchlist migration SQL. Ensure the test runs from within the repository.");
|
||||
}
|
||||
}
|
||||
|
||||
[CollectionDefinition(Name)]
|
||||
public sealed class WatchlistPostgresCollection : ICollectionFixture<WatchlistPostgresFixture>
|
||||
{
|
||||
public const string Name = "WatchlistPostgres";
|
||||
}
|
||||
Reference in New Issue
Block a user