using System.Text; using System.Text.Json.Nodes; using Microsoft.Extensions.Options; using StellaOps.Telemetry.Federation; using StellaOps.Telemetry.Federation.Consent; using StellaOps.Telemetry.Federation.Security; namespace StellaOps.Telemetry.Federation.Tests; public sealed class ConsentManagerTests { [Fact] public async Task Default_consent_state_is_not_granted() { var manager = CreateManager(); var state = await manager.GetConsentStateAsync("tenant-1"); Assert.False(state.Granted); Assert.Null(state.GrantedBy); Assert.Null(state.GrantedAt); Assert.Null(state.ExpiresAt); Assert.Null(state.DsseDigest); Assert.Null(state.SignerKeyId); } [Fact] public async Task Grant_consent_sets_granted_state_and_signer_metadata() { var manager = CreateManager(); var proof = await manager.GrantConsentAsync("tenant-1", "admin@example.com"); Assert.Equal("tenant-1", proof.TenantId); Assert.Equal("admin@example.com", proof.GrantedBy); Assert.NotNull(proof.DsseDigest); Assert.StartsWith("sha256:", proof.DsseDigest, StringComparison.Ordinal); Assert.Equal("consent-key", proof.SignerKeyId); Assert.NotEmpty(proof.Envelope); var state = await manager.GetConsentStateAsync("tenant-1"); Assert.True(state.Granted); Assert.Equal("admin@example.com", state.GrantedBy); Assert.Equal("consent-key", state.SignerKeyId); } [Fact] public async Task VerifyProofAsync_succeeds_for_valid_proof() { var manager = CreateManager(); var proof = await manager.GrantConsentAsync("tenant-1", "admin@example.com"); var valid = await manager.VerifyProofAsync(proof); Assert.True(valid); } [Fact] public async Task VerifyProofAsync_fails_for_payload_tampering() { var manager = CreateManager(); var proof = await manager.GrantConsentAsync("tenant-1", "admin@example.com"); var tamperedEnvelope = TamperEnvelopePayload(proof.Envelope); var valid = await manager.VerifyProofAsync(proof with { Envelope = tamperedEnvelope }); Assert.False(valid); } [Fact] public async Task VerifyProofAsync_fails_for_signature_tampering() { var manager = CreateManager(); var proof = await manager.GrantConsentAsync("tenant-1", "admin@example.com"); var tamperedEnvelope = TamperEnvelopeSignature(proof.Envelope); var valid = await manager.VerifyProofAsync(proof with { Envelope = tamperedEnvelope }); Assert.False(valid); } [Fact] public async Task VerifyProofAsync_fails_for_wrong_key_verifier() { var signerOptions = CreateOptions("consent-key", "sign-secret", ("consent-key", "sign-secret")); var verifierOptions = CreateOptions("consent-key", "verify-secret", ("consent-key", "wrong-secret")); var manager = new ConsentManager( signerOptions, new HmacFederationDsseEnvelopeService(signerOptions), new HmacFederationDsseEnvelopeService(verifierOptions)); var proof = await manager.GrantConsentAsync("tenant-1", "admin@example.com"); var valid = await manager.VerifyProofAsync(proof); Assert.False(valid); } [Fact] public async Task VerifyProofAsync_fails_when_signature_valid_but_consent_expired() { var fakeTime = new FakeTimeProvider(new DateTimeOffset(2026, 03, 04, 12, 0, 0, TimeSpan.Zero)); var manager = CreateManager(timeProvider: fakeTime); var proof = await manager.GrantConsentAsync("tenant-1", "admin@example.com", ttl: TimeSpan.FromMinutes(30)); Assert.True(await manager.VerifyProofAsync(proof)); fakeTime.Advance(TimeSpan.FromHours(1)); var afterExpiry = await manager.VerifyProofAsync(proof); Assert.False(afterExpiry); } [Fact] public async Task Grant_with_fixed_clock_and_key_material_produces_deterministic_digest() { var fixedNow = new DateTimeOffset(2026, 03, 04, 15, 0, 0, TimeSpan.Zero); var fakeTime = new FakeTimeProvider(fixedNow); var manager = CreateManager(timeProvider: fakeTime); var proof1 = await manager.GrantConsentAsync("tenant-1", "admin@example.com"); var proof2 = await manager.GrantConsentAsync("tenant-1", "admin@example.com"); Assert.Equal(proof1.DsseDigest, proof2.DsseDigest); Assert.Equal(proof1.Envelope, proof2.Envelope); Assert.Equal(proof1.SignerKeyId, proof2.SignerKeyId); } [Fact] public async Task Revoke_consent_clears_state() { var manager = CreateManager(); await manager.GrantConsentAsync("tenant-1", "admin@example.com"); await manager.RevokeConsentAsync("tenant-1", "admin@example.com"); var state = await manager.GetConsentStateAsync("tenant-1"); Assert.False(state.Granted); } [Fact] public async Task Multiple_tenants_independent() { var manager = CreateManager(); await manager.GrantConsentAsync("tenant-1", "admin1@example.com"); await manager.GrantConsentAsync("tenant-2", "admin2@example.com"); await manager.RevokeConsentAsync("tenant-1", "admin1@example.com"); var state1 = await manager.GetConsentStateAsync("tenant-1"); var state2 = await manager.GetConsentStateAsync("tenant-2"); Assert.False(state1.Granted); Assert.True(state2.Granted); } [Fact] public async Task Grant_overwrites_previous_consent() { var fakeTime = new FakeTimeProvider(new DateTimeOffset(2026, 03, 04, 9, 0, 0, TimeSpan.Zero)); var manager = CreateManager(timeProvider: fakeTime); var proof1 = await manager.GrantConsentAsync("tenant-1", "admin@example.com"); fakeTime.Advance(TimeSpan.FromSeconds(5)); var proof2 = await manager.GrantConsentAsync("tenant-1", "newadmin@example.com"); Assert.NotEqual(proof1.DsseDigest, proof2.DsseDigest); var state = await manager.GetConsentStateAsync("tenant-1"); Assert.Equal("newadmin@example.com", state.GrantedBy); } private static ConsentManager CreateManager( IOptions? options = null, TimeProvider? timeProvider = null, IFederationDsseEnvelopeSigner? signer = null, IFederationDsseEnvelopeVerifier? verifier = null) { options ??= CreateOptions("consent-key", "consent-secret", ("consent-key", "consent-secret")); signer ??= new HmacFederationDsseEnvelopeService(options); verifier ??= new HmacFederationDsseEnvelopeService(options); return new ConsentManager(options, signer, verifier, timeProvider); } private static IOptions CreateOptions( string signerKeyId, string signerSecret, params (string KeyId, string Secret)[] trustedSecrets) { var options = new FederatedTelemetryOptions { ConsentPredicateType = "stella.ops/federatedConsent@v1", DsseSignerKeyId = signerKeyId, DsseSignerSecret = signerSecret }; options.DsseTrustedKeys.Clear(); foreach (var trusted in trustedSecrets) { options.DsseTrustedKeys[trusted.KeyId] = trusted.Secret; } return Options.Create(options); } private static byte[] TamperEnvelopePayload(byte[] envelope) { var document = JsonNode.Parse(envelope)!.AsObject(); var payload = Convert.FromBase64String(document["payload"]!.GetValue()); payload[0] ^= 0x01; document["payload"] = Convert.ToBase64String(payload); return Encoding.UTF8.GetBytes(document.ToJsonString()); } private static byte[] TamperEnvelopeSignature(byte[] envelope) { var document = JsonNode.Parse(envelope)!.AsObject(); var signatureNode = document["signatures"]!.AsArray()[0]!.AsObject(); var signature = signatureNode["sig"]!.GetValue(); signatureNode["sig"] = signature[^1] == 'A' ? $"{signature[..^1]}B" : $"{signature[..^1]}A"; return Encoding.UTF8.GetBytes(document.ToJsonString()); } } /// /// Simple fake TimeProvider for testing time-dependent behavior. /// internal sealed class FakeTimeProvider : TimeProvider { private DateTimeOffset _utcNow; public FakeTimeProvider(DateTimeOffset start) { _utcNow = start; } public override DateTimeOffset GetUtcNow() => _utcNow; public void Advance(TimeSpan duration) => _utcNow += duration; public void SetUtcNow(DateTimeOffset value) => _utcNow = value; }