Files
Libation/Source/_Tests/LibationFileManager.Tests/InstallUpgradeManagerTests.cs
T
Cursor Agentandrmcrackan 47980e1f86 Check the rollback's own work, and offer a restart when it went well
The rollback restored files, announced 'Libation restored your previous install
files', and deleted the pending marker without ever reading back what it had
written. It also could not tell the difference between an overlay that had not
started, where putting the backup back returns the install to one version, and
an overlay that got partway, where the backup covers a dozen names out of the
few hundred in the folder and cannot.

Grade the outcome instead. Verify each restored file against its backup, and
use the manifest files that did match the upgrade package as the signal that
the overlay had begun. Report which files could not be restored, and keep
restoring the rest rather than abandoning them at the first failure.

Libation has to close either way, since the files underneath it just changed,
so the wording and the offer now follow the grade: back on the previous version
asks to restart, a mixed install asks but says a fresh install would be better,
and a restore that could not finish only offers OK. A relaunched process never
offers again, so a pending marker we failed to delete cannot become a loop.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-26 14:23:48 +00:00

366 lines
15 KiB
C#

using Microsoft.VisualStudio.TestTools.UnitTesting;
using System;
using System.IO;
using System.IO.Compression;
using System.Linq;
namespace LibationFileManager.Tests;
[TestClass]
public class InstallUpgradeManagerTests
{
private string _tempRoot = null!;
private string _installDir = null!;
[TestInitialize]
public void Setup()
{
_tempRoot = Path.Combine(Path.GetTempPath(), "LibationUpgradeTests-" + Guid.NewGuid().ToString("N"));
_installDir = Path.Combine(_tempRoot, "install");
Directory.CreateDirectory(_installDir);
}
[TestCleanup]
public void Cleanup()
{
StartupLog.ResetForTests();
try
{
if (Directory.Exists(_tempRoot))
Directory.Delete(_tempRoot, recursive: true);
}
catch { /* best effort */ }
}
[TestMethod]
public void PrepareForUpgrade_creates_backup_and_pending_manifest()
{
WriteInstallFile("LibationUiBase.dll", "old-ui-base");
WriteInstallFile("LibationFileManager.dll", "old-file-manager");
WriteInstallFile("AppScaffolding.dll", "old-app-scaffolding");
WriteInstallFile("Microsoft.EntityFrameworkCore.Sqlite.dll", "old-ef");
var zipPath = CreateUpgradeZip(
("LibationUiBase.dll", "new-ui-base"),
("LibationFileManager.dll", "new-file-manager"),
("AppScaffolding.dll", "new-app-scaffolding"),
("Microsoft.EntityFrameworkCore.Sqlite.dll", "new-ef"));
InstallUpgradeManager.PrepareForUpgrade(_installDir, zipPath, new Version(9, 9, 9));
Assert.IsTrue(File.Exists(InstallUpgradeManager.GetPendingStatePath(_installDir)));
Assert.IsTrue(File.Exists(Path.Combine(InstallUpgradeManager.GetBackupDirectory(_installDir), "LibationUiBase.dll")));
Assert.AreEqual("old-ui-base", File.ReadAllText(Path.Combine(InstallUpgradeManager.GetBackupDirectory(_installDir), "LibationUiBase.dll")));
}
[TestMethod]
public void RecoverPendingUpgradeIfNeeded_rolls_back_when_install_files_do_not_match_zip()
{
WriteInstallFile("LibationUiBase.dll", "old-ui-base");
WriteInstallFile("LibationFileManager.dll", "old-file-manager");
WriteInstallFile("AppScaffolding.dll", "old-app-scaffolding");
WriteInstallFile("Microsoft.EntityFrameworkCore.Sqlite.dll", "old-ef");
var zipPath = CreateUpgradeZip(
("LibationUiBase.dll", "new-ui-base"),
("LibationFileManager.dll", "new-file-manager"),
("AppScaffolding.dll", "new-app-scaffolding"),
("Microsoft.EntityFrameworkCore.Sqlite.dll", "new-ef"));
InstallUpgradeManager.PrepareForUpgrade(_installDir, zipPath, new Version(9, 9, 9));
// Simulate a partial overlay: only some files updated.
WriteInstallFile("LibationFileManager.dll", "new-file-manager");
WriteInstallFile("AppScaffolding.dll", "new-app-scaffolding");
WriteInstallFile("Microsoft.EntityFrameworkCore.Sqlite.dll", "new-ef");
var recovery = InstallUpgradeManager.RecoverPendingUpgradeIfNeeded(_installDir);
Assert.IsNotNull(recovery);
Assert.IsTrue(recovery!.RolledBack);
Assert.AreEqual("old-ui-base", File.ReadAllText(Path.Combine(_installDir, "LibationUiBase.dll")));
Assert.IsFalse(File.Exists(InstallUpgradeManager.GetPendingStatePath(_installDir)));
var recoveryAlert = InstallUpgradeManager.TakeStartupRecoveryAlert();
Assert.IsNotNull(recoveryAlert);
Assert.AreEqual("In-app upgrade failed -- Libation was restored", recoveryAlert.Title);
StringAssert.Contains(recoveryAlert.Body, "LibationUiBase.dll");
}
// Issue #2001: the recovery logged through Serilog, and on an install broken badly enough to need
// recovering, Serilog itself would not load. The logging call threw from inside the very catch block
// that was meant to report the problem, which discarded the real exception and left the rollback undone.
// Logging now goes through one sink that swallows its own failures, so prove a hostile sink is harmless.
[TestMethod]
public void RecoverPendingUpgradeIfNeeded_rolls_back_even_when_every_log_call_throws()
{
StartupLog.ReplayTo(_ => throw new FileNotFoundException(
"Could not load file or assembly 'Serilog, Version=4.3.0.0, Culture=neutral, PublicKeyToken=24c2f752a8e58a10'.",
"Serilog, Version=4.3.0.0, Culture=neutral, PublicKeyToken=24c2f752a8e58a10"));
WriteInstallFile("LibationUiBase.dll", "old-ui-base");
WriteInstallFile("LibationFileManager.dll", "old-file-manager");
var zipPath = CreateUpgradeZip(
("LibationUiBase.dll", "new-ui-base"),
("LibationFileManager.dll", "new-file-manager"));
InstallUpgradeManager.PrepareForUpgrade(_installDir, zipPath, new Version(9, 9, 9));
// A partial overlay: LibationUiBase.dll was never replaced.
WriteInstallFile("LibationFileManager.dll", "new-file-manager");
var recovery = InstallUpgradeManager.RecoverPendingUpgradeIfNeeded(_installDir);
Assert.IsNotNull(recovery);
Assert.IsTrue(recovery!.RolledBack);
Assert.AreEqual("old-file-manager", File.ReadAllText(Path.Combine(_installDir, "LibationFileManager.dll")));
Assert.IsFalse(File.Exists(InstallUpgradeManager.GetPendingStatePath(_installDir)));
}
// Issue #2001: every backed-up file is an assembly, and by the time startup recovery runs this process
// has already loaded and mapped several of them. File.Copy(overwrite: true) over a mapped file segfaulted
// the process on Linux and is denied on Windows, so the rollback could never finish.
[TestMethod]
public void RecoverPendingUpgradeIfNeeded_restores_a_file_that_is_still_open()
{
WriteInstallFile("LibationUiBase.dll", "old-ui-base");
WriteInstallFile("LibationFileManager.dll", "old-file-manager");
var zipPath = CreateUpgradeZip(
("LibationUiBase.dll", "new-ui-base"),
("LibationFileManager.dll", "new-file-manager"));
InstallUpgradeManager.PrepareForUpgrade(_installDir, zipPath, new Version(9, 9, 9));
WriteInstallFile("LibationUiBase.dll", "new-ui-base");
// LibationFileManager.dll keeps its old contents, so verification fails and a rollback follows.
var openPath = Path.Combine(_installDir, "LibationUiBase.dll");
// The sharing mode .NET uses for a loaded assembly: readers and renames allowed, writers denied.
using (var held = new FileStream(openPath, FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete))
{
var recovery = InstallUpgradeManager.RecoverPendingUpgradeIfNeeded(_installDir);
Assert.IsNotNull(recovery);
Assert.IsTrue(recovery!.RolledBack);
Assert.AreEqual("old-ui-base", File.ReadAllText(openPath));
// The handle still reads the file it opened, which is what keeps a loaded assembly working.
using var reader = new StreamReader(held);
Assert.AreEqual("new-ui-base", reader.ReadToEnd());
}
}
// The rollback used to restore, announce "Libation restored your previous install files" and delete the
// pending marker without ever reading back what it wrote. These three cover the grading that replaced
// that assumption, since it decides both how firmly the message pushes a reinstall and whether
// restarting is offered at all.
[TestMethod]
public void An_overlay_that_never_started_leaves_the_previous_version_behind()
{
WriteInstallFile("LibationUiBase.dll", "old-ui-base");
WriteInstallFile("LibationFileManager.dll", "old-file-manager");
var zipPath = CreateUpgradeZip(
("LibationUiBase.dll", "new-ui-base"),
("LibationFileManager.dll", "new-file-manager"));
InstallUpgradeManager.PrepareForUpgrade(_installDir, zipPath, new Version(9, 9, 9));
// Nothing was replaced: ZipExtractor never ran.
var recovery = InstallUpgradeManager.RecoverPendingUpgradeIfNeeded(_installDir);
Assert.IsNotNull(recovery);
Assert.AreEqual(RollbackConfidence.RestoredToPreviousVersion, recovery!.Confidence);
Assert.IsTrue(recovery.WorthRestarting);
StringAssert.Contains(recovery.Message, "the version you were running before");
}
[TestMethod]
public void An_overlay_that_got_partway_leaves_a_mixed_install()
{
WriteInstallFile("LibationUiBase.dll", "old-ui-base");
WriteInstallFile("LibationFileManager.dll", "old-file-manager");
var zipPath = CreateUpgradeZip(
("LibationUiBase.dll", "new-ui-base"),
("LibationFileManager.dll", "new-file-manager"));
InstallUpgradeManager.PrepareForUpgrade(_installDir, zipPath, new Version(9, 9, 9));
// One file was replaced and one was not, so the overlay was underway when it stopped. The backup
// covers a dozen names out of hundreds, so restoring them cannot undo what else it replaced.
WriteInstallFile("LibationFileManager.dll", "new-file-manager");
var recovery = InstallUpgradeManager.RecoverPendingUpgradeIfNeeded(_installDir);
Assert.IsNotNull(recovery);
Assert.AreEqual(RollbackConfidence.RestoredButInstallIsMixed, recovery!.Confidence);
Assert.IsTrue(recovery.WorthRestarting, "a mixed install still starts, so restarting stays on offer");
StringAssert.Contains(recovery.Message, "mixture of both versions");
}
[TestMethod]
public void A_restore_that_cannot_write_every_file_does_not_claim_success()
{
WriteInstallFile("LibationUiBase.dll", "old-ui-base");
WriteInstallFile("LibationFileManager.dll", "old-file-manager");
var zipPath = CreateUpgradeZip(
("LibationUiBase.dll", "new-ui-base"),
("LibationFileManager.dll", "new-file-manager"));
InstallUpgradeManager.PrepareForUpgrade(_installDir, zipPath, new Version(9, 9, 9));
// Make one backup file unreadable so its restore fails while the other succeeds.
var unreadableBackup = Path.Combine(InstallUpgradeManager.GetBackupDirectory(_installDir), "LibationUiBase.dll");
using (var exclusive = new FileStream(unreadableBackup, FileMode.Open, FileAccess.Read, FileShare.None))
{
var recovery = InstallUpgradeManager.RecoverPendingUpgradeIfNeeded(_installDir);
Assert.IsNotNull(recovery);
Assert.AreEqual(RollbackConfidence.RestoreIncomplete, recovery!.Confidence);
Assert.IsFalse(recovery.WorthRestarting, "do not invite the user back into an install we could not finish");
StringAssert.Contains(recovery.Message, "could not put all of your previous install files back");
StringAssert.Contains(recovery.Message, "Could not be restored:");
// The file it could reach was still restored rather than abandoned.
Assert.AreEqual("old-file-manager", File.ReadAllText(Path.Combine(_installDir, "LibationFileManager.dll")));
}
}
[TestMethod]
public void A_displaced_file_is_swept_up_on_a_later_startup()
{
var displaced = Path.Combine(_installDir, "LibationUiBase.dll" + InstallUpgradeManager.DisplacedFileSuffix);
File.WriteAllText(displaced, "left behind by an earlier rollback");
// No pending upgrade, so this is the ordinary startup path.
Assert.IsNull(InstallUpgradeManager.RecoverPendingUpgradeIfNeeded(_installDir));
Assert.IsFalse(File.Exists(displaced));
}
[TestMethod]
public void RecoverPendingUpgradeIfNeeded_completes_when_install_matches_zip()
{
WriteInstallFile("LibationUiBase.dll", "old-ui-base");
WriteInstallFile("LibationFileManager.dll", "old-file-manager");
WriteInstallFile("AppScaffolding.dll", "old-app-scaffolding");
WriteInstallFile("Microsoft.EntityFrameworkCore.Sqlite.dll", "old-ef");
var zipPath = CreateUpgradeZip(
("LibationUiBase.dll", "new-ui-base"),
("LibationFileManager.dll", "new-file-manager"),
("AppScaffolding.dll", "new-app-scaffolding"),
("Microsoft.EntityFrameworkCore.Sqlite.dll", "new-ef"));
InstallUpgradeManager.PrepareForUpgrade(_installDir, zipPath, new Version(9, 9, 9));
WriteInstallFile("LibationUiBase.dll", "new-ui-base");
WriteInstallFile("LibationFileManager.dll", "new-file-manager");
WriteInstallFile("AppScaffolding.dll", "new-app-scaffolding");
WriteInstallFile("Microsoft.EntityFrameworkCore.Sqlite.dll", "new-ef");
var recovery = InstallUpgradeManager.RecoverPendingUpgradeIfNeeded(_installDir);
Assert.IsNull(recovery);
Assert.IsFalse(Directory.Exists(InstallUpgradeManager.GetUpgradeStateDirectory(_installDir)));
}
[TestMethod]
public void VerifyInstallMatchesUpgrade_reports_missing_files()
{
var zipPath = CreateUpgradeZip(("LibationUiBase.dll", "new-ui-base"));
InstallUpgradeManager.PrepareForUpgrade(_installDir, zipPath, new Version(1, 2, 3));
File.Delete(Path.Combine(_installDir, "LibationUiBase.dll"));
var verification = InstallUpgradeManager.VerifyInstallMatchesUpgrade(_installDir);
Assert.IsFalse(verification.Success);
Assert.IsTrue(verification.FailedFiles.Any(f => f.Contains("LibationUiBase.dll", StringComparison.OrdinalIgnoreCase)));
}
[TestMethod]
public void GetFatalStartupMessage_uses_incomplete_upgrade_body_for_LibationUiBase_TypeLoadException()
{
var ex = new TypeLoadException("Could not load type 'LibationUiBase.ShowBadBookDialogAsyncDelegate' from assembly 'LibationUiBase'.");
var message = StartupAssemblyBootstrap.GetFatalStartupMessage(
ex,
new FatalStartupMessage("Generic", "Generic body"));
Assert.AreEqual("In-app upgrade failed", message.Title);
StringAssert.Contains(message.Body, "LibationUiBase");
}
[TestMethod]
public void GetStartupFailureMessage_detects_LibationUiBase_TypeLoadException()
{
var ex = new TypeLoadException("Could not load type 'LibationUiBase.ShowBadBookDialogAsyncDelegate' from assembly 'LibationUiBase'.");
Assert.IsTrue(StartupAssemblyBootstrap.IsIncompleteUpgradeAssemblyFailure(ex));
var message = StartupAssemblyBootstrap.GetStartupFailureMessage(ex);
Assert.IsNotNull(message);
Assert.AreEqual("In-app upgrade failed", message.Title);
StringAssert.Contains(message.Body, "LibationUiBase");
}
[TestMethod]
public void GetStartupFailureMessage_names_the_blocked_file_and_explains_the_missing_signature()
{
var ex = new FileLoadException(
"An Application Control policy has blocked this file. (0x800711C7)",
@"C:\Libation\Serilog.Settings.Configuration.dll");
Assert.IsTrue(StartupAssemblyBootstrap.IsApplicationControlBlockedAssembly(ex));
var message = StartupAssemblyBootstrap.GetStartupFailureMessage(ex);
Assert.IsNotNull(message);
Assert.AreEqual("Libation blocked by Windows security", message.Title);
StringAssert.Contains(message.Body, @"C:\Libation\Serilog.Settings.Configuration.dll");
StringAssert.Contains(message.Body, "Smart App Control");
StringAssert.Contains(message.Body, "not signed");
}
// Unblock-File takes no -Recurse argument, and Smart App Control gates on the signature
// rather than on Mark-of-the-Web, so telling users to unblock the folder sent them after a
// command that both fails to run and cannot fix the block. See issue #1967.
[TestMethod]
public void GetApplicationControlBlockedMessage_does_not_suggest_unblocking_the_install_folder()
{
var body = StartupAssemblyBootstrap.GetApplicationControlBlockedMessage();
Assert.IsFalse(body.Contains("Unblock-File", StringComparison.OrdinalIgnoreCase));
}
[TestMethod]
public void Startup_messages_link_to_their_own_docs_sections()
{
StringAssert.Contains(
StartupAssemblyBootstrap.GetIncompleteUpgradeFailureMessage(),
"troubleshoot#windows-incomplete-in-app-upgrade");
StringAssert.Contains(
StartupAssemblyBootstrap.GetApplicationControlBlockedMessage(),
"troubleshoot#windows-smart-app-control-and-in-app-upgrades");
}
private void WriteInstallFile(string fileName, string contents)
=> File.WriteAllText(Path.Combine(_installDir, fileName), contents);
private static string CreateUpgradeZip(params (string FileName, string Contents)[] files)
{
var zipPath = Path.Combine(Path.GetTempPath(), "LibationUpgradeTests-" + Guid.NewGuid().ToString("N") + ".zip");
using var zip = ZipFile.Open(zipPath, ZipArchiveMode.Create);
foreach (var (fileName, contents) in files)
{
var entry = zip.CreateEntry(fileName);
using var writer = new StreamWriter(entry.Open());
writer.Write(contents);
}
return zipPath;
}
}