mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-30 06:54:25 +08:00
chabged.进一步清理启动器内的更新逻辑
This commit is contained in:
@@ -9,7 +9,7 @@ public sealed class CommandContextTests
|
||||
{
|
||||
{ [], "normal" },
|
||||
{ ["preview-oobe"], "debug-preview" },
|
||||
{ ["apply-update"], "apply-update" },
|
||||
{ ["apply-update"], "normal" },
|
||||
{ ["--source", "plugin.lmdp", "--plugins-dir", "plugins", "--result", "result.json"], "plugin-install" },
|
||||
{ ["launch", "--launch-source", "postinstall"], "postinstall" }
|
||||
};
|
||||
|
||||
7
LanMountainDesktop.Tests/GlobalUsings.cs
Normal file
7
LanMountainDesktop.Tests/GlobalUsings.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
global using LanMountainDesktop.Launcher.AirApp;
|
||||
global using LanMountainDesktop.Launcher.Deployment;
|
||||
global using LanMountainDesktop.Launcher.Infrastructure;
|
||||
global using LanMountainDesktop.Launcher.Ipc;
|
||||
global using LanMountainDesktop.Launcher.Oobe;
|
||||
global using LanMountainDesktop.Launcher.Plugins;
|
||||
global using LanMountainDesktop.Launcher.Startup;
|
||||
69
LanMountainDesktop.Tests/HostStartupMonitorTests.cs
Normal file
69
LanMountainDesktop.Tests/HostStartupMonitorTests.cs
Normal file
@@ -0,0 +1,69 @@
|
||||
using LanMountainDesktop.Launcher.Startup;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class HostStartupMonitorTests
|
||||
{
|
||||
[Fact]
|
||||
public void InitialIpcConnectUsesStagedBackoff()
|
||||
{
|
||||
var source = ReadRepositoryFile("LanMountainDesktop.Launcher", "Startup", "HostStartupMonitor.cs");
|
||||
|
||||
Assert.Contains("StartupTimeoutPolicy.InitialIpcConnectTimeout", source);
|
||||
Assert.Contains("TimeSpan.FromMilliseconds(3000)", source);
|
||||
Assert.Contains("TimeSpan.FromMilliseconds(5000)", source);
|
||||
Assert.Contains("TryConnectWithBackoffAsync", source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RefreshShellStatus_UsesStartupSuccessTrackerForSuccess()
|
||||
{
|
||||
var source = ReadRepositoryFile("LanMountainDesktop.Launcher", "Startup", "HostStartupMonitor.cs");
|
||||
|
||||
Assert.Contains("SuccessTracker.TryResolve(shellStatus, out var successState)", source);
|
||||
var refreshBlock = source[
|
||||
source.IndexOf("RefreshShellStatusAsync", StringComparison.Ordinal) ..
|
||||
source.IndexOf("var connected = await PublicIpcConnection.TryConnectWithBackoffAsync", StringComparison.Ordinal)];
|
||||
Assert.DoesNotContain("return new StartupSuccessState", refreshBlock);
|
||||
Assert.DoesNotContain("successState = new StartupSuccessState", refreshBlock);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildDelayedLoadingState_AddsSoftTimeoutItem()
|
||||
{
|
||||
var loadingState = new LoadingStateMessage
|
||||
{
|
||||
ActiveItems = [],
|
||||
OverallProgressPercent = 0,
|
||||
TotalCount = 0
|
||||
};
|
||||
|
||||
var delayed = HostStartupMonitor.BuildDelayedLoadingState(
|
||||
loadingState,
|
||||
"Still starting",
|
||||
"Host is still warming up.",
|
||||
DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Equal("Still starting", delayed.Message);
|
||||
Assert.Contains(delayed.ActiveItems, item => item.Id == "launcher-soft-timeout");
|
||||
}
|
||||
|
||||
private static string ReadRepositoryFile(params string[] pathParts)
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "LanMountainDesktop.slnx")))
|
||||
{
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
if (directory is null)
|
||||
{
|
||||
throw new DirectoryNotFoundException("Unable to locate repository root.");
|
||||
}
|
||||
|
||||
return File.ReadAllText(Path.Combine([directory.FullName, .. pathParts]));
|
||||
}
|
||||
}
|
||||
@@ -33,8 +33,7 @@ public sealed class LauncherArchitectureTests
|
||||
{
|
||||
var guardedFiles = new[]
|
||||
{
|
||||
Path.Combine(LauncherProjectRoot, "Infrastructure", "Commands.cs"),
|
||||
Path.Combine(LauncherProjectRoot, "Shell", "ApplyUpdateGuiFlow.cs")
|
||||
Path.Combine(LauncherProjectRoot, "Infrastructure", "Commands.cs")
|
||||
}.Concat(Directory.EnumerateFiles(
|
||||
Path.Combine(LauncherProjectRoot, "Shell", "EntryHandlers"),
|
||||
"*.cs",
|
||||
@@ -49,9 +48,8 @@ public sealed class LauncherArchitectureTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LauncherFacadeAndCompositionRootStayThin()
|
||||
public void LauncherCompositionRootStaysThin()
|
||||
{
|
||||
AssertFileLineCountAtMost(Path.Combine(LauncherProjectRoot, "Update", "UpdateEngineFacade.cs"), 140);
|
||||
AssertFileLineCountAtMost(Path.Combine(LauncherProjectRoot, "Shell", "LauncherCompositionRoot.cs"), 80);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,4 +4,3 @@ global using LanMountainDesktop.Launcher.Infrastructure;
|
||||
global using LanMountainDesktop.Launcher.Ipc;
|
||||
global using LanMountainDesktop.Launcher.Oobe;
|
||||
global using LanMountainDesktop.Launcher.Startup;
|
||||
global using LanMountainDesktop.Launcher.Update;
|
||||
@@ -134,13 +134,13 @@ public sealed class PendingPluginUpgradeServiceTests : IDisposable
|
||||
return packagePath;
|
||||
}
|
||||
|
||||
private static PluginManifest ReadManifestFromPackage(string packagePath)
|
||||
private static LanMountainDesktop.PluginSdk.PluginManifest ReadManifestFromPackage(string packagePath)
|
||||
{
|
||||
using var archive = ZipFile.OpenRead(packagePath);
|
||||
var entry = archive.GetEntry(PluginSdkInfo.ManifestFileName)
|
||||
?? throw new InvalidOperationException("Missing plugin manifest.");
|
||||
using var stream = entry.Open();
|
||||
return PluginManifest.Load(stream, $"{packagePath}!/{entry.FullName}");
|
||||
return LanMountainDesktop.PluginSdk.PluginManifest.Load(stream, $"{packagePath}!/{entry.FullName}");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Launcher;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class PendingUpdateDetectorTests : IDisposable
|
||||
{
|
||||
private readonly TempLauncherRoot _root = new();
|
||||
|
||||
[Fact]
|
||||
public void ValidateIncomingState_WhenNoPayloadButDeploymentLockExists_ReturnsNoop()
|
||||
{
|
||||
_root.WriteDeploymentLock();
|
||||
var detector = new PendingUpdateDetector(
|
||||
new DeploymentLocator(_root.AppRoot),
|
||||
_root.Paths,
|
||||
new UpdateSignatureVerifier(_root.Paths));
|
||||
|
||||
var result = detector.ValidateIncomingState();
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("noop", result.Code);
|
||||
}
|
||||
|
||||
public void Dispose() => _root.Dispose();
|
||||
}
|
||||
|
||||
public sealed class UpdateSignatureVerifierTests : IDisposable
|
||||
{
|
||||
private readonly TempLauncherRoot _root = new();
|
||||
|
||||
[Fact]
|
||||
public void Verify_WhenSignatureIsMissing_ReturnsStructuredFailure()
|
||||
{
|
||||
var payload = Path.Combine(_root.Paths.IncomingRoot, "files.json");
|
||||
Directory.CreateDirectory(_root.Paths.IncomingRoot);
|
||||
File.WriteAllText(payload, "{}");
|
||||
|
||||
var result = new UpdateSignatureVerifier(_root.Paths)
|
||||
.Verify(payload, Path.Combine(_root.Paths.IncomingRoot, "files.json.sig"), "files.json.sig");
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("Missing files.json.sig.", result.Message);
|
||||
}
|
||||
|
||||
public void Dispose() => _root.Dispose();
|
||||
}
|
||||
|
||||
public sealed class IncomingArtifactsCleanerTests : IDisposable
|
||||
{
|
||||
private readonly TempLauncherRoot _root = new();
|
||||
|
||||
[Fact]
|
||||
public void Cleanup_RemovesLegacyPlondsAndCheckpointArtifacts()
|
||||
{
|
||||
Directory.CreateDirectory(_root.Paths.PlondsObjectsRoot);
|
||||
foreach (var path in new[]
|
||||
{
|
||||
_root.Paths.FileMapPath,
|
||||
_root.Paths.SignaturePath,
|
||||
_root.Paths.ArchivePath,
|
||||
_root.Paths.PlondsFileMapPath,
|
||||
_root.Paths.PlondsSignaturePath,
|
||||
_root.Paths.PlondsUpdateMetadataPath,
|
||||
_root.Paths.InstallCheckpointPath,
|
||||
Path.Combine(_root.Paths.PlondsObjectsRoot, "payload")
|
||||
})
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
File.WriteAllText(path, "x");
|
||||
}
|
||||
|
||||
new IncomingArtifactsCleaner(_root.Paths).Cleanup();
|
||||
|
||||
Assert.False(File.Exists(_root.Paths.FileMapPath));
|
||||
Assert.False(File.Exists(_root.Paths.InstallCheckpointPath));
|
||||
Assert.False(Directory.Exists(_root.Paths.PlondsObjectsRoot));
|
||||
}
|
||||
|
||||
public void Dispose() => _root.Dispose();
|
||||
}
|
||||
|
||||
public sealed class DeploymentActivatorTests : IDisposable
|
||||
{
|
||||
private readonly TempLauncherRoot _root = new();
|
||||
|
||||
[Fact]
|
||||
public void Activate_MovesCurrentMarkerAndMarksPreviousDestroy()
|
||||
{
|
||||
var from = Path.Combine(_root.AppRoot, "app-1");
|
||||
var to = Path.Combine(_root.AppRoot, "app-2");
|
||||
Directory.CreateDirectory(from);
|
||||
Directory.CreateDirectory(to);
|
||||
File.WriteAllText(Path.Combine(from, ".current"), string.Empty);
|
||||
File.WriteAllText(Path.Combine(to, ".partial"), string.Empty);
|
||||
|
||||
new DeploymentActivator(new DeploymentLocator(_root.AppRoot)).Activate(from, to);
|
||||
|
||||
Assert.False(File.Exists(Path.Combine(from, ".current")));
|
||||
Assert.True(File.Exists(Path.Combine(from, ".destroy")));
|
||||
Assert.True(File.Exists(Path.Combine(to, ".current")));
|
||||
Assert.False(File.Exists(Path.Combine(to, ".partial")));
|
||||
}
|
||||
|
||||
public void Dispose() => _root.Dispose();
|
||||
}
|
||||
|
||||
public sealed class RollbackStrategyTests : IDisposable
|
||||
{
|
||||
private readonly TempLauncherRoot _root = new();
|
||||
|
||||
[Fact]
|
||||
public void RollbackLatest_WhenNoSnapshotsExist_ReturnsNoSnapshot()
|
||||
{
|
||||
var snapshotStore = new UpdateSnapshotStore(_root.Paths);
|
||||
var activator = new DeploymentActivator(new DeploymentLocator(_root.AppRoot));
|
||||
|
||||
var result = new RollbackStrategy(new DeploymentLocator(_root.AppRoot), snapshotStore, activator)
|
||||
.RollbackLatest();
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("no_snapshot", result.Code);
|
||||
}
|
||||
|
||||
public void Dispose() => _root.Dispose();
|
||||
}
|
||||
|
||||
public sealed class PlondsUpdateApplierTests
|
||||
{
|
||||
[Fact]
|
||||
public void ManifestParser_ReadsObjectComponentFiles()
|
||||
{
|
||||
var map = new PlondsFileMap();
|
||||
var entries = PlondsManifestParser.CollectFileEntries(map);
|
||||
|
||||
PlondsManifestParser.PopulateFromRawJson(
|
||||
"""
|
||||
{
|
||||
"toVersion": "2.0.0",
|
||||
"components": {
|
||||
"desktop": {
|
||||
"files": {
|
||||
"LanMountainDesktop.exe": {
|
||||
"archiveSha512": "abcd",
|
||||
"archivePath": "objects/ab/cd"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""",
|
||||
map,
|
||||
entries);
|
||||
|
||||
Assert.Equal("2.0.0", PlondsManifestParser.ResolveTargetVersion(map, null));
|
||||
var entry = Assert.Single(entries);
|
||||
Assert.Equal("LanMountainDesktop.exe", entry.Path);
|
||||
Assert.Equal("desktop", entry.Metadata["component"]);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class LegacyUpdateApplierTests : IDisposable
|
||||
{
|
||||
private readonly TempLauncherRoot _root = new();
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyAsync_WhenSignatureIsMissing_ReturnsSignatureFailure()
|
||||
{
|
||||
_root.WriteDeploymentLock();
|
||||
Directory.CreateDirectory(_root.Paths.IncomingRoot);
|
||||
File.WriteAllText(_root.Paths.FileMapPath, JsonSerializer.Serialize(new SignedFileMap
|
||||
{
|
||||
FromVersion = "1.0.0",
|
||||
ToVersion = "2.0.0",
|
||||
Files = [new UpdateFileEntry { Path = "state.txt" }]
|
||||
}, AppJsonContext.Default.SignedFileMap));
|
||||
using (var archive = ZipFile.Open(_root.Paths.ArchivePath, ZipArchiveMode.Create))
|
||||
{
|
||||
var entry = archive.CreateEntry("state.txt");
|
||||
await using var stream = entry.Open();
|
||||
await stream.WriteAsync(Encoding.UTF8.GetBytes("state"));
|
||||
}
|
||||
|
||||
var applier = CreateLegacyApplier();
|
||||
var result = await applier.ApplyAsync();
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("signature_failed", result.Code);
|
||||
}
|
||||
|
||||
public void Dispose() => _root.Dispose();
|
||||
|
||||
private LegacyUpdateApplier CreateLegacyApplier()
|
||||
{
|
||||
var locator = new DeploymentLocator(_root.AppRoot);
|
||||
var snapshotStore = new UpdateSnapshotStore(_root.Paths);
|
||||
var checkpointStore = new InstallCheckpointStore(_root.Paths);
|
||||
var activator = new DeploymentActivator(locator);
|
||||
var cleaner = new IncomingArtifactsCleaner(_root.Paths);
|
||||
return new LegacyUpdateApplier(
|
||||
locator,
|
||||
_root.Paths,
|
||||
new UpdateSignatureVerifier(_root.Paths),
|
||||
new NullUpdateProgressReporter(),
|
||||
snapshotStore,
|
||||
checkpointStore,
|
||||
activator,
|
||||
cleaner);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TempLauncherRoot : IDisposable
|
||||
{
|
||||
public TempLauncherRoot()
|
||||
{
|
||||
AppRoot = Path.Combine(Path.GetTempPath(), "lmd-launcher-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(AppRoot);
|
||||
Paths = new UpdateEnginePaths(AppRoot);
|
||||
Directory.CreateDirectory(Paths.IncomingRoot);
|
||||
}
|
||||
|
||||
public string AppRoot { get; }
|
||||
|
||||
public UpdateEnginePaths Paths { get; }
|
||||
|
||||
public void WriteDeploymentLock()
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(Paths.DeploymentLockPath)!);
|
||||
File.WriteAllText(Paths.DeploymentLockPath, string.Empty);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(AppRoot))
|
||||
{
|
||||
Directory.Delete(AppRoot, true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,612 +0,0 @@
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop;
|
||||
using LanMountainDesktop.Launcher;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Update;
|
||||
using LanMountainDesktop.Shared.Contracts.Update;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class UpdateEngineRollbackRegressionTests : IDisposable
|
||||
{
|
||||
private readonly UpdateTestDirectory _directory = new();
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyPlondsUpdate_KeepsPreviousDeploymentForManualRollback()
|
||||
{
|
||||
var current = _directory.CreateDeployment("1.0.0", "old-state", isCurrent: true);
|
||||
var newState = Encoding.UTF8.GetBytes("new-state");
|
||||
|
||||
_directory.StagePlondsUpdate("1.0.0", "1.1.0", newState, Sha256Hex(newState));
|
||||
|
||||
var service = new UpdateEngineFacade(new DeploymentLocator(_directory.AppRoot));
|
||||
var result = await service.ApplyPendingUpdateAsync();
|
||||
|
||||
Assert.True(result.Success, result.ErrorMessage);
|
||||
Assert.True(Directory.Exists(current));
|
||||
Assert.False(File.Exists(Path.Combine(current, ".current")));
|
||||
|
||||
var rollback = service.RollbackLatest();
|
||||
|
||||
Assert.True(rollback.Success, rollback.ErrorMessage);
|
||||
Assert.Equal("1.0.0", rollback.RolledBackTo);
|
||||
Assert.True(File.Exists(Path.Combine(current, ".current")));
|
||||
Assert.False(File.Exists(Path.Combine(current, ".destroy")));
|
||||
Assert.Equal("old-state", File.ReadAllText(Path.Combine(current, "state.txt")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyPlondsUpdate_WhenObjectHashMismatches_RollsBackToPreviousDeployment()
|
||||
{
|
||||
var current = _directory.CreateDeployment("1.0.0", "old-state", isCurrent: true);
|
||||
var newState = Encoding.UTF8.GetBytes("new-state");
|
||||
|
||||
_directory.StagePlondsUpdate("1.0.0", "1.1.0", newState, new string('0', 64));
|
||||
|
||||
var service = new UpdateEngineFacade(new DeploymentLocator(_directory.AppRoot));
|
||||
var result = await service.ApplyPendingUpdateAsync();
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("apply_failed", result.Code);
|
||||
Assert.Equal("1.0.0", result.RolledBackTo);
|
||||
Assert.True(File.Exists(Path.Combine(current, ".current")));
|
||||
Assert.False(File.Exists(Path.Combine(current, ".destroy")));
|
||||
Assert.Equal("old-state", File.ReadAllText(Path.Combine(current, "state.txt")));
|
||||
Assert.Empty(Directory.GetDirectories(_directory.AppRoot, "app-1.1.0-*"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollbackLatest_WhenSnapshotSourceDirectoryIsMissing_ReturnsStructuredFailure()
|
||||
{
|
||||
_directory.CreateDeployment("1.1.0", "new-state", isCurrent: true);
|
||||
_directory.WriteSnapshot(
|
||||
sourceVersion: "1.0.0",
|
||||
sourceDirectory: Path.Combine(_directory.AppRoot, "app-1.0.0-0"),
|
||||
targetVersion: "1.1.0",
|
||||
targetDirectory: Path.Combine(_directory.AppRoot, "app-1.1.0-0"));
|
||||
|
||||
var service = new UpdateEngineFacade(new DeploymentLocator(_directory.AppRoot));
|
||||
var result = service.RollbackLatest();
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("source_missing", result.Code);
|
||||
Assert.Contains("app-1.0.0-0", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyPlondsUpdate_WhenInstallCheckpointIsStale_ReturnsStructuredFailure()
|
||||
{
|
||||
_directory.CreateDeployment("1.0.0", "old-state", isCurrent: true);
|
||||
var newState = Encoding.UTF8.GetBytes("new-state");
|
||||
_directory.StagePlondsUpdate("1.0.0", "1.1.0", newState, Sha256Hex(newState));
|
||||
_directory.WriteStaleInstallCheckpoint("9.9.9", "1.1.0");
|
||||
|
||||
var service = new UpdateEngineFacade(new DeploymentLocator(_directory.AppRoot));
|
||||
var result = await service.ApplyPendingUpdateAsync();
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("resume_state_invalid", result.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyLegacyUpdate_WhenInstallCheckpointIsStale_ReturnsStructuredFailure()
|
||||
{
|
||||
_directory.CreateDeployment("1.0.0", "old-state", isCurrent: true);
|
||||
_directory.StageLegacyUpdate("1.0.0", "1.1.0", "new-state");
|
||||
_directory.WriteStaleInstallCheckpoint("9.9.9", "1.1.0");
|
||||
|
||||
var service = new UpdateEngineFacade(new DeploymentLocator(_directory.AppRoot));
|
||||
var result = await service.ApplyPendingUpdateAsync();
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("resume_state_invalid", result.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyPlondsUpdate_WhenCheckpointIsValid_ResumesAndSucceeds()
|
||||
{
|
||||
var current = _directory.CreateDeployment("1.0.0", "old-state", isCurrent: true);
|
||||
var newState = Encoding.UTF8.GetBytes("new-state");
|
||||
_directory.StagePlondsUpdate("1.0.0", "1.1.0", newState, Sha256Hex(newState));
|
||||
_directory.WriteValidPlondsResumeCheckpoint("1.0.0", "1.1.0");
|
||||
|
||||
var service = new UpdateEngineFacade(new DeploymentLocator(_directory.AppRoot));
|
||||
var result = await service.ApplyPendingUpdateAsync();
|
||||
|
||||
Assert.True(result.Success, result.ErrorMessage);
|
||||
Assert.Equal("1.1.0", result.TargetVersion);
|
||||
Assert.False(File.Exists(Path.Combine(current, ".current")));
|
||||
var resumedTarget = Path.Combine(_directory.AppRoot, "app-1.1.0-0");
|
||||
Assert.True(File.Exists(Path.Combine(resumedTarget, ".current")));
|
||||
Assert.Equal("new-state", File.ReadAllText(Path.Combine(resumedTarget, "state.txt")));
|
||||
Assert.False(File.Exists(UpdatePaths.GetInstallCheckpointPath(_directory.AppRoot)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyLegacyUpdate_WhenCheckpointIsValid_ResumesAndSucceeds()
|
||||
{
|
||||
var current = _directory.CreateDeployment("1.0.0", "old-state", isCurrent: true);
|
||||
_directory.StageLegacyUpdate("1.0.0", "1.1.0", "new-state");
|
||||
_directory.WriteValidLegacyResumeCheckpoint("1.0.0", "1.1.0");
|
||||
|
||||
var service = new UpdateEngineFacade(new DeploymentLocator(_directory.AppRoot));
|
||||
var result = await service.ApplyPendingUpdateAsync();
|
||||
|
||||
Assert.True(result.Success, result.ErrorMessage);
|
||||
Assert.Equal("1.1.0", result.TargetVersion);
|
||||
Assert.False(File.Exists(Path.Combine(current, ".current")));
|
||||
var resumedTarget = Path.Combine(_directory.AppRoot, "app-1.1.0-0");
|
||||
Assert.True(File.Exists(Path.Combine(resumedTarget, ".current")));
|
||||
Assert.Equal("new-state", File.ReadAllText(Path.Combine(resumedTarget, "state.txt")));
|
||||
Assert.False(File.Exists(UpdatePaths.GetInstallCheckpointPath(_directory.AppRoot)));
|
||||
}
|
||||
|
||||
public void Dispose() => _directory.Dispose();
|
||||
|
||||
private static string Sha256Hex(byte[] bytes)
|
||||
{
|
||||
return Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private sealed class UpdateTestDirectory : IDisposable
|
||||
{
|
||||
private readonly string _root;
|
||||
private readonly RSA _rsa = RSA.Create(2048);
|
||||
|
||||
public UpdateTestDirectory()
|
||||
{
|
||||
_root = Path.Combine(Path.GetTempPath(), "LanMountainDesktop.UpdateRegression", Guid.NewGuid().ToString("N"));
|
||||
AppRoot = Path.Combine(_root, "app-root");
|
||||
Directory.CreateDirectory(AppRoot);
|
||||
|
||||
var resolver = new DataLocationResolver(AppRoot);
|
||||
LauncherRoot = resolver.ResolveLauncherDataPath();
|
||||
IncomingRoot = Path.Combine(LauncherRoot, "update", "incoming");
|
||||
SnapshotsRoot = Path.Combine(LauncherRoot, "snapshots");
|
||||
|
||||
Directory.CreateDirectory(Path.Combine(LauncherRoot, "update"));
|
||||
File.WriteAllText(Path.Combine(LauncherRoot, "update", "public-key.pem"), _rsa.ExportSubjectPublicKeyInfoPem());
|
||||
}
|
||||
|
||||
public string AppRoot { get; }
|
||||
|
||||
private string LauncherRoot { get; }
|
||||
|
||||
private string IncomingRoot { get; }
|
||||
|
||||
private string SnapshotsRoot { get; }
|
||||
|
||||
public string CreateDeployment(string version, string state, bool isCurrent)
|
||||
{
|
||||
var deployment = Path.Combine(AppRoot, $"app-{version}-0");
|
||||
Directory.CreateDirectory(deployment);
|
||||
File.WriteAllText(Path.Combine(deployment, ExecutableName), $"exe-{version}");
|
||||
File.WriteAllText(Path.Combine(deployment, "state.txt"), state);
|
||||
|
||||
if (isCurrent)
|
||||
{
|
||||
File.WriteAllText(Path.Combine(deployment, ".current"), string.Empty);
|
||||
}
|
||||
|
||||
return deployment;
|
||||
}
|
||||
|
||||
public void StagePlondsUpdate(string fromVersion, string toVersion, byte[] statePayload, string expectedStateSha256)
|
||||
{
|
||||
Directory.CreateDirectory(IncomingRoot);
|
||||
var objectsRoot = Path.Combine(IncomingRoot, "objects");
|
||||
Directory.CreateDirectory(objectsRoot);
|
||||
|
||||
var objectHash = Convert.ToHexString(SHA256.HashData(statePayload)).ToLowerInvariant();
|
||||
File.WriteAllBytes(Path.Combine(objectsRoot, objectHash), statePayload);
|
||||
|
||||
var currentExecutable = Path.Combine(AppRoot, $"app-{fromVersion}-0", ExecutableName);
|
||||
var fileMap = new PlondsFileMap
|
||||
{
|
||||
DistributionId = $"stable-{PlondsStaticUpdateService.ResolveCurrentPlatform()}-{toVersion}",
|
||||
FromVersion = fromVersion,
|
||||
ToVersion = toVersion,
|
||||
Platform = PlondsStaticUpdateService.ResolveCurrentPlatform(),
|
||||
Files =
|
||||
[
|
||||
new PlondsFileEntry
|
||||
{
|
||||
Path = ExecutableName,
|
||||
Action = "reuse",
|
||||
Sha256 = Sha256File(currentExecutable)
|
||||
},
|
||||
new PlondsFileEntry
|
||||
{
|
||||
Path = "state.txt",
|
||||
Action = "replace",
|
||||
Sha256 = expectedStateSha256,
|
||||
ObjectUrl = $"https://static.example/lanmountain/update/repo/sha256/{objectHash[..2]}/{objectHash}"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var fileMapPath = Path.Combine(IncomingRoot, "plonds-filemap.json");
|
||||
File.WriteAllText(fileMapPath, JsonSerializer.Serialize(fileMap, AppJsonContext.Default.PlondsFileMap));
|
||||
Sign(fileMapPath, Path.Combine(IncomingRoot, "plonds-filemap.sig"));
|
||||
|
||||
var deploymentLock = new DeploymentLock(
|
||||
SchemaVersion: 1,
|
||||
Kind: "delta",
|
||||
TargetVersion: toVersion,
|
||||
PayloadPath: fileMapPath,
|
||||
PayloadSha256: Sha256File(fileMapPath),
|
||||
CreatedAtUtc: DateTimeOffset.UtcNow);
|
||||
var deploymentLockPath = UpdatePaths.GetDeploymentLockPath(AppRoot);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(deploymentLockPath)!);
|
||||
File.WriteAllText(deploymentLockPath, JsonSerializer.Serialize(deploymentLock));
|
||||
|
||||
var markerPath = UpdatePaths.GetDownloadMarkerPath(AppRoot);
|
||||
File.WriteAllText(markerPath, UpdatePaths.GetDownloadMarkerContent(
|
||||
manifestSha256: Sha256File(fileMapPath),
|
||||
targetVersion: toVersion,
|
||||
objectCount: 1));
|
||||
}
|
||||
|
||||
public void StageLegacyUpdate(string fromVersion, string toVersion, string newState)
|
||||
{
|
||||
Directory.CreateDirectory(IncomingRoot);
|
||||
var extractRoot = Path.Combine(IncomingRoot, "legacy-src");
|
||||
Directory.CreateDirectory(extractRoot);
|
||||
|
||||
File.WriteAllText(Path.Combine(extractRoot, ExecutableName), $"exe-{toVersion}");
|
||||
File.WriteAllText(Path.Combine(extractRoot, "state.txt"), newState);
|
||||
|
||||
var archivePath = Path.Combine(IncomingRoot, "update.zip");
|
||||
if (File.Exists(archivePath))
|
||||
{
|
||||
File.Delete(archivePath);
|
||||
}
|
||||
|
||||
System.IO.Compression.ZipFile.CreateFromDirectory(extractRoot, archivePath);
|
||||
|
||||
var fileMap = new SignedFileMap
|
||||
{
|
||||
FromVersion = fromVersion,
|
||||
ToVersion = toVersion,
|
||||
Files =
|
||||
[
|
||||
new LanMountainDesktop.Launcher.Models.UpdateFileEntry
|
||||
{
|
||||
Path = ExecutableName,
|
||||
ArchivePath = ExecutableName,
|
||||
Action = "replace",
|
||||
Sha256 = Sha256File(Path.Combine(extractRoot, ExecutableName))
|
||||
},
|
||||
new LanMountainDesktop.Launcher.Models.UpdateFileEntry
|
||||
{
|
||||
Path = "state.txt",
|
||||
ArchivePath = "state.txt",
|
||||
Action = "replace",
|
||||
Sha256 = Sha256File(Path.Combine(extractRoot, "state.txt"))
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var fileMapPath = Path.Combine(IncomingRoot, "files.json");
|
||||
File.WriteAllText(fileMapPath, JsonSerializer.Serialize(fileMap, AppJsonContext.Default.SignedFileMap));
|
||||
Sign(fileMapPath, Path.Combine(IncomingRoot, "files.json.sig"));
|
||||
|
||||
var deploymentLock = new DeploymentLock(
|
||||
SchemaVersion: 1,
|
||||
Kind: "delta",
|
||||
TargetVersion: toVersion,
|
||||
PayloadPath: fileMapPath,
|
||||
PayloadSha256: Sha256File(fileMapPath),
|
||||
CreatedAtUtc: DateTimeOffset.UtcNow);
|
||||
var deploymentLockPath = UpdatePaths.GetDeploymentLockPath(AppRoot);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(deploymentLockPath)!);
|
||||
File.WriteAllText(deploymentLockPath, JsonSerializer.Serialize(deploymentLock));
|
||||
|
||||
Directory.Delete(extractRoot, true);
|
||||
}
|
||||
|
||||
public void WriteSnapshot(string sourceVersion, string sourceDirectory, string targetVersion, string targetDirectory)
|
||||
{
|
||||
Directory.CreateDirectory(SnapshotsRoot);
|
||||
var snapshot = new SnapshotMetadata
|
||||
{
|
||||
SnapshotId = Guid.NewGuid().ToString("N"),
|
||||
SourceVersion = sourceVersion,
|
||||
TargetVersion = targetVersion,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
SourceDirectory = sourceDirectory,
|
||||
TargetDirectory = targetDirectory,
|
||||
Status = "applied"
|
||||
};
|
||||
|
||||
File.WriteAllText(
|
||||
Path.Combine(SnapshotsRoot, $"{snapshot.SnapshotId}.json"),
|
||||
JsonSerializer.Serialize(snapshot, AppJsonContext.Default.SnapshotMetadata));
|
||||
}
|
||||
|
||||
public void WriteStaleInstallCheckpoint(string sourceVersion, string targetVersion)
|
||||
{
|
||||
var checkpoint = new InstallCheckpoint
|
||||
{
|
||||
SnapshotId = Guid.NewGuid().ToString("N"),
|
||||
SourceVersion = sourceVersion,
|
||||
TargetVersion = targetVersion,
|
||||
SourceDirectory = Path.Combine(AppRoot, $"app-{sourceVersion}-0"),
|
||||
TargetDirectory = Path.Combine(AppRoot, $"app-{targetVersion}-999"),
|
||||
IsInitialDeployment = false,
|
||||
AppliedCount = 1,
|
||||
VerifiedCount = 1
|
||||
};
|
||||
|
||||
var checkpointPath = UpdatePaths.GetInstallCheckpointPath(AppRoot);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(checkpointPath)!);
|
||||
File.WriteAllText(checkpointPath, JsonSerializer.Serialize(checkpoint, AppJsonContext.Default.InstallCheckpoint));
|
||||
}
|
||||
|
||||
public void WriteValidPlondsResumeCheckpoint(string sourceVersion, string targetVersion)
|
||||
{
|
||||
var targetDeployment = Path.Combine(AppRoot, $"app-{targetVersion}-0");
|
||||
Directory.CreateDirectory(targetDeployment);
|
||||
File.WriteAllText(Path.Combine(targetDeployment, ".partial"), string.Empty);
|
||||
File.WriteAllText(Path.Combine(targetDeployment, ExecutableName), $"exe-{sourceVersion}");
|
||||
|
||||
var checkpoint = new InstallCheckpoint
|
||||
{
|
||||
SnapshotId = Guid.NewGuid().ToString("N"),
|
||||
SourceVersion = sourceVersion,
|
||||
TargetVersion = targetVersion,
|
||||
SourceDirectory = Path.Combine(AppRoot, $"app-{sourceVersion}-0"),
|
||||
TargetDirectory = targetDeployment,
|
||||
IsInitialDeployment = false,
|
||||
AppliedCount = 1,
|
||||
VerifiedCount = 0
|
||||
};
|
||||
|
||||
var checkpointPath = UpdatePaths.GetInstallCheckpointPath(AppRoot);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(checkpointPath)!);
|
||||
File.WriteAllText(checkpointPath, JsonSerializer.Serialize(checkpoint, AppJsonContext.Default.InstallCheckpoint));
|
||||
}
|
||||
|
||||
public void WriteValidLegacyResumeCheckpoint(string sourceVersion, string targetVersion)
|
||||
{
|
||||
var targetDeployment = Path.Combine(AppRoot, $"app-{targetVersion}-0");
|
||||
Directory.CreateDirectory(targetDeployment);
|
||||
File.WriteAllText(Path.Combine(targetDeployment, ".partial"), string.Empty);
|
||||
|
||||
var checkpoint = new InstallCheckpoint
|
||||
{
|
||||
SnapshotId = Guid.NewGuid().ToString("N"),
|
||||
SourceVersion = sourceVersion,
|
||||
TargetVersion = targetVersion,
|
||||
SourceDirectory = Path.Combine(AppRoot, $"app-{sourceVersion}-0"),
|
||||
TargetDirectory = targetDeployment,
|
||||
IsInitialDeployment = false,
|
||||
AppliedCount = 0,
|
||||
VerifiedCount = 0
|
||||
};
|
||||
|
||||
var checkpointPath = UpdatePaths.GetInstallCheckpointPath(AppRoot);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(checkpointPath)!);
|
||||
File.WriteAllText(checkpointPath, JsonSerializer.Serialize(checkpoint, AppJsonContext.Default.InstallCheckpoint));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_rsa.Dispose();
|
||||
if (Directory.Exists(_root))
|
||||
{
|
||||
Directory.Delete(_root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private void Sign(string payloadPath, string signaturePath)
|
||||
{
|
||||
var signature = _rsa.SignData(File.ReadAllBytes(payloadPath), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
File.WriteAllText(signaturePath, Convert.ToBase64String(signature));
|
||||
}
|
||||
|
||||
private static string Sha256File(string path)
|
||||
{
|
||||
using var stream = File.OpenRead(path);
|
||||
return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string ExecutableName => OperatingSystem.IsWindows()
|
||||
? "LanMountainDesktop.exe"
|
||||
: "LanMountainDesktop";
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PlondsStaticUpdateServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CheckForUpdatesAsync_ReadsStaticLatestDistributionAndBuildsPayloadUrls()
|
||||
{
|
||||
var platform = PlondsStaticUpdateService.ResolveCurrentPlatform();
|
||||
var handler = new StaticManifestHandler(request =>
|
||||
{
|
||||
var path = request.RequestUri?.AbsolutePath ?? string.Empty;
|
||||
if (path.EndsWith($"/meta/channels/stable/{platform}/latest.json", StringComparison.Ordinal))
|
||||
{
|
||||
return Json("""{"distributionId":"dist-1","version":"1.2.0","channel":"stable","platform":"PLATFORM","publishedAt":"2026-05-06T00:00:00Z"}"""
|
||||
.Replace("PLATFORM", platform));
|
||||
}
|
||||
|
||||
if (path.EndsWith("/meta/distributions/dist-1.json", StringComparison.Ordinal))
|
||||
{
|
||||
return Json("""{"distributionId":"dist-1","version":"1.2.0","sourceVersion":"1.0.0","channel":"stable","platform":"PLATFORM","publishedAt":"2026-05-06T00:00:00Z","fileMapUrl":"https://static.example/lanmountain/update/manifests/dist-1/plonds-filemap.json","fileMapSignatureUrl":"https://static.example/lanmountain/update/manifests/dist-1/plonds-filemap.json.sig"}"""
|
||||
.Replace("PLATFORM", platform));
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.NotFound);
|
||||
});
|
||||
|
||||
using var client = new HttpClient(handler);
|
||||
using var service = new PlondsStaticUpdateService("https://static.example/lanmountain/update", client);
|
||||
|
||||
var result = await service.CheckForUpdatesAsync(new Version(1, 0, 0), includePrerelease: false);
|
||||
|
||||
Assert.True(result.Success, result.ErrorMessage);
|
||||
Assert.True(result.IsUpdateAvailable);
|
||||
Assert.Equal("1.2.0", result.LatestVersionText);
|
||||
Assert.NotNull(result.PlondsPayload);
|
||||
Assert.Equal("dist-1", result.PlondsPayload.DistributionId);
|
||||
Assert.Equal(platform, result.PlondsPayload.SubChannel);
|
||||
Assert.Equal("https://static.example/lanmountain/update/manifests/dist-1/plonds-filemap.json", result.PlondsPayload.FileMapJsonUrl);
|
||||
Assert.Equal("https://static.example/lanmountain/update/manifests/dist-1/plonds-filemap.json.sig", result.PlondsPayload.FileMapSignatureUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckForUpdatesAsync_WhenLatestIsMissing_ReturnsFailureForFallback()
|
||||
{
|
||||
using var client = new HttpClient(new StaticManifestHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound)));
|
||||
using var service = new PlondsStaticUpdateService("https://static.example/lanmountain/update", client);
|
||||
|
||||
var result = await service.CheckForUpdatesAsync(new Version(1, 0, 0), includePrerelease: false);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.False(result.IsUpdateAvailable);
|
||||
Assert.Contains("latest manifest", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveCurrentPlatform_UsesCanonicalNames()
|
||||
{
|
||||
var platform = PlondsStaticUpdateService.ResolveCurrentPlatform();
|
||||
|
||||
Assert.DoesNotContain("win-", platform, StringComparison.OrdinalIgnoreCase);
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
Assert.StartsWith("windows-", platform, StringComparison.Ordinal);
|
||||
}
|
||||
else if (OperatingSystem.IsLinux())
|
||||
{
|
||||
Assert.StartsWith("linux-", platform, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
private static HttpResponseMessage Json(string json)
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class StaticManifestHandler(Func<HttpRequestMessage, HttpResponseMessage> responder) : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(responder(request));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class UpdatePathConsistencyTests
|
||||
{
|
||||
[Fact]
|
||||
public void HostAndSharedUpdatePathsUseLauncherDirectoryCasing()
|
||||
{
|
||||
var incoming = UpdatePaths.GetIncomingDirectory("root");
|
||||
var sharedIncoming = UpdatePaths.GetIncomingDirectory("root");
|
||||
|
||||
Assert.Contains($"{Path.DirectorySeparatorChar}.Launcher{Path.DirectorySeparatorChar}", incoming);
|
||||
Assert.Equal(
|
||||
Path.Combine("root", ".Launcher", "update", "incoming"),
|
||||
sharedIncoming);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PlondsApiManifestProviderTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetLatestAsync_MapsCanonicalAndLegacyFileFields()
|
||||
{
|
||||
using var client = new HttpClient(new StaticManifestHandler(request =>
|
||||
{
|
||||
var path = request.RequestUri?.AbsolutePath ?? string.Empty;
|
||||
if (path.EndsWith("/api/plonds/v1/channels/stable/windows-x64/latest", StringComparison.Ordinal))
|
||||
{
|
||||
return Json("""{"distributionId":"dist-2","version":"1.2.0","publishedAt":"2026-05-06T00:00:00Z"}""");
|
||||
}
|
||||
|
||||
if (path.EndsWith("/api/plonds/v1/distributions/dist-2", StringComparison.Ordinal))
|
||||
{
|
||||
return Json("""
|
||||
{
|
||||
"distributionId": "dist-2",
|
||||
"version": "1.2.0",
|
||||
"sourceVersion": "1.1.0",
|
||||
"publishedAt": "2026-05-06T00:00:00Z",
|
||||
"fileMapUrl": "https://static.example/filemap.json",
|
||||
"signatures": [{ "signature": "https://static.example/filemap.json.sig" }],
|
||||
"components": [
|
||||
{
|
||||
"files": [
|
||||
{
|
||||
"path": "LanMountainDesktop.exe",
|
||||
"action": "replace",
|
||||
"sha256": "abc123",
|
||||
"size": 42,
|
||||
"objectUrl": "https://static.example/repo/sha256/ab/abc123",
|
||||
"archiveSha256": "archive123"
|
||||
},
|
||||
{
|
||||
"path": "legacy.dll",
|
||||
"op": "add",
|
||||
"contentHash": "def456",
|
||||
"size": 7
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.NotFound);
|
||||
}));
|
||||
var provider = new PlondsApiManifestProvider("https://static.example", client);
|
||||
|
||||
var manifest = await provider.GetLatestAsync("stable", "windows-x64", new Version(1, 1, 0), CancellationToken.None);
|
||||
|
||||
Assert.NotNull(manifest);
|
||||
Assert.Equal(UpdatePayloadKind.DeltaPlonds, manifest.Kind);
|
||||
Assert.Equal("https://static.example/filemap.json.sig", manifest.FileMapSignatureUrl);
|
||||
Assert.Collection(
|
||||
manifest.Files,
|
||||
first =>
|
||||
{
|
||||
Assert.Equal("replace", first.Action);
|
||||
Assert.Equal("abc123", first.Sha256);
|
||||
Assert.Equal("https://static.example/repo/sha256/ab/abc123", first.ObjectUrl);
|
||||
Assert.Equal("archive123", first.ArchiveSha256);
|
||||
},
|
||||
second =>
|
||||
{
|
||||
Assert.Equal("add", second.Action);
|
||||
Assert.Equal("def456", second.Sha256);
|
||||
});
|
||||
}
|
||||
|
||||
private static HttpResponseMessage Json(string json)
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class StaticManifestHandler(Func<HttpRequestMessage, HttpResponseMessage> responder) : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(responder(request));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user