tests fixes and sprints work

This commit is contained in:
master
2026-01-22 19:08:46 +02:00
parent c32fff8f86
commit 726d70dc7f
881 changed files with 134434 additions and 6228 deletions

View File

@@ -1,4 +1,4 @@
Microsoft Visual Studio Solution File, Format Version 12.00
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
@@ -144,53 +144,53 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.AirGap.Time", "St
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography", "StellaOps.Cryptography", "{66557252-B5C4-664B-D807-07018C627474}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.OfflineVerification", "StellaOps.Cryptography.Plugin.OfflineVerification", "{9FB0DDD7-7A77-8DA4-F9E2-A94E60ED8FC7}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.DependencyInjection", "StellaOps.DependencyInjection", "{589A43FD-8213-E9E3-6CFF-9CBA72D53E98}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Infrastructure.EfCore", "StellaOps.Infrastructure.EfCore", "{FCD529E0-DD17-6587-B29C-12D425C0AD0C}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Infrastructure.Postgres", "StellaOps.Infrastructure.Postgres", "{61B23570-4F2D-B060-BE1F-37995682E494}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Ingestion.Telemetry", "StellaOps.Ingestion.Telemetry", "{1182764D-2143-EEF0-9270-3DCE392F5D06}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Plugin", "StellaOps.Plugin", "{772B02B5-6280-E1D4-3E2E-248D0455C2FB}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Provenance", "StellaOps.Provenance", "{E69FA1A0-6D1B-A6E4-2DC0-8F4C5F21BF04}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.TestKit", "StellaOps.TestKit", "{8380A20C-A5B8-EE91-1A58-270323688CB9}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Tests", "__Tests", "{90659617-4DF7-809A-4E5B-29BB5A98E8E1}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{AB8B269C-5A2A-A4B8-0488-B5F81E55B4D9}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Infrastructure.Postgres.Testing", "StellaOps.Infrastructure.Postgres.Testing", "{CEDC2447-F717-3C95-7E08-F214D575A7B7}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{A5C98087-E847-D2C4-2143-20869479839D}"
EndProject
@@ -448,3 +448,4 @@ Global
{68C75AAB-0E77-F9CF-9924-6C2BF6488ACD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{68C75AAB-0E77-F9CF-9924-6C2BF6488ACD}.Release|Any CPU.ActiveCfg = Release|Any CPU

View File

@@ -72,7 +72,7 @@ public sealed record BundleManifest
/// </summary>
public sealed record BundleArtifact(
/// <summary>Relative path within the bundle.</summary>
string Path,
string? Path,
/// <summary>Artifact type: sbom, vex, dsse, rekor-proof, oci-referrers, etc.</summary>
string Type,
/// <summary>Content type (MIME).</summary>

View File

@@ -0,0 +1,55 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.AirGap.Bundle.Models;
public sealed record TrustProfile
{
[JsonPropertyName("profileId")]
public string ProfileId { get; init; } = string.Empty;
[JsonPropertyName("name")]
public string Name { get; init; } = string.Empty;
[JsonPropertyName("description")]
public string? Description { get; init; }
[JsonPropertyName("trustRoots")]
public ImmutableArray<TrustProfileEntry> TrustRoots { get; init; } = [];
[JsonPropertyName("rekorKeys")]
public ImmutableArray<TrustProfileEntry> RekorKeys { get; init; } = [];
[JsonPropertyName("tsaRoots")]
public ImmutableArray<TrustProfileEntry> TsaRoots { get; init; } = [];
[JsonPropertyName("metadata")]
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
[JsonIgnore]
public string? SourcePath { get; init; }
}
public sealed record TrustProfileEntry
{
[JsonPropertyName("id")]
public string Id { get; init; } = string.Empty;
[JsonPropertyName("path")]
public string Path { get; init; } = string.Empty;
[JsonPropertyName("algorithm")]
public string? Algorithm { get; init; }
[JsonPropertyName("purpose")]
public string? Purpose { get; init; }
[JsonPropertyName("sha256")]
public string? Sha256 { get; init; }
[JsonPropertyName("validFrom")]
public DateTimeOffset? ValidFrom { get; init; }
[JsonPropertyName("validUntil")]
public DateTimeOffset? ValidUntil { get; init; }
}

View File

@@ -3,6 +3,7 @@ using System.Security.Cryptography;
using System.Text;
using StellaOps.AirGap.Bundle.Models;
using StellaOps.AirGap.Bundle.Serialization;
using StellaOps.AirGap.Bundle.Validation;
namespace StellaOps.AirGap.Bundle.Services;
@@ -184,11 +185,27 @@ public sealed class BundleBuilder : IBundleBuilder
}
}
var artifacts = new List<BundleArtifact>();
long artifactsSizeBytes = 0;
var artifactConfigs = request.Artifacts ?? Array.Empty<BundleArtifactBuildConfig>();
foreach (var artifactConfig in artifactConfigs)
{
var (artifact, sizeBytes) = await AddArtifactAsync(
artifactConfig,
outputPath,
request.StrictInlineArtifacts,
request.WarningSink,
ct).ConfigureAwait(false);
artifacts.Add(artifact);
artifactsSizeBytes += sizeBytes;
}
var totalSize = feeds.Sum(f => f.SizeBytes) +
policies.Sum(p => p.SizeBytes) +
cryptoMaterials.Sum(c => c.SizeBytes) +
ruleBundles.Sum(r => r.SizeBytes) +
timestampSizeBytes;
timestampSizeBytes +
artifactsSizeBytes;
var manifest = new BundleManifest
{
@@ -203,12 +220,200 @@ public sealed class BundleBuilder : IBundleBuilder
CryptoMaterials = cryptoMaterials.ToImmutableArray(),
RuleBundles = ruleBundles.ToImmutableArray(),
Timestamps = timestamps.ToImmutableArray(),
Artifacts = artifacts.ToImmutableArray(),
TotalSizeBytes = totalSize
};
return BundleManifestSerializer.WithDigest(manifest);
}
private static async Task<(BundleArtifact Artifact, long SizeBytes)> AddArtifactAsync(
BundleArtifactBuildConfig config,
string outputPath,
bool strictInlineArtifacts,
ICollection<string>? warningSink,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(config);
if (string.IsNullOrWhiteSpace(config.Type))
{
throw new ArgumentException("Artifact type is required.", nameof(config));
}
var hasSourcePath = !string.IsNullOrWhiteSpace(config.SourcePath);
var hasContent = config.Content is { Length: > 0 };
if (!hasSourcePath && !hasContent)
{
throw new ArgumentException("Artifact content or source path is required.", nameof(config));
}
string? relativePath = string.IsNullOrWhiteSpace(config.RelativePath) ? null : config.RelativePath;
if (!string.IsNullOrWhiteSpace(relativePath) && !PathValidation.IsSafeRelativePath(relativePath))
{
throw new ArgumentException($"Invalid relative path: {relativePath}", nameof(config));
}
string digest;
long sizeBytes;
if (hasSourcePath)
{
var sourcePath = Path.GetFullPath(config.SourcePath!);
if (!File.Exists(sourcePath))
{
throw new FileNotFoundException("Artifact source file not found.", sourcePath);
}
var info = new FileInfo(sourcePath);
sizeBytes = info.Length;
digest = await ComputeSha256DigestAsync(sourcePath, ct).ConfigureAwait(false);
relativePath = ApplyInlineSizeGuard(
relativePath,
config,
digest,
sizeBytes,
strictInlineArtifacts,
warningSink);
if (!string.IsNullOrWhiteSpace(relativePath))
{
var targetPath = PathValidation.SafeCombine(outputPath, relativePath);
Directory.CreateDirectory(Path.GetDirectoryName(targetPath) ?? outputPath);
File.Copy(sourcePath, targetPath, overwrite: true);
}
}
else
{
var content = config.Content ?? Array.Empty<byte>();
sizeBytes = content.Length;
digest = ComputeSha256Digest(content);
relativePath = ApplyInlineSizeGuard(
relativePath,
config,
digest,
sizeBytes,
strictInlineArtifacts,
warningSink);
if (!string.IsNullOrWhiteSpace(relativePath))
{
var targetPath = PathValidation.SafeCombine(outputPath, relativePath);
Directory.CreateDirectory(Path.GetDirectoryName(targetPath) ?? outputPath);
await File.WriteAllBytesAsync(targetPath, content, ct).ConfigureAwait(false);
}
}
var artifact = new BundleArtifact(relativePath, config.Type, config.ContentType, digest, sizeBytes);
return (artifact, sizeBytes);
}
private static string? ApplyInlineSizeGuard(
string? relativePath,
BundleArtifactBuildConfig config,
string digest,
long sizeBytes,
bool strictInlineArtifacts,
ICollection<string>? warningSink)
{
if (!string.IsNullOrWhiteSpace(relativePath))
{
return relativePath;
}
if (!BundleSizeValidator.RequiresExternalization(sizeBytes))
{
return null;
}
var warning = BundleSizeValidator.GetInlineSizeWarning(sizeBytes)
?? "Inline artifact size exceeds the maximum allowed size.";
if (strictInlineArtifacts)
{
throw new InvalidOperationException(warning);
}
warningSink?.Add(warning);
var fileName = string.IsNullOrWhiteSpace(config.FileName)
? BuildInlineFallbackName(config.Type, digest)
: EnsureSafeFileName(config.FileName);
var fallbackPath = $"artifacts/{fileName}";
if (!PathValidation.IsSafeRelativePath(fallbackPath))
{
throw new ArgumentException($"Invalid artifact fallback path: {fallbackPath}", nameof(config));
}
return fallbackPath;
}
private static string BuildInlineFallbackName(string type, string digest)
{
var normalizedType = SanitizeFileSegment(type);
var digestValue = digest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase)
? digest[7..]
: digest;
var shortDigest = digestValue.Length > 12 ? digestValue[..12] : digestValue;
return $"{normalizedType}-{shortDigest}.blob";
}
private static string SanitizeFileSegment(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return "artifact";
}
var buffer = new char[value.Length];
var index = 0;
foreach (var ch in value)
{
if (char.IsLetterOrDigit(ch) || ch == '-' || ch == '_')
{
buffer[index++] = ch;
}
else
{
buffer[index++] = '-';
}
}
var cleaned = new string(buffer, 0, index).Trim('-');
return string.IsNullOrWhiteSpace(cleaned) ? "artifact" : cleaned;
}
private static string EnsureSafeFileName(string fileName)
{
if (string.IsNullOrWhiteSpace(fileName))
{
throw new ArgumentException("Artifact file name is required.");
}
if (fileName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 ||
fileName.Contains('/') ||
fileName.Contains('\\'))
{
throw new ArgumentException($"Invalid artifact file name: {fileName}");
}
return fileName;
}
private static async Task<string> ComputeSha256DigestAsync(string filePath, CancellationToken ct)
{
await using var stream = File.OpenRead(filePath);
var hash = await SHA256.HashDataAsync(stream, ct).ConfigureAwait(false);
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
private static string ComputeSha256Digest(byte[] content)
{
var hash = SHA256.HashData(content);
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
private static async Task<CopiedComponent> CopyComponentAsync(
BundleComponentSource source,
string outputPath,
@@ -297,7 +502,7 @@ public sealed class BundleBuilder : IBundleBuilder
foreach (var blob in blobs
.OrderBy(b => b.CertificateIndex)
.ThenBy(b => ComputeShortHash(blob.Data), StringComparer.Ordinal))
.ThenBy(b => ComputeShortHash(b.Data), StringComparer.Ordinal))
{
var hash = ComputeShortHash(blob.Data);
var fileName = $"{prefix}-{blob.CertificateIndex:D2}-{hash}.{extension}";
@@ -356,7 +561,10 @@ public sealed record BundleBuildRequest(
IReadOnlyList<PolicyBuildConfig> Policies,
IReadOnlyList<CryptoBuildConfig> CryptoMaterials,
IReadOnlyList<RuleBundleBuildConfig> RuleBundles,
IReadOnlyList<TimestampBuildConfig>? Timestamps = null);
IReadOnlyList<TimestampBuildConfig>? Timestamps = null,
IReadOnlyList<BundleArtifactBuildConfig>? Artifacts = null,
bool StrictInlineArtifacts = false,
ICollection<string>? WarningSink = null);
public abstract record BundleComponentSource(string SourcePath, string RelativePath);
@@ -396,6 +604,16 @@ public sealed record Rfc3161TimestampBuildConfig(byte[] TimeStampToken)
public sealed record EidasQtsTimestampBuildConfig(string SourcePath, string RelativePath)
: TimestampBuildConfig;
public sealed record BundleArtifactBuildConfig
{
public required string Type { get; init; }
public string? ContentType { get; init; }
public string? SourcePath { get; init; }
public byte[]? Content { get; init; }
public string? RelativePath { get; init; }
public string? FileName { get; init; }
}
/// <summary>
/// Configuration for building a rule bundle component.
/// </summary>

View File

@@ -0,0 +1,111 @@
using System.Collections.Immutable;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.AirGap.Bundle.Models;
namespace StellaOps.AirGap.Bundle.Services;
public sealed class TrustProfileLoader
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public IReadOnlyList<TrustProfile> LoadProfiles(string directory)
{
if (string.IsNullOrWhiteSpace(directory))
{
throw new ArgumentException("Profiles directory is required.", nameof(directory));
}
if (!Directory.Exists(directory))
{
return Array.Empty<TrustProfile>();
}
var profiles = Directory.GetFiles(directory, "*.trustprofile.json", SearchOption.TopDirectoryOnly)
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
.Select(LoadProfile)
.ToList();
return profiles;
}
public TrustProfile LoadProfile(string profilePath)
{
if (string.IsNullOrWhiteSpace(profilePath))
{
throw new ArgumentException("Profile path is required.", nameof(profilePath));
}
if (!File.Exists(profilePath))
{
throw new FileNotFoundException("Trust profile not found.", profilePath);
}
var json = File.ReadAllText(profilePath);
var profile = JsonSerializer.Deserialize<TrustProfile>(json, JsonOptions)
?? throw new InvalidOperationException("Failed to deserialize trust profile.");
return NormalizeProfile(profile, profilePath);
}
public string ResolveEntryPath(TrustProfile profile, TrustProfileEntry entry)
{
if (string.IsNullOrWhiteSpace(entry.Path))
{
throw new ArgumentException("Entry path is required.", nameof(entry));
}
if (Path.IsPathRooted(entry.Path))
{
return entry.Path;
}
if (string.IsNullOrWhiteSpace(profile.SourcePath))
{
throw new InvalidOperationException("Profile source path is missing.");
}
var baseDir = Path.GetDirectoryName(profile.SourcePath);
if (string.IsNullOrWhiteSpace(baseDir))
{
throw new InvalidOperationException("Profile base directory is missing.");
}
return PathValidation.SafeCombine(baseDir, entry.Path);
}
private static TrustProfile NormalizeProfile(TrustProfile profile, string sourcePath)
{
var profileId = string.IsNullOrWhiteSpace(profile.ProfileId)
? InferProfileId(sourcePath)
: profile.ProfileId;
var name = string.IsNullOrWhiteSpace(profile.Name) ? profileId : profile.Name;
return profile with
{
ProfileId = profileId,
Name = name,
TrustRoots = profile.TrustRoots.IsDefault ? [] : profile.TrustRoots,
RekorKeys = profile.RekorKeys.IsDefault ? [] : profile.RekorKeys,
TsaRoots = profile.TsaRoots.IsDefault ? [] : profile.TsaRoots,
SourcePath = sourcePath
};
}
private static string InferProfileId(string profilePath)
{
var fileName = Path.GetFileName(profilePath);
const string suffix = ".trustprofile.json";
if (fileName.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
{
return fileName[..^suffix.Length];
}
return Path.GetFileNameWithoutExtension(fileName);
}
}

View File

@@ -191,7 +191,13 @@ public sealed class TsaChainBundler : ITsaChainBundler
try
{
var ski = new X509SubjectKeyIdentifierExtension(ext, ext.Critical);
return Convert.FromHexString(ski.SubjectKeyIdentifier);
var keyId = ski.SubjectKeyIdentifier;
if (string.IsNullOrWhiteSpace(keyId))
{
return null;
}
return Convert.FromHexString(keyId);
}
catch
{

View File

@@ -0,0 +1,19 @@
namespace StellaOps.AirGap.Bundle.Validation;
public static class BundleSizeValidator
{
public const int MaxInlineBlobSize = 4 * 1024 * 1024;
public static bool RequiresExternalization(long sizeBytes) =>
sizeBytes > MaxInlineBlobSize;
public static string? GetInlineSizeWarning(long sizeBytes)
{
if (sizeBytes <= MaxInlineBlobSize)
{
return null;
}
return $"Inline artifact size {sizeBytes} exceeds {MaxInlineBlobSize} bytes.";
}
}

View File

@@ -0,0 +1,132 @@
using FluentAssertions;
using StellaOps.AirGap.Bundle.Models;
using StellaOps.AirGap.Bundle.Services;
using StellaOps.AirGap.Bundle.Validation;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.AirGap.Bundle.Tests;
public sealed class BundleInlineArtifactSizeTests : IAsyncLifetime
{
private string _tempRoot = null!;
public ValueTask InitializeAsync()
{
_tempRoot = Path.Combine(Path.GetTempPath(), $"bundle-inline-{Guid.NewGuid():N}");
Directory.CreateDirectory(_tempRoot);
return ValueTask.CompletedTask;
}
public ValueTask DisposeAsync()
{
if (Directory.Exists(_tempRoot))
{
Directory.Delete(_tempRoot, recursive: true);
}
return ValueTask.CompletedTask;
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task BuildAsync_InlineArtifactUnderLimit_StaysInline()
{
var builder = new BundleBuilder();
var outputPath = Path.Combine(_tempRoot, "inline-ok");
var content = new byte[BundleSizeValidator.MaxInlineBlobSize - 8];
var request = new BundleBuildRequest(
"inline-ok",
"1.0.0",
null,
Array.Empty<FeedBuildConfig>(),
Array.Empty<PolicyBuildConfig>(),
Array.Empty<CryptoBuildConfig>(),
Array.Empty<RuleBundleBuildConfig>(),
Artifacts: new[]
{
new BundleArtifactBuildConfig
{
Type = "sbom",
ContentType = "application/json",
Content = content
}
});
var manifest = await builder.BuildAsync(request, outputPath);
manifest.Artifacts.Should().HaveCount(1);
manifest.Artifacts[0].Path.Should().BeNull();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task BuildAsync_InlineArtifactOverLimit_ExternalizesToArtifactsDir()
{
var builder = new BundleBuilder();
var outputPath = Path.Combine(_tempRoot, "inline-over");
var warnings = new List<string>();
var content = new byte[BundleSizeValidator.MaxInlineBlobSize + 1];
var request = new BundleBuildRequest(
"inline-over",
"1.0.0",
null,
Array.Empty<FeedBuildConfig>(),
Array.Empty<PolicyBuildConfig>(),
Array.Empty<CryptoBuildConfig>(),
Array.Empty<RuleBundleBuildConfig>(),
Artifacts: new[]
{
new BundleArtifactBuildConfig
{
Type = "sbom",
ContentType = "application/json",
Content = content,
FileName = "sbom.json"
}
},
WarningSink: warnings);
var manifest = await builder.BuildAsync(request, outputPath);
manifest.Artifacts.Should().HaveCount(1);
var artifact = manifest.Artifacts[0];
artifact.Path.Should().NotBeNullOrEmpty();
artifact.Path.Should().StartWith("artifacts/");
warnings.Should().ContainSingle();
var artifactPath = Path.Combine(outputPath, artifact.Path!.Replace('/', Path.DirectorySeparatorChar));
File.Exists(artifactPath).Should().BeTrue();
new FileInfo(artifactPath).Length.Should().Be(content.Length);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task BuildAsync_InlineArtifactOverLimit_StrictModeThrows()
{
var builder = new BundleBuilder();
var outputPath = Path.Combine(_tempRoot, "inline-strict");
var content = new byte[BundleSizeValidator.MaxInlineBlobSize + 1];
var request = new BundleBuildRequest(
"inline-strict",
"1.0.0",
null,
Array.Empty<FeedBuildConfig>(),
Array.Empty<PolicyBuildConfig>(),
Array.Empty<CryptoBuildConfig>(),
Array.Empty<RuleBundleBuildConfig>(),
Artifacts: new[]
{
new BundleArtifactBuildConfig
{
Type = "sbom",
ContentType = "application/json",
Content = content
}
},
StrictInlineArtifacts: true);
await Assert.ThrowsAsync<InvalidOperationException>(
() => builder.BuildAsync(request, outputPath));
}
}

View File

@@ -159,11 +159,22 @@ public sealed class BundleTimestampOfflineVerificationTests : IAsyncLifetime
{
var writer = new AsnWriter(AsnEncodingRules.DER);
writer.PushSequence();
writer.WriteEnumeratedValue(0);
// OCSP response status: 0 = successful
writer.WriteEnumeratedValue(OcspResponseStatus.Successful);
writer.PopSequence();
return writer.Encode();
}
private enum OcspResponseStatus
{
Successful = 0,
MalformedRequest = 1,
InternalError = 2,
TryLater = 3,
SigRequired = 5,
Unauthorized = 6
}
private static byte[] CreateCrlPlaceholder()
{
var writer = new AsnWriter(AsnEncodingRules.DER);

View File

@@ -14,7 +14,7 @@
<ItemGroup>
<ProjectReference Include="../../StellaOps.AirGap.Bundle/StellaOps.AirGap.Bundle.csproj" />
<ProjectReference Include="../../StellaOps.AirGap.Time/StellaOps.AirGap.Time.csproj" />
<ProjectReference Include="../../../StellaOps.AirGap.Time/StellaOps.AirGap.Time.csproj" />
<ProjectReference Include="../../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,79 @@
using FluentAssertions;
using StellaOps.AirGap.Bundle.Models;
using StellaOps.AirGap.Bundle.Services;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.AirGap.Bundle.Tests;
public sealed class TrustProfileLoaderTests : IAsyncLifetime
{
private string _tempRoot = null!;
public ValueTask InitializeAsync()
{
_tempRoot = Path.Combine(Path.GetTempPath(), $"trust-profiles-{Guid.NewGuid():N}");
Directory.CreateDirectory(_tempRoot);
return ValueTask.CompletedTask;
}
public ValueTask DisposeAsync()
{
if (Directory.Exists(_tempRoot))
{
Directory.Delete(_tempRoot, recursive: true);
}
return ValueTask.CompletedTask;
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Loader_ResolvesProfileIdAndEntryPaths()
{
var assetsDir = Path.Combine(_tempRoot, "assets");
Directory.CreateDirectory(assetsDir);
var rootPath = Path.Combine(assetsDir, "root.pem");
File.WriteAllText(rootPath, "-----BEGIN PUBLIC KEY-----\nTEST\n-----END PUBLIC KEY-----");
var profilePath = Path.Combine(_tempRoot, "global.trustprofile.json");
var profileJson = """
{
"name": "Global",
"trustRoots": [
{
"id": "root-1",
"path": "assets/root.pem",
"algorithm": "x509"
}
]
}
""";
File.WriteAllText(profilePath, profileJson);
var loader = new TrustProfileLoader();
var profile = loader.LoadProfile(profilePath);
profile.ProfileId.Should().Be("global");
profile.Name.Should().Be("Global");
profile.SourcePath.Should().Be(profilePath);
profile.TrustRoots.Should().HaveCount(1);
var resolvedPath = loader.ResolveEntryPath(profile, profile.TrustRoots[0]);
resolvedPath.Should().Be(rootPath);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Loader_LoadsProfilesFromDirectory()
{
File.WriteAllText(Path.Combine(_tempRoot, "one.trustprofile.json"), "{}");
File.WriteAllText(Path.Combine(_tempRoot, "two.trustprofile.json"), "{}");
var loader = new TrustProfileLoader();
var profiles = loader.LoadProfiles(_tempRoot);
profiles.Should().HaveCount(2);
profiles.Select(p => p.ProfileId).Should().Contain(new[] { "one", "two" });
}
}