Add unit tests for SBOM ingestion and transformation
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Implement `SbomIngestServiceCollectionExtensionsTests` to verify the SBOM ingestion pipeline exports snapshots correctly.
- Create `SbomIngestTransformerTests` to ensure the transformation produces expected nodes and edges, including deduplication of license nodes and normalization of timestamps.
- Add `SbomSnapshotExporterTests` to test the export functionality for manifest, adjacency, nodes, and edges.
- Introduce `VexOverlayTransformerTests` to validate the transformation of VEX nodes and edges.
- Set up project file for the test project with necessary dependencies and configurations.
- Include JSON fixture files for testing purposes.
This commit is contained in:
master
2025-11-04 07:49:39 +02:00
parent f72c5c513a
commit 2eb6852d34
491 changed files with 39445 additions and 3917 deletions

View File

@@ -3,5 +3,5 @@
## Sprint 64 Bundle Implementation
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| DVOFF-64-001 | TODO | DevPortal Offline Guild, Exporter Guild | DEVPORT-64-001, SDKREL-64-002 | Implement Export Center job `devportal --offline` bundling portal HTML, specs, SDK artifacts, changelogs, and verification manifest. | Job executes in staging; manifest contains checksums + DSSE signatures; docs updated. |
| DVOFF-64-001 | DOING (2025-11-04) | DevPortal Offline Guild, Exporter Guild | DEVPORT-64-001, SDKREL-64-002 | Implement Export Center job `devportal --offline` bundling portal HTML, specs, SDK artifacts, changelogs, and verification manifest. | Job executes in staging; manifest contains checksums + DSSE signatures; docs updated. |
| DVOFF-64-002 | TODO | DevPortal Offline Guild, AirGap Controller Guild | DVOFF-64-001 | Provide verification CLI (`stella devportal verify bundle.tgz`) ensuring integrity before import. | CLI command validates signatures; integration test covers corrupted bundle; runbook updated. |

View File

@@ -1,6 +0,0 @@
namespace StellaOps.ExportCenter.Core;
public class Class1
{
}

View File

@@ -0,0 +1,430 @@
using System.Buffers;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Formats.Tar;
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Linq;
namespace StellaOps.ExportCenter.Core.DevPortalOffline;
public sealed class DevPortalOfflineBundleBuilder
{
private const string ManifestVersion = "devportal-offline/v1";
private static readonly DateTimeOffset FixedTimestamp = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
private static readonly UnixFileMode DefaultFileMode =
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead;
private static readonly UnixFileMode ExecutableFileMode =
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
UnixFileMode.GroupRead | UnixFileMode.GroupExecute |
UnixFileMode.OtherRead | UnixFileMode.OtherExecute;
private static readonly IReadOnlyDictionary<string, string> MediaTypeMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
[".html"] = "text/html",
[".htm"] = "text/html",
[".css"] = "text/css",
[".js"] = "application/javascript",
[".json"] = "application/json",
[".yaml"] = "application/yaml",
[".yml"] = "application/yaml",
[".md"] = "text/markdown",
[".txt"] = "text/plain",
[".zip"] = "application/zip",
[".whl"] = "application/zip",
[".tar"] = "application/x-tar",
[".tgz"] = "application/gzip",
[".gz"] = "application/gzip",
[".pdf"] = "application/pdf",
[".svg"] = "image/svg+xml",
[".png"] = "image/png",
[".jpg"] = "image/jpeg",
[".jpeg"] = "image/jpeg"
};
private readonly TimeProvider _timeProvider;
public DevPortalOfflineBundleBuilder(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
public DevPortalOfflineBundleResult Build(DevPortalOfflineBundleRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
if (request.BundleId == Guid.Empty)
{
throw new ArgumentException("Bundle identifier must be provided.", nameof(request));
}
cancellationToken.ThrowIfCancellationRequested();
var collected = new List<FileMetadata>();
var sdkNames = new List<string>();
var portalIncluded = CollectDirectory(request.PortalDirectory, "portal", "portal", collected, cancellationToken);
var specsIncluded = CollectDirectory(request.SpecsDirectory, "specs", "specs", collected, cancellationToken);
if (request.SdkSources is { Count: > 0 })
{
foreach (var source in request.SdkSources)
{
cancellationToken.ThrowIfCancellationRequested();
if (source is null)
{
throw new ArgumentException("SDK sources cannot contain null entries.", nameof(request));
}
if (string.IsNullOrWhiteSpace(source.Name))
{
throw new ArgumentException("SDK source name cannot be empty.", nameof(request));
}
var sanitizedName = SanitizeSegment(source.Name);
sdkNames.Add(sanitizedName);
var prefix = $"sdks/{sanitizedName}";
CollectDirectory(source.Directory, "sdk", prefix, collected, cancellationToken);
}
}
var changelogIncluded = CollectDirectory(request.ChangelogDirectory, "changelog", "changelog", collected, cancellationToken);
if (collected.Count == 0)
{
throw new InvalidOperationException("DevPortal offline bundle does not contain any files. Provide at least one source directory.");
}
collected.Sort((left, right) => StringComparer.Ordinal.Compare(left.CanonicalPath, right.CanonicalPath));
var entries = new DevPortalOfflineBundleEntry[collected.Count];
for (var i = 0; i < collected.Count; i++)
{
var item = collected[i];
entries[i] = new DevPortalOfflineBundleEntry(item.Category, item.CanonicalPath, item.Sha256, item.SizeBytes, item.ContentType);
}
IReadOnlyDictionary<string, string> metadata = request.Metadata is null
? new Dictionary<string, string>(StringComparer.Ordinal)
: new Dictionary<string, string>(request.Metadata, StringComparer.Ordinal);
var manifest = new DevPortalOfflineBundleManifest(
ManifestVersion,
request.BundleId,
_timeProvider.GetUtcNow(),
metadata,
new DevPortalOfflineBundleSourceSummary(
portalIncluded,
specsIncluded,
sdkNames.Count == 0 ? Array.Empty<string>() : sdkNames.OrderBy(name => name, StringComparer.Ordinal).ToArray(),
changelogIncluded),
new DevPortalOfflineBundleTotals(entries.Length, entries.Sum(static entry => entry.SizeBytes)),
entries);
var manifestJson = JsonSerializer.Serialize(manifest, SerializerOptions);
var rootHash = ComputeSha256(manifestJson);
var checksums = BuildChecksums(rootHash, collected);
var instructions = BuildInstructions(manifest);
var verificationScript = BuildVerificationScript();
var bundleStream = CreatePackageStream(collected, manifestJson, checksums, instructions, verificationScript);
bundleStream.Position = 0;
return new DevPortalOfflineBundleResult(manifest, manifestJson, checksums, rootHash, bundleStream);
}
private static bool CollectDirectory(
string? directory,
string category,
string prefix,
ICollection<FileMetadata> collected,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(directory))
{
return false;
}
var fullPath = Path.GetFullPath(directory);
if (!Directory.Exists(fullPath))
{
throw new DirectoryNotFoundException($"DevPortal offline bundle source directory '{fullPath}' does not exist.");
}
var files = Directory.GetFiles(fullPath, "*", SearchOption.AllDirectories);
if (files.Length == 0)
{
return false;
}
Array.Sort(files, StringComparer.Ordinal);
foreach (var file in files)
{
cancellationToken.ThrowIfCancellationRequested();
var relative = Path.GetRelativePath(fullPath, file);
var canonical = NormalizePath(prefix, relative);
var metadata = CreateFileMetadata(category, canonical, file);
collected.Add(metadata);
}
return true;
}
private static FileMetadata CreateFileMetadata(string category, string canonicalPath, string sourcePath)
{
using var stream = new FileStream(sourcePath, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 128 * 1024, FileOptions.SequentialScan);
using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
var buffer = ArrayPool<byte>.Shared.Rent(128 * 1024);
long totalBytes = 0;
try
{
int read;
while ((read = stream.Read(buffer, 0, buffer.Length)) > 0)
{
hash.AppendData(buffer, 0, read);
totalBytes += read;
}
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
var sha = Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant();
return new FileMetadata(category, canonicalPath, sourcePath, totalBytes, sha, GetContentType(sourcePath));
}
private static string ComputeSha256(string content)
{
var bytes = Encoding.UTF8.GetBytes(content);
var hash = SHA256.HashData(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static string BuildChecksums(string rootHash, IReadOnlyCollection<FileMetadata> files)
{
var builder = new StringBuilder();
builder.AppendLine("# DevPortal offline bundle checksums (sha256)");
builder.Append("root ").AppendLine(rootHash);
foreach (var file in files)
{
builder.Append(file.Sha256)
.Append(" ")
.AppendLine(file.CanonicalPath);
}
return builder.ToString();
}
private static string BuildInstructions(DevPortalOfflineBundleManifest manifest)
{
var builder = new StringBuilder();
builder.AppendLine("DevPortal Offline Bundle");
builder.AppendLine("========================");
builder.Append("Bundle ID: ").AppendLine(manifest.BundleId.ToString("D"));
builder.Append("Generated At: ").AppendLine(manifest.GeneratedAt.ToString("O"));
if (manifest.Metadata.TryGetValue("releaseVersion", out var releaseVersion))
{
builder.Append("Release Version: ").AppendLine(releaseVersion);
}
builder.AppendLine();
builder.AppendLine("Included sections:");
builder.Append("- Portal assets: ").AppendLine(manifest.Sources.PortalIncluded ? "yes" : "no");
builder.Append("- Specifications: ").AppendLine(manifest.Sources.SpecsIncluded ? "yes" : "no");
builder.Append("- SDKs: ").AppendLine(manifest.Sources.SdkNames.Count > 0
? string.Join(", ", manifest.Sources.SdkNames)
: "none");
builder.Append("- Changelog: ").AppendLine(manifest.Sources.ChangelogIncluded ? "yes" : "no");
builder.AppendLine();
builder.AppendLine("Verification steps:");
builder.AppendLine("1. Transfer the archive to the sealed environment.");
builder.AppendLine("2. Execute `./verify-offline.sh devportal-offline-bundle.tgz` (or supply your filename) to extract and validate checksums.");
builder.AppendLine("3. Run `stella devportal verify --bundle devportal-offline-bundle.tgz` to validate DSSE signatures once available.");
builder.AppendLine("4. Review extracted changelog and README content before distributing bundles further.");
builder.AppendLine();
builder.AppendLine("The manifest (`manifest.json`) lists every file with its category, size, and SHA-256 digest.");
return builder.ToString();
}
private static string BuildVerificationScript()
{
var builder = new StringBuilder();
builder.AppendLine("#!/usr/bin/env sh");
builder.AppendLine("set -euo pipefail");
builder.AppendLine();
builder.AppendLine("ARCHIVE=\"${1:-devportal-offline-bundle.tgz}\"");
builder.AppendLine("if [ ! -f \"$ARCHIVE\" ]; then");
builder.AppendLine(" echo \"Usage: $0 <devportal-offline-bundle.tgz>\" >&2");
builder.AppendLine(" exit 1");
builder.AppendLine("fi");
builder.AppendLine();
builder.AppendLine("WORKDIR=\"$(mktemp -d)\"");
builder.AppendLine("cleanup() { rm -rf \"$WORKDIR\"; }");
builder.AppendLine("trap cleanup EXIT INT TERM");
builder.AppendLine();
builder.AppendLine("tar -xzf \"$ARCHIVE\" -C \"$WORKDIR\"");
builder.AppendLine("echo \"DevPortal bundle extracted to $WORKDIR\"");
builder.AppendLine();
builder.AppendLine("if command -v sha256sum >/dev/null 2>&1; then");
builder.AppendLine(" (cd \"$WORKDIR\" && sha256sum --check checksums.txt)");
builder.AppendLine("else");
builder.AppendLine(" (cd \"$WORKDIR\" && shasum -a 256 --check checksums.txt)");
builder.AppendLine("fi");
builder.AppendLine();
builder.AppendLine("ROOT_HASH=$(sed -n 's/\\\"rootHash\\\"[[:space:]]*:[[:space:]]*\\\"\\([^\"]*\\)\\\"/\\1/p' \"$WORKDIR\"/manifest.json | head -n 1)");
builder.AppendLine("echo \"Manifest root hash: ${ROOT_HASH:-unknown}\"");
builder.AppendLine("echo \"Next: run 'stella devportal verify --bundle $ARCHIVE' for signature validation.\"");
builder.AppendLine("echo \"Leaving extracted files in $WORKDIR for inspection.\"");
return builder.ToString();
}
private static MemoryStream CreatePackageStream(
IReadOnlyList<FileMetadata> files,
string manifestJson,
string checksums,
string instructions,
string verificationScript)
{
var stream = new MemoryStream();
using (var gzip = new GZipStream(stream, CompressionLevel.SmallestSize, leaveOpen: true))
using (var tar = new TarWriter(gzip, TarEntryFormat.Pax, leaveOpen: true))
{
WriteTextEntry(tar, "manifest.json", manifestJson, DefaultFileMode);
WriteTextEntry(tar, "checksums.txt", checksums, DefaultFileMode);
WriteTextEntry(tar, "instructions-portable.txt", instructions, DefaultFileMode);
WriteTextEntry(tar, "verify-offline.sh", verificationScript, ExecutableFileMode);
foreach (var file in files)
{
WriteFileEntry(tar, file);
}
}
ApplyDeterministicGzipHeader(stream);
return stream;
}
private static void WriteTextEntry(TarWriter writer, string path, string content, UnixFileMode mode)
{
var bytes = Encoding.UTF8.GetBytes(content);
using var dataStream = new MemoryStream(bytes);
var entry = new PaxTarEntry(TarEntryType.RegularFile, path)
{
Mode = mode,
ModificationTime = FixedTimestamp,
DataStream = dataStream
};
writer.WriteEntry(entry);
}
private static void WriteFileEntry(TarWriter writer, FileMetadata metadata)
{
using var dataStream = new FileStream(metadata.SourcePath, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 128 * 1024, FileOptions.SequentialScan);
var entry = new PaxTarEntry(TarEntryType.RegularFile, metadata.CanonicalPath)
{
Mode = metadata.CanonicalPath.EndsWith(".sh", StringComparison.Ordinal) ? ExecutableFileMode : DefaultFileMode,
ModificationTime = FixedTimestamp,
DataStream = dataStream
};
writer.WriteEntry(entry);
}
private static void ApplyDeterministicGzipHeader(MemoryStream stream)
{
if (stream.Length < 10)
{
throw new InvalidOperationException("GZip header not fully written for devportal offline bundle.");
}
var seconds = checked((int)(FixedTimestamp - DateTimeOffset.UnixEpoch).TotalSeconds);
Span<byte> buffer = stackalloc byte[4];
BinaryPrimitives.WriteInt32LittleEndian(buffer, seconds);
var originalPosition = stream.Position;
stream.Position = 4;
stream.Write(buffer);
stream.Position = originalPosition;
}
private static string NormalizePath(string prefix, string relative)
{
var cleaned = relative.Replace('\\', '/').Trim('/');
if (cleaned.Length == 0 || cleaned == ".")
{
return prefix;
}
if (cleaned.Contains("..", StringComparison.Ordinal))
{
throw new InvalidOperationException($"Relative path '{relative}' escapes the source directory.");
}
return $"{prefix}/{cleaned}";
}
private static string SanitizeSegment(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return "sdk";
}
var span = value.Trim();
var builder = new StringBuilder(span.Length);
foreach (var ch in span)
{
if (char.IsLetterOrDigit(ch))
{
builder.Append(char.ToLowerInvariant(ch));
}
else if (ch is '-' or '_' or '.')
{
builder.Append(ch);
}
else
{
builder.Append('-');
}
}
return builder.Length == 0 ? "sdk" : builder.ToString();
}
private static string? GetContentType(string path)
{
var extension = Path.GetExtension(path);
if (extension.Length == 0)
{
return null;
}
return MediaTypeMap.TryGetValue(extension, out var mediaType) ? mediaType : "application/octet-stream";
}
private sealed record FileMetadata(
string Category,
string CanonicalPath,
string SourcePath,
long SizeBytes,
string Sha256,
string? ContentType);
}

View File

@@ -0,0 +1,30 @@
using System;
using System.Collections.Generic;
namespace StellaOps.ExportCenter.Core.DevPortalOffline;
public sealed record DevPortalOfflineBundleManifest(
string Version,
Guid BundleId,
DateTimeOffset GeneratedAt,
IReadOnlyDictionary<string, string> Metadata,
DevPortalOfflineBundleSourceSummary Sources,
DevPortalOfflineBundleTotals Totals,
IReadOnlyList<DevPortalOfflineBundleEntry> Entries);
public sealed record DevPortalOfflineBundleEntry(
string Category,
string Path,
string Sha256,
long SizeBytes,
string? ContentType);
public sealed record DevPortalOfflineBundleSourceSummary(
bool PortalIncluded,
bool SpecsIncluded,
IReadOnlyList<string> SdkNames,
bool ChangelogIncluded);
public sealed record DevPortalOfflineBundleTotals(
int EntryCount,
long TotalSizeBytes);

View File

@@ -0,0 +1,28 @@
using System.Collections.Generic;
namespace StellaOps.ExportCenter.Core.DevPortalOffline;
/// <summary>
/// Describes the source material required to build a devportal offline bundle.
/// All directory paths are optional; when null or whitespace, the category is skipped.
/// </summary>
/// <param name="BundleId">Unique identifier for the generated bundle.</param>
/// <param name="PortalDirectory">Root directory containing the portal static site assets.</param>
/// <param name="SpecsDirectory">Root directory containing OpenAPI or other specification documents.</param>
/// <param name="SdkSources">Optional SDK artifact sources, grouped by friendly name.</param>
/// <param name="ChangelogDirectory">Root directory containing release notes and changelog content.</param>
/// <param name="Metadata">Additional bundle metadata persisted in the manifest (for example release version).</param>
public sealed record DevPortalOfflineBundleRequest(
Guid BundleId,
string? PortalDirectory = null,
string? SpecsDirectory = null,
IReadOnlyList<DevPortalSdkSource>? SdkSources = null,
string? ChangelogDirectory = null,
IReadOnlyDictionary<string, string>? Metadata = null);
/// <summary>
/// Represents a named SDK artifact source that should be included in the offline bundle.
/// </summary>
/// <param name="Name">Logical name (for example, language) used to namespace the SDK artifacts.</param>
/// <param name="Directory">Filesystem directory that contains the artifacts to package.</param>
public sealed record DevPortalSdkSource(string Name, string Directory);

View File

@@ -0,0 +1,10 @@
using System.IO;
namespace StellaOps.ExportCenter.Core.DevPortalOffline;
public sealed record DevPortalOfflineBundleResult(
DevPortalOfflineBundleManifest Manifest,
string ManifestJson,
string Checksums,
string RootHash,
MemoryStream BundleStream);

View File

@@ -0,0 +1,30 @@
using System.IO;
namespace StellaOps.ExportCenter.Core.DevPortalOffline;
public sealed record DevPortalOfflineObjectStoreOptions(
string StorageKey,
string ContentType);
public sealed record DevPortalOfflineStorageMetadata(
string StorageKey,
string ContentType,
long SizeBytes,
string Sha256,
DateTimeOffset CreatedAt);
public interface IDevPortalOfflineObjectStore
{
Task<DevPortalOfflineStorageMetadata> StoreAsync(
Stream content,
DevPortalOfflineObjectStoreOptions options,
CancellationToken cancellationToken);
Task<bool> ExistsAsync(
string storageKey,
CancellationToken cancellationToken);
Task<Stream> OpenReadAsync(
string storageKey,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,218 @@
using System.Formats.Tar;
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using System.Linq;
using StellaOps.ExportCenter.Core.DevPortalOffline;
namespace StellaOps.ExportCenter.Tests;
public sealed class DevPortalOfflineBundleBuilderTests
{
[Fact]
public void Build_ComposesExpectedArchive()
{
var tempRoot = Directory.CreateTempSubdirectory();
try
{
var portalRoot = Path.Combine(tempRoot.FullName, "portal");
Directory.CreateDirectory(portalRoot);
File.WriteAllText(Path.Combine(portalRoot, "index.html"), "<html>hello</html>");
Directory.CreateDirectory(Path.Combine(portalRoot, "assets"));
File.WriteAllText(Path.Combine(portalRoot, "assets", "app.js"), "console.log('hello');");
var specsRoot = Path.Combine(tempRoot.FullName, "specs");
Directory.CreateDirectory(specsRoot);
File.WriteAllText(Path.Combine(specsRoot, "openapi.yaml"), "openapi: 3.1.0");
var changelogRoot = Path.Combine(tempRoot.FullName, "changelog");
Directory.CreateDirectory(changelogRoot);
File.WriteAllText(Path.Combine(changelogRoot, "CHANGELOG.md"), "# Changes");
var sdkDotnet = Path.Combine(tempRoot.FullName, "sdk-dotnet");
Directory.CreateDirectory(sdkDotnet);
File.WriteAllText(Path.Combine(sdkDotnet, "stellaops.sdk.nupkg"), "dotnet sdk");
var sdkPython = Path.Combine(tempRoot.FullName, "sdk-python");
Directory.CreateDirectory(sdkPython);
File.WriteAllText(Path.Combine(sdkPython, "stellaops_sdk.whl"), "python sdk");
var request = new DevPortalOfflineBundleRequest(
Guid.Parse("14b094c9-f0b4-4f9e-b221-b7a77c3f3445"),
portalRoot,
specsRoot,
new[]
{
new DevPortalSdkSource("dotnet", sdkDotnet),
new DevPortalSdkSource("python", sdkPython)
},
changelogRoot,
new Dictionary<string, string> { ["releaseVersion"] = "2025.11.0" });
var fixedNow = new DateTimeOffset(2025, 11, 4, 12, 30, 0, TimeSpan.Zero);
var builder = new DevPortalOfflineBundleBuilder(new FixedTimeProvider(fixedNow));
var result = builder.Build(request);
Assert.Equal(request.BundleId, result.Manifest.BundleId);
Assert.Equal("devportal-offline/v1", result.Manifest.Version);
Assert.Equal(fixedNow, result.Manifest.GeneratedAt);
Assert.True(result.Manifest.Sources.PortalIncluded);
Assert.True(result.Manifest.Sources.SpecsIncluded);
Assert.True(result.Manifest.Sources.ChangelogIncluded);
Assert.Equal(new[] { "dotnet", "python" }, result.Manifest.Sources.SdkNames);
Assert.Equal(6, result.Manifest.Totals.EntryCount);
var expectedPaths = new[]
{
"portal/assets/app.js",
"portal/index.html",
"specs/openapi.yaml",
"sdks/dotnet/stellaops.sdk.nupkg",
"sdks/python/stellaops_sdk.whl",
"changelog/CHANGELOG.md"
};
Assert.Equal(expectedPaths, result.Manifest.Entries.Select(entry => entry.Path).ToArray());
foreach (var entry in result.Manifest.Entries)
{
var fullPath = entry.Path switch
{
"portal/index.html" => Path.Combine(portalRoot, "index.html"),
"portal/assets/app.js" => Path.Combine(portalRoot, "assets", "app.js"),
"specs/openapi.yaml" => Path.Combine(specsRoot, "openapi.yaml"),
"sdks/dotnet/stellaops.sdk.nupkg" => Path.Combine(sdkDotnet, "stellaops.sdk.nupkg"),
"sdks/python/stellaops_sdk.whl" => Path.Combine(sdkPython, "stellaops_sdk.whl"),
"changelog/CHANGELOG.md" => Path.Combine(changelogRoot, "CHANGELOG.md"),
_ => throw new InvalidOperationException("Unexpected entry.")
};
Assert.Equal(CalculateFileHash(fullPath), entry.Sha256);
Assert.Equal(new FileInfo(fullPath).Length, entry.SizeBytes);
}
Assert.Equal(CalculateTextHash(result.ManifestJson), result.RootHash);
Assert.StartsWith("# DevPortal offline bundle checksums", result.Checksums, StringComparison.Ordinal);
Assert.Contains("portal/assets/app.js", result.Checksums, StringComparison.Ordinal);
using var bundleStream = result.BundleStream;
var bundleEntries = ExtractEntries(bundleStream);
Assert.Contains("manifest.json", bundleEntries.Keys);
Assert.Contains("checksums.txt", bundleEntries.Keys);
Assert.Contains("instructions-portable.txt", bundleEntries.Keys);
Assert.Contains("verify-offline.sh", bundleEntries.Keys);
foreach (var expectedPath in expectedPaths)
{
Assert.Contains(expectedPath, bundleEntries.Keys);
}
var instructions = Encoding.UTF8.GetString(bundleEntries["instructions-portable.txt"]);
Assert.Contains("DevPortal Offline Bundle", instructions, StringComparison.Ordinal);
Assert.Contains("verify-offline.sh", instructions, StringComparison.Ordinal);
var script = Encoding.UTF8.GetString(bundleEntries["verify-offline.sh"]);
Assert.Contains("sha256sum", script, StringComparison.Ordinal);
Assert.Contains("stella devportal verify", script, StringComparison.Ordinal);
}
finally
{
tempRoot.Dispose();
}
}
[Fact]
public void Build_ThrowsWhenNoContent()
{
var builder = new DevPortalOfflineBundleBuilder(new FixedTimeProvider(DateTimeOffset.UtcNow));
var request = new DevPortalOfflineBundleRequest(Guid.NewGuid());
var exception = Assert.Throws<InvalidOperationException>(() => builder.Build(request));
Assert.Contains("does not contain any files", exception.Message, StringComparison.Ordinal);
}
[Fact]
public void Build_UsesOptionalSources()
{
var tempRoot = Directory.CreateTempSubdirectory();
try
{
var portalRoot = Path.Combine(tempRoot.FullName, "portal");
Directory.CreateDirectory(portalRoot);
File.WriteAllText(Path.Combine(portalRoot, "index.html"), "<html/>");
var builder = new DevPortalOfflineBundleBuilder(new FixedTimeProvider(DateTimeOffset.UtcNow));
var result = builder.Build(new DevPortalOfflineBundleRequest(Guid.NewGuid(), portalRoot));
Assert.Single(result.Manifest.Entries);
Assert.True(result.Manifest.Sources.PortalIncluded);
Assert.False(result.Manifest.Sources.SpecsIncluded);
Assert.False(result.Manifest.Sources.ChangelogIncluded);
Assert.Empty(result.Manifest.Sources.SdkNames);
}
finally
{
tempRoot.Dispose();
}
}
[Fact]
public void Build_ThrowsWhenSourceDirectoryMissing()
{
var builder = new DevPortalOfflineBundleBuilder(new FixedTimeProvider(DateTimeOffset.UtcNow));
var missing = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
var request = new DevPortalOfflineBundleRequest(Guid.NewGuid(), missing);
Assert.Throws<DirectoryNotFoundException>(() => builder.Build(request));
}
private static string CalculateFileHash(string path)
{
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant();
}
private static string CalculateTextHash(string text)
=> Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(text))).ToLowerInvariant();
private static Dictionary<string, byte[]> ExtractEntries(Stream stream)
{
stream.Position = 0;
using var gzip = new GZipStream(stream, CompressionMode.Decompress, leaveOpen: true);
using var reader = new TarReader(gzip);
var result = new Dictionary<string, byte[]>(StringComparer.Ordinal);
TarEntry? entry;
while ((entry = reader.GetNextEntry()) is not null)
{
if (entry.EntryType != TarEntryType.RegularFile || entry.DataStream is null)
{
continue;
}
using var memory = new MemoryStream();
entry.DataStream.CopyTo(memory);
result[entry.Name] = memory.ToArray();
}
return result;
}
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _utcNow;
public FixedTimeProvider(DateTimeOffset utcNow)
{
_utcNow = utcNow;
}
public override DateTimeOffset GetUtcNow() => _utcNow;
public override long GetTimestamp() => TimeProvider.System.GetTimestamp();
}
}

View File

@@ -1,10 +0,0 @@
namespace StellaOps.ExportCenter.Tests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}