Files
MoShiro/backend/tests/MoShiro.Backend.Tests/InstallTests.cs
T
justme a2f88a344b
ci/woodpecker/tag/gitea-release Pipeline is pending
ci/woodpecker/tag/linux-release Pipeline failed
ci/woodpecker/tag/windows-release Pipeline was canceled
feat!: genesis-pinned operator set, server identity with an encrypted control link, key-less join codes; release key continuity
Join codes need no passkey touch and carry only public values (genesis
fingerprint, owner credentials, installer hashes). Nodes pin the genesis and
owners at enrollment and only follow passkey-signed operator set versions.
The panel's server key is listed in the set; pinned nodes require its
signature and run the control link under AES-256-GCM. Password sessions carry
no capabilities. The updater adopts a next_key from a signed manifest.

Bump version to 0.2.0. [CI SKIP]
2026-10-08 03:21:44 +08:00

131 lines
7.4 KiB
C#

using System.Net;
using System.Text.Json.Nodes;
namespace MoShiro.Backend.Tests;
/// <summary>
/// 一行安装:Agent 和 Probe 共用同一份<b>静态</b>脚本(所有人拿到的字节都一样,所以哈希固定、可以先核对再执行),
/// 面板地址、角色、一次性代码都走命令里的环境变量;脚本再向面板取 CI 签名的发布清单来验证镜像。
/// </summary>
[Collection("probe-e2e")]
public sealed class InstallTests
{
private readonly BackendFixture _backend;
public InstallTests(BackendFixture backend)
{
_backend = backend;
}
private async Task<JsonNode> CreateAsync(string role)
{
using HttpResponseMessage response = await _backend.Admin.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = role }));
return JsonNode.Parse(await response.Content.ReadAsStringAsync())!;
}
[Theory]
[InlineData("agent")]
[InlineData("probe")]
public async Task Commands_VerifyTheScriptHash_ThenRunItWithThePublicValues(string role)
{
JsonNode created = await CreateAsync(role);
string code = created["plaintext"]!.GetValue<string>();
JsonNode commands = created["install_commands"]!;
string linux = commands["linux"]!.GetValue<string>();
string macos = commands["macos"]!.GetValue<string>();
string windows = commands["windows"]!.GetValue<string>();
Assert.StartsWith("f=$(mktemp) && curl -fsSL '", linux, StringComparison.Ordinal);
Assert.Contains("/install' -o \"$f\" && echo \"", linux, StringComparison.Ordinal);
Assert.Contains("sha256sum -c -", linux, StringComparison.Ordinal);
Assert.Contains($"MOSHIRO_ROLE='{role}'", linux, StringComparison.Ordinal);
Assert.Contains($"MOSHIRO_CODE='{code}'", linux, StringComparison.Ordinal);
Assert.EndsWith("sh \"$f\"", linux, StringComparison.Ordinal);
Assert.Contains("shasum -a 256 -c -", macos, StringComparison.Ordinal);
Assert.StartsWith("$f = Join-Path $env:TEMP", windows, StringComparison.Ordinal);
Assert.Contains($"$env:MOSHIRO_ROLE='{role}'", windows, StringComparison.Ordinal);
Assert.EndsWith("iex (Get-Content -Raw $f)", windows, StringComparison.Ordinal);
Assert.DoesNotContain("<", linux + macos + windows, StringComparison.Ordinal); // 没有要用户自己替换的占位符
}
[Fact]
public async Task TheScripts_AreStatic_TheSameBytesForEveryone_AndNeedNoCode()
{
foreach (string path in new[] { "/install", "/install.sh", "/install.ps1" })
{
using HttpResponseMessage first = await _backend.Anonymous.GetAsync(path);
using HttpResponseMessage second = await _backend.Anonymous.GetAsync(path + "?code=jt_whatever");
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
Assert.Equal("no-store", first.Headers.CacheControl?.ToString());
string body = await first.Content.ReadAsStringAsync();
Assert.Equal(body, await second.Content.ReadAsStringAsync()); // 带不带码、带什么码,内容都一样
Assert.DoesNotContain("@@", body, StringComparison.Ordinal);
Assert.Contains("MOSHIRO_SERVER", body, StringComparison.Ordinal);
Assert.Contains("MOSHIRO_ROLE", body, StringComparison.Ordinal);
Assert.Contains("MOSHIRO_CODE", body, StringComparison.Ordinal);
}
// 同一份字节:sh 的两个路径一致
Assert.Equal(await _backend.Anonymous.GetStringAsync("/install"), await _backend.Anonymous.GetStringAsync("/install.sh"));
}
[Fact]
public async Task TheScripts_CarryTheReleaseKey_ThatTheBackendShips()
{
string key = MoShiro.Backend.Api.InstallScripts.ReleaseKey();
Assert.StartsWith("-----BEGIN PUBLIC KEY-----", key, StringComparison.Ordinal);
foreach (string path in new[] { "/install", "/install.ps1" })
{
string script = (await _backend.Anonymous.GetStringAsync(path)).Replace("\r\n", "\n", StringComparison.Ordinal);
Assert.Contains(key.Replace("\r\n", "\n", StringComparison.Ordinal), script, StringComparison.Ordinal); // 脚本里内置的和面板发布的是同一把
}
}
[Fact]
public async Task TheScript_GetsTheSignedManifestOfItsRole_ByteForByte()
{
string manifest = """{"schema":1,"component":"probe","channel":"dev","image":"registry.example/moshiro-dev/moshiro-probe","digest":"sha256:0000000000000000000000000000000000000000000000000000000000000000","tag":"dev-1","version":"0.1.1","commit":"abc","built_at":"2026-10-06T00:00:00Z","seq":1}""";
using HttpResponseMessage published = await _backend.Admin.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = manifest, ["signature"] = "MEUCIQDc" }));
Assert.True(published.StatusCode is HttpStatusCode.Created or HttpStatusCode.Conflict, await published.Content.ReadAsStringAsync());
string code = (await CreateAsync("probe"))["plaintext"]!.GetValue<string>();
using HttpResponseMessage served = await _backend.Anonymous.GetAsync("/install/manifest?code=" + code);
Assert.Equal(HttpStatusCode.OK, served.StatusCode);
Assert.Equal("no-store", served.Headers.CacheControl?.ToString());
Assert.Contains("\"component\":\"probe\"", await served.Content.ReadAsStringAsync(), StringComparison.Ordinal); // 签名对象是原始字节,面板一个字节也不改
using HttpResponseMessage signature = await _backend.Anonymous.GetAsync("/install/manifest.sig?code=" + code);
Assert.Equal(HttpStatusCode.OK, signature.StatusCode);
Assert.False(string.IsNullOrWhiteSpace(await signature.Content.ReadAsStringAsync()));
}
[Fact]
public async Task TheManifest_IsNotServedForUnknownRevokedOrUsedUpCodes()
{
foreach (string path in new[] { "/install/manifest", "/install/manifest?code=", "/install/manifest?code=jt_nope", "/install/manifest.sig?code=jt_nope" })
{
using HttpResponseMessage response = await _backend.Anonymous.GetAsync(path);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
JsonNode revoked = await CreateAsync("probe");
using HttpResponseMessage delete = await _backend.Admin.DeleteAsync("/api/admin/join-tokens/" + revoked["token"]!["id"]!.GetValue<string>());
Assert.True(delete.IsSuccessStatusCode);
using HttpResponseMessage gone = await _backend.Anonymous.GetAsync("/install/manifest?code=" + revoked["plaintext"]!.GetValue<string>());
Assert.Equal(HttpStatusCode.NotFound, gone.StatusCode);
// 用完了(一次性令牌已经注册过一个节点):脚本在动手之前就能从这里知道,给出明确的提示
JsonNode used = await CreateAsync("probe");
string code = used["plaintext"]!.GetValue<string>();
await using (await MoShiro.TestSupport.AgentSimulator.EnrollAsync(_backend.Anonymous, code, _backend.UniqueName("used"))) { }
using HttpResponseMessage after = await _backend.Anonymous.GetAsync("/install/manifest?code=" + code);
Assert.Equal(HttpStatusCode.NotFound, after.StatusCode);
}
[Fact]
public async Task Codes_RecordWhoCreatedThem()
{
JsonNode created = await CreateAsync("agent");
Assert.StartsWith("key:", created["token"]!["created_by"]!.GetValue<string>(), StringComparison.Ordinal); // Admin 客户端是 API key
}
}