save progress

This commit is contained in:
StellaOps Bot
2026-01-04 19:08:47 +02:00
parent f7d27c6fda
commit 75611a505f
97 changed files with 4531 additions and 293 deletions

View File

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