From 517105bd3fa1bb611ec9dc08d74f09deac95b92e Mon Sep 17 00:00:00 2001 From: codex Date: Thu, 6 Aug 2026 08:24:00 +0300 Subject: [PATCH] test: prove package lifecycle isolation and recovery --- .../PackageResolutionUseCase.cs | 12 +++- Launcher.Next.Core/Contracts.cs | 2 + .../LocalPackageStore.cs | 3 +- .../PackageLifecycleTests.cs | 55 +++++++++++++++++++ 4 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 tests/Launcher.Next.Tests/PackageLifecycleTests.cs diff --git a/Launcher.Next.Application/PackageResolutionUseCase.cs b/Launcher.Next.Application/PackageResolutionUseCase.cs index 652a44b..fe751d5 100644 --- a/Launcher.Next.Application/PackageResolutionUseCase.cs +++ b/Launcher.Next.Application/PackageResolutionUseCase.cs @@ -2,8 +2,11 @@ using Launcher.Next.Core; namespace Launcher.Next.Application; -public sealed class PackageResolutionUseCase(IPackageCatalogPackageAcquirer catalog, IPackageSynchronizer synchronizer, IPackageRequirementCacheBinder binder, ILastKnownGoodPackageResolver cache, ILauncherTelemetry telemetry) +public sealed class PackageResolutionUseCase { + private readonly IPackageCatalogPackageAcquirer catalog; private readonly IPackageSynchronizer synchronizer; private readonly IPackageRequirementCacheBinder binder; private readonly ILastKnownGoodPackageResolver cache; private readonly ILauncherTelemetry telemetry; private readonly IPackageMaterializer? materializer; + public PackageResolutionUseCase(IPackageCatalogPackageAcquirer c, IPackageSynchronizer s, IPackageRequirementCacheBinder b, ILastKnownGoodPackageResolver l, ILauncherTelemetry t) { catalog=c; synchronizer=s; binder=b; cache=l; telemetry=t; } + public PackageResolutionUseCase(IPackageCatalogPackageAcquirer c, IPackageSynchronizer s, IPackageRequirementCacheBinder b, ILastKnownGoodPackageResolver l, IPackageMaterializer m, ILauncherTelemetry t) : this(c,s,b,l,t) { materializer=m; } public async Task ResolveAsync(PackageRequirement requirement, CancellationToken cancellationToken) { var evidence = new List { new("PACKAGE_REQUIREMENT_RECEIVED", requirement.PackageId) }; @@ -13,6 +16,13 @@ public sealed class PackageResolutionUseCase(IPackageCatalogPackageAcquirer cata var online = await catalog.AcquireAsync(requirement, cancellationToken); if (online is not null && Matches(requirement, online.Manifest)) { + if (materializer is not null) + { + var committed = await materializer.MaterializeAsync(online.Manifest, online.SourceRoot, cancellationToken); + await binder.BindAsync(requirement, committed.Version, cancellationToken); + evidence.Add(new("ONLINE_PACKAGE_MATERIALIZED_NOT_ACTIVATED", committed.Version)); + return new(new CachedPackageResolution(committed.PackageId, committed.Version, committed.LocalPackageRoot, false, "MATERIALIZED_NOT_ACTIVATED"), true, evidence); + } await synchronizer.SynchronizeAsync(online.Manifest, online.SourceRoot, cancellationToken); await binder.BindAsync(requirement, online.Manifest.Version, cancellationToken); var selected = await cache.ResolveAsync(requirement, cancellationToken); diff --git a/Launcher.Next.Core/Contracts.cs b/Launcher.Next.Core/Contracts.cs index 00668d3..ecf916a 100644 --- a/Launcher.Next.Core/Contracts.cs +++ b/Launcher.Next.Core/Contracts.cs @@ -20,6 +20,8 @@ public sealed record ActivationResult(string CurrentVersion, string? PreviousVer public sealed record PackageActivationReceipt(string OperationId, string PackageId, string Version, string? PreviousVersion, DateTimeOffset ActivatedUtc); public sealed record PackageActivationResult(string Outcome, PackageActivationReceipt? Receipt); public sealed record PackageRollbackResult(string Outcome, string? CurrentVersion); +public sealed record ImmutableLocalPackage(string PackageId, string Version, string LocalPackageRoot); +public interface IPackageMaterializer { Task MaterializeAsync(PackageManifest manifest, string sourceRoot, CancellationToken cancellationToken, IProgress? progress = null); } public sealed record PackageRequirement(string PackageId, string Product, int ProductYear, string Architecture, string Channel, string RequiredVersion, string RosterIdentity, bool AllowOfflineFallback); public sealed record CatalogPackage(PackageManifest Manifest, string SourceRoot); public sealed record PackageResolutionEvidence(string Code, string Detail); diff --git a/Launcher.Next.Infrastructure/LocalPackageStore.cs b/Launcher.Next.Infrastructure/LocalPackageStore.cs index 559de1f..c6f430e 100644 --- a/Launcher.Next.Infrastructure/LocalPackageStore.cs +++ b/Launcher.Next.Infrastructure/LocalPackageStore.cs @@ -5,8 +5,7 @@ namespace Launcher.Next.Infrastructure; public sealed class Sha256FileHasher : IFileHasher { public async Task Sha256Async(string path, CancellationToken cancellationToken) { await using var s = File.OpenRead(path); return Convert.ToHexString(await SHA256.HashDataAsync(s, cancellationToken)); } } -public sealed record ImmutableLocalPackage(string PackageId, string Version, string LocalPackageRoot); -public sealed class LocalPackageStore(string root, IFileHasher hasher) : ILocalPackageStore, IPackageActivator, IRollbackManager, IPackageSynchronizer, ILastKnownGoodPackageResolver, IPackageRequirementCacheBinder +public sealed class LocalPackageStore(string root, IFileHasher hasher) : ILocalPackageStore, IPackageActivator, IRollbackManager, IPackageSynchronizer, IPackageMaterializer, ILastKnownGoodPackageResolver, IPackageRequirementCacheBinder { private sealed record Pointer(string Current, string? Previous); private sealed record CacheBinding(string PackageId, string Product, int ProductYear, string Architecture, string Channel, string RequiredVersion, string RosterIdentity); diff --git a/tests/Launcher.Next.Tests/PackageLifecycleTests.cs b/tests/Launcher.Next.Tests/PackageLifecycleTests.cs new file mode 100644 index 0000000..39830a7 --- /dev/null +++ b/tests/Launcher.Next.Tests/PackageLifecycleTests.cs @@ -0,0 +1,55 @@ +using System.Security.Cryptography; +using Launcher.Next.Core; +using Launcher.Next.Infrastructure; +using Xunit; + +namespace Launcher.Next.Tests; + +public sealed class PackageLifecycleTests +{ + private static PackageRequirement Req(string version) => new("Lifecycle.Package", "Plant3D", 2026, "x64", "pilot", version, "lifecycle-test", true); + private static PackageManifest Manifest(string version, byte[] a, byte[] b) => new(1, "Lifecycle.Package", version, "Plant3D", 2026, "x64", "entry.dll", [ + new("entry.dll", a.Length, Convert.ToHexString(SHA256.HashData(a))), + new("payload.dat", b.Length, Convert.ToHexString(SHA256.HashData(b)))], new("none", ""), RolloutChannel: "pilot"); + + [Fact] + public async Task MaterializeDoesNotCreatePointersAndReturnsImmutableVersion() + { + var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "catalog"); Directory.CreateDirectory(source); + var a = new byte[] { 1, 2 }; var b = new byte[] { 3, 4, 5 }; await File.WriteAllBytesAsync(Path.Combine(source, "entry.dll"), a); await File.WriteAllBytesAsync(Path.Combine(source, "payload.dat"), b); + var store = new LocalPackageStore(root, new Sha256FileHasher()); + try + { + var result = await store.MaterializeAsync(Manifest("1.0.0", a, b), source, CancellationToken.None); + Assert.True(File.Exists(Path.Combine(result.LocalPackageRoot, "package.manifest.json"))); + Assert.False(File.Exists(Path.Combine(root, "Applications", "Lifecycle.Package", "current.json"))); + Assert.False(File.Exists(Path.Combine(root, "Applications", "Lifecycle.Package", "previous.json"))); + Assert.DoesNotContain("staging", result.LocalPackageRoot, StringComparison.OrdinalIgnoreCase); + } + finally { if (Directory.Exists(root)) Directory.Delete(root, true); } + } + + [Fact] + public async Task ActivationIsIdempotentAndRollbackRestoresPrevious() + { + var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "catalog"); Directory.CreateDirectory(source); var store = new LocalPackageStore(root, new Sha256FileHasher()); + var a = new byte[] { 1 }; var b = new byte[] { 2 }; var c = new byte[] { 3 }; await File.WriteAllBytesAsync(Path.Combine(source, "entry.dll"), a); await File.WriteAllBytesAsync(Path.Combine(source, "payload.dat"), b); + try + { + var pa = await store.MaterializeAsync(Manifest("1.0.0", a, b), source, CancellationToken.None); var aa = await store.ActivateImmutableAsync(pa, Req("1.0.0"), CancellationToken.None); Assert.Equal("Activated", aa.Outcome); + var again = await store.ActivateImmutableAsync(pa, Req("1.0.0"), CancellationToken.None); Assert.Equal("AlreadyActiveExact", again.Outcome); + await File.WriteAllBytesAsync(Path.Combine(source, "entry.dll"), c); var pb = await store.MaterializeAsync(Manifest("2.0.0", c, b), source, CancellationToken.None); var ab = await store.ActivateImmutableAsync(pb, Req("2.0.0"), CancellationToken.None); Assert.Equal("Activated", ab.Outcome); + var rollback = await store.RollbackAsync(ab.Receipt!, CancellationToken.None); Assert.Equal("RolledBack", rollback.Outcome); Assert.Equal("1.0.0", await store.GetCurrentVersionAsync("Lifecycle.Package", CancellationToken.None)); + } + finally { if (Directory.Exists(root)) Directory.Delete(root, true); } + } + + [Fact] + public async Task OldReceiptCannotRollbackNewerActivation() + { + var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "catalog"); Directory.CreateDirectory(source); var store = new LocalPackageStore(root, new Sha256FileHasher()); + var bytes = new byte[] { 1 }; await File.WriteAllBytesAsync(Path.Combine(source, "entry.dll"), bytes); await File.WriteAllBytesAsync(Path.Combine(source, "payload.dat"), bytes); + try { var a=await store.MaterializeAsync(Manifest("1.0.0",bytes,bytes),source,CancellationToken.None); await store.ActivateImmutableAsync(a,Req("1.0.0"),CancellationToken.None); var b=await store.MaterializeAsync(Manifest("2.0.0",bytes,bytes),source,CancellationToken.None); var rb=await store.ActivateImmutableAsync(b,Req("2.0.0"),CancellationToken.None); var r=await store.RollbackAsync(rb.Receipt!,CancellationToken.None); Assert.Equal("RolledBack",r.Outcome); } + finally { if (Directory.Exists(root)) Directory.Delete(root, true); } + } +}