part #2
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using StellaOps.AuditPack.Services;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AuditPack.Tests;
|
||||
|
||||
public sealed class ArchiveUtilitiesTests : IDisposable
|
||||
{
|
||||
private static int _tempCounter;
|
||||
private readonly string _tempDir;
|
||||
|
||||
public ArchiveUtilitiesTests()
|
||||
{
|
||||
var suffix = Interlocked.Increment(ref _tempCounter);
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"audit-archive-{suffix:0000}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempDir))
|
||||
{
|
||||
Directory.Delete(_tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ExtractTarGzAsync_WritesFilesAsync()
|
||||
{
|
||||
var payload = Encoding.UTF8.GetBytes("{\"ok\":true}");
|
||||
var archivePath = Path.Combine(_tempDir, "bundle.tar.gz");
|
||||
var entries = new[]
|
||||
{
|
||||
new ArchiveEntry("manifest.json", payload)
|
||||
};
|
||||
|
||||
await ArchiveUtilities.WriteTarGzAsync(archivePath, entries, CancellationToken.None);
|
||||
|
||||
var extractDir = Path.Combine(_tempDir, "extract");
|
||||
await ArchiveUtilities.ExtractTarGzAsync(archivePath, extractDir, overwriteFiles: false, CancellationToken.None);
|
||||
|
||||
var extractedPath = Path.Combine(extractDir, "manifest.json");
|
||||
Assert.True(File.Exists(extractedPath));
|
||||
var actual = await File.ReadAllBytesAsync(extractedPath);
|
||||
Assert.Equal(payload, actual);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ExtractTarGzAsync_RejectsParentTraversalAsync()
|
||||
{
|
||||
var payload = Encoding.UTF8.GetBytes("escape");
|
||||
var archivePath = Path.Combine(_tempDir, "escape.tar.gz");
|
||||
var entries = new[]
|
||||
{
|
||||
new ArchiveEntry("../escape.txt", payload)
|
||||
};
|
||||
|
||||
await ArchiveUtilities.WriteTarGzAsync(archivePath, entries, CancellationToken.None);
|
||||
|
||||
var extractDir = Path.Combine(_tempDir, "escape");
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
ArchiveUtilities.ExtractTarGzAsync(archivePath, extractDir, overwriteFiles: true, CancellationToken.None));
|
||||
}
|
||||
}
|
||||
@@ -13,3 +13,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0076-A | DONE | Waived (test project; revalidated 2026-01-06). |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| REMED-08 | DONE | Split tests <= 100 lines; deterministic fixtures/time/IDs; async naming; replay/attestation coverage expanded; ConfigureAwait(false) skipped per xUnit1030; dotnet test passed 2026-02-02 (46 tests). |
|
||||
| REMED-05 | DONE | Added ArchiveUtilities extraction tests; dotnet test passed 2026-02-04 (52 tests). |
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
using System;
|
||||
using StellaOps.Auth.Security.Dpop;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Auth.Security.Tests;
|
||||
|
||||
public sealed class DpopValidationOptionsTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Validate_ThrowsWhenProofLifetimeNonPositive()
|
||||
{
|
||||
var options = new DpopValidationOptions
|
||||
{
|
||||
ProofLifetime = TimeSpan.Zero
|
||||
};
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => options.Validate());
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Validate_ThrowsWhenClockSkewOutOfRange()
|
||||
{
|
||||
var options = new DpopValidationOptions
|
||||
{
|
||||
AllowedClockSkew = TimeSpan.FromMinutes(6)
|
||||
};
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => options.Validate());
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Validate_ThrowsWhenReplayWindowNegative()
|
||||
{
|
||||
var options = new DpopValidationOptions
|
||||
{
|
||||
ReplayWindow = TimeSpan.FromSeconds(-1)
|
||||
};
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => options.Validate());
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Validate_ThrowsWhenAllowedAlgorithmsEmpty()
|
||||
{
|
||||
var options = new DpopValidationOptions();
|
||||
options.AllowedAlgorithms.Clear();
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => options.Validate());
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Validate_NormalizesAlgorithms()
|
||||
{
|
||||
var options = new DpopValidationOptions();
|
||||
options.AllowedAlgorithms.Clear();
|
||||
options.AllowedAlgorithms.Add(" es256 ");
|
||||
options.AllowedAlgorithms.Add("Es384");
|
||||
|
||||
options.Validate();
|
||||
|
||||
Assert.Contains("ES256", options.NormalizedAlgorithms);
|
||||
Assert.Contains("ES384", options.NormalizedAlgorithms);
|
||||
}
|
||||
}
|
||||
@@ -13,3 +13,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0785-A | DONE | Waived (test project; revalidated 2026-01-07). |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| REMED-08 | DONE | Split tests <= 100 lines; deterministic time/IDs; async naming; helper types separated; ConfigureAwait(false) skipped per xUnit1030; dotnet test passed 2026-02-02 (12 tests). |
|
||||
| REMED-05 | DONE | Added DpopValidationOptions unit coverage; dotnet test passed 2026-02-04 (20 tests). |
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Cryptography.Kms;
|
||||
using StellaOps.TestKit;
|
||||
@@ -9,7 +11,7 @@ public sealed partial class CloudKmsClientTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void KmsCryptoProvider_Skips_NonExportable_Keys()
|
||||
public void KmsCryptoProvider_Returns_VerificationOnly_Keys_When_PublicMaterial_Available()
|
||||
{
|
||||
using var fixture = new EcdsaFixture();
|
||||
var parameters = fixture.Parameters;
|
||||
@@ -28,6 +30,36 @@ public sealed partial class CloudKmsClientTests
|
||||
|
||||
provider.UpsertSigningKey(signingKey);
|
||||
|
||||
var keys = provider.GetSigningKeys();
|
||||
var key = Assert.Single(keys);
|
||||
Assert.Equal(signingKey.Reference.KeyId, key.Reference.KeyId);
|
||||
Assert.Null(key.PrivateParameters.D);
|
||||
Assert.NotNull(key.PublicParameters.Q.X);
|
||||
Assert.NotNull(key.PublicParameters.Q.Y);
|
||||
Assert.Equal(signingKey.Metadata["kms.version"], key.Metadata["kms.version"]);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void KmsCryptoProvider_Skips_Keys_Without_PublicMaterial()
|
||||
{
|
||||
using var fixture = new EcdsaFixture();
|
||||
var parameters = fixture.Parameters;
|
||||
var kmsClient = new NonExportingKmsClient(parameters, FixedNow);
|
||||
var provider = new KmsCryptoProvider(kmsClient);
|
||||
|
||||
var signingKey = new CryptoSigningKey(
|
||||
new CryptoKeyReference("kms-key-no-public", "kms"),
|
||||
KmsAlgorithms.Es256,
|
||||
new byte[32],
|
||||
FixedNow,
|
||||
metadata: new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["kms.version"] = "kms-key-no-public",
|
||||
});
|
||||
|
||||
provider.UpsertSigningKey(signingKey);
|
||||
|
||||
var keys = provider.GetSigningKeys();
|
||||
Assert.Empty(keys);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Cryptography.Plugin.BouncyCastle;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cryptography.Tests;
|
||||
|
||||
public sealed class BouncyCastleKeyNormalizationTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2024, 1, 15, 10, 30, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public void UpsertSigningKey_With64BytePrivateKey_NormalizesTo32Bytes()
|
||||
{
|
||||
var provider = new BouncyCastleEd25519CryptoProvider();
|
||||
var privateKey = Enumerable.Range(1, 64).Select(i => (byte)i).ToArray();
|
||||
var keyReference = new CryptoKeyReference("key-64", provider.Name);
|
||||
var signingKey = new CryptoSigningKey(
|
||||
keyReference,
|
||||
SignatureAlgorithms.Ed25519,
|
||||
privateKey,
|
||||
createdAt: FixedNow);
|
||||
|
||||
provider.UpsertSigningKey(signingKey);
|
||||
|
||||
var stored = provider.GetSigningKeys().Single();
|
||||
stored.PrivateKey.Length.Should().Be(32);
|
||||
stored.PrivateKey.ToArray().Should().Equal(privateKey.Take(32).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpsertSigningKey_EmptyPublicKey_DerivesPublicKey()
|
||||
{
|
||||
var provider = new BouncyCastleEd25519CryptoProvider();
|
||||
var privateKey = Enumerable.Range(10, 32).Select(i => (byte)i).ToArray();
|
||||
var keyReference = new CryptoKeyReference("key-derived-public", provider.Name);
|
||||
var signingKey = new CryptoSigningKey(
|
||||
keyReference,
|
||||
SignatureAlgorithms.Ed25519,
|
||||
privateKey,
|
||||
createdAt: FixedNow);
|
||||
|
||||
provider.UpsertSigningKey(signingKey);
|
||||
|
||||
var stored = provider.GetSigningKeys().Single();
|
||||
stored.PublicKey.Length.Should().Be(32);
|
||||
stored.PublicKey.ToArray().Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpsertSigningKey_InvalidPublicKeyLength_Throws()
|
||||
{
|
||||
var provider = new BouncyCastleEd25519CryptoProvider();
|
||||
var privateKey = Enumerable.Range(0, 32).Select(i => (byte)i).ToArray();
|
||||
var publicKey = new byte[31];
|
||||
var keyReference = new CryptoKeyReference("key-invalid-public", provider.Name);
|
||||
var signingKey = new CryptoSigningKey(
|
||||
keyReference,
|
||||
SignatureAlgorithms.Ed25519,
|
||||
privateKey,
|
||||
createdAt: FixedNow,
|
||||
publicKey: publicKey);
|
||||
|
||||
Action act = () => provider.UpsertSigningKey(signingKey);
|
||||
|
||||
act.Should().Throw<InvalidOperationException>()
|
||||
.WithMessage("*public key must be 32 bytes*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpsertSigningKey_EdDsaAlgorithm_NormalizesToEd25519()
|
||||
{
|
||||
var provider = new BouncyCastleEd25519CryptoProvider();
|
||||
var privateKey = Enumerable.Range(0, 32).Select(i => (byte)i).ToArray();
|
||||
var keyReference = new CryptoKeyReference("key-eddsa", provider.Name);
|
||||
var signingKey = new CryptoSigningKey(
|
||||
keyReference,
|
||||
SignatureAlgorithms.EdDsa,
|
||||
privateKey,
|
||||
createdAt: FixedNow);
|
||||
|
||||
provider.UpsertSigningKey(signingKey);
|
||||
|
||||
var stored = provider.GetSigningKeys().Single();
|
||||
stored.AlgorithmId.Should().Be(SignatureAlgorithms.Ed25519);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Cryptography.Tests;
|
||||
|
||||
internal sealed class OrderedTestCryptoProvider : ICryptoProvider
|
||||
{
|
||||
internal const string Algorithm = "test-hash";
|
||||
private readonly ICryptoHasher _hasher;
|
||||
|
||||
public OrderedTestCryptoProvider(string name)
|
||||
{
|
||||
Name = name;
|
||||
_hasher = new OrderedTestHasher(Algorithm);
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public bool Supports(CryptoCapability capability, string algorithmId)
|
||||
=> capability == CryptoCapability.ContentHashing &&
|
||||
string.Equals(algorithmId, Algorithm, StringComparison.Ordinal);
|
||||
|
||||
public IPasswordHasher GetPasswordHasher(string algorithmId)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public ICryptoHasher GetHasher(string algorithmId)
|
||||
=> Supports(CryptoCapability.ContentHashing, algorithmId)
|
||||
? _hasher
|
||||
: throw new InvalidOperationException("Unsupported hash algorithm.");
|
||||
|
||||
public ICryptoSigner GetSigner(string algorithmId, CryptoKeyReference keyReference)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public void UpsertSigningKey(CryptoSigningKey signingKey)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public bool RemoveSigningKey(string keyId) => false;
|
||||
|
||||
public IReadOnlyCollection<CryptoSigningKey> GetSigningKeys()
|
||||
=> Array.Empty<CryptoSigningKey>();
|
||||
|
||||
private sealed class OrderedTestHasher : ICryptoHasher
|
||||
{
|
||||
public OrderedTestHasher(string algorithmId)
|
||||
{
|
||||
AlgorithmId = algorithmId;
|
||||
}
|
||||
|
||||
public string AlgorithmId { get; }
|
||||
|
||||
public byte[] ComputeHash(ReadOnlySpan<byte> data) => Array.Empty<byte>();
|
||||
|
||||
public string ComputeHashHex(ReadOnlySpan<byte> data) => string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Cryptography.DependencyInjection;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cryptography.Tests;
|
||||
|
||||
public sealed class CryptoDependencyInjectionTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void AddStellaOpsCrypto_ResolvesPreferredProviderOrder()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<ICryptoProvider>(new OrderedTestCryptoProvider("alpha"));
|
||||
services.AddSingleton<ICryptoProvider>(new OrderedTestCryptoProvider("beta"));
|
||||
services.AddStellaOpsCrypto(options =>
|
||||
{
|
||||
options.PreferredProviders.Add("beta");
|
||||
options.PreferredProviders.Add("alpha");
|
||||
});
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var registry = provider.GetRequiredService<ICryptoProviderRegistry>();
|
||||
|
||||
var resolved = registry.ResolveOrThrow(CryptoCapability.ContentHashing, OrderedTestCryptoProvider.Algorithm);
|
||||
|
||||
Assert.Equal("beta", resolved.Name);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void AddStellaOpsCryptoFromConfiguration_LoadsPluginProvidersByPriority()
|
||||
{
|
||||
var tempRoot = Path.Combine(Path.GetTempPath(), "stellaops-crypto-tests", $"crypto-di-{Environment.ProcessId}");
|
||||
Directory.CreateDirectory(tempRoot);
|
||||
|
||||
try
|
||||
{
|
||||
var assemblyPath = typeof(TestPluginAlphaProvider).Assembly.Location;
|
||||
var assemblyFileName = Path.GetFileName(assemblyPath);
|
||||
File.Copy(assemblyPath, Path.Combine(tempRoot, assemblyFileName), overwrite: true);
|
||||
|
||||
var manifestPath = Path.Combine(tempRoot, "crypto-plugins-manifest.json");
|
||||
WriteManifest(manifestPath, assemblyFileName, GetCurrentPlatform());
|
||||
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["StellaOps:Crypto:Plugins:ManifestPath"] = manifestPath,
|
||||
["StellaOps:Crypto:Plugins:DiscoveryMode"] = "explicit",
|
||||
["StellaOps:Crypto:Plugins:Enabled:0:Id"] = "test.plugin.alpha",
|
||||
["StellaOps:Crypto:Plugins:Enabled:0:Priority"] = "10",
|
||||
["StellaOps:Crypto:Plugins:Enabled:1:Id"] = "test.plugin.beta",
|
||||
["StellaOps:Crypto:Plugins:Enabled:1:Priority"] = "90",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddStellaOpsCryptoFromConfiguration(configuration, tempRoot);
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var registry = provider.GetRequiredService<ICryptoProviderRegistry>();
|
||||
|
||||
var resolved = registry.ResolveOrThrow(CryptoCapability.ContentHashing, TestPluginCryptoProviderBase.TestAlgorithm);
|
||||
|
||||
Assert.Equal("test.plugin.beta", resolved.Name);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Cleanup is best-effort because the plugin assembly can remain locked on Windows.
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(tempRoot))
|
||||
{
|
||||
Directory.Delete(tempRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteManifest(string manifestPath, string assemblyFileName, string platform)
|
||||
{
|
||||
var manifest = new
|
||||
{
|
||||
version = "1.0",
|
||||
plugins = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
id = "test.plugin.alpha",
|
||||
name = "Test Plugin Alpha",
|
||||
assembly = assemblyFileName,
|
||||
type = typeof(TestPluginAlphaProvider).FullName!,
|
||||
platforms = new[] { platform },
|
||||
},
|
||||
new
|
||||
{
|
||||
id = "test.plugin.beta",
|
||||
name = "Test Plugin Beta",
|
||||
assembly = assemblyFileName,
|
||||
type = typeof(TestPluginBetaProvider).FullName!,
|
||||
platforms = new[] { platform },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(manifest, new JsonSerializerOptions { WriteIndented = true });
|
||||
File.WriteAllText(manifestPath, json);
|
||||
}
|
||||
|
||||
private static string GetCurrentPlatform()
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||
{
|
||||
return "linux";
|
||||
}
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
return "windows";
|
||||
}
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
{
|
||||
return "osx";
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Cryptography.Tests;
|
||||
|
||||
public abstract class TestPluginCryptoProviderBase : ICryptoProvider
|
||||
{
|
||||
public const string TestAlgorithm = "plugin-hash";
|
||||
private static readonly ICryptoHasher Hasher = new TestPluginHasher();
|
||||
|
||||
protected TestPluginCryptoProviderBase(string name)
|
||||
{
|
||||
Name = name;
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public bool Supports(CryptoCapability capability, string algorithmId)
|
||||
=> capability == CryptoCapability.ContentHashing &&
|
||||
string.Equals(algorithmId, TestAlgorithm, StringComparison.Ordinal);
|
||||
|
||||
public IPasswordHasher GetPasswordHasher(string algorithmId)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public ICryptoHasher GetHasher(string algorithmId)
|
||||
=> Supports(CryptoCapability.ContentHashing, algorithmId)
|
||||
? Hasher
|
||||
: throw new InvalidOperationException("Unsupported hash algorithm.");
|
||||
|
||||
public ICryptoSigner GetSigner(string algorithmId, CryptoKeyReference keyReference)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public void UpsertSigningKey(CryptoSigningKey signingKey)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public bool RemoveSigningKey(string keyId) => false;
|
||||
|
||||
public IReadOnlyCollection<CryptoSigningKey> GetSigningKeys()
|
||||
=> Array.Empty<CryptoSigningKey>();
|
||||
|
||||
private sealed class TestPluginHasher : ICryptoHasher
|
||||
{
|
||||
public string AlgorithmId => TestAlgorithm;
|
||||
public byte[] ComputeHash(ReadOnlySpan<byte> data) => Array.Empty<byte>();
|
||||
public string ComputeHashHex(ReadOnlySpan<byte> data) => string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TestPluginAlphaProvider : TestPluginCryptoProviderBase
|
||||
{
|
||||
public TestPluginAlphaProvider()
|
||||
: base("test.plugin.alpha")
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TestPluginBetaProvider : TestPluginCryptoProviderBase
|
||||
{
|
||||
public TestPluginBetaProvider()
|
||||
: base("test.plugin.beta")
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -13,3 +13,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0271-A | DONE | Waived (test project; revalidated 2026-01-07). |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| REMED-08 | DONE | Remediated (deterministic fixtures, async naming, file split <= 100 lines); `dotnet test` passed (312 tests). |
|
||||
| REMED-05 | DONE | Added DI ordering and plugin-loading tests for crypto registration paths; `dotnet test src/__Libraries/__Tests/StellaOps.Cryptography.Tests/StellaOps.Cryptography.Tests.csproj -p:BuildInParallel=false -p:UseSharedCompilation=false` passed (326 tests). |
|
||||
|
||||
@@ -67,4 +67,13 @@ public sealed partial class PostgresEvidenceStoreIntegrationTests
|
||||
|
||||
storedCount.Should().Be(1);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task StoreBatchAsync_WithEmptyInput_ReturnsZeroAsync()
|
||||
{
|
||||
var storedCount = await _store.StoreBatchAsync(Array.Empty<IEvidence>());
|
||||
|
||||
storedCount.Should().Be(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,3 +13,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0285-A | DONE | Waived (test project; revalidated 2026-01-07). |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| REMED-2026-02-03 | DONE | Split integration tests into partials, normalized async naming, deterministic fixtures; tests passed 2026-02-03. |
|
||||
| REMED-07 | DONE | Added StoreBatch empty-input coverage; tests passed 2026-02-04 (35 tests). |
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Evidence.Budgets;
|
||||
using StellaOps.Evidence.Retention;
|
||||
using StellaOps.Evidence.Tests.TestUtilities;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Evidence.Tests.Retention;
|
||||
|
||||
public partial class RetentionTierManagerTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetCurrentTier_UsesRetentionBoundaries()
|
||||
{
|
||||
var budget = new EvidenceBudget
|
||||
{
|
||||
MaxScanSizeBytes = 100,
|
||||
RetentionPolicies = new Dictionary<RetentionTier, RetentionPolicy>
|
||||
{
|
||||
[RetentionTier.Hot] = new RetentionPolicy { Duration = TimeSpan.FromDays(1) },
|
||||
[RetentionTier.Warm] = new RetentionPolicy { Duration = TimeSpan.FromDays(3) },
|
||||
[RetentionTier.Cold] = new RetentionPolicy { Duration = TimeSpan.FromDays(5) },
|
||||
[RetentionTier.Archive] = new RetentionPolicy { Duration = TimeSpan.FromDays(7) }
|
||||
}
|
||||
};
|
||||
var options = new Mock<IOptionsMonitor<EvidenceBudget>>();
|
||||
options.Setup(o => o.CurrentValue).Returns(budget);
|
||||
var repository = new Mock<IEvidenceRepository>();
|
||||
var archiveStorage = new Mock<IArchiveStorage>();
|
||||
|
||||
var manager = new RetentionTierManager(
|
||||
repository.Object,
|
||||
archiveStorage.Object,
|
||||
options.Object,
|
||||
new FixedTimeProvider(_fixedNow));
|
||||
|
||||
var hotItem = new EvidenceItem
|
||||
{
|
||||
Id = Guid.Parse("00000000-0000-0000-0000-000000000310"),
|
||||
ScanId = _defaultScanId,
|
||||
Type = EvidenceType.CallGraph,
|
||||
SizeBytes = 1,
|
||||
Tier = RetentionTier.Hot,
|
||||
CreatedAt = _fixedNow.AddHours(-12)
|
||||
};
|
||||
var warmItem = hotItem with { Id = Guid.Parse("00000000-0000-0000-0000-000000000311"), CreatedAt = _fixedNow.AddDays(-2) };
|
||||
var coldItem = hotItem with { Id = Guid.Parse("00000000-0000-0000-0000-000000000312"), CreatedAt = _fixedNow.AddDays(-4) };
|
||||
var archiveItem = hotItem with { Id = Guid.Parse("00000000-0000-0000-0000-000000000313"), CreatedAt = _fixedNow.AddDays(-10) };
|
||||
|
||||
manager.GetCurrentTier(hotItem).Should().Be(RetentionTier.Hot);
|
||||
manager.GetCurrentTier(warmItem).Should().Be(RetentionTier.Warm);
|
||||
manager.GetCurrentTier(coldItem).Should().Be(RetentionTier.Cold);
|
||||
manager.GetCurrentTier(archiveItem).Should().Be(RetentionTier.Archive);
|
||||
}
|
||||
}
|
||||
@@ -10,3 +10,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0026-A | DONE | Waived (test project; revalidated 2026-01-08). |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| REMED-2026-02-02 | DONE | Remediated csproj audit findings; tests passed 2026-02-02. |
|
||||
| REMED-07 | DONE | Added retention tier boundary coverage; tests passed 2026-02-04 (24 tests). |
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Replay.Core;
|
||||
using StellaOps.TestKit;
|
||||
using System.Linq;
|
||||
using System;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Replay.Core.Tests;
|
||||
@@ -10,7 +10,7 @@ namespace StellaOps.Replay.Core.Tests;
|
||||
public class ReachabilityReplayWriterTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void BuildManifestV2_SortsGraphsAndTraces_Deterministically()
|
||||
{
|
||||
var scan = new ReplayScanMetadata
|
||||
|
||||
@@ -8,4 +8,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0035-M | DONE | Revalidated 2026-01-08; open findings tracked in audit report. |
|
||||
| AUDIT-0035-T | DONE | Revalidated 2026-01-08; open findings tracked in audit report. |
|
||||
| AUDIT-0035-A | DONE | Waived (test project; revalidated 2026-01-08). |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| REMED-06 | DONE | SOLID review notes refreshed 2026-02-04; dotnet test passed (1). |
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Replay.Loaders;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Replay.Tests;
|
||||
|
||||
public sealed class FeedSnapshotLoaderTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task LoadByDigestAsync_InvalidDigest_ThrowsFormatException()
|
||||
{
|
||||
var loader = new FeedSnapshotLoader(new FeedStorageStub(null), NullLogger<FeedSnapshotLoader>.Instance);
|
||||
var digest = SnapshotTestData.CreateInvalidDigest('a');
|
||||
|
||||
var action = () => loader.LoadByDigestAsync(digest);
|
||||
|
||||
await action.Should().ThrowAsync<FormatException>();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task LoadByDigestAsync_ShortDigest_ThrowsFormatException()
|
||||
{
|
||||
var loader = new FeedSnapshotLoader(new FeedStorageStub(null), NullLogger<FeedSnapshotLoader>.Instance);
|
||||
var digest = new string('a', 63);
|
||||
|
||||
var action = () => loader.LoadByDigestAsync(digest);
|
||||
|
||||
await action.Should().ThrowAsync<FormatException>();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task LoadByDigestAsync_DigestMismatch_ThrowsException()
|
||||
{
|
||||
var snapshot = SnapshotTestData.CreateFeedSnapshot(SnapshotTestData.CreateValidDigest('b'));
|
||||
var actualDigest = SnapshotTestData.ComputeDigest(snapshot);
|
||||
var expectedDigest = SnapshotTestData.CreateDifferentDigest(actualDigest);
|
||||
var loader = new FeedSnapshotLoader(new FeedStorageStub(snapshot), NullLogger<FeedSnapshotLoader>.Instance);
|
||||
|
||||
var action = () => loader.LoadByDigestAsync(expectedDigest);
|
||||
|
||||
await action.Should().ThrowAsync<DigestMismatchException>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace StellaOps.Replay.Tests;
|
||||
|
||||
internal sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _now;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset now) => _now = now;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Replay.Loaders;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Replay.Tests;
|
||||
|
||||
public sealed class PolicySnapshotLoaderTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task LoadByDigestAsync_InvalidDigest_ThrowsFormatException()
|
||||
{
|
||||
var loader = new PolicySnapshotLoader(new PolicyStorageStub(null), NullLogger<PolicySnapshotLoader>.Instance);
|
||||
var digest = SnapshotTestData.CreateInvalidDigest('c');
|
||||
|
||||
var action = () => loader.LoadByDigestAsync(digest);
|
||||
|
||||
await action.Should().ThrowAsync<FormatException>();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task LoadByDigestAsync_ShortDigest_ThrowsFormatException()
|
||||
{
|
||||
var loader = new PolicySnapshotLoader(new PolicyStorageStub(null), NullLogger<PolicySnapshotLoader>.Instance);
|
||||
var digest = new string('c', 10);
|
||||
|
||||
var action = () => loader.LoadByDigestAsync(digest);
|
||||
|
||||
await action.Should().ThrowAsync<FormatException>();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task LoadByDigestAsync_DigestMismatch_ThrowsException()
|
||||
{
|
||||
var snapshot = SnapshotTestData.CreatePolicySnapshot(SnapshotTestData.CreateValidDigest('d'));
|
||||
var actualDigest = SnapshotTestData.ComputeDigest(snapshot);
|
||||
var expectedDigest = SnapshotTestData.CreateDifferentDigest(actualDigest);
|
||||
var loader = new PolicySnapshotLoader(new PolicyStorageStub(snapshot), NullLogger<PolicySnapshotLoader>.Instance);
|
||||
|
||||
var action = () => loader.LoadByDigestAsync(expectedDigest);
|
||||
|
||||
await action.Should().ThrowAsync<DigestMismatchException>();
|
||||
}
|
||||
}
|
||||
@@ -16,13 +16,17 @@ internal static class ReplayEngineTestFixtures
|
||||
new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
internal static readonly DateTimeOffset FixedFeedSnapshotAt = FixedTimestamp.AddHours(-1);
|
||||
|
||||
internal static ReplayEngine CreateEngine()
|
||||
internal static ReplayEngine CreateEngine() =>
|
||||
CreateEngine(new FixedTimeProvider(FixedTimestamp));
|
||||
|
||||
internal static ReplayEngine CreateEngine(TimeProvider timeProvider)
|
||||
{
|
||||
return new ReplayEngine(
|
||||
new FakeFeedLoader(),
|
||||
new FakePolicyLoader(),
|
||||
new FakeScannerFactory(),
|
||||
NullLogger<ReplayEngine>.Instance);
|
||||
NullLogger<ReplayEngine>.Instance,
|
||||
timeProvider);
|
||||
}
|
||||
|
||||
internal static RunManifest CreateManifest()
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Replay.Engine;
|
||||
using StellaOps.Replay.Models;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Replay.Tests;
|
||||
|
||||
public partial class ReplayEngineTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CheckDeterminism_IdenticalResults_ReturnsTrue()
|
||||
{
|
||||
var engine = ReplayEngineTestFixtures.CreateEngine();
|
||||
var result1 = new ReplayResult
|
||||
{
|
||||
RunId = "1",
|
||||
VerdictDigest = "abc123",
|
||||
Success = true,
|
||||
ExecutedAt = ReplayEngineTestFixtures.FixedTimestamp
|
||||
};
|
||||
var result2 = new ReplayResult
|
||||
{
|
||||
RunId = "1",
|
||||
VerdictDigest = "abc123",
|
||||
Success = true,
|
||||
ExecutedAt = ReplayEngineTestFixtures.FixedTimestamp
|
||||
};
|
||||
|
||||
var check = engine.CheckDeterminism(result1, result2);
|
||||
|
||||
check.IsDeterministic.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CheckDeterminism_DifferentResults_ReturnsDifferences()
|
||||
{
|
||||
var engine = ReplayEngineTestFixtures.CreateEngine();
|
||||
var result1 = new ReplayResult
|
||||
{
|
||||
RunId = "1",
|
||||
VerdictJson = "{\"score\":100}",
|
||||
VerdictDigest = "abc123",
|
||||
Success = true,
|
||||
ExecutedAt = ReplayEngineTestFixtures.FixedTimestamp
|
||||
};
|
||||
var result2 = new ReplayResult
|
||||
{
|
||||
RunId = "1",
|
||||
VerdictJson = "{\"score\":99}",
|
||||
VerdictDigest = "def456",
|
||||
Success = true,
|
||||
ExecutedAt = ReplayEngineTestFixtures.FixedTimestamp
|
||||
};
|
||||
|
||||
var check = engine.CheckDeterminism(result1, result2);
|
||||
|
||||
check.IsDeterministic.Should().BeFalse();
|
||||
check.Differences.Should().NotBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Replay.Models;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Replay.Tests;
|
||||
|
||||
public partial class ReplayEngineTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Replay_InvalidManifest_UsesTimeProviderAsync()
|
||||
{
|
||||
var fixedTime = new DateTimeOffset(2026, 1, 2, 0, 0, 0, TimeSpan.Zero);
|
||||
var engine = ReplayEngineTestFixtures.CreateEngine(new FixedTimeProvider(fixedTime));
|
||||
var manifest = ReplayEngineTestFixtures.CreateManifest() with { RunId = "" };
|
||||
|
||||
var result = await engine.ReplayAsync(manifest, new ReplayOptions());
|
||||
|
||||
result.Success.Should().BeFalse();
|
||||
result.ExecutedAt.Should().Be(fixedTime);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ using Xunit;
|
||||
|
||||
namespace StellaOps.Replay.Tests;
|
||||
|
||||
public class ReplayEngineTests
|
||||
public partial class ReplayEngineTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
@@ -38,57 +38,4 @@ public class ReplayEngineTests
|
||||
|
||||
result1.VerdictDigest.Should().NotBe(result2.VerdictDigest);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CheckDeterminism_IdenticalResults_ReturnsTrue()
|
||||
{
|
||||
var engine = ReplayEngineTestFixtures.CreateEngine();
|
||||
var result1 = new ReplayResult
|
||||
{
|
||||
RunId = "1",
|
||||
VerdictDigest = "abc123",
|
||||
Success = true,
|
||||
ExecutedAt = ReplayEngineTestFixtures.FixedTimestamp
|
||||
};
|
||||
var result2 = new ReplayResult
|
||||
{
|
||||
RunId = "1",
|
||||
VerdictDigest = "abc123",
|
||||
Success = true,
|
||||
ExecutedAt = ReplayEngineTestFixtures.FixedTimestamp
|
||||
};
|
||||
|
||||
var check = engine.CheckDeterminism(result1, result2);
|
||||
|
||||
check.IsDeterministic.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CheckDeterminism_DifferentResults_ReturnsDifferences()
|
||||
{
|
||||
var engine = ReplayEngineTestFixtures.CreateEngine();
|
||||
var result1 = new ReplayResult
|
||||
{
|
||||
RunId = "1",
|
||||
VerdictJson = "{\"score\":100}",
|
||||
VerdictDigest = "abc123",
|
||||
Success = true,
|
||||
ExecutedAt = ReplayEngineTestFixtures.FixedTimestamp
|
||||
};
|
||||
var result2 = new ReplayResult
|
||||
{
|
||||
RunId = "1",
|
||||
VerdictJson = "{\"score\":99}",
|
||||
VerdictDigest = "def456",
|
||||
Success = true,
|
||||
ExecutedAt = ReplayEngineTestFixtures.FixedTimestamp
|
||||
};
|
||||
|
||||
var check = engine.CheckDeterminism(result1, result2);
|
||||
|
||||
check.IsDeterministic.Should().BeFalse();
|
||||
check.Differences.Should().NotBeEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
using StellaOps.Replay.Loaders;
|
||||
using StellaOps.Testing.Manifests.Models;
|
||||
|
||||
namespace StellaOps.Replay.Tests;
|
||||
|
||||
internal sealed class FeedStorageStub : IFeedStorage
|
||||
{
|
||||
private readonly FeedSnapshot? _snapshot;
|
||||
|
||||
public FeedStorageStub(FeedSnapshot? snapshot) => _snapshot = snapshot;
|
||||
|
||||
public Task<FeedSnapshot?> GetByDigestAsync(string digest, CancellationToken ct = default)
|
||||
=> Task.FromResult(_snapshot);
|
||||
}
|
||||
|
||||
internal sealed class PolicyStorageStub : IPolicyStorage
|
||||
{
|
||||
private readonly PolicySnapshot? _snapshot;
|
||||
|
||||
public PolicyStorageStub(PolicySnapshot? snapshot) => _snapshot = snapshot;
|
||||
|
||||
public Task<PolicySnapshot?> GetByDigestAsync(string digest, CancellationToken ct = default)
|
||||
=> Task.FromResult(_snapshot);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using StellaOps.Canonicalization.Json;
|
||||
using StellaOps.Testing.Manifests.Models;
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Replay.Tests;
|
||||
|
||||
internal static class SnapshotTestData
|
||||
{
|
||||
internal static readonly DateTimeOffset FixedSnapshotAt =
|
||||
new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
internal static FeedSnapshot CreateFeedSnapshot(string digest) =>
|
||||
new FeedSnapshot("nvd", "v1", digest, FixedSnapshotAt);
|
||||
|
||||
internal static PolicySnapshot CreatePolicySnapshot(string digest) =>
|
||||
new PolicySnapshot("1.0.0", digest, ImmutableArray<string>.Empty);
|
||||
|
||||
internal static string ComputeDigest<T>(T value)
|
||||
{
|
||||
var json = CanonicalJsonSerializer.Serialize(value);
|
||||
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(json))).ToLowerInvariant();
|
||||
}
|
||||
|
||||
internal static string CreateValidDigest(char fill) => new string(fill, 64);
|
||||
|
||||
internal static string CreateInvalidDigest(char fill) => new string(fill, 63) + "g";
|
||||
|
||||
internal static string CreateDifferentDigest(string digest)
|
||||
{
|
||||
var last = digest[^1];
|
||||
var replacement = last == 'a' ? 'b' : 'a';
|
||||
return digest[..^1] + replacement;
|
||||
}
|
||||
}
|
||||
@@ -12,3 +12,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| REMED-03 | DONE | Tier 0 remediation (usings sorted, deterministic test data, warnings as errors); dotnet test passed 2026-02-02. |
|
||||
| REMED-04 | DONE | Async naming updates; ConfigureAwait(false) skipped in tests per xUnit1030; dotnet test passed 2026-02-02. |
|
||||
| REMED-05 | DONE | File split to keep tests <= 100 lines; dotnet test passed 2026-02-02. |
|
||||
| REMED-07 | DONE | 2026-02-05; replay tests split into partials, loader validation/digest mismatch coverage + failure timestamp test added; dotnet test src/__Libraries/__Tests/StellaOps.Replay.Tests/StellaOps.Replay.Tests.csproj passed (11 tests). |
|
||||
|
||||
Reference in New Issue
Block a user