save progress
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using StellaOps.AuditPack.Services;
|
||||
@@ -25,7 +26,10 @@ public class AuditPackExportServiceIntegrationTests
|
||||
public AuditPackExportServiceIntegrationTests()
|
||||
{
|
||||
var mockWriter = new MockAuditBundleWriter();
|
||||
_service = new AuditPackExportService(mockWriter);
|
||||
var repository = new FakeAuditPackRepository();
|
||||
var timeProvider = new FixedTimeProvider(new DateTimeOffset(2025, 6, 1, 12, 0, 0, TimeSpan.Zero));
|
||||
var dsseSigner = new FakeDsseSigner();
|
||||
_service = new AuditPackExportService(mockWriter, repository, timeProvider, dsseSigner);
|
||||
}
|
||||
|
||||
#region ZIP Export Tests
|
||||
@@ -94,8 +98,7 @@ public class AuditPackExportServiceIntegrationTests
|
||||
using var memoryStream = new MemoryStream(result.Data!);
|
||||
using var archive = new ZipArchive(memoryStream, ZipArchiveMode.Read);
|
||||
|
||||
// Note: Attestations entry may be empty without repository
|
||||
archive.Entries.Should().Contain(e => e.FullName.Contains("manifest.json"));
|
||||
archive.GetEntry("attestations/attestations.json").Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "ZIP export includes proof chain when requested")]
|
||||
@@ -117,6 +120,10 @@ public class AuditPackExportServiceIntegrationTests
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
|
||||
using var memoryStream = new MemoryStream(result.Data!);
|
||||
using var archive = new ZipArchive(memoryStream, ZipArchiveMode.Read);
|
||||
archive.GetEntry("proof/proof-chain.json").Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "ZIP manifest contains export metadata")]
|
||||
@@ -202,11 +209,10 @@ public class AuditPackExportServiceIntegrationTests
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequest(ExportFormat.Json);
|
||||
var beforeExport = DateTimeOffset.UtcNow;
|
||||
var expected = new DateTimeOffset(2025, 6, 1, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
// Act
|
||||
var result = await _service.ExportAsync(request);
|
||||
var afterExport = DateTimeOffset.UtcNow;
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
@@ -214,8 +220,7 @@ public class AuditPackExportServiceIntegrationTests
|
||||
var doc = JsonDocument.Parse(result.Data!);
|
||||
var exportedAt = DateTimeOffset.Parse(doc.RootElement.GetProperty("exportedAt").GetString()!);
|
||||
|
||||
exportedAt.Should().BeOnOrAfter(beforeExport);
|
||||
exportedAt.Should().BeOnOrBefore(afterExport);
|
||||
exportedAt.Should().Be(expected);
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -409,3 +414,39 @@ internal class MockAuditBundleWriter : Services.IAuditBundleWriter
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FakeAuditPackRepository : IAuditPackRepository
|
||||
{
|
||||
public Task<byte[]?> GetSegmentDataAsync(string scanId, ExportSegment segment, CancellationToken ct)
|
||||
{
|
||||
var payload = new Dictionary<string, object>
|
||||
{
|
||||
["segment"] = segment.ToString(),
|
||||
["scanId"] = scanId
|
||||
};
|
||||
return Task.FromResult<byte[]?>(JsonSerializer.SerializeToUtf8Bytes(payload));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<object>> GetAttestationsAsync(string scanId, CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<object>>(new[] { new { attestationId = "att-1", scanId } });
|
||||
|
||||
public Task<object?> GetProofChainAsync(string scanId, CancellationToken ct)
|
||||
=> Task.FromResult<object?>(new { proof = "chain", scanId });
|
||||
}
|
||||
|
||||
internal sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider
|
||||
{
|
||||
public override DateTimeOffset GetUtcNow() => now;
|
||||
}
|
||||
|
||||
internal sealed class FakeDsseSigner : IAuditPackExportSigner
|
||||
{
|
||||
public Task<DsseSignature> SignAsync(byte[] payload, CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult(new DsseSignature
|
||||
{
|
||||
KeyId = "test-key",
|
||||
Sig = Convert.ToBase64String(payload.Take(4).ToArray())
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.IO.Compression;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using StellaOps.AuditPack.Models;
|
||||
using StellaOps.AuditPack.Services;
|
||||
|
||||
namespace StellaOps.AuditPack.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AuditPackImporterTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ImportAsync_DeletesTempDirectory_WhenKeepExtractedIsFalse()
|
||||
{
|
||||
var archivePath = CreateArchiveWithManifest();
|
||||
var importer = new AuditPackImporter(new GuidAuditPackIdGenerator());
|
||||
|
||||
var result = await importer.ImportAsync(archivePath, new ImportOptions { KeepExtracted = false });
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.ExtractDirectory.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportAsync_FailsOnPathTraversalEntries()
|
||||
{
|
||||
var archivePath = CreateArchiveWithEntries(
|
||||
new ArchivePayload("manifest.json", CreateManifestBytes()),
|
||||
new ArchivePayload("../evil.txt", new byte[] { 1, 2, 3 }));
|
||||
|
||||
var importer = new AuditPackImporter(new GuidAuditPackIdGenerator());
|
||||
var result = await importer.ImportAsync(archivePath, new ImportOptions());
|
||||
|
||||
result.Success.Should().BeFalse();
|
||||
result.Errors.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportAsync_FailsWhenSignaturePresentWithoutTrustRoots()
|
||||
{
|
||||
var archivePath = CreateArchiveWithEntries(
|
||||
new ArchivePayload("manifest.json", CreateManifestBytes()),
|
||||
new ArchivePayload("manifest.sig", new byte[] { 1, 2, 3 }));
|
||||
|
||||
var importer = new AuditPackImporter(new GuidAuditPackIdGenerator());
|
||||
var result = await importer.ImportAsync(archivePath, new ImportOptions { VerifySignatures = true });
|
||||
|
||||
result.Success.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("Signature verification failed", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private static string CreateArchiveWithManifest()
|
||||
=> CreateArchiveWithEntries(new ArchivePayload("manifest.json", CreateManifestBytes()));
|
||||
|
||||
private static string CreateArchiveWithEntries(params ArchivePayload[] payloads)
|
||||
{
|
||||
var outputPath = Path.Combine(Path.GetTempPath(), $"audit-pack-test-{Guid.NewGuid():N}.tar.gz");
|
||||
|
||||
using (var fileStream = File.Create(outputPath))
|
||||
using (var gzip = new GZipStream(fileStream, CompressionLevel.Optimal, leaveOpen: false))
|
||||
using (var tarWriter = new System.Formats.Tar.TarWriter(gzip, System.Formats.Tar.TarEntryFormat.Pax, leaveOpen: false))
|
||||
{
|
||||
foreach (var payload in payloads)
|
||||
{
|
||||
var entry = new System.Formats.Tar.PaxTarEntry(System.Formats.Tar.TarEntryType.RegularFile, payload.Path)
|
||||
{
|
||||
DataStream = new MemoryStream(payload.Content, writable: false)
|
||||
};
|
||||
tarWriter.WriteEntry(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
private static byte[] CreateManifestBytes()
|
||||
{
|
||||
var pack = new StellaOps.AuditPack.Models.AuditPack
|
||||
{
|
||||
PackId = "pack-1",
|
||||
Name = "pack",
|
||||
CreatedAt = DateTimeOffset.UnixEpoch,
|
||||
RunManifest = new RunManifest("scan-1", DateTimeOffset.UnixEpoch),
|
||||
EvidenceIndex = new EvidenceIndex(Array.Empty<string>().ToImmutableArray()),
|
||||
Verdict = new Verdict("verdict-1", "completed"),
|
||||
OfflineBundle = new BundleManifest("bundle-1", "1.0"),
|
||||
Contents = new PackContents
|
||||
{
|
||||
Files = Array.Empty<PackFile>().ToImmutableArray(),
|
||||
TotalSizeBytes = 0,
|
||||
FileCount = 0
|
||||
}
|
||||
};
|
||||
|
||||
return JsonSerializer.SerializeToUtf8Bytes(pack);
|
||||
}
|
||||
|
||||
private sealed record ArchivePayload(string Path, byte[] Content);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.AuditPack.Models;
|
||||
using StellaOps.AuditPack.Services;
|
||||
|
||||
namespace StellaOps.AuditPack.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ReplayAttestationServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task VerifyAsync_Fails_WhenEnvelopeHasNoSignatures()
|
||||
{
|
||||
var service = new ReplayAttestationService(timeProvider: new FixedTimeProvider(DateTimeOffset.UnixEpoch));
|
||||
|
||||
var attestation = await service.GenerateAsync(
|
||||
new AuditBundleManifest
|
||||
{
|
||||
BundleId = "bundle-1",
|
||||
Name = "bundle",
|
||||
CreatedAt = DateTimeOffset.UnixEpoch,
|
||||
ScanId = "scan-1",
|
||||
ImageRef = "image",
|
||||
ImageDigest = "sha256:abc",
|
||||
MerkleRoot = "sha256:root",
|
||||
Inputs = new InputDigests
|
||||
{
|
||||
SbomDigest = "sha256:sbom",
|
||||
FeedsDigest = "sha256:feeds",
|
||||
PolicyDigest = "sha256:policy"
|
||||
},
|
||||
VerdictDigest = "sha256:verdict",
|
||||
Decision = "pass",
|
||||
Files = [],
|
||||
TotalSizeBytes = 0
|
||||
},
|
||||
new ReplayExecutionResult
|
||||
{
|
||||
Success = true,
|
||||
Status = ReplayStatus.Match,
|
||||
InputsVerified = true,
|
||||
VerdictMatches = true,
|
||||
DecisionMatches = true,
|
||||
OriginalVerdictDigest = "sha256:verdict",
|
||||
ReplayedVerdictDigest = "sha256:verdict",
|
||||
OriginalDecision = "pass",
|
||||
ReplayedDecision = "pass",
|
||||
Drifts = [],
|
||||
Errors = [],
|
||||
DurationMs = 0,
|
||||
EvaluatedAt = DateTimeOffset.UnixEpoch
|
||||
});
|
||||
|
||||
var result = await service.VerifyAsync(attestation);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Contains("signatures", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_Succeeds_WithVerifierAndValidPayload()
|
||||
{
|
||||
var verifier = new AcceptAllVerifier();
|
||||
var service = new ReplayAttestationService(verifier: verifier);
|
||||
|
||||
var payload = CanonicalJson.Serialize(new InTotoStatement
|
||||
{
|
||||
Type = "https://in-toto.io/Statement/v1",
|
||||
Subject = [new InTotoSubject
|
||||
{
|
||||
Name = "verdict:bundle-1",
|
||||
Digest = new Dictionary<string, string> { ["sha256"] = "abc" }
|
||||
}],
|
||||
PredicateType = "https://stellaops.io/attestation/verdict-replay/v1",
|
||||
Predicate = new VerdictReplayAttestation
|
||||
{
|
||||
ManifestId = "bundle-1",
|
||||
ScanId = "scan-1",
|
||||
ImageRef = "image",
|
||||
ImageDigest = "sha256:abc",
|
||||
InputsDigest = "sha256:inputs",
|
||||
OriginalVerdictDigest = "sha256:verdict",
|
||||
OriginalDecision = "pass",
|
||||
Match = true,
|
||||
Status = "Match",
|
||||
DriftCount = 0,
|
||||
EvaluatedAt = DateTimeOffset.UnixEpoch,
|
||||
ReplayedAt = DateTimeOffset.UnixEpoch,
|
||||
DurationMs = 0
|
||||
}
|
||||
});
|
||||
|
||||
var attestation = new ReplayAttestation
|
||||
{
|
||||
AttestationId = "att-1",
|
||||
ManifestId = "bundle-1",
|
||||
CreatedAt = DateTimeOffset.UnixEpoch,
|
||||
Statement = JsonSerializer.Deserialize<InTotoStatement>(payload)!,
|
||||
StatementDigest = "sha256:" + Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(payload)).ToLowerInvariant(),
|
||||
Envelope = new ReplayDsseEnvelope
|
||||
{
|
||||
PayloadType = "application/vnd.in-toto+json",
|
||||
Payload = Convert.ToBase64String(payload),
|
||||
Signatures = [new ReplayDsseSignature { KeyId = "key", Sig = "sig" }]
|
||||
},
|
||||
Match = true,
|
||||
ReplayStatus = "Match"
|
||||
};
|
||||
|
||||
var result = await service.VerifyAsync(attestation);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.True(result.SignatureVerified);
|
||||
}
|
||||
|
||||
private sealed class AcceptAllVerifier : IReplayAttestationSignatureVerifier
|
||||
{
|
||||
public Task<ReplayAttestationSignatureVerification> VerifyAsync(
|
||||
ReplayDsseEnvelope envelope,
|
||||
byte[] payload,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new ReplayAttestationSignatureVerification { Verified = true });
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider
|
||||
{
|
||||
public override DateTimeOffset GetUtcNow() => now;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
# Auth Security Tests AGENTS
|
||||
|
||||
## Purpose & Scope
|
||||
- Working directory: `src/__Libraries/__Tests/StellaOps.Auth.Security.Tests/`.
|
||||
- Roles: QA automation, backend engineer.
|
||||
- Focus: DPoP proof validation, nonce/replay caches, and edge-case coverage.
|
||||
|
||||
## Required Reading (treat as read before DOING)
|
||||
- `docs/README.md`
|
||||
- `docs/07_HIGH_LEVEL_ARCHITECTURE.md`
|
||||
- `docs/modules/platform/architecture-overview.md`
|
||||
- `docs/modules/authority/architecture.md`
|
||||
- Relevant sprint files.
|
||||
|
||||
## Working Agreements
|
||||
- Keep tests deterministic (fixed time/IDs, stable ordering).
|
||||
- Avoid live network calls and nondeterministic RNG.
|
||||
- Update `docs/implplan/SPRINT_*.md` and local `TASKS.md` when starting or completing work.
|
||||
|
||||
## Testing
|
||||
- Use xUnit + FluentAssertions + TestKit.
|
||||
- Cover validator error paths, nonce stores, and replay cache semantics.
|
||||
@@ -0,0 +1,17 @@
|
||||
using StellaOps.Auth.Security.Dpop;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Auth.Security.Tests;
|
||||
|
||||
public class DpopNonceUtilitiesTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ComputeStorageKey_NormalizesToLowerInvariant()
|
||||
{
|
||||
var key = DpopNonceUtilities.ComputeStorageKey("API", "Client-Id", "ThumbPrint");
|
||||
|
||||
Assert.Equal("dpop-nonce:api:client-id:thumbprint", key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
using System;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using StellaOps.Auth.Security.Dpop;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Auth.Security.Tests;
|
||||
|
||||
public class DpopProofValidatorTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ValidateAsync_ReturnsFailure_ForNonStringTyp()
|
||||
{
|
||||
var proof = BuildUnsignedToken(
|
||||
new { typ = 123, alg = "ES256" },
|
||||
new { htm = "GET", htu = "https://api.test/resource", iat = 0, jti = "1" });
|
||||
|
||||
var validator = CreateValidator();
|
||||
var result = await validator.ValidateAsync(proof, "GET", new Uri("https://api.test/resource"));
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("invalid_header", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ValidateAsync_ReturnsFailure_ForNonStringAlg()
|
||||
{
|
||||
var proof = BuildUnsignedToken(
|
||||
new { typ = "dpop+jwt", alg = 55 },
|
||||
new { htm = "GET", htu = "https://api.test/resource", iat = 0, jti = "1" });
|
||||
|
||||
var validator = CreateValidator();
|
||||
var result = await validator.ValidateAsync(proof, "GET", new Uri("https://api.test/resource"));
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("invalid_header", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ValidateAsync_ReturnsFailure_ForNonStringHtm()
|
||||
{
|
||||
var now = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
|
||||
var (proof, _) = CreateSignedProof(now, payloadMutator: payload => payload["htm"] = 123);
|
||||
|
||||
var validator = CreateValidator(now);
|
||||
var result = await validator.ValidateAsync(proof, "GET", new Uri("https://api.test/resource"));
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("invalid_payload", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ValidateAsync_ReturnsFailure_ForNonStringHtu()
|
||||
{
|
||||
var now = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
|
||||
var (proof, _) = CreateSignedProof(now, payloadMutator: payload => payload["htu"] = 123);
|
||||
|
||||
var validator = CreateValidator(now);
|
||||
var result = await validator.ValidateAsync(proof, "GET", new Uri("https://api.test/resource"));
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("invalid_payload", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ValidateAsync_ReturnsFailure_ForNonStringNonce()
|
||||
{
|
||||
var now = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
|
||||
var (proof, _) = CreateSignedProof(now, payloadMutator: payload => payload["nonce"] = 999);
|
||||
|
||||
var validator = CreateValidator(now);
|
||||
var result = await validator.ValidateAsync(proof, "GET", new Uri("https://api.test/resource"), nonce: "nonce-1");
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("invalid_token", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ValidateAsync_RejectsProofIssuedInFuture()
|
||||
{
|
||||
var now = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
|
||||
var issuedAt = now.AddMinutes(2);
|
||||
var (proof, _) = CreateSignedProof(issuedAt);
|
||||
|
||||
var validator = CreateValidator(now, options => options.AllowedClockSkew = TimeSpan.FromSeconds(5));
|
||||
var result = await validator.ValidateAsync(proof, "GET", new Uri("https://api.test/resource"));
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("invalid_token", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ValidateAsync_RejectsExpiredProofs()
|
||||
{
|
||||
var now = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
|
||||
var issuedAt = now.AddMinutes(-10);
|
||||
var (proof, _) = CreateSignedProof(issuedAt);
|
||||
|
||||
var validator = CreateValidator(now, options =>
|
||||
{
|
||||
options.ProofLifetime = TimeSpan.FromMinutes(1);
|
||||
options.AllowedClockSkew = TimeSpan.FromSeconds(5);
|
||||
});
|
||||
|
||||
var result = await validator.ValidateAsync(proof, "GET", new Uri("https://api.test/resource"));
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("invalid_token", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ValidateAsync_RejectsReplayTokens()
|
||||
{
|
||||
var now = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
|
||||
var jwtId = "jwt-1";
|
||||
var (proof, _) = CreateSignedProof(now, jti: jwtId);
|
||||
|
||||
var timeProvider = new FakeTimeProvider(now);
|
||||
var replayCache = new InMemoryDpopReplayCache(timeProvider);
|
||||
var validator = CreateValidator(timeProvider, replayCache);
|
||||
|
||||
var first = await validator.ValidateAsync(proof, "GET", new Uri("https://api.test/resource"));
|
||||
var second = await validator.ValidateAsync(proof, "GET", new Uri("https://api.test/resource"));
|
||||
|
||||
Assert.True(first.IsValid);
|
||||
Assert.False(second.IsValid);
|
||||
Assert.Equal("replay", second.ErrorCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ValidateAsync_UsesSnapshotOfOptions()
|
||||
{
|
||||
var now = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
|
||||
var (proof, _) = CreateSignedProof(now);
|
||||
|
||||
var options = new DpopValidationOptions();
|
||||
var timeProvider = new FakeTimeProvider(now);
|
||||
var validator = new DpopProofValidator(Options.Create(options), new InMemoryDpopReplayCache(timeProvider), timeProvider);
|
||||
|
||||
options.AllowedAlgorithms.Clear();
|
||||
options.AllowedAlgorithms.Add("ES512");
|
||||
|
||||
var result = await validator.ValidateAsync(proof, "GET", new Uri("https://api.test/resource"));
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
private static DpopProofValidator CreateValidator(DateTimeOffset now, Action<DpopValidationOptions>? configure = null)
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(now);
|
||||
return CreateValidator(timeProvider, null, configure);
|
||||
}
|
||||
|
||||
private static DpopProofValidator CreateValidator(TimeProvider timeProvider, IDpopReplayCache? replayCache = null, Action<DpopValidationOptions>? configure = null)
|
||||
{
|
||||
var options = new DpopValidationOptions();
|
||||
configure?.Invoke(options);
|
||||
return new DpopProofValidator(Options.Create(options), replayCache, timeProvider);
|
||||
}
|
||||
|
||||
private static (string Token, JsonWebKey Jwk) CreateSignedProof(
|
||||
DateTimeOffset issuedAt,
|
||||
string? method = "GET",
|
||||
Uri? httpUri = null,
|
||||
string? nonce = null,
|
||||
string? jti = null,
|
||||
Action<JwtHeader>? headerMutator = null,
|
||||
Action<JwtPayload>? payloadMutator = null)
|
||||
{
|
||||
httpUri ??= new Uri("https://api.test/resource");
|
||||
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var securityKey = new ECDsaSecurityKey(ecdsa) { KeyId = Guid.NewGuid().ToString("N") };
|
||||
var jwk = JsonWebKeyConverter.ConvertFromECDsaSecurityKey(securityKey);
|
||||
|
||||
var header = new JwtHeader(new SigningCredentials(securityKey, SecurityAlgorithms.EcdsaSha256))
|
||||
{
|
||||
{ "typ", "dpop+jwt" },
|
||||
{ "jwk", jwk }
|
||||
};
|
||||
headerMutator?.Invoke(header);
|
||||
|
||||
var payload = new JwtPayload
|
||||
{
|
||||
{ "htm", method ?? "GET" },
|
||||
{ "htu", httpUri.ToString() },
|
||||
{ "iat", issuedAt.ToUnixTimeSeconds() },
|
||||
{ "jti", jti ?? Guid.NewGuid().ToString("N") }
|
||||
};
|
||||
|
||||
if (nonce is not null)
|
||||
{
|
||||
payload["nonce"] = nonce;
|
||||
}
|
||||
|
||||
payloadMutator?.Invoke(payload);
|
||||
|
||||
var token = new JwtSecurityToken(header, payload);
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
return (handler.WriteToken(token), jwk);
|
||||
}
|
||||
|
||||
private static string BuildUnsignedToken(object header, object payload)
|
||||
{
|
||||
var headerJson = JsonSerializer.Serialize(header);
|
||||
var payloadJson = JsonSerializer.Serialize(payload);
|
||||
var encodedHeader = Base64UrlEncoder.Encode(Encoding.UTF8.GetBytes(headerJson));
|
||||
var encodedPayload = Base64UrlEncoder.Encode(Encoding.UTF8.GetBytes(payloadJson));
|
||||
return $"{encodedHeader}.{encodedPayload}.signature";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Auth.Security.Dpop;
|
||||
using StellaOps.Messaging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Auth.Security.Tests;
|
||||
|
||||
public class DpopReplayCacheTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task InMemoryReplayCache_RejectsDuplicatesUntilExpiry()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-01-01T00:00:00Z"));
|
||||
var cache = new InMemoryDpopReplayCache(timeProvider);
|
||||
|
||||
var expiresAt = timeProvider.GetUtcNow().AddMinutes(1);
|
||||
|
||||
Assert.True(await cache.TryStoreAsync("jti-1", expiresAt));
|
||||
Assert.False(await cache.TryStoreAsync("jti-1", expiresAt));
|
||||
|
||||
timeProvider.Advance(TimeSpan.FromMinutes(2));
|
||||
Assert.True(await cache.TryStoreAsync("jti-1", timeProvider.GetUtcNow().AddMinutes(1)));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task MessagingReplayCache_RejectsDuplicatesUntilExpiry()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-01-01T00:00:00Z"));
|
||||
var factory = new FakeIdempotencyStoreFactory(timeProvider);
|
||||
var cache = new MessagingDpopReplayCache(factory, timeProvider);
|
||||
|
||||
var expiresAt = timeProvider.GetUtcNow().AddMinutes(1);
|
||||
|
||||
Assert.True(await cache.TryStoreAsync("jti-1", expiresAt));
|
||||
Assert.False(await cache.TryStoreAsync("jti-1", expiresAt));
|
||||
|
||||
timeProvider.Advance(TimeSpan.FromMinutes(2));
|
||||
Assert.True(await cache.TryStoreAsync("jti-1", timeProvider.GetUtcNow().AddMinutes(1)));
|
||||
}
|
||||
|
||||
private sealed class FakeIdempotencyStoreFactory : IIdempotencyStoreFactory
|
||||
{
|
||||
private readonly FakeIdempotencyStore store;
|
||||
|
||||
public FakeIdempotencyStoreFactory(TimeProvider timeProvider)
|
||||
{
|
||||
store = new FakeIdempotencyStore(timeProvider);
|
||||
}
|
||||
|
||||
public string ProviderName => "fake";
|
||||
|
||||
public IIdempotencyStore Create(string name) => store;
|
||||
}
|
||||
|
||||
private sealed class FakeIdempotencyStore : IIdempotencyStore
|
||||
{
|
||||
private readonly Dictionary<string, Entry> entries = new(StringComparer.Ordinal);
|
||||
private readonly TimeProvider timeProvider;
|
||||
|
||||
public FakeIdempotencyStore(TimeProvider timeProvider)
|
||||
{
|
||||
this.timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public string ProviderName => "fake";
|
||||
|
||||
public ValueTask<IdempotencyResult> TryClaimAsync(string key, string value, TimeSpan window, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
if (entries.TryGetValue(key, out var entry) && entry.ExpiresAt > now)
|
||||
{
|
||||
return ValueTask.FromResult(IdempotencyResult.Duplicate(entry.Value));
|
||||
}
|
||||
|
||||
entries[key] = new Entry(value, now.Add(window));
|
||||
return ValueTask.FromResult(IdempotencyResult.Claimed());
|
||||
}
|
||||
|
||||
public ValueTask<bool> ExistsAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.FromResult(entries.TryGetValue(key, out var entry) && entry.ExpiresAt > timeProvider.GetUtcNow());
|
||||
|
||||
public ValueTask<string?> GetAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.FromResult(entries.TryGetValue(key, out var entry) && entry.ExpiresAt > timeProvider.GetUtcNow() ? entry.Value : null);
|
||||
|
||||
public ValueTask<bool> ReleaseAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.FromResult(entries.Remove(key));
|
||||
|
||||
public ValueTask<bool> ExtendAsync(string key, TimeSpan extension, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!entries.TryGetValue(key, out var entry))
|
||||
{
|
||||
return ValueTask.FromResult(false);
|
||||
}
|
||||
|
||||
entries[key] = entry with { ExpiresAt = entry.ExpiresAt.Add(extension) };
|
||||
return ValueTask.FromResult(true);
|
||||
}
|
||||
|
||||
private readonly record struct Entry(string Value, DateTimeOffset ExpiresAt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.Auth.Security/StellaOps.Auth.Security.csproj" />
|
||||
<ProjectReference Include="../../StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,8 @@
|
||||
# Auth Security Tests Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0082-A | DONE | Test coverage for DPoP validation, nonce stores, and replay cache. |
|
||||
@@ -0,0 +1,26 @@
|
||||
# Infrastructure Postgres Tests Agent Charter
|
||||
|
||||
## Mission
|
||||
- Validate Infrastructure.Postgres migration and fixture behaviors with deterministic integration tests.
|
||||
|
||||
## Responsibilities
|
||||
- Maintain Testcontainers-based coverage for migrations and fixtures.
|
||||
- Keep tests categorized for CI selection and handle Docker availability gracefully.
|
||||
- Ensure test data and schema naming remain deterministic and cleaned up.
|
||||
|
||||
## Required Reading
|
||||
- src/__Libraries/StellaOps.Infrastructure.Postgres/AGENTS.md
|
||||
- src/__Tests/__Libraries/StellaOps.Infrastructure.Postgres.Testing/AGENTS.md
|
||||
- docs/modules/platform/architecture-overview.md
|
||||
|
||||
## Working Directory & Scope
|
||||
- Primary: src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests
|
||||
- Allowed shared libs/tests: src/__Libraries/StellaOps.Infrastructure.Postgres, src/__Tests/__Libraries/StellaOps.Infrastructure.Postgres.Testing
|
||||
|
||||
## Testing Expectations
|
||||
- Integration tests must be tagged as Integration and isolated per schema.
|
||||
- Skip or gate tests when Docker/Testcontainers is unavailable.
|
||||
|
||||
## Working Agreement
|
||||
- Update sprint status in docs/implplan/SPRINT_*.md and local TASKS.md.
|
||||
- Keep test artifacts deterministic and clean up schemas after runs.
|
||||
@@ -0,0 +1,10 @@
|
||||
# StellaOps.Infrastructure.Postgres.Tests Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0360-M | DONE | Maintainability audit for Infrastructure.Postgres.Tests. |
|
||||
| AUDIT-0360-T | DONE | Test coverage audit for Infrastructure.Postgres.Tests. |
|
||||
| AUDIT-0360-A | DONE | Waived (test project). |
|
||||
Reference in New Issue
Block a user