tests fixes and some product advisories tunes ups

This commit is contained in:
master
2026-01-30 07:57:43 +02:00
parent 644887997c
commit 55744f6a39
345 changed files with 26290 additions and 2267 deletions

View File

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

View File

@@ -0,0 +1,4 @@
// Copyright (c) StellaOps. All rights reserved.
// Licensed under the BUSL-1.1 license.
global using Xunit;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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