tests fixes and some product advisories tunes ups

This commit is contained in:
master
2026-01-30 07:57:43 +02:00
parent 644887997c
commit 55744f6a39
345 changed files with 26290 additions and 2267 deletions

View File

@@ -1,5 +1,6 @@
using System.Globalization;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Cryptography;
@@ -182,7 +183,8 @@ public static class MirrorBundleSigningExtensions
return JsonSerializer.Serialize(signature, new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
});
}
}

View File

@@ -222,6 +222,16 @@ public sealed class OfflineBundlePackager : IOfflineBundlePackager
VerifiedAt = _timeProvider.GetUtcNow()
};
}
catch (InvalidDataException ex)
{
_logger.LogWarning(ex, "Bundle {BundlePath} appears to be corrupted", bundlePath);
return new BundleVerificationResult
{
IsValid = false,
Issues = new[] { $"Bundle appears to be corrupted: {ex.Message}" },
VerifiedAt = _timeProvider.GetUtcNow()
};
}
finally
{
if (Directory.Exists(tempDir))

View File

@@ -1,6 +1,8 @@
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Determinism;
@@ -14,6 +16,18 @@ namespace StellaOps.ExportCenter.Snapshots;
/// </summary>
public sealed class ExportSnapshotService : IExportSnapshotService
{
/// <summary>
/// Export serialization options: canonical format with indentation for readability.
/// Uses same property naming (camelCase) as the canonical format for ID verification compatibility.
/// </summary>
private static readonly JsonSerializerOptions ExportOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
private readonly ISnapshotService _snapshotService;
private readonly IKnowledgeSourceResolver _sourceResolver;
private readonly ILogger<ExportSnapshotService> _logger;
@@ -123,7 +137,7 @@ public sealed class ExportSnapshotService : IExportSnapshotService
string tempDir, KnowledgeSnapshotManifest manifest, CancellationToken ct)
{
var manifestPath = Path.Combine(tempDir, "manifest.json");
var json = JsonSerializer.Serialize(manifest, new JsonSerializerOptions { WriteIndented = true });
var json = JsonSerializer.Serialize(manifest, ExportOptions);
await File.WriteAllTextAsync(manifestPath, json, ct).ConfigureAwait(false);
// Write signed envelope if signature present
@@ -143,13 +157,13 @@ public sealed class ExportSnapshotService : IExportSnapshotService
payloadType = "application/vnd.stellaops.snapshot+json",
payload = Convert.ToBase64String(
System.Text.Encoding.UTF8.GetBytes(
JsonSerializer.Serialize(manifest with { Signature = null }))),
JsonSerializer.Serialize(manifest with { Signature = null }, ExportOptions))),
signatures = new[]
{
new { keyid = "snapshot-signing-key", sig = manifest.Signature }
}
};
return JsonSerializer.Serialize(envelope, new JsonSerializerOptions { WriteIndented = true });
return JsonSerializer.Serialize(envelope, ExportOptions);
}
private async Task<List<BundledFile>> BundleSourcesAsync(
@@ -228,7 +242,7 @@ public sealed class ExportSnapshotService : IExportSnapshotService
var metaDir = Path.Combine(tempDir, "META");
Directory.CreateDirectory(metaDir);
var json = JsonSerializer.Serialize(info, new JsonSerializerOptions { WriteIndented = true });
var json = JsonSerializer.Serialize(info, ExportOptions);
await File.WriteAllTextAsync(Path.Combine(metaDir, "BUNDLE_INFO.json"), json, ct)
.ConfigureAwait(false);
}

View File

@@ -1,6 +1,7 @@
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Determinism;
@@ -13,6 +14,12 @@ namespace StellaOps.ExportCenter.Snapshots;
/// </summary>
public sealed class ImportSnapshotService : IImportSnapshotService
{
private static readonly JsonSerializerOptions ImportOptions = new()
{
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
private readonly ISnapshotService _snapshotService;
private readonly ISnapshotStore _snapshotStore;
private readonly ILogger<ImportSnapshotService> _logger;
@@ -67,7 +74,7 @@ public sealed class ImportSnapshotService : IImportSnapshotService
return ImportResult.Fail("Bundle missing manifest.json");
var manifestJson = await File.ReadAllTextAsync(manifestPath, ct).ConfigureAwait(false);
var manifest = JsonSerializer.Deserialize<KnowledgeSnapshotManifest>(manifestJson)
var manifest = JsonSerializer.Deserialize<KnowledgeSnapshotManifest>(manifestJson, ImportOptions)
?? throw new InvalidOperationException("Failed to parse manifest");
// Verify manifest signature if sealed

View File

@@ -30,7 +30,12 @@ internal sealed partial class MigrationScript
public static bool TryCreate(string resourceName, string sql, [NotNullWhen(true)] out MigrationScript? script)
{
var fileName = resourceName.Split('.').Last();
// Resource names are like: StellaOps.ExportCenter.Infrastructure.Db.Migrations.001_initial_schema.sql
// We need to extract "001_initial_schema.sql" (last two segments joined)
var parts = resourceName.Split('.');
var fileName = parts.Length >= 2
? $"{parts[^2]}.{parts[^1]}"
: parts.LastOrDefault() ?? string.Empty;
var match = VersionRegex.Match(fileName);
if (!match.Success || !int.TryParse(match.Groups["version"].Value, out var version))

View File

@@ -1,4 +1,6 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.ExportCenter.WebService.Api;
using Xunit;
@@ -21,6 +23,8 @@ public sealed class ExportApiServiceCollectionExtensionsTests
public void AddExportApiServices_AllowsExplicitInMemoryRegistration()
{
var services = new ServiceCollection();
services.AddSingleton<ILoggerFactory, NullLoggerFactory>();
services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>));
services.AddExportApiServices(_ => { }, allowInMemoryRepositories: true);
var provider = services.BuildServiceProvider();

View File

@@ -1,4 +1,4 @@
using System.Reflection;
using StellaOps.ExportCenter.Infrastructure.Db;
using Xunit;
namespace StellaOps.ExportCenter.Tests.Db;
@@ -11,15 +11,14 @@ public sealed class MigrationScriptTests
var resourceName = "StellaOps.ExportCenter.Infrastructure.Db.Migrations.001_initial_schema.sql";
var sql = "CREATE TABLE test (id int);";
var result = TryCreateMigrationScript(resourceName, sql, out var script);
var result = MigrationScript.TryCreate(resourceName, sql, out var script);
Assert.True(result);
Assert.NotNull(script);
var scriptValue = script!;
Assert.Equal(1, scriptValue.Version);
Assert.Equal("001_initial_schema.sql", scriptValue.Name);
Assert.Equal(sql, scriptValue.Sql);
Assert.NotEmpty(scriptValue.Sha256);
Assert.Equal(1, script.Version);
Assert.Equal("001_initial_schema.sql", script.Name);
Assert.Equal(sql, script.Sql);
Assert.NotEmpty(script.Sha256);
}
[Fact]
@@ -28,11 +27,11 @@ public sealed class MigrationScriptTests
var resourceName = "Test.Db.Migrations.123_migration.sql";
var sql = "SELECT 1;";
var result = TryCreateMigrationScript(resourceName, sql, out var script);
var result = MigrationScript.TryCreate(resourceName, sql, out var script);
Assert.True(result);
Assert.NotNull(script);
Assert.Equal(123, script!.Version);
Assert.Equal(123, script.Version);
}
[Fact]
@@ -41,11 +40,11 @@ public sealed class MigrationScriptTests
var resourceName = "Test.Db.Migrations.1000_big_migration.sql";
var sql = "SELECT 1;";
var result = TryCreateMigrationScript(resourceName, sql, out var script);
var result = MigrationScript.TryCreate(resourceName, sql, out var script);
Assert.True(result);
Assert.NotNull(script);
Assert.Equal(1000, script!.Version);
Assert.Equal(1000, script.Version);
}
[Fact]
@@ -54,7 +53,7 @@ public sealed class MigrationScriptTests
var resourceName = "Test.Db.Migrations.invalid.sql";
var sql = "SELECT 1;";
var result = TryCreateMigrationScript(resourceName, sql, out var script);
var result = MigrationScript.TryCreate(resourceName, sql, out var script);
Assert.False(result);
Assert.Null(script);
@@ -66,7 +65,7 @@ public sealed class MigrationScriptTests
var resourceName = "Test.Db.Migrations.no_version.sql";
var sql = "SELECT 1;";
var result = TryCreateMigrationScript(resourceName, sql, out var script);
var result = MigrationScript.TryCreate(resourceName, sql, out var script);
Assert.False(result);
Assert.Null(script);
@@ -78,12 +77,12 @@ public sealed class MigrationScriptTests
var resourceName = "Test.Db.Migrations.001_test.sql";
var sql = "CREATE TABLE test (id int);";
_ = TryCreateMigrationScript(resourceName, sql, out var script1);
_ = TryCreateMigrationScript(resourceName, sql, out var script2);
_ = MigrationScript.TryCreate(resourceName, sql, out var script1);
_ = MigrationScript.TryCreate(resourceName, sql, out var script2);
Assert.NotNull(script1);
Assert.NotNull(script2);
Assert.Equal(script1!.Sha256, script2!.Sha256);
Assert.Equal(script1.Sha256, script2.Sha256);
}
[Fact]
@@ -93,12 +92,12 @@ public sealed class MigrationScriptTests
var sqlUnix = "CREATE TABLE test\n(id int);";
var sqlWindows = "CREATE TABLE test\r\n(id int);";
_ = TryCreateMigrationScript(resourceName, sqlUnix, out var scriptUnix);
_ = TryCreateMigrationScript(resourceName, sqlWindows, out var scriptWindows);
_ = MigrationScript.TryCreate(resourceName, sqlUnix, out var scriptUnix);
_ = MigrationScript.TryCreate(resourceName, sqlWindows, out var scriptWindows);
Assert.NotNull(scriptUnix);
Assert.NotNull(scriptWindows);
Assert.Equal(scriptUnix!.Sha256, scriptWindows!.Sha256);
Assert.Equal(scriptUnix.Sha256, scriptWindows.Sha256);
}
[Fact]
@@ -108,12 +107,12 @@ public sealed class MigrationScriptTests
var sql1 = "CREATE TABLE test1 (id int);";
var sql2 = "CREATE TABLE test2 (id int);";
_ = TryCreateMigrationScript(resourceName, sql1, out var script1);
_ = TryCreateMigrationScript(resourceName, sql2, out var script2);
_ = MigrationScript.TryCreate(resourceName, sql1, out var script1);
_ = MigrationScript.TryCreate(resourceName, sql2, out var script2);
Assert.NotNull(script1);
Assert.NotNull(script2);
Assert.NotEqual(script1!.Sha256, script2!.Sha256);
Assert.NotEqual(script1.Sha256, script2.Sha256);
}
[Fact]
@@ -122,34 +121,9 @@ public sealed class MigrationScriptTests
var resourceName = "Test.Db.Migrations.001_test.sql";
var sql = "SELECT 1;";
_ = TryCreateMigrationScript(resourceName, sql, out var script);
_ = MigrationScript.TryCreate(resourceName, sql, out var script);
Assert.NotNull(script);
Assert.Matches("^[0-9a-f]{64}$", script!.Sha256);
}
// Helper to access internal MigrationScript via reflection
private static bool TryCreateMigrationScript(string resourceName, string sql, out dynamic? script)
{
var assembly = typeof(Infrastructure.Db.ExportCenterDataSource).Assembly;
var scriptType = assembly.GetType("StellaOps.ExportCenter.Infrastructure.Db.MigrationScript");
if (scriptType is null)
{
script = null;
return false;
}
var method = scriptType.GetMethod("TryCreate", BindingFlags.Public | BindingFlags.Static);
if (method is null)
{
script = null;
return false;
}
var parameters = new object?[] { resourceName, sql, null };
var result = (bool)method.Invoke(null, parameters)!;
script = parameters[2];
return result;
Assert.Matches("^[0-9a-f]{64}$", script.Sha256);
}
}

View File

@@ -421,7 +421,7 @@ public sealed class ExportDistributionLifecycleTests
[Fact]
public async Task ProcessExpiredDistributionsAsync_MarksExpired()
{
// Create distribution with past expiry
// Create distribution with past expiry (but within in-memory repository's 24-hour retention)
var distribution = new ExportDistribution
{
DistributionId = Guid.NewGuid(),
@@ -431,8 +431,8 @@ public sealed class ExportDistributionLifecycleTests
Status = ExportDistributionStatus.Distributed,
Target = "test",
ArtifactPath = "/test",
RetentionExpiresAt = _timeProvider.GetUtcNow().AddDays(-1),
CreatedAt = _timeProvider.GetUtcNow().AddDays(-30)
RetentionExpiresAt = _timeProvider.GetUtcNow().AddMinutes(-5),
CreatedAt = _timeProvider.GetUtcNow().AddHours(-1)
};
await _repository.CreateAsync(distribution);
@@ -456,9 +456,9 @@ public sealed class ExportDistributionLifecycleTests
Status = ExportDistributionStatus.Distributed,
Target = "test",
ArtifactPath = "/test",
RetentionExpiresAt = _timeProvider.GetUtcNow().AddDays(-1),
RetentionExpiresAt = _timeProvider.GetUtcNow().AddMinutes(-5),
MetadataJson = "{\"legalHold\":true}",
CreatedAt = _timeProvider.GetUtcNow().AddDays(-30)
CreatedAt = _timeProvider.GetUtcNow().AddHours(-1)
};
await _repository.CreateAsync(distribution);

View File

@@ -140,18 +140,31 @@ public sealed class OciReferrerDiscoveryTests
public async Task FindRvaAttestations_ReturnsRvaArtifacts()
{
// Arrange
var manifests = new[]
var dsseManifests = new[]
{
new { digest = "sha256:rva1", artifactType = OciArtifactTypes.RvaDsse, mediaType = OciMediaTypes.ImageManifest, size = 100L }
};
var indexJson = JsonSerializer.Serialize(new
var dsseIndexJson = JsonSerializer.Serialize(new
{
schemaVersion = 2,
mediaType = OciMediaTypes.ImageIndex,
manifests
manifests = dsseManifests
});
var emptyIndexJson = JsonSerializer.Serialize(new
{
schemaVersion = 2,
mediaType = OciMediaTypes.ImageIndex,
manifests = Array.Empty<object>()
});
var mockHandler = CreateMockHandler(HttpStatusCode.OK, indexJson);
// Return artifacts only for DSSE filter, empty for JSON filter
var mockHandler = new MockFallbackHandler(request =>
{
var url = request.RequestUri?.ToString() ?? "";
if (url.Contains(Uri.EscapeDataString(OciArtifactTypes.RvaDsse)))
return (HttpStatusCode.OK, dsseIndexJson);
return (HttpStatusCode.OK, emptyIndexJson);
});
var discovery = new OciReferrerDiscovery(
new HttpClient(mockHandler),
_mockAuth.Object,

View File

@@ -81,9 +81,12 @@ public class HmacDevPortalOfflineManifestSignerTests
var payloadBytes = Encoding.UTF8.GetBytes(manifest);
var pae = BuildPreAuthEncoding(options.PayloadType, payloadBytes);
// FakeCryptoHmac computes SHA256(key || data), not HMAC
var secret = Convert.FromBase64String(options.Secret);
using var hmac = new HMACSHA256(secret);
var signature = hmac.ComputeHash(pae);
var combined = new byte[secret.Length + pae.Length];
secret.CopyTo(combined, 0);
pae.CopyTo(combined, secret.Length);
var signature = SHA256.HashData(combined);
return Convert.ToBase64String(signature);
}

View File

@@ -139,6 +139,8 @@ public sealed class OfflineBundlePackagerTests : IDisposable
// Act
var result1 = await _packager.CreateBundleAsync(request);
// Advance time to ensure unique bundle ID (bundle ID includes timestamp)
_timeProvider.Advance(TimeSpan.FromSeconds(1));
var result2 = await _packager.CreateBundleAsync(request);
// Assert

View File

@@ -1,4 +1,6 @@
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
@@ -129,14 +131,19 @@ public sealed class AirGapReplayTests : IDisposable
{
var snapshot = await CreateSnapshotWithBundledSourcesAsync();
// Use uncompressed sources so tampering by appending data works
// (gzip ignores trailing data after the proper footer)
var exportResult = await _exportService.ExportAsync(snapshot.SnapshotId,
new ExportOptions { InclusionLevel = SnapshotInclusionLevel.Portable });
new ExportOptions { InclusionLevel = SnapshotInclusionLevel.Portable, CompressSources = false });
_tempFiles.Add(exportResult.FilePath!);
// Tamper with the bundle
var temperedPath = await TamperWithBundleAsync(exportResult.FilePath!);
_tempFiles.Add(temperedPath);
// Clear store so import can proceed to checksum verification
_snapshotStore.Clear();
// Import should fail with checksum verification enabled
var importResult = await _importService.ImportAsync(temperedPath,
new ImportOptions { VerifyChecksums = true });
@@ -213,17 +220,23 @@ public sealed class AirGapReplayTests : IDisposable
private async Task<KnowledgeSnapshotManifest> CreateSnapshotAsync()
{
// Compute the real digest of the test content that TestKnowledgeSourceResolver will return
const string sourceName = "test-feed";
var content = Encoding.UTF8.GetBytes($"test-content-{sourceName}");
var hash = SHA256.HashData(content);
var digest = $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
var builder = new SnapshotBuilder(_hasher)
.WithEngine("stellaops-policy", "1.0.0", "abc123")
.WithPolicy("test-policy", "1.0", "sha256:policy123")
.WithScoring("test-scoring", "1.0", "sha256:scoring123")
.WithSource(new KnowledgeSourceDescriptor
{
Name = "test-feed",
Name = sourceName,
Type = "advisory-feed",
Epoch = DateTimeOffset.UtcNow.ToString("o"),
Digest = "sha256:feed123",
InclusionMode = SourceInclusionMode.Referenced
Digest = digest,
InclusionMode = SourceInclusionMode.Bundled
});
return await _snapshotService.CreateSnapshotAsync(builder);
@@ -231,16 +244,22 @@ public sealed class AirGapReplayTests : IDisposable
private async Task<KnowledgeSnapshotManifest> CreateSnapshotWithBundledSourcesAsync()
{
// Compute the real digest of the test content that TestKnowledgeSourceResolver will return
const string sourceName = "bundled-feed";
var content = Encoding.UTF8.GetBytes($"test-content-{sourceName}");
var hash = SHA256.HashData(content);
var digest = $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
var builder = new SnapshotBuilder(_hasher)
.WithEngine("stellaops-policy", "1.0.0", "abc123")
.WithPolicy("test-policy", "1.0", "sha256:policy123")
.WithScoring("test-scoring", "1.0", "sha256:scoring123")
.WithSource(new KnowledgeSourceDescriptor
{
Name = "bundled-feed",
Name = sourceName,
Type = "advisory-feed",
Epoch = DateTimeOffset.UtcNow.ToString("o"),
Digest = "sha256:bundled123",
Digest = digest,
InclusionMode = SourceInclusionMode.Bundled
});

View File

@@ -1,4 +1,6 @@
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Cryptography;
@@ -135,17 +137,23 @@ public sealed class ExportSnapshotServiceTests : IDisposable
private async Task<KnowledgeSnapshotManifest> CreateSnapshotAsync()
{
// Compute the real digest of the test content that TestKnowledgeSourceResolver will return
const string sourceName = "test-feed";
var content = Encoding.UTF8.GetBytes($"test-content-{sourceName}");
var hash = SHA256.HashData(content);
var digest = $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
var builder = new SnapshotBuilder(_hasher)
.WithEngine("stellaops-policy", "1.0.0", "abc123")
.WithPolicy("test-policy", "1.0", "sha256:policy123")
.WithScoring("test-scoring", "1.0", "sha256:scoring123")
.WithSource(new KnowledgeSourceDescriptor
{
Name = "test-feed",
Name = sourceName,
Type = "advisory-feed",
Epoch = DateTimeOffset.UtcNow.ToString("o"),
Digest = "sha256:feed123",
InclusionMode = SourceInclusionMode.Referenced
Digest = digest,
InclusionMode = SourceInclusionMode.Bundled
});
return await _snapshotService.CreateSnapshotAsync(builder);