up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-27 07:46:56 +02:00
parent d63af51f84
commit ea970ead2a
302 changed files with 43161 additions and 1534 deletions

View File

@@ -0,0 +1,170 @@
using System;
using System.Collections.Generic;
using StellaOps.Signer.Core;
namespace StellaOps.Signer.Tests.Fixtures;
/// <summary>
/// Deterministic test data constants for reproducible test execution.
/// All values are fixed and should not change between test runs.
/// </summary>
public static class DeterministicTestData
{
// Trusted scanner digests
public const string TrustedScannerDigest = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
public const string UntrustedScannerDigest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
// Default subject data
public const string DefaultSubjectName = "ghcr.io/stellaops/scanner:v2.5.0";
public const string DefaultSubjectDigest = "abc123def456789012345678901234567890abcdef1234567890abcdef123456";
// Additional subject data for multi-subject tests
public const string SecondSubjectName = "ghcr.io/stellaops/sbomer:v1.8.0";
public const string SecondSubjectDigest = "def456789012345678901234567890abcdef1234567890abcdef123456abc123";
public const string ThirdSubjectName = "ghcr.io/stellaops/policy-engine:v2.1.0";
public const string ThirdSubjectDigest = "789012345678901234567890abcdef1234567890abcdef123456abc123def456";
// Proof of entitlement tokens
public const string ValidPoeToken = "valid-poe-token-12345";
public const string ExpiredPoeToken = "expired-poe-token-99999";
public const string InvalidPoeToken = "invalid-poe-token-00000";
// Tenant identifiers
public const string DefaultTenant = "stellaops-default";
public const string TestTenant = "test-tenant-12345";
public const string EnterpriseCustomerTenant = "enterprise-customer-67890";
// Key identifiers
public const string KeylessKeyId = "keyless-ephemeral-20250115";
public const string KmsKeyId = "alias/stellaops-signing-key";
public const string TestKmsKeyId = "test-kms-key-12345";
// Issuer/subject for signing identity
public const string DefaultIssuer = "https://signer.stellaops.io";
public const string FulcioIssuer = "https://fulcio.sigstore.dev";
public const string TestIssuer = "https://test.signer.local";
// Fixed timestamps for deterministic testing
public static readonly DateTimeOffset FixedTimestamp = new(2025, 1, 15, 10, 30, 0, TimeSpan.Zero);
public static readonly DateTimeOffset ExpiryTimestamp = new(2025, 1, 15, 11, 30, 0, TimeSpan.Zero);
public static readonly DateTimeOffset FarFutureExpiry = new(2026, 1, 15, 10, 30, 0, TimeSpan.Zero);
// License/entitlement data
public const string TestLicenseId = "LIC-TEST-12345";
public const string TestCustomerId = "CUST-TEST-67890";
public const string ProPlan = "pro";
public const string EnterprisePlan = "enterprise";
public const int DefaultMaxArtifactBytes = 128 * 1024;
public const int DefaultQpsLimit = 10;
public const int DefaultQpsRemaining = 10;
/// <summary>
/// Creates a default caller context for testing.
/// Includes required scope "signer.sign" and audience "signer" for pipeline authorization.
/// </summary>
public static CallerContext CreateDefaultCallerContext()
{
return new CallerContext(
Subject: "test-service@stellaops.io",
Tenant: DefaultTenant,
Scopes: new[] { "signer.sign", "signer.verify" },
Audiences: new[] { "signer", "https://signer.stellaops.io" },
SenderBinding: "dpop-proof-12345",
ClientCertificateThumbprint: null);
}
/// <summary>
/// Creates a caller context for a specific tenant.
/// Includes required scope "signer.sign" and audience "signer" for pipeline authorization.
/// </summary>
public static CallerContext CreateCallerContextForTenant(string tenant)
{
return new CallerContext(
Subject: $"service@{tenant}.stellaops.io",
Tenant: tenant,
Scopes: new[] { "signer.sign", "signer.verify" },
Audiences: new[] { "signer", "https://signer.stellaops.io" },
SenderBinding: "dpop-proof-12345",
ClientCertificateThumbprint: null);
}
/// <summary>
/// Creates a default proof of entitlement result.
/// </summary>
public static ProofOfEntitlementResult CreateDefaultEntitlement()
{
return new ProofOfEntitlementResult(
LicenseId: TestLicenseId,
CustomerId: TestCustomerId,
Plan: ProPlan,
MaxArtifactBytes: DefaultMaxArtifactBytes,
QpsLimit: DefaultQpsLimit,
QpsRemaining: DefaultQpsRemaining,
ExpiresAtUtc: FarFutureExpiry);
}
/// <summary>
/// Creates an entitlement result for a specific plan.
/// </summary>
public static ProofOfEntitlementResult CreateEntitlementForPlan(string plan, int maxArtifactBytes = DefaultMaxArtifactBytes)
{
return new ProofOfEntitlementResult(
LicenseId: $"LIC-{plan.ToUpperInvariant()}",
CustomerId: TestCustomerId,
Plan: plan,
MaxArtifactBytes: maxArtifactBytes,
QpsLimit: DefaultQpsLimit,
QpsRemaining: DefaultQpsRemaining,
ExpiresAtUtc: FarFutureExpiry);
}
/// <summary>
/// Creates a list of default signing subjects.
/// </summary>
public static IReadOnlyList<SigningSubject> CreateDefaultSubjects()
{
return new[]
{
new SigningSubject(DefaultSubjectName, new Dictionary<string, string>
{
["sha256"] = DefaultSubjectDigest
})
};
}
/// <summary>
/// Creates multiple signing subjects for multi-subject tests.
/// </summary>
public static IReadOnlyList<SigningSubject> CreateMultipleSubjects()
{
return new[]
{
new SigningSubject(DefaultSubjectName, new Dictionary<string, string>
{
["sha256"] = DefaultSubjectDigest
}),
new SigningSubject(SecondSubjectName, new Dictionary<string, string>
{
["sha256"] = SecondSubjectDigest
}),
new SigningSubject(ThirdSubjectName, new Dictionary<string, string>
{
["sha256"] = ThirdSubjectDigest
})
};
}
/// <summary>
/// Creates a signing subject with multiple digest algorithms.
/// </summary>
public static SigningSubject CreateSubjectWithMultipleDigests()
{
return new SigningSubject(DefaultSubjectName, new Dictionary<string, string>
{
["sha256"] = DefaultSubjectDigest,
["sha512"] = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
["sha384"] = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
});
}
}

View File

@@ -0,0 +1,580 @@
using System.Text.Json;
namespace StellaOps.Signer.Tests.Fixtures;
/// <summary>
/// Provides deterministic test fixtures for predicate types used in signing tests.
/// All fixtures use static, reproducible data for deterministic test execution.
/// </summary>
public static class PredicateFixtures
{
/// <summary>
/// Deterministic timestamp for test reproducibility.
/// </summary>
public const string FixedTimestamp = "2025-01-15T10:30:00Z";
/// <summary>
/// Creates a StellaOps promotion predicate fixture.
/// </summary>
public static JsonDocument CreatePromotionPredicate()
{
return JsonDocument.Parse(PromotionPredicateJson);
}
/// <summary>
/// Creates a StellaOps SBOM predicate fixture.
/// </summary>
public static JsonDocument CreateSbomPredicate()
{
return JsonDocument.Parse(SbomPredicateJson);
}
/// <summary>
/// Creates a StellaOps replay predicate fixture.
/// </summary>
public static JsonDocument CreateReplayPredicate()
{
return JsonDocument.Parse(ReplayPredicateJson);
}
/// <summary>
/// Creates a SLSA provenance v0.2 predicate fixture.
/// </summary>
public static JsonDocument CreateSlsaProvenanceV02Predicate()
{
return JsonDocument.Parse(SlsaProvenanceV02PredicateJson);
}
/// <summary>
/// Creates a SLSA provenance v1 predicate fixture.
/// </summary>
public static JsonDocument CreateSlsaProvenanceV1Predicate()
{
return JsonDocument.Parse(SlsaProvenanceV1PredicateJson);
}
/// <summary>
/// Creates a StellaOps VEX predicate fixture.
/// </summary>
public static JsonDocument CreateVexPredicate()
{
return JsonDocument.Parse(VexPredicateJson);
}
/// <summary>
/// Creates a StellaOps policy predicate fixture.
/// </summary>
public static JsonDocument CreatePolicyPredicate()
{
return JsonDocument.Parse(PolicyPredicateJson);
}
/// <summary>
/// Creates a StellaOps evidence predicate fixture.
/// </summary>
public static JsonDocument CreateEvidencePredicate()
{
return JsonDocument.Parse(EvidencePredicateJson);
}
/// <summary>
/// Creates a StellaOps VEX Decision predicate fixture (OpenVEX format).
/// </summary>
public static JsonDocument CreateVexDecisionPredicate()
{
return JsonDocument.Parse(VexDecisionPredicateJson);
}
/// <summary>
/// Creates a StellaOps Graph predicate fixture for reachability call-graphs.
/// </summary>
public static JsonDocument CreateGraphPredicate()
{
return JsonDocument.Parse(GraphPredicateJson);
}
public const string PromotionPredicateJson = """
{
"version": "1.0",
"promotionId": "promo-20250115-103000-abc123",
"sourceEnvironment": {
"name": "staging",
"clusterId": "staging-us-west-2",
"namespace": "stellaops-app"
},
"targetEnvironment": {
"name": "production",
"clusterId": "prod-us-west-2",
"namespace": "stellaops-app"
},
"artifact": {
"repository": "ghcr.io/stellaops/scanner",
"tag": "v2.5.0",
"digest": "sha256:abc123def456789012345678901234567890abcdef1234567890abcdef123456"
},
"approval": {
"approvedBy": "security-team@stellaops.io",
"approvedAt": "2025-01-15T10:30:00Z",
"policy": "require-two-approvals",
"policyVersion": "v1.2.0"
},
"evidence": {
"scanCompleted": true,
"vulnerabilitiesFound": 0,
"signatureVerified": true,
"policyPassed": true
},
"timestamp": "2025-01-15T10:30:00Z"
}
""";
public const string SbomPredicateJson = """
{
"version": "1.0",
"sbomId": "sbom-20250115-103000-xyz789",
"format": "spdx-json",
"formatVersion": "3.0.1",
"generator": {
"tool": "stellaops-sbomer",
"version": "1.8.0",
"timestamp": "2025-01-15T10:30:00Z"
},
"artifact": {
"repository": "ghcr.io/stellaops/scanner",
"tag": "v2.5.0",
"digest": "sha256:abc123def456789012345678901234567890abcdef1234567890abcdef123456"
},
"packages": {
"total": 127,
"direct": 24,
"transitive": 103
},
"licenses": {
"approved": ["MIT", "Apache-2.0", "BSD-3-Clause"],
"flagged": [],
"unknown": 2
},
"vulnerabilities": {
"critical": 0,
"high": 0,
"medium": 3,
"low": 12,
"informational": 5
},
"checksums": {
"sbomSha256": "fedcba987654321098765432109876543210fedcba987654321098765432109876",
"contentSha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
},
"timestamp": "2025-01-15T10:30:00Z"
}
""";
public const string ReplayPredicateJson = """
{
"version": "1.0",
"replayId": "replay-20250115-103000-def456",
"originalScanId": "scan-20250114-090000-original",
"mode": "verification",
"inputs": {
"manifestDigest": "sha256:manifest123456789012345678901234567890abcdef12345678901234567890",
"feedPins": {
"nvd": "2025-01-14T00:00:00Z",
"osv": "2025-01-14T00:00:00Z"
},
"policyVersion": "v2.1.0",
"toolVersions": {
"trivy": "0.58.0",
"grype": "0.87.0",
"syft": "1.20.0"
}
},
"execution": {
"startedAt": "2025-01-15T10:00:00Z",
"completedAt": "2025-01-15T10:30:00Z",
"durationSeconds": 1800,
"workerCount": 4,
"deterministic": true
},
"outputs": {
"layersProcessed": 12,
"merkleRoot": "sha256:merkle0123456789abcdef0123456789abcdef0123456789abcdef01234567",
"outputDigest": "sha256:output0123456789abcdef0123456789abcdef0123456789abcdef01234567"
},
"verification": {
"inputHashMatch": true,
"outputHashMatch": true,
"determinismScore": 1.0,
"diffPaths": []
},
"timestamp": "2025-01-15T10:30:00Z"
}
""";
public const string SlsaProvenanceV02PredicateJson = """
{
"builder": {
"id": "https://github.com/stellaops/scanner/.github/workflows/build.yml@refs/tags/v2.5.0"
},
"buildType": "https://github.com/Attestations/GitHubActionsWorkflow@v1",
"invocation": {
"configSource": {
"uri": "git+https://github.com/stellaops/scanner@refs/tags/v2.5.0",
"digest": {
"sha1": "abc123def456789012345678901234567890abcd"
},
"entryPoint": ".github/workflows/build.yml"
},
"parameters": {},
"environment": {
"github_actor": "stellaops-bot",
"github_event_name": "push",
"github_ref": "refs/tags/v2.5.0",
"github_repository": "stellaops/scanner",
"github_run_id": "12345678901",
"github_run_number": "456",
"github_sha": "abc123def456789012345678901234567890abcd"
}
},
"metadata": {
"buildInvocationId": "12345678901-456",
"buildStartedOn": "2025-01-15T10:00:00Z",
"buildFinishedOn": "2025-01-15T10:30:00Z",
"completeness": {
"parameters": true,
"environment": true,
"materials": true
},
"reproducible": true
},
"materials": [
{
"uri": "git+https://github.com/stellaops/scanner@refs/tags/v2.5.0",
"digest": {
"sha1": "abc123def456789012345678901234567890abcd"
}
},
{
"uri": "pkg:golang/github.com/stellaops/go-sdk@v1.5.0",
"digest": {
"sha256": "fedcba987654321098765432109876543210fedcba987654321098765432109876"
}
}
]
}
""";
public const string SlsaProvenanceV1PredicateJson = """
{
"buildDefinition": {
"buildType": "https://slsa.dev/container-based-build/v0.1",
"externalParameters": {
"repository": "https://github.com/stellaops/scanner",
"ref": "refs/tags/v2.5.0"
},
"internalParameters": {
"workflow": ".github/workflows/build.yml"
},
"resolvedDependencies": [
{
"uri": "git+https://github.com/stellaops/scanner@refs/tags/v2.5.0",
"digest": {
"gitCommit": "abc123def456789012345678901234567890abcd"
}
}
]
},
"runDetails": {
"builder": {
"id": "https://github.com/stellaops/scanner/.github/workflows/build.yml@refs/tags/v2.5.0",
"builderDependencies": [
{
"uri": "https://github.com/actions/runner-images/releases/tag/ubuntu22/20250110.1"
}
],
"version": {
"stellaops-builder": "1.0.0"
}
},
"metadata": {
"invocationId": "https://github.com/stellaops/scanner/actions/runs/12345678901/attempts/1",
"startedOn": "2025-01-15T10:00:00Z",
"finishedOn": "2025-01-15T10:30:00Z"
},
"byproducts": []
}
}
""";
public const string VexPredicateJson = """
{
"version": "1.0",
"vexId": "vex-20250115-103000-ghi789",
"artifact": {
"repository": "ghcr.io/stellaops/scanner",
"tag": "v2.5.0",
"digest": "sha256:abc123def456789012345678901234567890abcdef1234567890abcdef123456"
},
"statements": [
{
"vulnerability": "CVE-2024-12345",
"status": "not_affected",
"justification": "vulnerable_code_not_present",
"impact": "The affected function is not used in our build configuration.",
"actionStatement": "No action required.",
"timestamp": "2025-01-15T10:30:00Z"
},
{
"vulnerability": "CVE-2024-67890",
"status": "fixed",
"justification": "component_not_present",
"impact": "Dependency was removed in v2.4.0.",
"actionStatement": "Upgrade to v2.4.0 or later.",
"timestamp": "2025-01-15T10:30:00Z"
}
],
"author": {
"name": "StellaOps Security Team",
"email": "security@stellaops.io"
},
"timestamp": "2025-01-15T10:30:00Z"
}
""";
public const string PolicyPredicateJson = """
{
"version": "1.0",
"evaluationId": "eval-20250115-103000-jkl012",
"policy": {
"id": "stellaops-production-policy",
"version": "v2.1.0",
"digest": "sha256:policy0123456789abcdef0123456789abcdef0123456789abcdef01234567"
},
"artifact": {
"repository": "ghcr.io/stellaops/scanner",
"tag": "v2.5.0",
"digest": "sha256:abc123def456789012345678901234567890abcdef1234567890abcdef123456"
},
"result": {
"passed": true,
"score": 98,
"threshold": 85
},
"rules": {
"evaluated": 42,
"passed": 41,
"failed": 0,
"skipped": 1,
"warnings": 3
},
"violations": [],
"warnings": [
{
"ruleId": "warn-sbom-completeness",
"message": "SBOM completeness is 97%, recommended minimum is 99%.",
"severity": "low"
}
],
"evidence": {
"sbomVerified": true,
"signatureVerified": true,
"provenanceVerified": true,
"vulnerabilityScanPassed": true
},
"timestamp": "2025-01-15T10:30:00Z"
}
""";
public const string EvidencePredicateJson = """
{
"version": "1.0",
"evidenceId": "evidence-20250115-103000-mno345",
"artifact": {
"repository": "ghcr.io/stellaops/scanner",
"tag": "v2.5.0",
"digest": "sha256:abc123def456789012345678901234567890abcdef1234567890abcdef123456"
},
"chain": [
{
"type": "provenance",
"digest": "sha256:prov0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
"issuer": "https://github.com/stellaops/scanner/.github/workflows/build.yml",
"timestamp": "2025-01-15T10:15:00Z"
},
{
"type": "sbom",
"digest": "sha256:sbom0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
"issuer": "stellaops-sbomer",
"timestamp": "2025-01-15T10:20:00Z"
},
{
"type": "vulnerability-scan",
"digest": "sha256:scan0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
"issuer": "stellaops-scanner",
"timestamp": "2025-01-15T10:25:00Z"
},
{
"type": "policy-evaluation",
"digest": "sha256:eval0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
"issuer": "stellaops-policy-engine",
"timestamp": "2025-01-15T10:30:00Z"
}
],
"aggregated": {
"trustLevel": "high",
"completeness": 1.0,
"validFrom": "2025-01-15T10:00:00Z",
"validUntil": "2025-07-15T10:00:00Z"
},
"verificationLog": {
"verifiedAt": "2025-01-15T10:30:00Z",
"verifiedBy": "stellaops-authority",
"rekorLogIndex": 12345678,
"transparencyLogId": "https://rekor.sigstore.dev"
},
"timestamp": "2025-01-15T10:30:00Z"
}
""";
/// <summary>
/// VEX Decision predicate in OpenVEX format for policy decision signing.
/// This is the per-finding OpenVEX statement used by the Policy Engine.
/// </summary>
public const string VexDecisionPredicateJson = """
{
"@context": "https://openvex.dev/ns/v0.2.0",
"@id": "https://stellaops.io/vex/decision/20250115-103000-pqr678",
"author": "StellaOps Policy Engine",
"role": "automated-policy-engine",
"timestamp": "2025-01-15T10:30:00Z",
"version": 1,
"tooling": "stellaops-policy-engine/v2.1.0",
"statements": [
{
"vulnerability": {
"@id": "https://nvd.nist.gov/vuln/detail/CVE-2024-12345",
"name": "CVE-2024-12345",
"description": "Buffer overflow in example library"
},
"timestamp": "2025-01-15T10:30:00Z",
"products": [
{
"@id": "pkg:oci/scanner@sha256:abc123def456789012345678901234567890abcdef1234567890abcdef123456",
"identifiers": {
"purl": "pkg:oci/scanner@sha256:abc123"
},
"subcomponents": [
{
"@id": "pkg:npm/lodash@4.17.20",
"identifiers": {
"purl": "pkg:npm/lodash@4.17.20"
}
}
]
}
],
"status": "not_affected",
"justification": "vulnerable_code_not_in_execute_path",
"impact_statement": "The vulnerable function _.template() is not called in this build. Reachability analysis confirms no execution path reaches the affected code.",
"action_statement": "No remediation required. Continue monitoring for status changes.",
"status_notes": "Determined via static reachability analysis using stellaops-scanner v2.5.0",
"supplier": "StellaOps Security Team"
}
],
"stellaops_extensions": {
"policy_id": "stellaops-production-policy",
"policy_version": "v2.1.0",
"evaluation_id": "eval-20250115-103000-jkl012",
"reachability": {
"analyzed": true,
"confidence": 0.95,
"graph_digest": "sha256:graph0123456789abcdef0123456789abcdef0123456789abcdef01234567",
"method": "static-callgraph"
},
"evidence_refs": [
{
"type": "sbom",
"digest": "sha256:sbom0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
},
{
"type": "scan-report",
"digest": "sha256:scan0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
},
{
"type": "callgraph",
"digest": "sha256:graph0123456789abcdef0123456789abcdef0123456789abcdef01234567"
}
]
}
}
""";
/// <summary>
/// Graph predicate for reachability call-graph attestations (richgraph-v1 schema).
/// Used by Scanner to sign deterministic call-graph manifests.
/// </summary>
public const string GraphPredicateJson = """
{
"version": "1.0",
"schema": "richgraph-v1",
"graphId": "graph-20250115-103000-stu901",
"artifact": {
"repository": "ghcr.io/stellaops/scanner",
"tag": "v2.5.0",
"digest": "sha256:abc123def456789012345678901234567890abcdef1234567890abcdef123456"
},
"generation": {
"tool": "stellaops-scanner",
"toolVersion": "2.5.0",
"generatedAt": "2025-01-15T10:30:00Z",
"deterministic": true,
"hashAlgorithm": "blake3"
},
"graph": {
"rootNodes": [
{
"symbolId": "main:0x1000",
"name": "main",
"demangled": "main(int, char**)",
"source": "native",
"file": "src/main.c",
"line": 42
}
],
"nodes": {
"total": 1247,
"native": 823,
"managed": 424
},
"edges": {
"total": 3891,
"direct": 2456,
"indirect": 1435
},
"components": {
"analyzed": 156,
"purls": [
"pkg:npm/lodash@4.17.20",
"pkg:npm/express@4.18.2",
"pkg:golang/github.com/stellaops/go-sdk@v1.5.0"
]
}
},
"hashes": {
"graphHash": "blake3:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"nodesHash": "blake3:fedcba987654321098765432109876543210fedcba987654321098765432109876",
"edgesHash": "blake3:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
},
"cas": {
"location": "cas://reachability/graphs/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"bundleDigest": "sha256:bundle0123456789abcdef0123456789abcdef0123456789abcdef012345678"
},
"metadata": {
"scanId": "scan-20250115-103000-original",
"layersAnalyzed": 12,
"initRootsIncluded": true,
"purlResolutionEnabled": true
},
"timestamp": "2025-01-15T10:30:00Z"
}
""";
}

View File

@@ -0,0 +1,191 @@
using System.Collections.Generic;
using System.Text.Json;
using StellaOps.Signer.Core;
namespace StellaOps.Signer.Tests.Fixtures;
/// <summary>
/// Builder for creating deterministic signing requests in tests.
/// Uses fixed values to ensure reproducible test results.
/// </summary>
public sealed class SigningRequestBuilder
{
private List<SigningSubject> _subjects = new();
private string _predicateType = PredicateTypes.SlsaProvenanceV02;
private JsonDocument _predicate = PredicateFixtures.CreateSlsaProvenanceV02Predicate();
private string _scannerImageDigest = DeterministicTestData.TrustedScannerDigest;
private SignerPoEFormat _poeFormat = SignerPoEFormat.Jwt;
private string _poeValue = DeterministicTestData.ValidPoeToken;
private SigningMode _signingMode = SigningMode.Keyless;
private int? _expirySeconds = 3600;
private string _returnBundle = "dsse+cert";
public SigningRequestBuilder WithSubject(string name, string sha256Hash)
{
_subjects.Add(new SigningSubject(name, new Dictionary<string, string>
{
["sha256"] = sha256Hash
}));
return this;
}
public SigningRequestBuilder WithSubject(string name, Dictionary<string, string> digest)
{
_subjects.Add(new SigningSubject(name, digest));
return this;
}
public SigningRequestBuilder WithDefaultSubject()
{
return WithSubject(
DeterministicTestData.DefaultSubjectName,
DeterministicTestData.DefaultSubjectDigest);
}
public SigningRequestBuilder WithPredicateType(string predicateType)
{
_predicateType = predicateType;
return this;
}
public SigningRequestBuilder WithPredicate(JsonDocument predicate)
{
_predicate = predicate;
return this;
}
public SigningRequestBuilder WithPromotionPredicate()
{
_predicateType = PredicateTypes.StellaOpsPromotion;
_predicate = PredicateFixtures.CreatePromotionPredicate();
return this;
}
public SigningRequestBuilder WithSbomPredicate()
{
_predicateType = PredicateTypes.StellaOpsSbom;
_predicate = PredicateFixtures.CreateSbomPredicate();
return this;
}
public SigningRequestBuilder WithReplayPredicate()
{
_predicateType = PredicateTypes.StellaOpsReplay;
_predicate = PredicateFixtures.CreateReplayPredicate();
return this;
}
public SigningRequestBuilder WithVexPredicate()
{
_predicateType = PredicateTypes.StellaOpsVex;
_predicate = PredicateFixtures.CreateVexPredicate();
return this;
}
public SigningRequestBuilder WithPolicyPredicate()
{
_predicateType = PredicateTypes.StellaOpsPolicy;
_predicate = PredicateFixtures.CreatePolicyPredicate();
return this;
}
public SigningRequestBuilder WithEvidencePredicate()
{
_predicateType = PredicateTypes.StellaOpsEvidence;
_predicate = PredicateFixtures.CreateEvidencePredicate();
return this;
}
public SigningRequestBuilder WithVexDecisionPredicate()
{
_predicateType = PredicateTypes.StellaOpsVexDecision;
_predicate = PredicateFixtures.CreateVexDecisionPredicate();
return this;
}
public SigningRequestBuilder WithGraphPredicate()
{
_predicateType = PredicateTypes.StellaOpsGraph;
_predicate = PredicateFixtures.CreateGraphPredicate();
return this;
}
public SigningRequestBuilder WithSlsaProvenanceV02()
{
_predicateType = PredicateTypes.SlsaProvenanceV02;
_predicate = PredicateFixtures.CreateSlsaProvenanceV02Predicate();
return this;
}
public SigningRequestBuilder WithSlsaProvenanceV1()
{
_predicateType = PredicateTypes.SlsaProvenanceV1;
_predicate = PredicateFixtures.CreateSlsaProvenanceV1Predicate();
return this;
}
public SigningRequestBuilder WithScannerImageDigest(string digest)
{
_scannerImageDigest = digest;
return this;
}
public SigningRequestBuilder WithProofOfEntitlement(SignerPoEFormat format, string value)
{
_poeFormat = format;
_poeValue = value;
return this;
}
public SigningRequestBuilder WithSigningMode(SigningMode mode)
{
_signingMode = mode;
return this;
}
public SigningRequestBuilder WithKeylessMode()
{
_signingMode = SigningMode.Keyless;
return this;
}
public SigningRequestBuilder WithKmsMode()
{
_signingMode = SigningMode.Kms;
return this;
}
public SigningRequestBuilder WithExpirySeconds(int? expirySeconds)
{
_expirySeconds = expirySeconds;
return this;
}
public SigningRequestBuilder WithReturnBundle(string returnBundle)
{
_returnBundle = returnBundle;
return this;
}
public SigningRequest Build()
{
// Add default subject if none specified
if (_subjects.Count == 0)
{
WithDefaultSubject();
}
return new SigningRequest(
Subjects: _subjects,
PredicateType: _predicateType,
Predicate: _predicate,
ScannerImageDigest: _scannerImageDigest,
ProofOfEntitlement: new ProofOfEntitlement(_poeFormat, _poeValue),
Options: new SigningOptions(_signingMode, _expirySeconds, _returnBundle));
}
/// <summary>
/// Creates a new builder instance.
/// </summary>
public static SigningRequestBuilder Create() => new();
}

View File

@@ -0,0 +1,116 @@
using System;
using System.Security.Cryptography;
using StellaOps.Cryptography;
namespace StellaOps.Signer.Tests.Fixtures;
/// <summary>
/// Factory for creating deterministic test crypto providers and signing keys.
/// Uses fixed seed data to ensure reproducible test results.
/// </summary>
public static class TestCryptoFactory
{
/// <summary>
/// Fixed test key ID for deterministic testing.
/// </summary>
public const string TestKeyId = "test-signing-key-12345";
/// <summary>
/// Fixed keyless key ID for ephemeral signing.
/// </summary>
public const string KeylessKeyId = "keyless-ephemeral-20250115";
/// <summary>
/// Creates a DefaultCryptoProvider with a pre-registered test signing key.
/// </summary>
public static DefaultCryptoProvider CreateProviderWithTestKey()
{
var provider = new DefaultCryptoProvider();
var signingKey = CreateDeterministicSigningKey(TestKeyId);
provider.UpsertSigningKey(signingKey);
return provider;
}
/// <summary>
/// Creates a DefaultCryptoProvider with a keyless signing key.
/// </summary>
public static DefaultCryptoProvider CreateProviderWithKeylessKey()
{
var provider = new DefaultCryptoProvider();
var signingKey = CreateDeterministicSigningKey(KeylessKeyId);
provider.UpsertSigningKey(signingKey);
return provider;
}
/// <summary>
/// Creates a DefaultCryptoProvider with multiple signing keys.
/// </summary>
public static DefaultCryptoProvider CreateProviderWithMultipleKeys(params string[] keyIds)
{
var provider = new DefaultCryptoProvider();
foreach (var keyId in keyIds)
{
var signingKey = CreateDeterministicSigningKey(keyId);
provider.UpsertSigningKey(signingKey);
}
return provider;
}
/// <summary>
/// Creates a CryptoProviderRegistry with a test provider containing both test and keyless keys.
/// </summary>
public static ICryptoProviderRegistry CreateTestRegistry()
{
var provider = CreateProviderWithMultipleKeys(TestKeyId, KeylessKeyId);
return new CryptoProviderRegistry(new[] { provider }, new[] { provider.Name });
}
/// <summary>
/// Creates a CryptoProviderRegistry for keyless signing tests (includes both keys for flexibility).
/// </summary>
public static ICryptoProviderRegistry CreateKeylessRegistry()
{
var provider = CreateProviderWithMultipleKeys(TestKeyId, KeylessKeyId);
return new CryptoProviderRegistry(new[] { provider }, new[] { provider.Name });
}
/// <summary>
/// Creates a signing key with deterministic parameters.
/// The key is generated from the keyId to ensure determinism.
/// </summary>
public static CryptoSigningKey CreateDeterministicSigningKey(string keyId)
{
// Generate a P-256 key pair - using the keyId as a seed for determinism
// Note: In production this would use secure random generation
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var parameters = ecdsa.ExportParameters(includePrivateParameters: true);
return new CryptoSigningKey(
reference: new CryptoKeyReference(keyId, "default"),
algorithmId: SignatureAlgorithms.Es256,
privateParameters: parameters,
createdAt: DeterministicTestData.FixedTimestamp,
expiresAt: DeterministicTestData.FarFutureExpiry,
metadata: new System.Collections.Generic.Dictionary<string, string?>
{
["purpose"] = "testing",
["environment"] = "unit-test"
});
}
/// <summary>
/// Creates a CryptoKeyReference for the default test key.
/// </summary>
public static CryptoKeyReference CreateTestKeyReference()
{
return new CryptoKeyReference(TestKeyId, "default");
}
/// <summary>
/// Creates a CryptoKeyReference for keyless mode.
/// </summary>
public static CryptoKeyReference CreateKeylessKeyReference()
{
return new CryptoKeyReference(KeylessKeyId, "default");
}
}

View File

@@ -0,0 +1,511 @@
using System;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using StellaOps.Cryptography;
using StellaOps.Signer.Core;
using StellaOps.Signer.Infrastructure.Signing;
using StellaOps.Signer.Tests.Fixtures;
using Xunit;
namespace StellaOps.Signer.Tests.Integration;
/// <summary>
/// Integration tests for CryptoDsseSigner using real crypto providers.
/// Tests signing workflows with deterministic fixture predicates.
/// </summary>
public sealed class CryptoDsseSignerIntegrationTests
{
private readonly ICryptoProviderRegistry _cryptoRegistry;
private readonly ISigningKeyResolver _keyResolver;
private readonly CryptoDsseSigner _signer;
public CryptoDsseSignerIntegrationTests()
{
_cryptoRegistry = TestCryptoFactory.CreateTestRegistry();
_keyResolver = CreateTestKeyResolver();
_signer = CreateSigner();
}
[Fact]
public async Task SignAsync_WithPromotionPredicate_ProducesValidDsseEnvelope()
{
// Arrange
var request = SigningRequestBuilder.Create()
.WithDefaultSubject()
.WithPromotionPredicate()
.WithKeylessMode()
.Build();
var entitlement = DeterministicTestData.CreateDefaultEntitlement();
var caller = DeterministicTestData.CreateDefaultCallerContext();
// Act
var bundle = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert
bundle.Should().NotBeNull();
bundle.Envelope.Should().NotBeNull();
bundle.Envelope.PayloadType.Should().Be("application/vnd.in-toto+json");
bundle.Envelope.Payload.Should().NotBeNullOrEmpty();
bundle.Envelope.Signatures.Should().HaveCount(1);
bundle.Envelope.Signatures[0].Signature.Should().NotBeNullOrEmpty();
bundle.Metadata.Identity.Mode.Should().Be("keyless");
}
[Fact]
public async Task SignAsync_WithSbomPredicate_ProducesValidDsseEnvelope()
{
// Arrange
var request = SigningRequestBuilder.Create()
.WithDefaultSubject()
.WithSbomPredicate()
.WithKeylessMode()
.Build();
var entitlement = DeterministicTestData.CreateDefaultEntitlement();
var caller = DeterministicTestData.CreateDefaultCallerContext();
// Act
var bundle = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert
bundle.Should().NotBeNull();
bundle.Envelope.PayloadType.Should().Be("application/vnd.in-toto+json");
// Verify payload contains SBOM predicate
var payloadBytes = DecodeBase64Url(bundle.Envelope.Payload);
var payloadJson = Encoding.UTF8.GetString(payloadBytes);
using var doc = JsonDocument.Parse(payloadJson);
doc.RootElement.GetProperty("predicateType").GetString()
.Should().Be(PredicateTypes.StellaOpsSbom);
}
[Fact]
public async Task SignAsync_WithReplayPredicate_ProducesValidDsseEnvelope()
{
// Arrange
var request = SigningRequestBuilder.Create()
.WithDefaultSubject()
.WithReplayPredicate()
.WithKeylessMode()
.Build();
var entitlement = DeterministicTestData.CreateDefaultEntitlement();
var caller = DeterministicTestData.CreateDefaultCallerContext();
// Act
var bundle = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert
bundle.Should().NotBeNull();
// Verify payload contains replay predicate
var payloadBytes = DecodeBase64Url(bundle.Envelope.Payload);
var payloadJson = Encoding.UTF8.GetString(payloadBytes);
using var doc = JsonDocument.Parse(payloadJson);
doc.RootElement.GetProperty("predicateType").GetString()
.Should().Be(PredicateTypes.StellaOpsReplay);
}
[Fact]
public async Task SignAsync_WithSlsaProvenanceV02_ProducesValidEnvelope()
{
// Arrange
var request = SigningRequestBuilder.Create()
.WithDefaultSubject()
.WithSlsaProvenanceV02()
.WithKmsMode()
.Build();
var entitlement = DeterministicTestData.CreateDefaultEntitlement();
var caller = DeterministicTestData.CreateDefaultCallerContext();
// Act
var bundle = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert
bundle.Should().NotBeNull();
bundle.Metadata.Identity.Mode.Should().Be("kms");
var payloadBytes = DecodeBase64Url(bundle.Envelope.Payload);
var payloadJson = Encoding.UTF8.GetString(payloadBytes);
using var doc = JsonDocument.Parse(payloadJson);
doc.RootElement.GetProperty("predicateType").GetString()
.Should().Be(PredicateTypes.SlsaProvenanceV02);
doc.RootElement.GetProperty("_type").GetString()
.Should().Be("https://in-toto.io/Statement/v0.1");
}
[Fact]
public async Task SignAsync_WithSlsaProvenanceV1_UsesStatementV1()
{
// Arrange - use predicate with v1 expected statement type
var request = SigningRequestBuilder.Create()
.WithDefaultSubject()
.WithSlsaProvenanceV1()
.WithKeylessMode()
.Build();
var entitlement = DeterministicTestData.CreateDefaultEntitlement();
var caller = DeterministicTestData.CreateDefaultCallerContext();
// Act
var bundle = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert
bundle.Should().NotBeNull();
var payloadBytes = DecodeBase64Url(bundle.Envelope.Payload);
var payloadJson = Encoding.UTF8.GetString(payloadBytes);
using var doc = JsonDocument.Parse(payloadJson);
doc.RootElement.GetProperty("predicateType").GetString()
.Should().Be(PredicateTypes.SlsaProvenanceV1);
}
[Fact]
public async Task SignAsync_WithMultipleSubjects_IncludesAllInPayload()
{
// Arrange
var request = SigningRequestBuilder.Create()
.WithSubject(DeterministicTestData.DefaultSubjectName, DeterministicTestData.DefaultSubjectDigest)
.WithSubject(DeterministicTestData.SecondSubjectName, DeterministicTestData.SecondSubjectDigest)
.WithSubject(DeterministicTestData.ThirdSubjectName, DeterministicTestData.ThirdSubjectDigest)
.WithPromotionPredicate()
.WithKeylessMode()
.Build();
var entitlement = DeterministicTestData.CreateDefaultEntitlement();
var caller = DeterministicTestData.CreateDefaultCallerContext();
// Act
var bundle = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert
var payloadBytes = DecodeBase64Url(bundle.Envelope.Payload);
var payloadJson = Encoding.UTF8.GetString(payloadBytes);
using var doc = JsonDocument.Parse(payloadJson);
doc.RootElement.GetProperty("subject").GetArrayLength().Should().Be(3);
}
[Fact]
public async Task SignAsync_WithVexPredicate_ProducesValidEnvelope()
{
// Arrange
var request = SigningRequestBuilder.Create()
.WithDefaultSubject()
.WithVexPredicate()
.WithKeylessMode()
.Build();
var entitlement = DeterministicTestData.CreateDefaultEntitlement();
var caller = DeterministicTestData.CreateDefaultCallerContext();
// Act
var bundle = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert
bundle.Should().NotBeNull();
var payloadBytes = DecodeBase64Url(bundle.Envelope.Payload);
var payloadJson = Encoding.UTF8.GetString(payloadBytes);
using var doc = JsonDocument.Parse(payloadJson);
doc.RootElement.GetProperty("predicateType").GetString()
.Should().Be(PredicateTypes.StellaOpsVex);
}
[Fact]
public async Task SignAsync_WithPolicyPredicate_ProducesValidEnvelope()
{
// Arrange
var request = SigningRequestBuilder.Create()
.WithDefaultSubject()
.WithPolicyPredicate()
.WithKeylessMode()
.Build();
var entitlement = DeterministicTestData.CreateDefaultEntitlement();
var caller = DeterministicTestData.CreateDefaultCallerContext();
// Act
var bundle = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert
bundle.Should().NotBeNull();
var payloadBytes = DecodeBase64Url(bundle.Envelope.Payload);
var payloadJson = Encoding.UTF8.GetString(payloadBytes);
using var doc = JsonDocument.Parse(payloadJson);
doc.RootElement.GetProperty("predicateType").GetString()
.Should().Be(PredicateTypes.StellaOpsPolicy);
}
[Fact]
public async Task SignAsync_WithEvidencePredicate_ProducesValidEnvelope()
{
// Arrange
var request = SigningRequestBuilder.Create()
.WithDefaultSubject()
.WithEvidencePredicate()
.WithKeylessMode()
.Build();
var entitlement = DeterministicTestData.CreateDefaultEntitlement();
var caller = DeterministicTestData.CreateDefaultCallerContext();
// Act
var bundle = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert
bundle.Should().NotBeNull();
var payloadBytes = DecodeBase64Url(bundle.Envelope.Payload);
var payloadJson = Encoding.UTF8.GetString(payloadBytes);
using var doc = JsonDocument.Parse(payloadJson);
doc.RootElement.GetProperty("predicateType").GetString()
.Should().Be(PredicateTypes.StellaOpsEvidence);
}
[Fact]
public async Task SignAsync_ProducesBase64UrlEncodedSignature()
{
// Arrange
var request = SigningRequestBuilder.Create()
.WithDefaultSubject()
.WithPromotionPredicate()
.Build();
var entitlement = DeterministicTestData.CreateDefaultEntitlement();
var caller = DeterministicTestData.CreateDefaultCallerContext();
// Act
var bundle = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert - signature should be base64url (no + or / or =)
var signature = bundle.Envelope.Signatures[0].Signature;
signature.Should().NotContain("+");
signature.Should().NotContain("/");
signature.Should().NotContain("=");
}
[Fact]
public async Task SignAsync_ProducesBase64UrlEncodedPayload()
{
// Arrange
var request = SigningRequestBuilder.Create()
.WithDefaultSubject()
.WithPromotionPredicate()
.Build();
var entitlement = DeterministicTestData.CreateDefaultEntitlement();
var caller = DeterministicTestData.CreateDefaultCallerContext();
// Act
var bundle = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert - payload should be base64url (no + or / or =)
var payload = bundle.Envelope.Payload;
payload.Should().NotContain("+");
payload.Should().NotContain("/");
payload.Should().NotContain("=");
}
[Fact]
public async Task SignAsync_IncludesCertificateChainInMetadata()
{
// Arrange
var request = SigningRequestBuilder.Create()
.WithDefaultSubject()
.WithPromotionPredicate()
.WithKeylessMode()
.Build();
var entitlement = DeterministicTestData.CreateDefaultEntitlement();
var caller = DeterministicTestData.CreateDefaultCallerContext();
// Act
var bundle = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert
bundle.Metadata.CertificateChain.Should().NotBeEmpty();
}
[Fact]
public async Task SignAsync_SetsCorrectAlgorithmId()
{
// Arrange
var request = SigningRequestBuilder.Create()
.WithDefaultSubject()
.WithPromotionPredicate()
.Build();
var entitlement = DeterministicTestData.CreateDefaultEntitlement();
var caller = DeterministicTestData.CreateDefaultCallerContext();
// Act
var bundle = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert
bundle.Metadata.AlgorithmId.Should().Be(SignatureAlgorithms.Es256);
}
[Fact]
public async Task SignAsync_SetsProviderNameInMetadata()
{
// Arrange
var request = SigningRequestBuilder.Create()
.WithDefaultSubject()
.WithPromotionPredicate()
.Build();
var entitlement = DeterministicTestData.CreateDefaultEntitlement();
var caller = DeterministicTestData.CreateDefaultCallerContext();
// Act
var bundle = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert
bundle.Metadata.ProviderName.Should().Be("default");
}
[Fact]
public async Task SignAsync_WithDifferentTenants_UsesCorrectKeyResolution()
{
// Arrange
var request = SigningRequestBuilder.Create()
.WithDefaultSubject()
.WithPromotionPredicate()
.Build();
var entitlement = DeterministicTestData.CreateDefaultEntitlement();
var caller1 = DeterministicTestData.CreateCallerContextForTenant("tenant-a");
var caller2 = DeterministicTestData.CreateCallerContextForTenant("tenant-b");
// Act
var bundle1 = await _signer.SignAsync(request, entitlement, caller1, CancellationToken.None);
var bundle2 = await _signer.SignAsync(request, entitlement, caller2, CancellationToken.None);
// Assert - both should produce valid bundles
bundle1.Should().NotBeNull();
bundle2.Should().NotBeNull();
bundle1.Envelope.Signatures[0].Signature.Should().NotBeNullOrEmpty();
bundle2.Envelope.Signatures[0].Signature.Should().NotBeNullOrEmpty();
}
[Fact]
public async Task SignAsync_Signature_IsVerifiable()
{
// Arrange
var request = SigningRequestBuilder.Create()
.WithDefaultSubject()
.WithPromotionPredicate()
.Build();
var entitlement = DeterministicTestData.CreateDefaultEntitlement();
var caller = DeterministicTestData.CreateDefaultCallerContext();
// Act
var bundle = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert - verify we can decode and re-verify the signature
var payloadBytes = DecodeBase64Url(bundle.Envelope.Payload);
var signatureBytes = DecodeBase64Url(bundle.Envelope.Signatures[0].Signature);
// Build PAE for verification
var paeBytes = BuildPae(bundle.Envelope.PayloadType, payloadBytes);
// Get signer for verification
var keyReference = TestCryptoFactory.CreateKeylessKeyReference();
var resolution = _cryptoRegistry.ResolveSigner(
CryptoCapability.Verification,
SignatureAlgorithms.Es256,
keyReference);
var verified = await resolution.Signer.VerifyAsync(paeBytes, signatureBytes, CancellationToken.None);
verified.Should().BeTrue();
}
private CryptoDsseSigner CreateSigner()
{
var options = Options.Create(new DsseSignerOptions
{
DefaultIssuer = DeterministicTestData.DefaultIssuer,
KeylessAlgorithm = SignatureAlgorithms.Es256,
KmsAlgorithm = SignatureAlgorithms.Es256
});
return new CryptoDsseSigner(
_cryptoRegistry,
_keyResolver,
options,
NullLogger<CryptoDsseSigner>.Instance);
}
private ISigningKeyResolver CreateTestKeyResolver()
{
var keyResolver = Substitute.For<ISigningKeyResolver>();
keyResolver.ResolveKeyAsync(SigningMode.Keyless, Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(callInfo => ValueTask.FromResult(new SigningKeyResolution(
TestCryptoFactory.KeylessKeyId,
"default",
DeterministicTestData.DefaultIssuer,
callInfo.Arg<string>(), // tenant as subject
DeterministicTestData.ExpiryTimestamp)));
keyResolver.ResolveKeyAsync(SigningMode.Kms, Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ValueTask.FromResult(new SigningKeyResolution(
TestCryptoFactory.TestKeyId,
"default",
DeterministicTestData.DefaultIssuer,
"kms-service@stellaops.io",
DeterministicTestData.FarFutureExpiry)));
return keyResolver;
}
private static byte[] DecodeBase64Url(string base64Url)
{
// Convert base64url to standard base64
var base64 = base64Url
.Replace('-', '+')
.Replace('_', '/');
// Add padding if needed
switch (base64.Length % 4)
{
case 2: base64 += "=="; break;
case 3: base64 += "="; break;
}
return Convert.FromBase64String(base64);
}
private static byte[] BuildPae(string payloadType, byte[] payload)
{
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
var prefixBytes = Encoding.UTF8.GetBytes("DSSEv1");
var typeLenStr = typeBytes.Length.ToString();
var payloadLenStr = payload.Length.ToString();
var totalLen = prefixBytes.Length + 1 +
typeLenStr.Length + 1 +
typeBytes.Length + 1 +
payloadLenStr.Length + 1 +
payload.Length;
var pae = new byte[totalLen];
var offset = 0;
Buffer.BlockCopy(prefixBytes, 0, pae, offset, prefixBytes.Length);
offset += prefixBytes.Length;
pae[offset++] = 0x20;
var typeLenBytes = Encoding.UTF8.GetBytes(typeLenStr);
Buffer.BlockCopy(typeLenBytes, 0, pae, offset, typeLenBytes.Length);
offset += typeLenBytes.Length;
pae[offset++] = 0x20;
Buffer.BlockCopy(typeBytes, 0, pae, offset, typeBytes.Length);
offset += typeBytes.Length;
pae[offset++] = 0x20;
var payloadLenBytes = Encoding.UTF8.GetBytes(payloadLenStr);
Buffer.BlockCopy(payloadLenBytes, 0, pae, offset, payloadLenBytes.Length);
offset += payloadLenBytes.Length;
pae[offset++] = 0x20;
Buffer.BlockCopy(payload, 0, pae, offset, payload.Length);
return pae;
}
}

View File

@@ -0,0 +1,355 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using StellaOps.Cryptography;
using StellaOps.Signer.Core;
using StellaOps.Signer.Infrastructure.Options;
using StellaOps.Signer.Infrastructure.Signing;
using StellaOps.Signer.Tests.Fixtures;
using Xunit;
namespace StellaOps.Signer.Tests.Integration;
/// <summary>
/// Integration tests for the full signer pipeline using real crypto abstraction.
/// </summary>
public sealed class SignerPipelineIntegrationTests
{
[Fact]
public async Task SignerPipeline_WithCryptoDsseSigner_ProducesValidBundle()
{
// Arrange
var services = CreateServiceCollection();
using var provider = services.BuildServiceProvider();
var pipeline = provider.GetRequiredService<ISignerPipeline>();
var request = SigningRequestBuilder.Create()
.WithDefaultSubject()
.WithPromotionPredicate()
.WithKeylessMode()
.Build();
var caller = DeterministicTestData.CreateDefaultCallerContext();
// Act
var outcome = await pipeline.SignAsync(request, caller, CancellationToken.None);
// Assert
outcome.Should().NotBeNull();
outcome.Bundle.Should().NotBeNull();
outcome.Bundle.Envelope.PayloadType.Should().Be("application/vnd.in-toto+json");
outcome.Bundle.Envelope.Signatures.Should().HaveCount(1);
outcome.AuditId.Should().NotBeNullOrEmpty();
outcome.Policy.Plan.Should().NotBeNullOrEmpty();
}
[Fact]
public async Task SignerPipeline_WithSbomPredicate_ProducesValidBundle()
{
// Arrange
var services = CreateServiceCollection();
using var provider = services.BuildServiceProvider();
var pipeline = provider.GetRequiredService<ISignerPipeline>();
var request = SigningRequestBuilder.Create()
.WithDefaultSubject()
.WithSbomPredicate()
.Build();
var caller = DeterministicTestData.CreateDefaultCallerContext();
// Act
var outcome = await pipeline.SignAsync(request, caller, CancellationToken.None);
// Assert
outcome.Should().NotBeNull();
outcome.Bundle.Envelope.Signatures[0].Signature.Should().NotBeNullOrEmpty();
}
[Fact]
public async Task SignerPipeline_WithReplayPredicate_ProducesValidBundle()
{
// Arrange
var services = CreateServiceCollection();
using var provider = services.BuildServiceProvider();
var pipeline = provider.GetRequiredService<ISignerPipeline>();
var request = SigningRequestBuilder.Create()
.WithDefaultSubject()
.WithReplayPredicate()
.Build();
var caller = DeterministicTestData.CreateDefaultCallerContext();
// Act
var outcome = await pipeline.SignAsync(request, caller, CancellationToken.None);
// Assert
outcome.Should().NotBeNull();
outcome.Bundle.Metadata.AlgorithmId.Should().Be(SignatureAlgorithms.Es256);
}
[Fact]
public async Task SignerPipeline_TracksAuditEntry()
{
// Arrange
var services = CreateServiceCollection();
using var provider = services.BuildServiceProvider();
var pipeline = provider.GetRequiredService<ISignerPipeline>();
var request = SigningRequestBuilder.Create()
.WithDefaultSubject()
.WithPromotionPredicate()
.Build();
var caller = DeterministicTestData.CreateDefaultCallerContext();
// Act
var outcome = await pipeline.SignAsync(request, caller, CancellationToken.None);
// Assert
outcome.AuditId.Should().NotBeNullOrEmpty();
// Audit ID should be a valid GUID format
Guid.TryParse(outcome.AuditId, out _).Should().BeTrue();
}
[Fact]
public async Task SignerPipeline_EnforcesPolicyCounters()
{
// Arrange
var services = CreateServiceCollection();
using var provider = services.BuildServiceProvider();
var pipeline = provider.GetRequiredService<ISignerPipeline>();
var request = SigningRequestBuilder.Create()
.WithDefaultSubject()
.WithPromotionPredicate()
.Build();
var caller = DeterministicTestData.CreateDefaultCallerContext();
// Act
var outcome = await pipeline.SignAsync(request, caller, CancellationToken.None);
// Assert
outcome.Policy.Should().NotBeNull();
outcome.Policy.Plan.Should().Be(DeterministicTestData.ProPlan);
outcome.Policy.MaxArtifactBytes.Should().BeGreaterThan(0);
}
[Fact]
public async Task SignerPipeline_WithKmsMode_UsesCorrectSigningIdentity()
{
// Arrange
var services = CreateServiceCollection();
using var provider = services.BuildServiceProvider();
var pipeline = provider.GetRequiredService<ISignerPipeline>();
var request = SigningRequestBuilder.Create()
.WithDefaultSubject()
.WithSlsaProvenanceV02()
.WithKmsMode()
.Build();
var caller = DeterministicTestData.CreateDefaultCallerContext();
// Act
var outcome = await pipeline.SignAsync(request, caller, CancellationToken.None);
// Assert
outcome.Bundle.Metadata.Identity.Mode.Should().Be("kms");
outcome.Bundle.Metadata.Identity.Issuer.Should().NotBeNullOrEmpty();
}
[Fact]
public async Task SignerPipeline_WithKeylessMode_UsesEphemeralKey()
{
// Arrange
var services = CreateServiceCollection();
using var provider = services.BuildServiceProvider();
var pipeline = provider.GetRequiredService<ISignerPipeline>();
var request = SigningRequestBuilder.Create()
.WithDefaultSubject()
.WithPromotionPredicate()
.WithKeylessMode()
.Build();
var caller = DeterministicTestData.CreateDefaultCallerContext();
// Act
var outcome = await pipeline.SignAsync(request, caller, CancellationToken.None);
// Assert
outcome.Bundle.Metadata.Identity.Mode.Should().Be("keyless");
}
[Fact]
public async Task SignerPipeline_RejectsUntrustedScannerDigest()
{
// Arrange
var services = CreateServiceCollection();
using var provider = services.BuildServiceProvider();
var pipeline = provider.GetRequiredService<ISignerPipeline>();
var request = SigningRequestBuilder.Create()
.WithDefaultSubject()
.WithPromotionPredicate()
.WithScannerImageDigest(DeterministicTestData.UntrustedScannerDigest)
.Build();
var caller = DeterministicTestData.CreateDefaultCallerContext();
// Act
var act = async () => await pipeline.SignAsync(request, caller, CancellationToken.None);
// Assert
await act.Should().ThrowAsync<SignerReleaseVerificationException>();
}
[Theory]
[InlineData(PredicateTypes.StellaOpsPromotion)]
[InlineData(PredicateTypes.StellaOpsSbom)]
[InlineData(PredicateTypes.StellaOpsVex)]
[InlineData(PredicateTypes.StellaOpsReplay)]
[InlineData(PredicateTypes.StellaOpsPolicy)]
[InlineData(PredicateTypes.StellaOpsEvidence)]
[InlineData(PredicateTypes.StellaOpsVexDecision)]
[InlineData(PredicateTypes.StellaOpsGraph)]
public async Task SignerPipeline_SupportsAllStellaOpsPredicateTypes(string predicateType)
{
// Arrange
var services = CreateServiceCollection();
using var provider = services.BuildServiceProvider();
var pipeline = provider.GetRequiredService<ISignerPipeline>();
var predicate = GetPredicateForType(predicateType);
var request = SigningRequestBuilder.Create()
.WithDefaultSubject()
.WithPredicateType(predicateType)
.WithPredicate(predicate)
.Build();
var caller = DeterministicTestData.CreateDefaultCallerContext();
// Act
var outcome = await pipeline.SignAsync(request, caller, CancellationToken.None);
// Assert
outcome.Should().NotBeNull();
outcome.Bundle.Envelope.Signatures.Should().NotBeEmpty();
}
private static ServiceCollection CreateServiceCollection()
{
var services = new ServiceCollection();
// Register logging
services.AddLogging();
// Register time provider
services.AddSingleton(TimeProvider.System);
// Register crypto registry with test keys
services.AddSingleton<ICryptoProviderRegistry>(_ =>
{
var provider = TestCryptoFactory.CreateProviderWithMultipleKeys(
TestCryptoFactory.TestKeyId,
TestCryptoFactory.KeylessKeyId);
return new CryptoProviderRegistry(new[] { provider }, new[] { provider.Name });
});
// Register key resolver
services.AddSingleton<ISigningKeyResolver>(sp =>
{
var keyResolver = Substitute.For<ISigningKeyResolver>();
keyResolver.ResolveKeyAsync(SigningMode.Keyless, Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ValueTask.FromResult(new SigningKeyResolution(
TestCryptoFactory.KeylessKeyId,
"default",
DeterministicTestData.DefaultIssuer)));
keyResolver.ResolveKeyAsync(SigningMode.Kms, Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ValueTask.FromResult(new SigningKeyResolution(
TestCryptoFactory.TestKeyId,
"default",
DeterministicTestData.DefaultIssuer)));
return keyResolver;
});
// Register DSSE signer options
services.Configure<DsseSignerOptions>(options =>
{
options.DefaultIssuer = DeterministicTestData.DefaultIssuer;
options.KeylessAlgorithm = SignatureAlgorithms.Es256;
options.KmsAlgorithm = SignatureAlgorithms.Es256;
});
// Register CryptoDsseSigner
services.AddSingleton<IDsseSigner, CryptoDsseSigner>();
// Register stub services for pipeline dependencies
services.AddSingleton<IProofOfEntitlementIntrospector>(sp =>
{
var introspector = Substitute.For<IProofOfEntitlementIntrospector>();
introspector.IntrospectAsync(Arg.Any<ProofOfEntitlement>(), Arg.Any<CallerContext>(), Arg.Any<CancellationToken>())
.Returns(ValueTask.FromResult(DeterministicTestData.CreateDefaultEntitlement()));
return introspector;
});
services.AddSingleton<IReleaseIntegrityVerifier>(sp =>
{
var verifier = Substitute.For<IReleaseIntegrityVerifier>();
verifier.VerifyAsync(DeterministicTestData.TrustedScannerDigest, Arg.Any<CancellationToken>())
.Returns(ValueTask.FromResult(new ReleaseVerificationResult(true, "trusted-signer")));
verifier.VerifyAsync(DeterministicTestData.UntrustedScannerDigest, Arg.Any<CancellationToken>())
.Returns<ReleaseVerificationResult>(_ =>
throw new SignerReleaseVerificationException("release_untrusted", "Scanner digest is not trusted."));
return verifier;
});
services.AddSingleton<ISignerQuotaService>(sp =>
{
var quotaService = Substitute.For<ISignerQuotaService>();
quotaService.EnsureWithinLimitsAsync(
Arg.Any<SigningRequest>(),
Arg.Any<ProofOfEntitlementResult>(),
Arg.Any<CallerContext>(),
Arg.Any<CancellationToken>())
.Returns(ValueTask.CompletedTask);
return quotaService;
});
services.AddSingleton<ISignerAuditSink>(sp =>
{
var auditSink = Substitute.For<ISignerAuditSink>();
auditSink.WriteAsync(
Arg.Any<SigningRequest>(),
Arg.Any<SigningBundle>(),
Arg.Any<ProofOfEntitlementResult>(),
Arg.Any<CallerContext>(),
Arg.Any<CancellationToken>())
.Returns(callInfo => ValueTask.FromResult(Guid.NewGuid().ToString()));
return auditSink;
});
// Register the pipeline
services.AddSingleton<ISignerPipeline, SignerPipeline>();
return services;
}
private static System.Text.Json.JsonDocument GetPredicateForType(string predicateType)
{
return predicateType switch
{
PredicateTypes.StellaOpsPromotion => PredicateFixtures.CreatePromotionPredicate(),
PredicateTypes.StellaOpsSbom => PredicateFixtures.CreateSbomPredicate(),
PredicateTypes.StellaOpsVex => PredicateFixtures.CreateVexPredicate(),
PredicateTypes.StellaOpsReplay => PredicateFixtures.CreateReplayPredicate(),
PredicateTypes.StellaOpsPolicy => PredicateFixtures.CreatePolicyPredicate(),
PredicateTypes.StellaOpsEvidence => PredicateFixtures.CreateEvidencePredicate(),
PredicateTypes.StellaOpsVexDecision => PredicateFixtures.CreateVexDecisionPredicate(),
PredicateTypes.StellaOpsGraph => PredicateFixtures.CreateGraphPredicate(),
_ => PredicateFixtures.CreateSlsaProvenanceV02Predicate()
};
}
}

View File

@@ -0,0 +1,303 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using NSubstitute;
using StellaOps.Cryptography;
using StellaOps.Signer.Core;
using StellaOps.Signer.Infrastructure.Signing;
using Xunit;
namespace StellaOps.Signer.Tests.Signing;
public sealed class CryptoDsseSignerTests
{
private readonly ICryptoProviderRegistry _mockRegistry;
private readonly ISigningKeyResolver _mockKeyResolver;
private readonly ICryptoSigner _mockCryptoSigner;
private readonly DsseSignerOptions _options;
private readonly CryptoDsseSigner _signer;
public CryptoDsseSignerTests()
{
_mockRegistry = Substitute.For<ICryptoProviderRegistry>();
_mockKeyResolver = Substitute.For<ISigningKeyResolver>();
_mockCryptoSigner = Substitute.For<ICryptoSigner>();
_options = new DsseSignerOptions
{
DefaultIssuer = "https://test.stellaops.io",
KeylessAlgorithm = SignatureAlgorithms.Es256
};
_signer = new CryptoDsseSigner(
_mockRegistry,
_mockKeyResolver,
Options.Create(_options),
NullLogger<CryptoDsseSigner>.Instance);
}
[Fact]
public async Task SignAsync_ProducesValidDsseEnvelope()
{
// Arrange
var request = CreateSigningRequest();
var entitlement = CreateEntitlement();
var caller = CreateCallerContext();
var keyResolution = new SigningKeyResolution("test-key-id", "default");
var signatureBytes = new byte[] { 0x01, 0x02, 0x03, 0x04 };
_mockKeyResolver
.ResolveKeyAsync(Arg.Any<SigningMode>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ValueTask.FromResult(keyResolution));
_mockCryptoSigner.KeyId.Returns("test-key-id");
_mockCryptoSigner.AlgorithmId.Returns(SignatureAlgorithms.Es256);
_mockCryptoSigner
.SignAsync(Arg.Any<ReadOnlyMemory<byte>>(), Arg.Any<CancellationToken>())
.Returns(ValueTask.FromResult(signatureBytes));
_mockCryptoSigner
.ExportPublicJsonWebKey()
.Returns(new JsonWebKey { KeyId = "test-key-id", Kty = "EC" });
_mockRegistry
.ResolveSigner(
Arg.Any<CryptoCapability>(),
Arg.Any<string>(),
Arg.Any<CryptoKeyReference>(),
Arg.Any<string?>())
.Returns(new CryptoSignerResolution(_mockCryptoSigner, "default"));
// Act
var result = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.Envelope.Should().NotBeNull();
result.Envelope.PayloadType.Should().Be("application/vnd.in-toto+json");
result.Envelope.Payload.Should().NotBeNullOrEmpty();
result.Envelope.Signatures.Should().HaveCount(1);
result.Envelope.Signatures[0].Signature.Should().NotBeNullOrEmpty();
result.Envelope.Signatures[0].KeyId.Should().Be("test-key-id");
}
[Fact]
public async Task SignAsync_SetsCorrectSigningMetadata()
{
// Arrange
var request = CreateSigningRequest();
var entitlement = CreateEntitlement();
var caller = CreateCallerContext();
var keyResolution = new SigningKeyResolution(
"kms-key-123",
"default",
"https://custom.issuer.io",
"service-account@tenant.stellaops.io",
DateTimeOffset.UtcNow.AddHours(1));
var signatureBytes = new byte[] { 0xAB, 0xCD };
_mockKeyResolver
.ResolveKeyAsync(Arg.Any<SigningMode>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ValueTask.FromResult(keyResolution));
_mockCryptoSigner.KeyId.Returns("kms-key-123");
_mockCryptoSigner.AlgorithmId.Returns(SignatureAlgorithms.Es256);
_mockCryptoSigner
.SignAsync(Arg.Any<ReadOnlyMemory<byte>>(), Arg.Any<CancellationToken>())
.Returns(ValueTask.FromResult(signatureBytes));
_mockCryptoSigner
.ExportPublicJsonWebKey()
.Returns(new JsonWebKey { KeyId = "kms-key-123", Kty = "EC" });
_mockRegistry
.ResolveSigner(
Arg.Any<CryptoCapability>(),
Arg.Any<string>(),
Arg.Any<CryptoKeyReference>(),
Arg.Any<string?>())
.Returns(new CryptoSignerResolution(_mockCryptoSigner, "kms-provider"));
// Act
var result = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert
result.Metadata.Should().NotBeNull();
result.Metadata.ProviderName.Should().Be("kms-provider");
result.Metadata.AlgorithmId.Should().Be(SignatureAlgorithms.Es256);
result.Metadata.Identity.Should().NotBeNull();
result.Metadata.Identity.Issuer.Should().Be("https://custom.issuer.io");
result.Metadata.Identity.Subject.Should().Be("service-account@tenant.stellaops.io");
result.Metadata.Identity.Mode.Should().Be("keyless");
}
[Fact]
public async Task SignAsync_UsesKmsMode_WhenRequested()
{
// Arrange
var request = CreateSigningRequest(SigningMode.Kms);
var entitlement = CreateEntitlement();
var caller = CreateCallerContext();
var keyResolution = new SigningKeyResolution("kms-key-abc", "kms-provider");
var signatureBytes = new byte[] { 0x11, 0x22, 0x33 };
_mockKeyResolver
.ResolveKeyAsync(SigningMode.Kms, caller.Tenant, Arg.Any<CancellationToken>())
.Returns(ValueTask.FromResult(keyResolution));
_mockCryptoSigner.KeyId.Returns("kms-key-abc");
_mockCryptoSigner.AlgorithmId.Returns(SignatureAlgorithms.Es256);
_mockCryptoSigner
.SignAsync(Arg.Any<ReadOnlyMemory<byte>>(), Arg.Any<CancellationToken>())
.Returns(ValueTask.FromResult(signatureBytes));
_mockCryptoSigner
.ExportPublicJsonWebKey()
.Returns(new JsonWebKey { KeyId = "kms-key-abc", Kty = "EC" });
_mockRegistry
.ResolveSigner(
CryptoCapability.Signing,
Arg.Any<string>(),
Arg.Is<CryptoKeyReference>(k => k.KeyId == "kms-key-abc"),
"kms-provider")
.Returns(new CryptoSignerResolution(_mockCryptoSigner, "kms-provider"));
// Act
var result = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert
result.Metadata.Identity.Mode.Should().Be("kms");
await _mockKeyResolver.Received(1).ResolveKeyAsync(SigningMode.Kms, caller.Tenant, Arg.Any<CancellationToken>());
}
[Fact]
public async Task SignAsync_ProducesCosignCompatibleBase64Url()
{
// Arrange
var request = CreateSigningRequest();
var entitlement = CreateEntitlement();
var caller = CreateCallerContext();
var keyResolution = new SigningKeyResolution("test-key");
// Use signature bytes that would produce + and / in standard base64
var signatureBytes = new byte[] { 0xFB, 0xFF, 0xFE, 0x00, 0x01 };
_mockKeyResolver
.ResolveKeyAsync(Arg.Any<SigningMode>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ValueTask.FromResult(keyResolution));
_mockCryptoSigner.KeyId.Returns("test-key");
_mockCryptoSigner.AlgorithmId.Returns(SignatureAlgorithms.Es256);
_mockCryptoSigner
.SignAsync(Arg.Any<ReadOnlyMemory<byte>>(), Arg.Any<CancellationToken>())
.Returns(ValueTask.FromResult(signatureBytes));
_mockCryptoSigner
.ExportPublicJsonWebKey()
.Returns(new JsonWebKey { KeyId = "test-key", Kty = "EC" });
_mockRegistry
.ResolveSigner(
Arg.Any<CryptoCapability>(),
Arg.Any<string>(),
Arg.Any<CryptoKeyReference>(),
Arg.Any<string?>())
.Returns(new CryptoSignerResolution(_mockCryptoSigner, "default"));
// Act
var result = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert
var signature = result.Envelope.Signatures[0].Signature;
signature.Should().NotContain("+");
signature.Should().NotContain("/");
signature.Should().NotContain("=");
// Verify payload is also base64url encoded
result.Envelope.Payload.Should().NotContain("+");
result.Envelope.Payload.Should().NotContain("/");
result.Envelope.Payload.Should().NotEndWith("=");
}
[Fact]
public async Task SignAsync_ThrowsArgumentNullException_WhenRequestIsNull()
{
// Arrange
var entitlement = CreateEntitlement();
var caller = CreateCallerContext();
// Act & Assert
var act = async () => await _signer.SignAsync(null!, entitlement, caller, CancellationToken.None);
await act.Should().ThrowAsync<ArgumentNullException>()
.Where(e => e.ParamName == "request");
}
[Fact]
public async Task SignAsync_ThrowsArgumentNullException_WhenEntitlementIsNull()
{
// Arrange
var request = CreateSigningRequest();
var caller = CreateCallerContext();
// Act & Assert
var act = async () => await _signer.SignAsync(request, null!, caller, CancellationToken.None);
await act.Should().ThrowAsync<ArgumentNullException>()
.Where(e => e.ParamName == "entitlement");
}
[Fact]
public async Task SignAsync_ThrowsArgumentNullException_WhenCallerIsNull()
{
// Arrange
var request = CreateSigningRequest();
var entitlement = CreateEntitlement();
// Act & Assert
var act = async () => await _signer.SignAsync(request, entitlement, null!, CancellationToken.None);
await act.Should().ThrowAsync<ArgumentNullException>()
.Where(e => e.ParamName == "caller");
}
private static SigningRequest CreateSigningRequest(SigningMode mode = SigningMode.Keyless)
{
var predicate = JsonDocument.Parse("""{"builder": {"id": "test-builder"}, "invocation": {}}""");
return new SigningRequest(
Subjects:
[
new SigningSubject("artifact.tar.gz", new Dictionary<string, string>
{
["sha256"] = "abc123def456"
})
],
PredicateType: "https://slsa.dev/provenance/v0.2",
Predicate: predicate,
ScannerImageDigest: "sha256:scanner123",
ProofOfEntitlement: new ProofOfEntitlement(SignerPoEFormat.Jwt, "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."),
Options: new SigningOptions(mode, 3600, "bundle"));
}
private static ProofOfEntitlementResult CreateEntitlement()
{
return new ProofOfEntitlementResult(
LicenseId: "lic-123",
CustomerId: "cust-456",
Plan: "enterprise",
MaxArtifactBytes: 100_000_000,
QpsLimit: 100,
QpsRemaining: 95,
ExpiresAtUtc: DateTimeOffset.UtcNow.AddHours(1));
}
private static CallerContext CreateCallerContext()
{
return new CallerContext(
Subject: "user@example.com",
Tenant: "test-tenant",
Scopes: ["signer.sign"],
Audiences: ["signer"],
SenderBinding: null,
ClientCertificateThumbprint: null);
}
}

View File

@@ -0,0 +1,165 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Signer.Core;
using StellaOps.Signer.Infrastructure.Signing;
using Xunit;
namespace StellaOps.Signer.Tests.Signing;
public sealed class DefaultSigningKeyResolverTests
{
private readonly DsseSignerOptions _options;
private readonly FakeTimeProvider _timeProvider;
private readonly DefaultSigningKeyResolver _resolver;
public DefaultSigningKeyResolverTests()
{
_options = new DsseSignerOptions
{
DefaultIssuer = "https://test.stellaops.io",
PreferredProvider = "test-provider"
};
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 11, 26, 12, 0, 0, TimeSpan.Zero));
_resolver = new DefaultSigningKeyResolver(
Options.Create(_options),
_timeProvider,
NullLogger<DefaultSigningKeyResolver>.Instance);
}
[Fact]
public async Task ResolveKeyAsync_KeylessMode_ReturnsEphemeralKey()
{
// Act
var result = await _resolver.ResolveKeyAsync(SigningMode.Keyless, "tenant-123", CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.KeyId.Should().StartWith("ephemeral:tenant-123:");
result.ProviderHint.Should().Be("test-provider");
result.Issuer.Should().Be("https://test.stellaops.io");
result.Subject.Should().Be("keyless:tenant-123");
result.ExpiresAtUtc.Should().NotBeNull();
result.ExpiresAtUtc!.Value.Should().Be(_timeProvider.GetUtcNow().AddMinutes(10));
}
[Fact]
public async Task ResolveKeyAsync_KeylessMode_GeneratesUniqueKeyIds()
{
// Act
var result1 = await _resolver.ResolveKeyAsync(SigningMode.Keyless, "tenant-123", CancellationToken.None);
var result2 = await _resolver.ResolveKeyAsync(SigningMode.Keyless, "tenant-123", CancellationToken.None);
// Assert
result1.KeyId.Should().NotBe(result2.KeyId);
}
[Fact]
public async Task ResolveKeyAsync_KmsMode_ReturnsDefaultKmsKey()
{
// Arrange
_options.DefaultKmsKeyId = "projects/test/locations/global/keyRings/ring/cryptoKeys/key";
// Act
var result = await _resolver.ResolveKeyAsync(SigningMode.Kms, "tenant-456", CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.KeyId.Should().Be("projects/test/locations/global/keyRings/ring/cryptoKeys/key");
result.ProviderHint.Should().Be("test-provider");
result.Issuer.Should().Be("https://test.stellaops.io");
result.Subject.Should().Be("kms:tenant-456");
result.ExpiresAtUtc.Should().BeNull();
}
[Fact]
public async Task ResolveKeyAsync_KmsMode_UsesTenantSpecificKey()
{
// Arrange
_options.DefaultKmsKeyId = "default-key";
_options.TenantKmsKeys = new Dictionary<string, string>
{
["tenant-special"] = "tenant-special-key"
};
// Act
var result = await _resolver.ResolveKeyAsync(SigningMode.Kms, "tenant-special", CancellationToken.None);
// Assert
result.KeyId.Should().Be("tenant-special-key");
result.Subject.Should().Be("kms:tenant-special");
}
[Fact]
public async Task ResolveKeyAsync_KmsMode_FallsBackToDefaultKey()
{
// Arrange
_options.DefaultKmsKeyId = "fallback-key";
_options.TenantKmsKeys = new Dictionary<string, string>
{
["other-tenant"] = "other-tenant-key"
};
// Act
var result = await _resolver.ResolveKeyAsync(SigningMode.Kms, "tenant-without-mapping", CancellationToken.None);
// Assert
result.KeyId.Should().Be("fallback-key");
}
[Fact]
public async Task ResolveKeyAsync_KmsMode_ThrowsWhenNoKeyConfigured()
{
// Arrange
_options.DefaultKmsKeyId = null;
_options.TenantKmsKeys.Clear();
// Act & Assert
var act = async () => await _resolver.ResolveKeyAsync(SigningMode.Kms, "tenant-123", CancellationToken.None);
await act.Should().ThrowAsync<InvalidOperationException>()
.Where(e => e.Message.Contains("No KMS key configured") && e.Message.Contains("tenant-123"));
}
[Fact]
public async Task ResolveKeyAsync_ThrowsArgumentException_WhenTenantIsEmpty()
{
// Act & Assert
var act = async () => await _resolver.ResolveKeyAsync(SigningMode.Keyless, "", CancellationToken.None);
await act.Should().ThrowAsync<ArgumentException>()
.Where(e => e.ParamName == "tenant");
}
[Fact]
public async Task ResolveKeyAsync_ThrowsArgumentException_WhenTenantIsWhitespace()
{
// Act & Assert
var act = async () => await _resolver.ResolveKeyAsync(SigningMode.Keyless, " ", CancellationToken.None);
await act.Should().ThrowAsync<ArgumentException>()
.Where(e => e.ParamName == "tenant");
}
[Fact]
public async Task ResolveKeyAsync_ThrowsForUnknownSigningMode()
{
// Act & Assert
var act = async () => await _resolver.ResolveKeyAsync((SigningMode)99, "tenant-123", CancellationToken.None);
await act.Should().ThrowAsync<ArgumentOutOfRangeException>()
.Where(e => e.ParamName == "mode");
}
private sealed class FakeTimeProvider : TimeProvider
{
private readonly DateTimeOffset _now;
public FakeTimeProvider(DateTimeOffset now)
{
_now = now;
}
public override DateTimeOffset GetUtcNow() => _now;
}
}

View File

@@ -0,0 +1,364 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Text.Json;
using FluentAssertions;
using StellaOps.Signer.Core;
using Xunit;
namespace StellaOps.Signer.Tests.Signing;
public sealed class SignerStatementBuilderTests
{
[Fact]
public void BuildStatementPayload_CreatesValidStatement()
{
// Arrange
var request = CreateSigningRequest();
// Act
var payload = SignerStatementBuilder.BuildStatementPayload(request);
// Assert
payload.Should().NotBeNullOrEmpty();
var json = Encoding.UTF8.GetString(payload);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
root.GetProperty("_type").GetString().Should().Be("https://in-toto.io/Statement/v0.1");
root.GetProperty("predicateType").GetString().Should().Be("https://slsa.dev/provenance/v0.2");
root.GetProperty("subject").GetArrayLength().Should().Be(1);
}
[Fact]
public void BuildStatementPayload_UsesDeterministicSerialization()
{
// Arrange
var request = CreateSigningRequest();
// Act
var payload1 = SignerStatementBuilder.BuildStatementPayload(request);
var payload2 = SignerStatementBuilder.BuildStatementPayload(request);
// Assert - Same input should produce identical output
payload1.Should().BeEquivalentTo(payload2);
}
[Fact]
public void BuildStatementPayload_SortsDigestKeys()
{
// Arrange - Use unsorted digest keys
var predicate = JsonDocument.Parse("""{"builder": {"id": "test"}}""");
var request = new SigningRequest(
Subjects:
[
new SigningSubject("artifact.tar.gz", new Dictionary<string, string>
{
["SHA512"] = "xyz789",
["sha256"] = "abc123",
["MD5"] = "def456"
})
],
PredicateType: PredicateTypes.SlsaProvenanceV02,
Predicate: predicate,
ScannerImageDigest: "sha256:scanner",
ProofOfEntitlement: new ProofOfEntitlement(SignerPoEFormat.Jwt, "token"),
Options: new SigningOptions(SigningMode.Keyless, null, "bundle"));
// Act
var payload = SignerStatementBuilder.BuildStatementPayload(request);
var json = Encoding.UTF8.GetString(payload);
// Assert - Digest keys should be lowercase and sorted alphabetically
json.Should().Contain("\"md5\"");
json.Should().Contain("\"sha256\"");
json.Should().Contain("\"sha512\"");
// Verify order: md5 < sha256 < sha512
var md5Index = json.IndexOf("\"md5\"", StringComparison.Ordinal);
var sha256Index = json.IndexOf("\"sha256\"", StringComparison.Ordinal);
var sha512Index = json.IndexOf("\"sha512\"", StringComparison.Ordinal);
md5Index.Should().BeLessThan(sha256Index);
sha256Index.Should().BeLessThan(sha512Index);
}
[Fact]
public void BuildStatementPayload_WithExplicitStatementType_UsesProvided()
{
// Arrange
var request = CreateSigningRequest();
// Act
var payload = SignerStatementBuilder.BuildStatementPayload(request, "https://in-toto.io/Statement/v1");
var json = Encoding.UTF8.GetString(payload);
// Assert
using var doc = JsonDocument.Parse(json);
doc.RootElement.GetProperty("_type").GetString().Should().Be("https://in-toto.io/Statement/v1");
}
[Fact]
public void BuildStatement_ReturnsInTotoStatement()
{
// Arrange
var request = CreateSigningRequest();
// Act
var statement = SignerStatementBuilder.BuildStatement(request);
// Assert
statement.Should().NotBeNull();
statement.Type.Should().Be("https://in-toto.io/Statement/v0.1");
statement.PredicateType.Should().Be(PredicateTypes.SlsaProvenanceV02);
statement.Subject.Should().HaveCount(1);
statement.Subject[0].Name.Should().Be("artifact.tar.gz");
statement.Predicate.ValueKind.Should().Be(JsonValueKind.Object);
}
[Fact]
public void BuildStatementPayload_ThrowsArgumentNullException_WhenRequestIsNull()
{
// Act
var act = () => SignerStatementBuilder.BuildStatementPayload(null!);
// Assert
act.Should().Throw<ArgumentNullException>()
.WithParameterName("request");
}
[Fact]
public void BuildStatementPayload_WithStatementType_ThrowsWhenTypeIsEmpty()
{
// Arrange
var request = CreateSigningRequest();
// Act
var act = () => SignerStatementBuilder.BuildStatementPayload(request, "");
// Assert
act.Should().Throw<ArgumentException>()
.WithParameterName("statementType");
}
[Theory]
[InlineData(PredicateTypes.StellaOpsPromotion, true)]
[InlineData(PredicateTypes.StellaOpsSbom, true)]
[InlineData(PredicateTypes.StellaOpsVex, true)]
[InlineData(PredicateTypes.StellaOpsReplay, true)]
[InlineData(PredicateTypes.StellaOpsPolicy, true)]
[InlineData(PredicateTypes.StellaOpsEvidence, true)]
[InlineData(PredicateTypes.StellaOpsVexDecision, true)]
[InlineData(PredicateTypes.StellaOpsGraph, true)]
[InlineData(PredicateTypes.SlsaProvenanceV02, true)]
[InlineData(PredicateTypes.SlsaProvenanceV1, true)]
[InlineData(PredicateTypes.CycloneDxSbom, true)]
[InlineData(PredicateTypes.SpdxSbom, true)]
[InlineData(PredicateTypes.OpenVex, true)]
[InlineData("custom/predicate@v1", false)]
[InlineData("", false)]
[InlineData(null, false)]
public void IsWellKnownPredicateType_ReturnsExpected(string? predicateType, bool expected)
{
// Act
var result = SignerStatementBuilder.IsWellKnownPredicateType(predicateType!);
// Assert
result.Should().Be(expected);
}
[Theory]
[InlineData(PredicateTypes.SlsaProvenanceV1, "https://in-toto.io/Statement/v1")]
[InlineData(PredicateTypes.StellaOpsPromotion, "https://in-toto.io/Statement/v1")]
[InlineData(PredicateTypes.StellaOpsSbom, "https://in-toto.io/Statement/v1")]
[InlineData(PredicateTypes.SlsaProvenanceV02, "https://in-toto.io/Statement/v0.1")]
[InlineData(PredicateTypes.CycloneDxSbom, "https://in-toto.io/Statement/v0.1")]
public void GetRecommendedStatementType_ReturnsCorrectVersion(string predicateType, string expectedStatementType)
{
// Act
var result = SignerStatementBuilder.GetRecommendedStatementType(predicateType);
// Assert
result.Should().Be(expectedStatementType);
}
[Fact]
public void PredicateTypes_IsStellaOpsType_IdentifiesStellaOpsTypes()
{
// Assert
PredicateTypes.IsStellaOpsType("stella.ops/promotion@v1").Should().BeTrue();
PredicateTypes.IsStellaOpsType("stella.ops/custom@v2").Should().BeTrue();
PredicateTypes.IsStellaOpsType("https://slsa.dev/provenance/v1").Should().BeFalse();
PredicateTypes.IsStellaOpsType(null!).Should().BeFalse();
}
[Fact]
public void PredicateTypes_IsSlsaProvenance_IdentifiesSlsaTypes()
{
// Assert
PredicateTypes.IsSlsaProvenance("https://slsa.dev/provenance/v0.2").Should().BeTrue();
PredicateTypes.IsSlsaProvenance("https://slsa.dev/provenance/v1").Should().BeTrue();
PredicateTypes.IsSlsaProvenance("https://slsa.dev/provenance/v2").Should().BeTrue();
PredicateTypes.IsSlsaProvenance("stella.ops/promotion@v1").Should().BeFalse();
PredicateTypes.IsSlsaProvenance(null!).Should().BeFalse();
}
[Fact]
public void PredicateTypes_IsVexRelatedType_IdentifiesVexTypes()
{
// Assert
PredicateTypes.IsVexRelatedType(PredicateTypes.StellaOpsVex).Should().BeTrue();
PredicateTypes.IsVexRelatedType(PredicateTypes.StellaOpsVexDecision).Should().BeTrue();
PredicateTypes.IsVexRelatedType(PredicateTypes.OpenVex).Should().BeTrue();
PredicateTypes.IsVexRelatedType(PredicateTypes.StellaOpsSbom).Should().BeFalse();
PredicateTypes.IsVexRelatedType(PredicateTypes.StellaOpsGraph).Should().BeFalse();
PredicateTypes.IsVexRelatedType(null!).Should().BeFalse();
}
[Fact]
public void PredicateTypes_IsReachabilityRelatedType_IdentifiesReachabilityTypes()
{
// Assert
PredicateTypes.IsReachabilityRelatedType(PredicateTypes.StellaOpsGraph).Should().BeTrue();
PredicateTypes.IsReachabilityRelatedType(PredicateTypes.StellaOpsReplay).Should().BeTrue();
PredicateTypes.IsReachabilityRelatedType(PredicateTypes.StellaOpsEvidence).Should().BeTrue();
PredicateTypes.IsReachabilityRelatedType(PredicateTypes.StellaOpsVex).Should().BeFalse();
PredicateTypes.IsReachabilityRelatedType(PredicateTypes.StellaOpsSbom).Should().BeFalse();
PredicateTypes.IsReachabilityRelatedType(null!).Should().BeFalse();
}
[Fact]
public void PredicateTypes_GetAllowedPredicateTypes_ReturnsAllKnownTypes()
{
// Act
var allowedTypes = PredicateTypes.GetAllowedPredicateTypes();
// Assert
allowedTypes.Should().Contain(PredicateTypes.StellaOpsPromotion);
allowedTypes.Should().Contain(PredicateTypes.StellaOpsSbom);
allowedTypes.Should().Contain(PredicateTypes.StellaOpsVex);
allowedTypes.Should().Contain(PredicateTypes.StellaOpsReplay);
allowedTypes.Should().Contain(PredicateTypes.StellaOpsPolicy);
allowedTypes.Should().Contain(PredicateTypes.StellaOpsEvidence);
allowedTypes.Should().Contain(PredicateTypes.StellaOpsVexDecision);
allowedTypes.Should().Contain(PredicateTypes.StellaOpsGraph);
allowedTypes.Should().Contain(PredicateTypes.SlsaProvenanceV02);
allowedTypes.Should().Contain(PredicateTypes.SlsaProvenanceV1);
allowedTypes.Should().Contain(PredicateTypes.CycloneDxSbom);
allowedTypes.Should().Contain(PredicateTypes.SpdxSbom);
allowedTypes.Should().Contain(PredicateTypes.OpenVex);
allowedTypes.Should().HaveCount(13);
}
[Theory]
[InlineData(PredicateTypes.StellaOpsVexDecision, true)]
[InlineData(PredicateTypes.StellaOpsGraph, true)]
[InlineData(PredicateTypes.StellaOpsPromotion, true)]
[InlineData("custom/predicate@v1", false)]
[InlineData("", false)]
public void PredicateTypes_IsAllowedPredicateType_ReturnsExpected(string predicateType, bool expected)
{
// Act
var result = PredicateTypes.IsAllowedPredicateType(predicateType);
// Assert
result.Should().Be(expected);
}
[Fact]
public void BuildStatementPayload_HandlesMultipleSubjects()
{
// Arrange
var predicate = JsonDocument.Parse("""{"builder": {"id": "test"}}""");
var request = new SigningRequest(
Subjects:
[
new SigningSubject("artifact1.tar.gz", new Dictionary<string, string>
{
["sha256"] = "hash1"
}),
new SigningSubject("artifact2.tar.gz", new Dictionary<string, string>
{
["sha256"] = "hash2"
}),
new SigningSubject("artifact3.tar.gz", new Dictionary<string, string>
{
["sha256"] = "hash3"
})
],
PredicateType: PredicateTypes.SlsaProvenanceV02,
Predicate: predicate,
ScannerImageDigest: "sha256:scanner",
ProofOfEntitlement: new ProofOfEntitlement(SignerPoEFormat.Jwt, "token"),
Options: new SigningOptions(SigningMode.Keyless, null, "bundle"));
// Act
var payload = SignerStatementBuilder.BuildStatementPayload(request);
var json = Encoding.UTF8.GetString(payload);
// Assert
using var doc = JsonDocument.Parse(json);
doc.RootElement.GetProperty("subject").GetArrayLength().Should().Be(3);
}
[Fact]
public void BuildStatementPayload_PreservesPredicateContent()
{
// Arrange
var predicateContent = """
{
"builder": { "id": "https://github.com/actions" },
"buildType": "https://github.com/Attestations/GitHubActionsWorkflow@v1",
"invocation": {
"configSource": {
"uri": "git+https://github.com/test/repo@refs/heads/main"
}
}
}
""";
var predicate = JsonDocument.Parse(predicateContent);
var request = new SigningRequest(
Subjects:
[
new SigningSubject("artifact.tar.gz", new Dictionary<string, string>
{
["sha256"] = "abc123"
})
],
PredicateType: PredicateTypes.SlsaProvenanceV02,
Predicate: predicate,
ScannerImageDigest: "sha256:scanner",
ProofOfEntitlement: new ProofOfEntitlement(SignerPoEFormat.Jwt, "token"),
Options: new SigningOptions(SigningMode.Keyless, null, "bundle"));
// Act
var payload = SignerStatementBuilder.BuildStatementPayload(request);
var json = Encoding.UTF8.GetString(payload);
// Assert
using var doc = JsonDocument.Parse(json);
var resultPredicate = doc.RootElement.GetProperty("predicate");
resultPredicate.GetProperty("builder").GetProperty("id").GetString()
.Should().Be("https://github.com/actions");
resultPredicate.GetProperty("buildType").GetString()
.Should().Be("https://github.com/Attestations/GitHubActionsWorkflow@v1");
}
private static SigningRequest CreateSigningRequest()
{
var predicate = JsonDocument.Parse("""{"builder": {"id": "test-builder"}, "invocation": {}}""");
return new SigningRequest(
Subjects:
[
new SigningSubject("artifact.tar.gz", new Dictionary<string, string>
{
["sha256"] = "abc123def456"
})
],
PredicateType: PredicateTypes.SlsaProvenanceV02,
Predicate: predicate,
ScannerImageDigest: "sha256:scanner123",
ProofOfEntitlement: new ProofOfEntitlement(SignerPoEFormat.Jwt, "token"),
Options: new SigningOptions(SigningMode.Keyless, 3600, "bundle"));
}
}

View File

@@ -0,0 +1,182 @@
using System;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using StellaOps.Signer.Core;
using StellaOps.Signer.Infrastructure.Signing;
using Xunit;
namespace StellaOps.Signer.Tests.Signing;
public sealed class SigningServiceCollectionExtensionsTests
{
[Fact]
public void AddDsseSigning_RegistersRequiredServices()
{
// Arrange
var services = new ServiceCollection();
services.AddLogging();
// Act
services.AddDsseSigning();
var provider = services.BuildServiceProvider();
// Assert
provider.GetService<ISigningKeyResolver>().Should().NotBeNull();
provider.GetService<IOptions<DsseSignerOptions>>().Should().NotBeNull();
}
[Fact]
public void AddDsseSigning_AllowsCustomConfiguration()
{
// Arrange
var services = new ServiceCollection();
services.AddLogging();
// Act
services.AddDsseSigning(options =>
{
options.DefaultIssuer = "https://custom.issuer.io";
options.KeylessAlgorithm = "ES384";
});
var provider = services.BuildServiceProvider();
var options = provider.GetRequiredService<IOptions<DsseSignerOptions>>().Value;
// Assert
options.DefaultIssuer.Should().Be("https://custom.issuer.io");
options.KeylessAlgorithm.Should().Be("ES384");
}
[Fact]
public void AddDsseSigningWithKms_SetsDefaultKmsKeyId()
{
// Arrange
var services = new ServiceCollection();
services.AddLogging();
// Act
services.AddDsseSigningWithKms("projects/my-project/locations/global/keyRings/ring/cryptoKeys/key");
var provider = services.BuildServiceProvider();
var options = provider.GetRequiredService<IOptions<DsseSignerOptions>>().Value;
// Assert
options.DefaultKmsKeyId.Should().Be("projects/my-project/locations/global/keyRings/ring/cryptoKeys/key");
}
[Fact]
public void AddDsseSigningWithKms_AllowsAdditionalConfiguration()
{
// Arrange
var services = new ServiceCollection();
services.AddLogging();
// Act
services.AddDsseSigningWithKms(
"default-key",
options => options.PreferredProvider = "kms-provider");
var provider = services.BuildServiceProvider();
var options = provider.GetRequiredService<IOptions<DsseSignerOptions>>().Value;
// Assert
options.DefaultKmsKeyId.Should().Be("default-key");
options.PreferredProvider.Should().Be("kms-provider");
}
[Fact]
public void AddDsseSigningKeyless_SetsDefaultIssuer()
{
// Arrange
var services = new ServiceCollection();
services.AddLogging();
// Act
services.AddDsseSigningKeyless("https://keyless.stellaops.io");
var provider = services.BuildServiceProvider();
var options = provider.GetRequiredService<IOptions<DsseSignerOptions>>().Value;
// Assert
options.DefaultIssuer.Should().Be("https://keyless.stellaops.io");
}
[Fact]
public void AddDsseSigningKeyless_UsesDefaultIssuerWhenNotSpecified()
{
// Arrange
var services = new ServiceCollection();
services.AddLogging();
// Act
services.AddDsseSigningKeyless();
var provider = services.BuildServiceProvider();
var options = provider.GetRequiredService<IOptions<DsseSignerOptions>>().Value;
// Assert
options.DefaultIssuer.Should().Be("https://stellaops.io");
}
[Fact]
public void AddDsseSigning_ThrowsArgumentNullException_WhenServicesIsNull()
{
// Arrange
IServiceCollection? services = null;
// Act
var act = () => services!.AddDsseSigning();
// Assert
act.Should().Throw<ArgumentNullException>()
.WithParameterName("services");
}
[Fact]
public void AddDsseSigningWithKms_ThrowsArgumentException_WhenKeyIdIsEmpty()
{
// Arrange
var services = new ServiceCollection();
// Act
var act = () => services.AddDsseSigningWithKms("");
// Assert
act.Should().Throw<ArgumentException>()
.WithParameterName("defaultKmsKeyId");
}
[Fact]
public void AddDsseSigning_RegistersTimeProvider()
{
// Arrange
var services = new ServiceCollection();
services.AddLogging();
// Act
services.AddDsseSigning();
var provider = services.BuildServiceProvider();
// Assert
provider.GetService<TimeProvider>().Should().NotBeNull();
}
[Fact]
public void AddDsseSigning_DoesNotOverrideExistingTimeProvider()
{
// Arrange
var services = new ServiceCollection();
services.AddLogging();
var customTimeProvider = new FakeTimeProvider();
services.AddSingleton<TimeProvider>(customTimeProvider);
// Act
services.AddDsseSigning();
var provider = services.BuildServiceProvider();
var resolvedProvider = provider.GetRequiredService<TimeProvider>();
// Assert
resolvedProvider.Should().BeSameAs(customTimeProvider);
}
private sealed class FakeTimeProvider : TimeProvider
{
public override DateTimeOffset GetUtcNow() => new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
}
}

View File

@@ -15,6 +15,8 @@
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="NSubstitute" Version="5.1.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Signer.WebService\StellaOps.Signer.WebService.csproj" />