using System.Text.Json; using System.Text.Json.Serialization; using AcDream.Launcher.Core.Updates; namespace AcDream.Launcher.Core.Tests.Updates; public sealed class LauncherSelfUpdateManagerTests : IDisposable { private static readonly JsonSerializerOptions PlanOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = true, Converters = { new JsonStringEnumConverter( JsonNamingPolicy.CamelCase, allowIntegerValues: false), }, }; private readonly string _root = Path.Combine( Path.GetTempPath(), "acdream-self-update-tests", "target & $(literal)-" + Guid.NewGuid().ToString("N")); public void Dispose() { if (Directory.Exists(_root)) { Directory.Delete(_root, recursive: true); } } [Fact] public async Task StartupWithoutPlanReclaimsOnlyExactOwnedResidue() { using var harness = new Harness(_root); string orphan = harness.Manager.GetTransactionDirectory(Guid.NewGuid().ToString("N")); Directory.CreateDirectory(orphan); await File.WriteAllTextAsync(Path.Combine(orphan, "partial"), "partial"); Directory.CreateDirectory(harness.Manager.RootDirectory); string temporary = Path.Combine( harness.Manager.RootDirectory, $".pending.json.{Guid.NewGuid():N}.tmp"); string unrelated = Path.Combine(harness.Manager.RootDirectory, "pending.user.tmp"); await File.WriteAllTextAsync(temporary, "partial"); await File.WriteAllTextAsync(unrelated, "preserve"); Assert.Null(await harness.Manager.LoadPendingAsync()); Assert.False(Directory.Exists(orphan)); Assert.False(File.Exists(temporary)); Assert.True(File.Exists(unrelated)); } [Fact] public async Task VerifiedStageIsDurableAndDoesNotTouchRunningTarget() { using var harness = new Harness(_root); SelfUpdateStageResult result = await harness.StageAsync(); SelfUpdatePlan plan = Assert.IsType( await harness.Manager.LoadPendingAsync()); Assert.Equal("2.0.0", result.Version.Value); Assert.Equal(SelfUpdatePlanState.Staged, plan.State); Assert.Null(plan.Apply); Assert.Equal("old-launcher", await File.ReadAllTextAsync(harness.LauncherPath)); Assert.Equal("new-launcher", await File.ReadAllTextAsync( Path.Combine(harness.Manager.GetPayloadDirectory(plan.TransactionId), harness.LauncherName))); string pendingJson = await File.ReadAllTextAsync(result.PendingPlanPath); Assert.Contains("\"state\": \"staged\"", pendingJson, StringComparison.Ordinal); Assert.DoesNotContain("\"state\": \"Staged\"", pendingJson, StringComparison.Ordinal); Assert.DoesNotContain("cmd", pendingJson, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task ApplyConfirmAndCompletionUseMoveJournalAndRemoveTransaction() { using var harness = new Harness(_root); _ = await harness.StageAsync(); SelfUpdatePlan applied = await harness.Manager.ApplyPendingAsync(harness.Target); Assert.Equal(SelfUpdatePlanState.AwaitingConfirmation, applied.State); Assert.NotNull(applied.Apply); Assert.Equal("new-launcher", await File.ReadAllTextAsync(harness.LauncherPath)); Assert.Equal("support-new-launcher", await File.ReadAllTextAsync(harness.SupportPath)); await harness.Manager.ConfirmAsync( applied.TransactionId, harness.Target, harness.LauncherPath); Assert.True(harness.Manager.IsConfirmed(applied.TransactionId)); await harness.Manager.CompleteConfirmedAsync(applied.TransactionId, harness.Target); Assert.False(File.Exists(harness.Manager.PendingPlanPath)); Assert.False(Directory.Exists( harness.Manager.GetTransactionDirectory(applied.TransactionId))); Assert.Equal("new-launcher", await File.ReadAllTextAsync(harness.LauncherPath)); } [Fact] public async Task AwaitingConfirmationRollbackRestoresEveryOldFileAndCanRetry() { using var harness = new Harness(_root); _ = await harness.StageAsync(); SelfUpdatePlan applied = await harness.Manager.ApplyPendingAsync(harness.Target); SelfUpdatePlan rolledBack = await harness.Manager .RollbackAwaitingConfirmationAsync(harness.Target); Assert.Equal(SelfUpdatePlanState.Staged, rolledBack.State); Assert.Null(rolledBack.Apply); Assert.Equal("old-launcher", await File.ReadAllTextAsync(harness.LauncherPath)); Assert.Equal("old-support", await File.ReadAllTextAsync(harness.SupportPath)); SelfUpdatePlan retried = await harness.Manager.ApplyPendingAsync(harness.Target); Assert.Equal(SelfUpdatePlanState.AwaitingConfirmation, retried.State); Assert.Equal("new-launcher", await File.ReadAllTextAsync(harness.LauncherPath)); } [Fact] public async Task CrashDuringApplyingReplaysReverseJournalToStagedState() { using var harness = new Harness(_root); _ = await harness.StageAsync(); SelfUpdatePlan staged = Assert.IsType( await harness.Manager.LoadPendingAsync()); SelfUpdateApplyEntry[] apply = staged.Files .Select(file => new SelfUpdateApplyEntry( file.Path, File.Exists(Path.Combine( harness.Target, file.Path.Replace('/', Path.DirectorySeparatorChar))))) .ToArray(); SelfUpdatePlan applying = staged with { State = SelfUpdatePlanState.Applying, Apply = apply, }; await File.WriteAllTextAsync( harness.Manager.PendingPlanPath, JsonSerializer.Serialize(applying, PlanOptions)); SelfUpdateApplyEntry first = apply[0]; string payload = Path.Combine( harness.Manager.GetPayloadDirectory(staged.TransactionId), first.Path.Replace('/', Path.DirectorySeparatorChar)); string target = Path.Combine( harness.Target, first.Path.Replace('/', Path.DirectorySeparatorChar)); string backup = Path.Combine( harness.Manager.GetBackupDirectory(staged.TransactionId), first.Path.Replace('/', Path.DirectorySeparatorChar)); Directory.CreateDirectory(Path.GetDirectoryName(backup)!); File.Move(target, backup); File.Move(payload, target); SelfUpdatePlan recovered = await harness.Manager.RecoverApplyingAsync(harness.Target); Assert.Equal(SelfUpdatePlanState.Staged, recovered.State); Assert.Equal("old-launcher", await File.ReadAllTextAsync(harness.LauncherPath)); Assert.Equal("old-support", await File.ReadAllTextAsync(harness.SupportPath)); Assert.Equal("new-launcher", await File.ReadAllTextAsync(Path.Combine( harness.Manager.GetPayloadDirectory(staged.TransactionId), harness.LauncherName))); } [Fact] public async Task CorruptPayloadWrongTargetAndUnknownPlanFieldFailClosed() { using var harness = new Harness(_root); _ = await harness.StageAsync(); SelfUpdatePlan staged = Assert.IsType( await harness.Manager.LoadPendingAsync()); await File.WriteAllTextAsync( Path.Combine( harness.Manager.GetPayloadDirectory(staged.TransactionId), harness.LauncherName), "bad-payload!"); await Assert.ThrowsAsync(() => harness.Manager.ApplyPendingAsync(harness.Target)); Assert.Equal("old-launcher", await File.ReadAllTextAsync(harness.LauncherPath)); string wrongTarget = Path.Combine(_root, "other-target"); Directory.CreateDirectory(wrongTarget); await Assert.ThrowsAsync(() => harness.Manager.ApplyPendingAsync(wrongTarget)); string json = await File.ReadAllTextAsync(harness.Manager.PendingPlanPath); await File.WriteAllTextAsync( harness.Manager.PendingPlanPath, json.TrimEnd().TrimEnd('}') + ",\"unknown\":true}"); await Assert.ThrowsAsync(() => harness.Manager.LoadPendingAsync()); } [Fact] public async Task BootstrapConfirmationAndOrdinaryStartupDoNotUseShellParsing() { using var harness = new Harness(_root); SelfUpdateStartupResult ordinary = await LauncherSelfUpdateBootstrap.HandleAsync( ["--literal", "argument with spaces & metacharacters"], harness.Manager, harness.Target, harness.LauncherPath); Assert.False(ordinary.ShouldExit); Assert.Equal(["--literal", "argument with spaces & metacharacters"], ordinary.RemainingArguments); _ = await harness.StageAsync(); SelfUpdatePlan applied = await harness.Manager.ApplyPendingAsync(harness.Target); SelfUpdateStartupResult confirmation = await LauncherSelfUpdateBootstrap.HandleAsync( [LauncherSelfUpdateBootstrap.ConfirmArgument, applied.TransactionId], harness.Manager, harness.Target, harness.LauncherPath); Assert.False(confirmation.ShouldExit); Assert.Empty(confirmation.RemainingArguments); Assert.True(harness.Manager.IsConfirmed(applied.TransactionId)); await harness.Manager.CompleteConfirmedAsync(applied.TransactionId, harness.Target); } private sealed class Harness : IDisposable { private readonly LocalHttpFixture _server = new(); private readonly HttpClient _http = new(); private readonly byte[] _archive; private readonly ReleaseArtifact _artifact; public Harness(string root) { Target = Path.Combine(root, "published launcher"); Directory.CreateDirectory(Target); Rid = LauncherRuntimeIdentity.DetectRid(); LauncherName = "acdream-launcher" + (Rid.StartsWith("win-", StringComparison.Ordinal) ? ".exe" : string.Empty); LauncherPath = Path.Combine(Target, LauncherName); SupportPath = Path.Combine(Target, "support.dat"); File.WriteAllText(LauncherPath, "old-launcher"); File.WriteAllText(SupportPath, "old-support"); _archive = UpdateTestData.LauncherZip(Rid, "new-launcher"); _server.Add("launcher.zip", _archive); _artifact = new ReleaseArtifact( _server.UriFor("launcher.zip"), UpdateTestData.Sha256(_archive), _archive.LongLength); Manager = new LauncherSelfUpdateManager(UpdateTestData.Paths(root), _http); } public string Target { get; } public string Rid { get; } public string LauncherName { get; } public string LauncherPath { get; } public string SupportPath { get; } public LauncherSelfUpdateManager Manager { get; } public Task StageAsync() => Manager.StageAsync( LauncherVersion.Parse("2.0.0"), Rid, _artifact, Target, progress: null, CancellationToken.None); public void Dispose() { _http.Dispose(); _server.Dispose(); } } }