节点在线、应用在线、配置在线使用令牌查询
大石头 authored at 2021-12-16 19:49:30
12.60 KiB
Stardust
using System;
using System.Collections.Generic;
using System.Linq;
using NewLife;
using NewLife.Caching;
using Stardust.Data.Nodes;
using Stardust.Models;
using Stardust.Server;
using Stardust.Server.Services;
using Xunit;

namespace ServerTest.Install;

/// <summary>安装引导服务测试。语义:清单优先取后台维护的包、缺失回退公共镜像;
/// 下载地址统一指回本站并在访问时现场签发令牌;安装脚本携带服务端地址且不含会过期的令牌。</summary>
public class InstallServiceTests
{
    private const String BaseUri = "https://star.example.com:6600";

    /// <summary>临时停用其它 StarAgent 发布版本与运行时包,避免共享测试库遗留数据干扰断言。</summary>
    private sealed class CatalogIsolation : IDisposable
    {
        private readonly List<ProductRelease> _releases = [];
        private readonly List<DotNetPackage> _packages = [];

        public CatalogIsolation()
        {
            foreach (var item in ProductRelease.FindAll().Where(e => e.Enable && e.ProductCode.EqualIgnoreCase("StarAgent")).ToList())
            {
                item.Enable = false;
                item.Update();
                _releases.Add(item);
            }

            foreach (var item in DotNetPackage.FindAll().Where(e => e.Enable).ToList())
            {
                item.Enable = false;
                item.Update();
                _packages.Add(item);
            }
        }

        public void Dispose()
        {
            foreach (var item in _packages)
            {
                item.Enable = true;
                item.Update();
            }

            foreach (var item in _releases)
            {
                item.Enable = true;
                item.Update();
            }
        }
    }

    /// <summary>测试用发布版本、安装包与运行时包,结束后清理。</summary>
    private sealed class CatalogScope : IDisposable
    {
        private readonly List<ProductPackage> _packages = [];
        private readonly List<DotNetPackage> _runtimes = [];

        public ProductRelease Release { get; }

        public CatalogScope(String version, Boolean enable = true)
        {
            Release = new ProductRelease
            {
                Version = version,
                ProductCode = "StarAgent",
                Enable = enable,
                Force = false,
                Channel = NodeChannels.Release,
            };
            Release.Insert();
        }

        public ProductPackage AddPackage(OSKind os, CpuArch arch, String runtime = "*", String source = "/cube/file?id=123.zip")
        {
            var pkg = new ProductPackage
            {
                ReleaseId = Release.Id,
                OSKind = os,
                Architecture = arch,
                TargetRuntime = runtime,
                FileName = $"staragent-{Guid.NewGuid():n}.tar.gz",
                Source = source,
                Size = 2048,
                FileHash = "0123456789abcdef0123456789abcdef",
            };
            pkg.Insert();
            _packages.Add(pkg);

            return pkg;
        }

        public DotNetPackage AddRuntime(String version, OSKind os, CpuArch arch, String source = "/cube/file?id=124.tar.gz")
        {
            var pkg = new DotNetPackage
            {
                Version = version,
                Kind = "runtime",
                OSKind = os,
                Architecture = arch,
                FileName = $"dotnet-runtime-{version}-linux.tar.gz",
                Source = source,
                Size = 1024,
                FileHash = "0123456789abcdef0123456789abcdef",
                Enable = true,
                // GetValids 会按通道过滤,必须显式指定 Release(枚举值不为 0)
                Channel = NodeChannels.Release,
            };
            pkg.Insert();
            _runtimes.Add(pkg);

            return pkg;
        }

        public void Dispose()
        {
            foreach (var pkg in _packages) pkg.Delete();
            foreach (var pkg in _runtimes) pkg.Delete();
            Release.Delete();
        }
    }

    private static (InstallService Service, DownloadTokenService Tokens) CreateService()
    {
        // CacheProvider 默认取全局共享缓存,测试内换成独立实例,避免用例之间互相污染清单缓存
        var provider = new CacheProvider { Cache = new MemoryCache(), InnerCache = new MemoryCache() };
        var tokens = new DownloadTokenService(provider);
        return (new InstallService(StarServerSetting.Current, provider, tokens), tokens);
    }

    [Fact]
    [Trait("Category", "Install")]
    public void Manifest_ContainsReleasePackagesAndRuntime()
    {
        using var isolation = new CatalogIsolation();
        using var scope = new CatalogScope("9.9.9999." + Guid.NewGuid().ToString("n")[..8]);

        var agentPkg = scope.AddPackage(OSKind.Linux, CpuArch.Arm64, "10");
        scope.AddRuntime("10.0.9", OSKind.Linux, CpuArch.Arm64);

        var (service, _) = CreateService();
        var manifest = service.GetManifest(BaseUri);

        Assert.Equal(BaseUri, manifest.Server);

        var version = manifest.Versions.FirstOrDefault(e => e.Id == scope.Release.Id);
        Assert.NotNull(version);
        Assert.Equal(scope.Release.Version, version!.Version);

        var package = version.Packages.FirstOrDefault(e => e.Id == agentPkg.Id);
        Assert.NotNull(package);
        Assert.Equal("agent", package!.Kind);
        Assert.Equal("Linux", package.OS);
        Assert.Equal("Arm64", package.Arch);
        Assert.Equal("10", package.Runtime);
        Assert.Equal($"/install/download/agent/{agentPkg.Id}", package.Url);
        Assert.Equal("db", package.Origin);

        Assert.Contains(manifest.Runtimes, e => e.Version == "10.0.9" && e.Kind == "runtime");
    }

    /// <summary>运行时只下发常用组合:win-x64 / linux-x64 / linux-arm64 / linux-arm。</summary>
    [Fact]
    [Trait("Category", "Install")]
    public void Manifest_RuntimesLimitedToCommonTargets()
    {
        using var isolation = new CatalogIsolation();

        var (service, _) = CreateService();
        var manifest = service.GetManifest(BaseUri);

        var targets = manifest.Runtimes.Select(e => e.OS + "/" + e.Arch).Distinct().ToList();
        Assert.All(targets, e => Assert.Contains(e, new[] { "Windows/X64", "Linux/X64", "Linux/Arm64", "Linux/Arm" }));
    }

    /// <summary>后台没有任何安装包时,公共镜像兜底必须仍然可用,保证页面开箱不为空。</summary>
    [Fact]
    [Trait("Category", "Install")]
    public void Manifest_AlwaysContainsMirror()
    {
        using var isolation = new CatalogIsolation();

        var (service, _) = CreateService();
        var manifest = service.GetManifest(BaseUri);

        Assert.NotEmpty(manifest.Mirror);
        Assert.All(manifest.Mirror, e => Assert.Equal("mirror", e.Origin));
        Assert.All(manifest.Mirror, e => Assert.True(e.Url!.StartsWithIgnoreCase("http://", "https://"), e.Url));
        Assert.All(manifest.Mirror, e => Assert.Equal("agent", e.Kind));

        // net6/8/10 是框架依赖包,系统与架构都中立;只有 net4.5 限 Windows
        Assert.All(manifest.Mirror.Where(e => e.Runtime != "4"), e =>
        {
            Assert.Equal("Any", e.OS);
            Assert.Equal("Any", e.Arch);
        });
        Assert.All(manifest.Mirror.Where(e => e.Runtime == "4"), e => Assert.Equal("Windows", e.OS));
    }

    /// <summary>下载地址指向本站下载入口,访问时才签发短时令牌。</summary>
    [Fact]
    [Trait("Category", "Install")]
    public void ResolveDownloadUrl_SignsShortToken()
    {
        using var isolation = new CatalogIsolation();
        using var scope = new CatalogScope("9.9.9999." + Guid.NewGuid().ToString("n")[..8]);
        var pkg = scope.AddPackage(OSKind.Linux, CpuArch.X64, "10", "/cube/file?id=123.zip");

        var (service, tokens) = CreateService();
        var url = service.ResolveDownloadUrl("agent", pkg.Id, BaseUri);

        Assert.NotNull(url);
        Assert.StartsWith($"{BaseUri}/cube/file?id=123.zip", url);
        Assert.Contains("token=", url);

        var token = url!.Split("token=")[1];
        Assert.True(tokens.Valid(123, token));
    }

    /// <summary>停用版本下的安装包不对外开放下载,避免匿名入口被用来枚举附件。</summary>
    [Fact]
    [Trait("Category", "Install")]
    public void ResolveDownloadUrl_DisabledRelease_ReturnsNull()
    {
        using var isolation = new CatalogIsolation();
        using var scope = new CatalogScope("9.9.9999." + Guid.NewGuid().ToString("n")[..8], enable: false);
        var pkg = scope.AddPackage(OSKind.Linux, CpuArch.X64, "10");

        var (service, _) = CreateService();

        Assert.Null(service.ResolveDownloadUrl("agent", pkg.Id, BaseUri));
        Assert.Null(service.ResolveDownloadUrl("agent", 0, BaseUri));
        Assert.Null(service.ResolveDownloadUrl("unknown", pkg.Id, BaseUri));
    }

    [Fact]
    [Trait("Category", "Install")]
    public void BuildScript_ContainsServerAndPackageUrl()
    {
        using var isolation = new CatalogIsolation();
        using var scope = new CatalogScope("9.9.9999." + Guid.NewGuid().ToString("n")[..8]);
        var pkg = scope.AddPackage(OSKind.Linux, CpuArch.X64, "10");
        var runtime = scope.AddRuntime("10.0.9", OSKind.Linux, CpuArch.X64);

        var (service, _) = CreateService();
        var script = service.BuildScript("linux", "x64", "10", null, BaseUri);

        Assert.NotNull(script);
        Assert.Contains($"SERVER=\"{BaseUri}\"", script);
        Assert.Contains($"{BaseUri}/install/download/agent/{pkg.Id}", script);
        Assert.Contains($"{BaseUri}/install/download/runtime/{runtime.Id}", script);
        Assert.Contains("StarAgent.dll -install -server", script);

        // 脚本里不允许出现会过期的签名地址
        Assert.DoesNotContain("token=", script);
    }

    /// <summary>服务端未维护安装包时,脚本退回公共镜像,仍然可安装。</summary>
    [Fact]
    [Trait("Category", "Install")]
    public void BuildScript_FallsBackToMirror()
    {
        using var isolation = new CatalogIsolation();

        var (service, _) = CreateService();
        var script = service.BuildScript("linux", "x64", null, null, BaseUri);

        Assert.NotNull(script);
        Assert.Contains("staragent10.tar.gz", script);
        Assert.Contains("StarAgent.dll -install -server", script);

        // Windows 回退镜像时取 zip,Linux 取 tar.gz
        var win = service.BuildScript("windows", "x64", null, null, BaseUri);
        Assert.NotNull(win);
        Assert.Contains("staragent10.zip", win);
    }

    /// <summary>未指定架构时下发自检脚本,架构由目标机识别,页面无需选择。</summary>
    [Fact]
    [Trait("Category", "Install")]
    public void BuildScript_WithoutArch_ReturnsBootstrap()
    {
        var (service, _) = CreateService();

        var script = service.BuildScript("linux", null, null, null, BaseUri);

        Assert.NotNull(script);
        Assert.Contains("uname -m", script);
        Assert.Contains("install.sh?arch=", script);
        Assert.DoesNotContain("os=linux", script);

        // Windows 自检脚本的 URI 必须加括号,否则 PowerShell 会把 + $arch 当成多余参数丢弃
        var win = service.BuildScript("windows", null, null, null, BaseUri);
        Assert.NotNull(win);
        Assert.Contains("install.ps1?arch=", win);
        Assert.Contains("-Uri ('", win);
    }

    [Fact]
    [Trait("Category", "Install")]
    public void BuildScript_RejectsInvalidServer()
    {
        var (service, _) = CreateService();

        Assert.Throws<ArgumentException>(() => service.BuildScript("linux", "x64", null, null, "https://evil.com'\nrm -rf /"));
        Assert.Throws<ArgumentException>(() => service.BuildScript("linux", "x64", null, null, ""));
    }

    /// <summary>无法识别的系统不生成脚本,避免把错误的包发给客户端。</summary>
    [Fact]
    [Trait("Category", "Install")]
    public void BuildScript_UnknownOS_ReturnsNull()
    {
        var (service, _) = CreateService();

        Assert.Null(service.BuildScript("plan9", "x64", null, null, BaseUri));
    }

    /// <summary>安装页面必须能从资源中读取到,避免根路径返回空。</summary>
    [Fact]
    [Trait("Category", "Install")]
    public void GetPage_ReturnsHtml()
    {
        var (service, _) = CreateService();
        var html = service.GetPage();

        Assert.NotNull(html);
        Assert.Contains("StarAgent", html);

        // 安装命令的平台语义来自 .ps1/.sh,不再附带冗余的 os 参数
        Assert.DoesNotContain("?os=", html);
    }
}