Files
Telegram-Panel/tests/TelegramPanel.Web.Tests/AppSelfUpdateServiceTests.cs
2026-07-23 07:16:44 +08:00

532 lines
19 KiB
C#

using System.Reflection;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.FileProviders;
using TelegramPanel.Web.Services;
using Xunit;
namespace TelegramPanel.Web.Tests;
public sealed class AppSelfUpdateServiceTests
{
[Theory]
[InlineData("auto", "auto")]
[InlineData("IMAGE", "image")]
[InlineData(" binary ", "binary")]
[InlineData("unsupported", "auto")]
[InlineData(null, "auto")]
public void SelfUpdateMode_NormalizesConfiguredValue(string? configured, string expected)
{
Assert.Equal(expected, SelfUpdateOptions.NormalizeMode(configured));
}
[Fact]
public void StartupCoordinator_InstallsEntrypointThenTransitionsPendingToAttempted()
{
var root = CreateTempDirectory();
try
{
var application = Path.Combine(root, "app-current");
Directory.CreateDirectory(application);
var source = Path.Combine(application, "packaged-entrypoint.sh");
var target = Path.Combine(root, "entrypoint.sh");
File.WriteAllText(source, "#!/usr/bin/env sh\necho new\n");
File.WriteAllText(target, "#!/usr/bin/env sh\necho old\n");
File.WriteAllText(
Path.Combine(application, SelfUpdateStartupCoordinator.PendingMarkerFileName),
"{}");
Assert.True(SelfUpdateStartupCoordinator.TryInstallEntrypoint(source, target));
Assert.True(SelfUpdateStartupCoordinator.TryMarkStartupAttempt(application));
Assert.Equal(File.ReadAllText(source), File.ReadAllText(target));
if (!OperatingSystem.IsWindows())
{
Assert.True(
File.GetUnixFileMode(target).HasFlag(UnixFileMode.UserExecute),
"安装后的入口脚本必须保留可执行权限");
}
Assert.False(File.Exists(Path.Combine(application, SelfUpdateStartupCoordinator.PendingMarkerFileName)));
Assert.True(File.Exists(Path.Combine(application, SelfUpdateStartupCoordinator.AttemptedMarkerFileName)));
}
finally
{
TryDeleteDirectory(root);
}
}
[Fact]
public void StartupCoordinator_DoesNotDowngradeNewerContainerEntrypoint()
{
var root = CreateTempDirectory();
try
{
var source = Path.Combine(root, "packaged-entrypoint.sh");
var target = Path.Combine(root, "container-entrypoint.sh");
File.WriteAllText(source, "#!/usr/bin/env sh\nENTRYPOINT_PROTOCOL_VERSION=2\necho packaged\n");
File.WriteAllText(target, "#!/usr/bin/env sh\nENTRYPOINT_PROTOCOL_VERSION=3\necho container\n");
Assert.True(SelfUpdateStartupCoordinator.TryInstallEntrypoint(source, target));
Assert.Contains("echo container", File.ReadAllText(target));
Assert.DoesNotContain("echo packaged", File.ReadAllText(target));
}
finally
{
TryDeleteDirectory(root);
}
}
[Fact]
public void StartupCoordinator_ConfirmsOnlyAfterAttemptAndCleansTransientMarkers()
{
var root = CreateTempDirectory();
try
{
File.WriteAllText(
Path.Combine(root, SelfUpdateStartupCoordinator.AttemptedMarkerFileName),
"{}");
Assert.True(SelfUpdateStartupCoordinator.TryConfirmSuccessfulStartup(root, "1.31.32"));
Assert.True(File.Exists(Path.Combine(root, SelfUpdateStartupCoordinator.ConfirmedMarkerFileName)));
Assert.False(File.Exists(Path.Combine(root, SelfUpdateStartupCoordinator.AttemptedMarkerFileName)));
Assert.False(SelfUpdateStartupCoordinator.TryConfirmSuccessfulStartup(root, "1.31.32"));
}
finally
{
TryDeleteDirectory(root);
}
}
[Fact]
public void StartupCoordinator_MigratesLegacyOnlyMarkerToAttempted()
{
var root = CreateTempDirectory();
try
{
var legacyPath = Path.Combine(root, SelfUpdateStartupCoordinator.LegacyMarkerFileName);
File.WriteAllText(legacyPath, "{\"tag\":\"v1.31.32\"}");
Assert.True(SelfUpdateStartupCoordinator.TryMarkStartupAttempt(root));
Assert.True(File.Exists(legacyPath));
Assert.True(File.Exists(Path.Combine(root, SelfUpdateStartupCoordinator.AttemptedMarkerFileName)));
}
finally
{
TryDeleteDirectory(root);
}
}
[Fact]
public void WriteSelfUpdateMarkers_ResetsOldStateAndCreatesPendingAndLegacyMarkers()
{
var root = CreateTempDirectory();
try
{
File.WriteAllText(Path.Combine(root, SelfUpdateStartupCoordinator.AttemptedMarkerFileName), "old");
File.WriteAllText(Path.Combine(root, SelfUpdateStartupCoordinator.ConfirmedMarkerFileName), "old");
var method = typeof(AppSelfUpdateService).GetMethod(
"WriteSelfUpdateMarkers",
BindingFlags.NonPublic | BindingFlags.Static)
?? throw new InvalidOperationException("未找到自更新状态写入方法");
method.Invoke(null, new object[]
{
root,
new AppSelfUpdateInfo
{
LatestVersion = "1.31.32",
LatestTag = "v1.31.32",
AssetName = "telegram-panel-v1.31.32-linux-x64.zip"
}
});
Assert.True(File.Exists(Path.Combine(root, SelfUpdateStartupCoordinator.PendingMarkerFileName)));
Assert.True(File.Exists(Path.Combine(root, SelfUpdateStartupCoordinator.LegacyMarkerFileName)));
Assert.False(File.Exists(Path.Combine(root, SelfUpdateStartupCoordinator.AttemptedMarkerFileName)));
Assert.False(File.Exists(Path.Combine(root, SelfUpdateStartupCoordinator.ConfirmedMarkerFileName)));
}
finally
{
TryDeleteDirectory(root);
}
}
[Fact]
public void PromoteCurrentDirectory_PreservesExistingBackupAsArchive()
{
var root = CreateTempDirectory();
try
{
var stage = CreateVersionDirectory(root, "stage", "new");
var current = CreateVersionDirectory(root, "app-current", "current");
var backup = CreateVersionDirectory(root, "app-previous", "previous");
InvokePromote(stage, current, backup);
Assert.Equal("new", ReadVersion(current));
Assert.Equal("current", ReadVersion(backup));
var archived = Assert.Single(Directory.GetDirectories(root, "app-previous-*")
.Where(path => !string.Equals(path, backup, StringComparison.OrdinalIgnoreCase)));
Assert.Equal("previous", ReadVersion(archived));
}
finally
{
TryDeleteDirectory(root);
}
}
[Fact]
public void PromoteCurrentDirectory_RestoresCurrentAndBackupWhenStageMoveFails()
{
var root = CreateTempDirectory();
try
{
var missingStage = Path.Combine(root, "missing-stage");
var current = CreateVersionDirectory(root, "app-current", "current");
var backup = CreateVersionDirectory(root, "app-previous", "previous");
Assert.Throws<TargetInvocationException>(() => InvokePromote(missingStage, current, backup));
Assert.Equal("current", ReadVersion(current));
Assert.Equal("previous", ReadVersion(backup));
}
finally
{
TryDeleteDirectory(root);
}
}
[Fact]
public void FindUnsafeStoragePaths_AllPersistentPathsOutsideRotation_ReturnsEmpty()
{
var root = CreateTempDirectory();
try
{
var fixture = CreateStorageSafetyFixture(root);
var result = FindUnsafeStoragePaths(fixture);
Assert.Empty(result);
}
finally
{
TryDeleteDirectory(root);
}
}
[Theory]
[InlineData("local-config", "本地配置")]
[InlineData("data-protection", "DataProtection 密钥")]
[InlineData("modules", "Modules")]
[InlineData("uploads", "Uploads")]
public void FindUnsafeStoragePaths_RejectsAdditionalPersistentPathUnderCurrent(
string pathKind,
string expectedName)
{
var root = CreateTempDirectory();
try
{
var fixture = CreateStorageSafetyFixture(root);
switch (pathKind)
{
case "local-config":
fixture.Configuration["LocalConfig:Path"] = Path.Combine(fixture.CurrentDir, "local.json");
break;
case "data-protection":
fixture.Configuration["DataProtection:KeysPath"] = Path.Combine(fixture.CurrentDir, "keys");
break;
case "modules":
fixture.Configuration["Modules:RootPath"] = Path.Combine(fixture.CurrentDir, "modules");
break;
case "uploads":
fixture.Configuration["Storage:RootPath"] = Path.Combine(fixture.CurrentDir, "storage");
break;
default:
throw new InvalidOperationException($"未知测试路径类型:{pathKind}");
}
var result = FindUnsafeStoragePaths(fixture);
Assert.Contains(result, item => item.StartsWith($"{expectedName}=", StringComparison.Ordinal));
}
finally
{
TryDeleteDirectory(root);
}
}
[Fact]
public void FindUnsafeStoragePaths_RejectsExternalLinkPointingIntoCurrent()
{
var root = CreateTempDirectory();
try
{
var fixture = CreateStorageSafetyFixture(root);
var target = Path.Combine(fixture.CurrentDir, "linked-config");
var link = Path.Combine(root, "persistent-link");
Directory.CreateDirectory(target);
Directory.CreateSymbolicLink(link, target);
fixture.Configuration["LocalConfig:Path"] = Path.Combine(link, "settings.json");
var result = FindUnsafeStoragePaths(fixture);
Assert.Contains(result, item =>
item.StartsWith("本地配置=", StringComparison.Ordinal)
&& item.Contains("真实路径=", StringComparison.Ordinal));
}
finally
{
TryDeleteDirectory(root);
}
}
[Fact]
public void FindUnsafeStoragePaths_DanglingLinkFailsClosed()
{
var root = CreateTempDirectory();
try
{
var fixture = CreateStorageSafetyFixture(root);
var link = Path.Combine(root, "dangling-link");
Directory.CreateSymbolicLink(link, Path.Combine(root, "missing-target"));
fixture.Configuration["LocalConfig:Path"] = Path.Combine(link, "settings.json");
var result = FindUnsafeStoragePaths(fixture);
Assert.Contains(result, item =>
item.StartsWith("本地配置路径无法安全解析", StringComparison.Ordinal));
}
finally
{
TryDeleteDirectory(root);
}
}
[Theory]
[InlineData("../outside")]
[InlineData("nested/child")]
[InlineData(@"nested\child")]
[InlineData(".")]
[InlineData("..")]
public void ResolveUpdateDirectories_RejectsNonChildDirectoryName(string configuredName)
{
var root = CreateTempDirectory();
try
{
var options = new SelfUpdateOptions
{
WorkDirectoryName = configuredName
};
var exception = Assert.Throws<InvalidOperationException>(
() => AppSelfUpdateService.ResolveUpdateDirectories(root, options));
Assert.Contains(
nameof(SelfUpdateOptions.WorkDirectoryName),
exception.Message,
StringComparison.Ordinal);
}
finally
{
TryDeleteDirectory(root);
}
}
[Fact]
public void ResolveUpdateDirectories_RejectsAbsoluteChildDirectory()
{
var root = CreateTempDirectory();
try
{
var options = new SelfUpdateOptions
{
CurrentDirectoryName = Path.GetFullPath(
Path.Combine(root, "..", $"outside-{Guid.NewGuid():N}"))
};
Assert.Throws<InvalidOperationException>(
() => AppSelfUpdateService.ResolveUpdateDirectories(root, options));
}
finally
{
TryDeleteDirectory(root);
}
}
[Fact]
public void ResolveUpdateDirectories_RejectsDuplicateDirectories()
{
var root = CreateTempDirectory();
try
{
var options = new SelfUpdateOptions
{
WorkDirectoryName = "same",
CurrentDirectoryName = "same"
};
var exception = Assert.Throws<InvalidOperationException>(
() => AppSelfUpdateService.ResolveUpdateDirectories(root, options));
Assert.Contains("必须互不相同", exception.Message, StringComparison.Ordinal);
}
finally
{
TryDeleteDirectory(root);
}
}
[Fact]
public void ResolveUpdateDirectories_RejectsChildLinkPointingOutsideWorkRoot()
{
var root = CreateTempDirectory();
try
{
var workRoot = Path.Combine(root, "work");
var outside = Path.Combine(root, "outside");
Directory.CreateDirectory(workRoot);
Directory.CreateDirectory(outside);
Directory.CreateSymbolicLink(
Path.Combine(workRoot, "self-update"),
outside);
var exception = Assert.Throws<InvalidOperationException>(
() => AppSelfUpdateService.ResolveUpdateDirectories(
workRoot,
new SelfUpdateOptions()));
Assert.Contains("真实路径超出", exception.Message, StringComparison.Ordinal);
}
finally
{
TryDeleteDirectory(root);
}
}
[Fact]
public void ResolveUpdateDirectories_DefaultsStayInsideWorkRoot()
{
var root = CreateTempDirectory();
try
{
var (workspace, current, backup) =
AppSelfUpdateService.ResolveUpdateDirectories(
root,
new SelfUpdateOptions());
Assert.Equal(Path.Combine(root, "self-update"), workspace);
Assert.Equal(Path.Combine(root, "app-current"), current);
Assert.Equal(Path.Combine(root, "app-previous"), backup);
}
finally
{
TryDeleteDirectory(root);
}
}
private static void InvokePromote(string stage, string current, string backup)
{
var method = typeof(AppSelfUpdateService).GetMethod(
"PromoteCurrentDirectory",
BindingFlags.NonPublic | BindingFlags.Static)
?? throw new InvalidOperationException("未找到自更新目录切换方法");
method.Invoke(null, new object[] { stage, current, backup });
}
private static List<string> FindUnsafeStoragePaths(StorageSafetyFixture fixture) =>
AppSelfUpdateService.FindUnsafeStoragePaths(
fixture.Configuration,
fixture.Environment,
fixture.CurrentDir,
fixture.BackupDir,
fixture.WorkspaceDir);
private static StorageSafetyFixture CreateStorageSafetyFixture(string root)
{
var currentDir = Path.Combine(root, "app-current");
var persistentDir = Path.Combine(root, "persistent");
Directory.CreateDirectory(currentDir);
Directory.CreateDirectory(persistentDir);
var configuration = new ConfigurationManager();
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["Storage:RootPath"] = persistentDir,
["ConnectionStrings:DefaultConnection"] = $"Data Source={Path.Combine(persistentDir, "telegram-panel.db")}",
["AdminAuth:CredentialsPath"] = Path.Combine(persistentDir, "admin_auth.json"),
["Telegram:SessionsPath"] = Path.Combine(persistentDir, "sessions"),
["LocalConfig:Path"] = Path.Combine(persistentDir, "appsettings.local.json"),
["DataProtection:KeysPath"] = Path.Combine(persistentDir, "keys"),
["Modules:RootPath"] = Path.Combine(persistentDir, "modules")
});
var environment = new StubWebHostEnvironment(currentDir)
{
WebRootPath = Path.Combine(currentDir, "wwwroot")
};
return new StorageSafetyFixture(
configuration,
environment,
currentDir,
Path.Combine(root, "app-previous"),
Path.Combine(root, "self-update"));
}
private static string CreateVersionDirectory(string root, string name, string version)
{
var path = Path.Combine(root, name);
Directory.CreateDirectory(path);
File.WriteAllText(Path.Combine(path, "version.txt"), version);
return path;
}
private static string ReadVersion(string path) =>
File.ReadAllText(Path.Combine(path, "version.txt"));
private static string CreateTempDirectory()
{
var path = Path.Combine(Path.GetTempPath(), "telegram-panel-update-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(path);
return path;
}
private static void TryDeleteDirectory(string path)
{
try
{
if (Directory.Exists(path))
Directory.Delete(path, recursive: true);
}
catch
{
// 测试清理失败不应掩盖断言结果。
}
}
private sealed record StorageSafetyFixture(
ConfigurationManager Configuration,
StubWebHostEnvironment Environment,
string CurrentDir,
string BackupDir,
string WorkspaceDir);
private sealed class StubWebHostEnvironment : IWebHostEnvironment
{
public StubWebHostEnvironment(string contentRootPath)
{
ContentRootPath = contentRootPath;
ContentRootFileProvider = new PhysicalFileProvider(contentRootPath);
}
public string ApplicationName { get; set; } = "TelegramPanel.Web.Tests";
public string EnvironmentName { get; set; } = "Test";
public string WebRootPath { get; set; } = string.Empty;
public IFileProvider WebRootFileProvider { get; set; } = new NullFileProvider();
public string ContentRootPath { get; set; }
public IFileProvider ContentRootFileProvider { get; set; }
}
}