work
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
using System.IO.Compression;
|
||||
using StellaOps.PacksRegistry.Core.Services;
|
||||
using StellaOps.PacksRegistry.Infrastructure.InMemory;
|
||||
using StellaOps.PacksRegistry.Infrastructure.Verification;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Tests;
|
||||
|
||||
public sealed class ExportServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Offline_seed_includes_metadata_and_content_when_requested()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var packRepo = new InMemoryPackRepository();
|
||||
var parityRepo = new InMemoryParityRepository();
|
||||
var lifecycleRepo = new InMemoryLifecycleRepository();
|
||||
var auditRepo = new InMemoryAuditRepository();
|
||||
var verifier = new SimpleSignatureVerifier();
|
||||
|
||||
var packService = new PackService(packRepo, verifier, auditRepo, null, TimeProvider.System);
|
||||
var parityService = new ParityService(parityRepo, packRepo, auditRepo, TimeProvider.System);
|
||||
var lifecycleService = new LifecycleService(lifecycleRepo, packRepo, auditRepo, TimeProvider.System);
|
||||
var exportService = new ExportService(packRepo, parityRepo, lifecycleRepo, auditRepo, TimeProvider.System);
|
||||
|
||||
var content = System.Text.Encoding.UTF8.GetBytes("export-pack");
|
||||
var provenance = System.Text.Encoding.UTF8.GetBytes("{\"p\":1}");
|
||||
var record = await packService.UploadAsync("demo", "1.2.3", "tenant-1", content, null, null, provenance, null, ct);
|
||||
await parityService.SetStatusAsync(record.PackId, record.TenantId, "ready", "seed", ct);
|
||||
await lifecycleService.SetStateAsync(record.PackId, record.TenantId, "promoted", "seed", ct);
|
||||
|
||||
var archiveStream = await exportService.ExportOfflineSeedAsync(record.TenantId, includeContent: true, includeProvenance: true, cancellationToken: ct);
|
||||
using var archive = new ZipArchive(archiveStream, ZipArchiveMode.Read);
|
||||
|
||||
Assert.NotNull(archive.GetEntry("packs.ndjson"));
|
||||
Assert.NotNull(archive.GetEntry("parity.ndjson"));
|
||||
Assert.NotNull(archive.GetEntry("lifecycle.ndjson"));
|
||||
Assert.NotNull(archive.GetEntry("audit.ndjson"));
|
||||
Assert.NotNull(archive.GetEntry($"content/{record.PackId}.bin"));
|
||||
Assert.NotNull(archive.GetEntry($"provenance/{record.PackId}.json"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
using StellaOps.PacksRegistry.Infrastructure.FileSystem;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Tests;
|
||||
|
||||
public sealed class FilePackRepositoryTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Upsert_and_List_round_trip()
|
||||
{
|
||||
var tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempPath);
|
||||
try
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var repo = new FilePackRepository(tempPath);
|
||||
|
||||
var record = new PackRecord(
|
||||
PackId: "demo@1.0.0",
|
||||
Name: "demo",
|
||||
Version: "1.0.0",
|
||||
TenantId: "t1",
|
||||
Digest: "sha256:abc",
|
||||
Signature: null,
|
||||
ProvenanceUri: null,
|
||||
ProvenanceDigest: null,
|
||||
CreatedAtUtc: DateTimeOffset.Parse("2025-11-24T00:00:00Z"),
|
||||
Metadata: new Dictionary<string, string> { ["lang"] = "csharp" });
|
||||
|
||||
await repo.UpsertAsync(record, new byte[] { 1, 2, 3 }, null, ct);
|
||||
|
||||
var listed = await repo.ListAsync("t1", ct);
|
||||
Assert.Single(listed);
|
||||
Assert.Equal(record.PackId, listed[0].PackId);
|
||||
|
||||
var fetched = await repo.GetAsync("demo@1.0.0", ct);
|
||||
Assert.NotNull(fetched);
|
||||
Assert.Equal(record.Digest, fetched!.Digest);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempPath, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using StellaOps.PacksRegistry.Core.Services;
|
||||
using StellaOps.PacksRegistry.Infrastructure.InMemory;
|
||||
using StellaOps.PacksRegistry.Infrastructure.Verification;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Tests;
|
||||
|
||||
public sealed class PackServiceTests
|
||||
{
|
||||
private static byte[] SampleContent => System.Text.Encoding.UTF8.GetBytes("sample-pack-content");
|
||||
|
||||
[Fact]
|
||||
public async Task Upload_persists_pack_with_digest()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var repo = new InMemoryPackRepository();
|
||||
var verifier = new SimpleSignatureVerifier();
|
||||
var service = new PackService(repo, verifier, new InMemoryAuditRepository(), null, TimeProvider.System);
|
||||
|
||||
var record = await service.UploadAsync(
|
||||
name: "demo-pack",
|
||||
version: "1.0.0",
|
||||
tenantId: "tenant-1",
|
||||
content: SampleContent,
|
||||
signature: null,
|
||||
provenanceUri: "https://example/manifest.json",
|
||||
provenanceContent: null,
|
||||
metadata: new Dictionary<string, string> { ["lang"] = "csharp" },
|
||||
cancellationToken: ct);
|
||||
|
||||
Assert.Equal("demo-pack@1.0.0", record.PackId);
|
||||
Assert.NotNull(record.Digest);
|
||||
|
||||
var listed = await service.ListAsync("tenant-1", ct);
|
||||
Assert.Single(listed);
|
||||
Assert.Equal(record.PackId, listed[0].PackId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upload_rejects_when_digest_mismatch()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var repo = new InMemoryPackRepository();
|
||||
var verifier = new AlwaysFailSignatureVerifier();
|
||||
var service = new PackService(repo, verifier, new InMemoryAuditRepository(), null, TimeProvider.System);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
service.UploadAsync(
|
||||
name: "demo-pack",
|
||||
version: "1.0.0",
|
||||
tenantId: "tenant-1",
|
||||
content: SampleContent,
|
||||
signature: "bogus",
|
||||
provenanceUri: null,
|
||||
provenanceContent: null,
|
||||
metadata: null,
|
||||
cancellationToken: ct));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rotate_signature_updates_record_and_audits()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var repo = new InMemoryPackRepository();
|
||||
var audit = new InMemoryAuditRepository();
|
||||
var verifier = new SimpleSignatureVerifier();
|
||||
var service = new PackService(repo, verifier, audit, null, TimeProvider.System);
|
||||
|
||||
var record = await service.UploadAsync(
|
||||
name: "demo-pack",
|
||||
version: "1.0.0",
|
||||
tenantId: "tenant-1",
|
||||
content: SampleContent,
|
||||
signature: null,
|
||||
provenanceUri: null,
|
||||
provenanceContent: null,
|
||||
metadata: null,
|
||||
cancellationToken: ct);
|
||||
|
||||
var digest = record.Digest;
|
||||
var newSignature = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(digest));
|
||||
|
||||
var rotated = await service.RotateSignatureAsync(record.PackId, record.TenantId, newSignature, cancellationToken: ct);
|
||||
|
||||
Assert.Equal(newSignature, rotated.Signature);
|
||||
|
||||
var auditEvents = await audit.ListAsync(record.TenantId, ct);
|
||||
Assert.Contains(auditEvents, a => a.Event == "signature.rotated" && a.PackId == record.PackId);
|
||||
}
|
||||
|
||||
private sealed class AlwaysFailSignatureVerifier : StellaOps.PacksRegistry.Core.Contracts.IPackSignatureVerifier
|
||||
{
|
||||
public Task<bool> VerifyAsync(byte[] content, string digest, string? signature, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.IO.Compression;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
using StellaOps.PacksRegistry.Core.Services;
|
||||
using StellaOps.PacksRegistry.Infrastructure.InMemory;
|
||||
using StellaOps.PacksRegistry.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Tests;
|
||||
|
||||
public sealed class PacksApiTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
{
|
||||
private readonly WebApplicationFactory<Program> _factory;
|
||||
|
||||
public PacksApiTests(WebApplicationFactory<Program> factory)
|
||||
{
|
||||
_factory = factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<IPackRepository>();
|
||||
services.RemoveAll<IParityRepository>();
|
||||
services.RemoveAll<ILifecycleRepository>();
|
||||
services.RemoveAll<IAuditRepository>();
|
||||
services.AddSingleton<IPackRepository, InMemoryPackRepository>();
|
||||
services.AddSingleton<IParityRepository, InMemoryParityRepository>();
|
||||
services.AddSingleton<ILifecycleRepository, InMemoryLifecycleRepository>();
|
||||
services.AddSingleton<IAuditRepository, InMemoryAuditRepository>();
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
services.RemoveAll<PackService>();
|
||||
services.RemoveAll<ParityService>();
|
||||
services.RemoveAll<LifecycleService>();
|
||||
services.RemoveAll<ExportService>();
|
||||
services.AddSingleton<PackService>();
|
||||
services.AddSingleton<ParityService>();
|
||||
services.AddSingleton<LifecycleService>();
|
||||
services.AddSingleton<ExportService>();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upload_and_download_round_trip()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "t1");
|
||||
var auth = _factory.Services.GetRequiredService<StellaOps.PacksRegistry.WebService.Options.AuthOptions>();
|
||||
if (!string.IsNullOrWhiteSpace(auth.ApiKey))
|
||||
{
|
||||
client.DefaultRequestHeaders.Add("X-API-Key", auth.ApiKey);
|
||||
}
|
||||
|
||||
var payload = new PackUploadRequest
|
||||
{
|
||||
Name = "demo",
|
||||
Version = "1.0.0",
|
||||
TenantId = "t1",
|
||||
Content = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("hello")),
|
||||
ProvenanceUri = "https://example/provenance.json",
|
||||
ProvenanceContent = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{\"provenance\":true}"))
|
||||
};
|
||||
|
||||
var message = new HttpRequestMessage(HttpMethod.Post, "/api/v1/packs")
|
||||
{
|
||||
Content = JsonContent.Create(payload)
|
||||
};
|
||||
|
||||
var response = await client.SendAsync(message, ct);
|
||||
if (response.StatusCode != HttpStatusCode.Created)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
throw new InvalidOperationException($"Upload failed with {response.StatusCode}: {body}");
|
||||
}
|
||||
|
||||
var created = await response.Content.ReadFromJsonAsync<PackResponse>(cancellationToken: ct);
|
||||
Assert.NotNull(created);
|
||||
Assert.Equal("demo", created!.Name);
|
||||
Assert.Equal("1.0.0", created.Version);
|
||||
|
||||
var get = await client.GetAsync($"/api/v1/packs/{created.PackId}", ct);
|
||||
Assert.Equal(HttpStatusCode.OK, get.StatusCode);
|
||||
|
||||
var content = await client.GetAsync($"/api/v1/packs/{created.PackId}/content", ct);
|
||||
Assert.Equal(HttpStatusCode.OK, content.StatusCode);
|
||||
var bytes = await content.Content.ReadAsByteArrayAsync(ct);
|
||||
Assert.Equal("hello", System.Text.Encoding.UTF8.GetString(bytes));
|
||||
Assert.True(content.Headers.Contains("X-Content-Digest"));
|
||||
|
||||
var prov = await client.GetAsync($"/api/v1/packs/{created.PackId}/provenance", ct);
|
||||
Assert.Equal(HttpStatusCode.OK, prov.StatusCode);
|
||||
var provBytes = await prov.Content.ReadAsByteArrayAsync(ct);
|
||||
Assert.Contains("provenance", System.Text.Encoding.UTF8.GetString(provBytes));
|
||||
Assert.True(prov.Headers.Contains("X-Provenance-Digest"));
|
||||
|
||||
var manifest = await client.GetFromJsonAsync<PackManifestResponse>($"/api/v1/packs/{created.PackId}/manifest", ct);
|
||||
Assert.NotNull(manifest);
|
||||
Assert.Equal(created.PackId, manifest!.PackId);
|
||||
Assert.True(manifest.ContentLength > 0);
|
||||
Assert.True(manifest.ProvenanceLength > 0);
|
||||
|
||||
// parity status
|
||||
var parityResponse = await client.PostAsJsonAsync($"/api/v1/packs/{created.PackId}/parity", new ParityRequest { Status = "ready", Notes = "tests" }, ct);
|
||||
Assert.Equal(HttpStatusCode.OK, parityResponse.StatusCode);
|
||||
|
||||
var parity = await client.GetFromJsonAsync<ParityResponse>($"/api/v1/packs/{created.PackId}/parity", ct);
|
||||
Assert.NotNull(parity);
|
||||
Assert.Equal("ready", parity!.Status);
|
||||
|
||||
var newSignature = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(created.Digest));
|
||||
var rotationResponse = await client.PostAsJsonAsync($"/api/v1/packs/{created.PackId}/signature", new RotateSignatureRequest { Signature = newSignature }, ct);
|
||||
Assert.Equal(HttpStatusCode.OK, rotationResponse.StatusCode);
|
||||
var rotated = await rotationResponse.Content.ReadFromJsonAsync<PackResponse>(cancellationToken: ct);
|
||||
Assert.Equal(newSignature, rotated!.Signature);
|
||||
|
||||
var offlineSeed = await client.PostAsJsonAsync("/api/v1/export/offline-seed", new OfflineSeedRequest { TenantId = "t1", IncludeContent = true, IncludeProvenance = true }, ct);
|
||||
Assert.Equal(HttpStatusCode.OK, offlineSeed.StatusCode);
|
||||
var bytesZip = await offlineSeed.Content.ReadAsByteArrayAsync(ct);
|
||||
using var archive = new ZipArchive(new MemoryStream(bytesZip));
|
||||
Assert.NotNull(archive.GetEntry("packs.ndjson"));
|
||||
Assert.NotNull(archive.GetEntry($"content/{created.PackId}.bin"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.PacksRegistry.Infrastructure.Verification;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Tests;
|
||||
|
||||
public sealed class RsaSignatureVerifierTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Verify_succeeds_when_signature_matches_digest()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
using var rsa = RSA.Create(2048);
|
||||
var publicPem = ExportPublicPem(rsa);
|
||||
|
||||
const string digest = "sha256:deadbeef";
|
||||
var sig = Convert.ToBase64String(rsa.SignData(Encoding.UTF8.GetBytes(digest), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1));
|
||||
|
||||
var verifier = new RsaSignatureVerifier(publicPem);
|
||||
var ok = await verifier.VerifyAsync(Array.Empty<byte>(), digest, sig, ct);
|
||||
|
||||
Assert.True(ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Verify_fails_on_invalid_signature()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
using var rsa = RSA.Create(2048);
|
||||
var publicPem = ExportPublicPem(rsa);
|
||||
const string digest = "sha256:deadbeef";
|
||||
var sig = Convert.ToBase64String(Encoding.UTF8.GetBytes("bogus"));
|
||||
|
||||
var verifier = new RsaSignatureVerifier(publicPem);
|
||||
var ok = await verifier.VerifyAsync(Array.Empty<byte>(), digest, sig, ct);
|
||||
|
||||
Assert.False(ok);
|
||||
}
|
||||
|
||||
private static string ExportPublicPem(RSA rsa)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("-----BEGIN PUBLIC KEY-----");
|
||||
builder.AppendLine(Convert.ToBase64String(rsa.ExportSubjectPublicKeyInfo(), Base64FormattingOptions.InsertLineBreaks));
|
||||
builder.AppendLine("-----END PUBLIC KEY-----");
|
||||
return builder.ToString();
|
||||
}
|
||||
}
|
||||
@@ -1,135 +1,34 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
|
||||
|
||||
|
||||
<OutputType>Exe</OutputType>
|
||||
|
||||
|
||||
|
||||
|
||||
<IsPackable>false</IsPackable>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
|
||||
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
|
||||
|
||||
<LangVersion>preview</LangVersion>
|
||||
|
||||
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
|
||||
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
|
||||
|
||||
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
|
||||
|
||||
|
||||
|
||||
|
||||
<PackageReference Include="xunit.v3" Version="3.0.0"/>
|
||||
|
||||
|
||||
|
||||
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.3"/>
|
||||
|
||||
|
||||
|
||||
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
|
||||
|
||||
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest"/>
|
||||
|
||||
|
||||
|
||||
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
|
||||
|
||||
|
||||
<Using Include="Xunit"/>
|
||||
|
||||
|
||||
|
||||
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
|
||||
|
||||
|
||||
<ProjectReference Include="..\StellaOps.PacksRegistry.Core\StellaOps.PacksRegistry.Core.csproj"/>
|
||||
|
||||
|
||||
|
||||
|
||||
<ProjectReference Include="..\StellaOps.PacksRegistry.Infrastructure\StellaOps.PacksRegistry.Infrastructure.csproj"/>
|
||||
|
||||
|
||||
|
||||
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</Project>
|
||||
<?xml version="1.0"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
<OutputType>Exe</OutputType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="xunit.v3" Version="3.0.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.PacksRegistry.Core\StellaOps.PacksRegistry.Core.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.PacksRegistry.Infrastructure\StellaOps.PacksRegistry.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.PacksRegistry.WebService\StellaOps.PacksRegistry.WebService.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace StellaOps.PacksRegistry.Tests;
|
||||
|
||||
public class UnitTest1
|
||||
{
|
||||
[Fact]
|
||||
public void Test1()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user