save progress
This commit is contained in:
@@ -0,0 +1,412 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// RuleBundleValidatorTests.cs
|
||||
// Sprint: SPRINT_20260104_005_AIRGAP (Secret Offline Kit Integration)
|
||||
// Task: OKS-008 - Add integration tests for offline flow
|
||||
// Description: Tests for rule bundle validation in offline import
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.AirGap.Importer.Contracts;
|
||||
using StellaOps.AirGap.Importer.Validation;
|
||||
using StellaOps.AirGap.Importer.Versioning;
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.AirGap.Importer.Tests.Validation;
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public sealed class RuleBundleValidatorTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
private readonly CapturingMonotonicityChecker _monotonicityChecker;
|
||||
|
||||
public RuleBundleValidatorTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), "stellaops-rulebundle-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
_monotonicityChecker = new CapturingMonotonicityChecker();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(_tempDir, recursive: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort cleanup
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_WhenManifestNotFound_ShouldFail()
|
||||
{
|
||||
// Arrange
|
||||
var validator = CreateValidator();
|
||||
var bundleDir = Path.Combine(_tempDir, "missing-manifest");
|
||||
Directory.CreateDirectory(bundleDir);
|
||||
|
||||
var request = CreateRequest(bundleDir, "test-bundle", "secrets");
|
||||
|
||||
// Act
|
||||
var result = await validator.ValidateAsync(request);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Reason.Should().StartWith("manifest-not-found");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_WhenManifestParseError_ShouldFail()
|
||||
{
|
||||
// Arrange
|
||||
var validator = CreateValidator();
|
||||
var bundleDir = Path.Combine(_tempDir, "invalid-manifest");
|
||||
Directory.CreateDirectory(bundleDir);
|
||||
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(bundleDir, "test-bundle.manifest.json"),
|
||||
"not-valid-json{{{");
|
||||
|
||||
var request = CreateRequest(bundleDir, "test-bundle", "secrets");
|
||||
|
||||
// Act
|
||||
var result = await validator.ValidateAsync(request);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Reason.Should().StartWith("manifest-parse-failed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_WhenFileDigestMismatch_ShouldFail()
|
||||
{
|
||||
// Arrange
|
||||
var validator = CreateValidator();
|
||||
var bundleDir = Path.Combine(_tempDir, "digest-mismatch");
|
||||
Directory.CreateDirectory(bundleDir);
|
||||
|
||||
var rulesContent = "{\"id\":\"test-rule\"}";
|
||||
var rulesPath = Path.Combine(bundleDir, "test-bundle.rules.jsonl");
|
||||
await File.WriteAllTextAsync(rulesPath, rulesContent);
|
||||
|
||||
// Create manifest with wrong digest
|
||||
var manifest = new
|
||||
{
|
||||
bundleId = "test-bundle",
|
||||
bundleType = "secrets",
|
||||
version = "2026.1.0",
|
||||
ruleCount = 1,
|
||||
files = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
name = "test-bundle.rules.jsonl",
|
||||
digest = "sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||
sizeBytes = rulesContent.Length
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(bundleDir, "test-bundle.manifest.json"),
|
||||
JsonSerializer.Serialize(manifest));
|
||||
|
||||
var request = CreateRequest(bundleDir, "test-bundle", "secrets");
|
||||
|
||||
// Act
|
||||
var result = await validator.ValidateAsync(request);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Reason.Should().Contain("digest-mismatch");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_WhenFileMissing_ShouldFail()
|
||||
{
|
||||
// Arrange
|
||||
var validator = CreateValidator();
|
||||
var bundleDir = Path.Combine(_tempDir, "file-missing");
|
||||
Directory.CreateDirectory(bundleDir);
|
||||
|
||||
var manifest = new
|
||||
{
|
||||
bundleId = "test-bundle",
|
||||
bundleType = "secrets",
|
||||
version = "2026.1.0",
|
||||
ruleCount = 1,
|
||||
files = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
name = "test-bundle.rules.jsonl",
|
||||
digest = "sha256:abcd1234",
|
||||
sizeBytes = 100
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(bundleDir, "test-bundle.manifest.json"),
|
||||
JsonSerializer.Serialize(manifest));
|
||||
|
||||
var request = CreateRequest(bundleDir, "test-bundle", "secrets");
|
||||
|
||||
// Act
|
||||
var result = await validator.ValidateAsync(request);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Reason.Should().Contain("file-missing");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_WhenSignatureRequiredButMissing_ShouldFail()
|
||||
{
|
||||
// Arrange
|
||||
var validator = CreateValidator();
|
||||
var bundleDir = await CreateValidBundleAsync("sig-required");
|
||||
|
||||
var request = CreateRequest(
|
||||
bundleDir,
|
||||
"test-bundle",
|
||||
"secrets",
|
||||
signatureEnvelope: null,
|
||||
requireSignature: true);
|
||||
|
||||
// Act
|
||||
var result = await validator.ValidateAsync(request);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Reason.Should().Be("signature-required-but-missing");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_WhenVersionNonMonotonic_ShouldFail()
|
||||
{
|
||||
// Arrange
|
||||
var monotonicityChecker = new NonMonotonicChecker();
|
||||
var validator = CreateValidator(monotonicityChecker);
|
||||
var bundleDir = await CreateValidBundleAsync("non-monotonic");
|
||||
|
||||
var request = CreateRequest(
|
||||
bundleDir,
|
||||
"test-bundle",
|
||||
"secrets",
|
||||
requireSignature: false);
|
||||
|
||||
// Act
|
||||
var result = await validator.ValidateAsync(request);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Reason.Should().StartWith("version-non-monotonic");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_WhenAllChecksPass_ShouldSucceed()
|
||||
{
|
||||
// Arrange
|
||||
var validator = CreateValidator();
|
||||
var bundleDir = await CreateValidBundleAsync("all-pass");
|
||||
|
||||
var request = CreateRequest(
|
||||
bundleDir,
|
||||
"test-bundle",
|
||||
"secrets",
|
||||
requireSignature: false);
|
||||
|
||||
// Act
|
||||
var result = await validator.ValidateAsync(request);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.Reason.Should().Be("rulebundle-validated");
|
||||
result.RuleCount.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_WhenForceActivateWithOlderVersion_ShouldSucceed()
|
||||
{
|
||||
// Arrange
|
||||
var monotonicityChecker = new NonMonotonicChecker();
|
||||
var validator = CreateValidator(monotonicityChecker);
|
||||
var bundleDir = await CreateValidBundleAsync("force-activate");
|
||||
|
||||
var request = CreateRequest(
|
||||
bundleDir,
|
||||
"test-bundle",
|
||||
"secrets",
|
||||
requireSignature: false,
|
||||
forceActivate: true,
|
||||
forceActivateReason: "Rollback due to compatibility issue");
|
||||
|
||||
// Act
|
||||
var result = await validator.ValidateAsync(request);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.Reason.Should().Be("rulebundle-validated");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_ShouldRecordActivation()
|
||||
{
|
||||
// Arrange
|
||||
var validator = CreateValidator();
|
||||
var bundleDir = await CreateValidBundleAsync("record-activation");
|
||||
|
||||
var request = CreateRequest(
|
||||
bundleDir,
|
||||
"test-bundle",
|
||||
"secrets",
|
||||
requireSignature: false);
|
||||
|
||||
// Act
|
||||
var result = await validator.ValidateAsync(request);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
_monotonicityChecker.RecordedActivations.Should().HaveCount(1);
|
||||
_monotonicityChecker.RecordedActivations[0].BundleType.Should().Contain("secrets");
|
||||
}
|
||||
|
||||
private RuleBundleValidator CreateValidator(IVersionMonotonicityChecker? checker = null)
|
||||
{
|
||||
return new RuleBundleValidator(
|
||||
new DsseVerifier(),
|
||||
checker ?? _monotonicityChecker,
|
||||
NullLogger<RuleBundleValidator>.Instance);
|
||||
}
|
||||
|
||||
private async Task<string> CreateValidBundleAsync(string name)
|
||||
{
|
||||
var bundleDir = Path.Combine(_tempDir, name);
|
||||
Directory.CreateDirectory(bundleDir);
|
||||
|
||||
// Create rules file
|
||||
var rulesContent = "{\"id\":\"test-rule-1\",\"name\":\"Test Rule\",\"pattern\":\"SECRET_\"}\n" +
|
||||
"{\"id\":\"test-rule-2\",\"name\":\"Another Rule\",\"pattern\":\"API_KEY_\"}";
|
||||
var rulesPath = Path.Combine(bundleDir, "test-bundle.rules.jsonl");
|
||||
await File.WriteAllTextAsync(rulesPath, rulesContent);
|
||||
|
||||
// Compute digest
|
||||
var rulesBytes = Encoding.UTF8.GetBytes(rulesContent);
|
||||
var rulesDigest = $"sha256:{Convert.ToHexString(SHA256.HashData(rulesBytes)).ToLowerInvariant()}";
|
||||
|
||||
// Create manifest
|
||||
var manifest = new
|
||||
{
|
||||
bundleId = "test-bundle",
|
||||
bundleType = "secrets",
|
||||
version = "2026.1.0",
|
||||
ruleCount = 2,
|
||||
files = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
name = "test-bundle.rules.jsonl",
|
||||
digest = rulesDigest,
|
||||
sizeBytes = rulesBytes.Length
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(bundleDir, "test-bundle.manifest.json"),
|
||||
JsonSerializer.Serialize(manifest, new JsonSerializerOptions { WriteIndented = true }));
|
||||
|
||||
return bundleDir;
|
||||
}
|
||||
|
||||
private static RuleBundleValidationRequest CreateRequest(
|
||||
string bundleDir,
|
||||
string bundleId,
|
||||
string bundleType,
|
||||
DsseEnvelope? signatureEnvelope = null,
|
||||
TrustRootConfig? trustRoots = null,
|
||||
bool requireSignature = false,
|
||||
bool forceActivate = false,
|
||||
string? forceActivateReason = null)
|
||||
{
|
||||
return new RuleBundleValidationRequest(
|
||||
TenantId: "tenant-test",
|
||||
BundleId: bundleId,
|
||||
BundleType: bundleType,
|
||||
Version: "2026.1.0",
|
||||
BundleDirectory: bundleDir,
|
||||
CreatedAt: DateTimeOffset.UtcNow,
|
||||
SignatureEnvelope: signatureEnvelope,
|
||||
TrustRoots: trustRoots ?? TrustRootConfig.Empty("/tmp"),
|
||||
RequireSignature: requireSignature,
|
||||
ForceActivate: forceActivate,
|
||||
ForceActivateReason: forceActivateReason);
|
||||
}
|
||||
|
||||
private sealed class CapturingMonotonicityChecker : IVersionMonotonicityChecker
|
||||
{
|
||||
public List<(string TenantId, string BundleType, BundleVersion Version)> RecordedActivations { get; } = [];
|
||||
|
||||
public Task<MonotonicityCheckResult> CheckAsync(
|
||||
string tenantId,
|
||||
string bundleType,
|
||||
BundleVersion incomingVersion,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new MonotonicityCheckResult(
|
||||
IsMonotonic: true,
|
||||
CurrentVersion: null,
|
||||
CurrentBundleDigest: null,
|
||||
CurrentActivatedAt: null,
|
||||
ReasonCode: "FIRST_ACTIVATION"));
|
||||
}
|
||||
|
||||
public Task RecordActivationAsync(
|
||||
string tenantId,
|
||||
string bundleType,
|
||||
BundleVersion version,
|
||||
string bundleDigest,
|
||||
bool wasForceActivated = false,
|
||||
string? forceActivateReason = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
RecordedActivations.Add((tenantId, bundleType, version));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NonMonotonicChecker : IVersionMonotonicityChecker
|
||||
{
|
||||
public Task<MonotonicityCheckResult> CheckAsync(
|
||||
string tenantId,
|
||||
string bundleType,
|
||||
BundleVersion incomingVersion,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new MonotonicityCheckResult(
|
||||
IsMonotonic: false,
|
||||
CurrentVersion: BundleVersion.Parse("2026.12.0", DateTimeOffset.UtcNow),
|
||||
CurrentBundleDigest: "sha256:current",
|
||||
CurrentActivatedAt: DateTimeOffset.UtcNow.AddDays(-1),
|
||||
ReasonCode: "OLDER_VERSION"));
|
||||
}
|
||||
|
||||
public Task RecordActivationAsync(
|
||||
string tenantId,
|
||||
string bundleType,
|
||||
BundleVersion version,
|
||||
string bundleDigest,
|
||||
bool wasForceActivated = false,
|
||||
string? forceActivateReason = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user