Files
MoShiro/backend/tests/MoShiro.Backend.Tests/ApiBasicsTests.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

235 lines
14 KiB
C#

using System.Net;
using System.Net.Http.Headers;
using System.Text.Json.Nodes;
namespace MoShiro.Backend.Tests;
[Collection("backend")]
public sealed class ApiBasicsTests
{
private readonly BackendFixture _backend;
public ApiBasicsTests(BackendFixture backend)
{
_backend = backend;
}
[Fact]
public async Task Healthz_ReportsStorageHealth_AndOpenApiIsServed()
{
JsonNode health = await _backend.GetJsonAsync(_backend.Anonymous, "/healthz");
Assert.Equal("ok", health["status"]!.GetValue<string>());
Assert.True(health["database"]!.GetValue<bool>());
Assert.True(health["raw_directory"]!.GetValue<bool>());
JsonNode openapi = await _backend.GetJsonAsync(_backend.Anonymous, "/openapi/v1.json");
Assert.NotNull(openapi["paths"]!["/api/public/overview"]);
Assert.Equal("overview_get", openapi["paths"]!["/api/public/overview"]!["get"]!["operationId"]!.GetValue<string>());
}
[Fact]
public async Task Admin_WithoutCredentials_Returns401ProblemJson_WithCodeAndTraceId()
{
using HttpResponseMessage response = await _backend.Anonymous.GetAsync("/api/admin/agents");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
Assert.Equal("application/problem+json", response.Content.Headers.ContentType!.MediaType);
JsonNode problem = JsonNode.Parse(await response.Content.ReadAsStringAsync())!;
Assert.Equal("unauthorized", problem["code"]!.GetValue<string>());
Assert.Equal(401, problem["status"]!.GetValue<int>());
Assert.False(string.IsNullOrEmpty(problem["trace_id"]!.GetValue<string>()));
}
[Fact]
public async Task Login_WrongPassword_IsGeneric401_AndSuccessSetsAHardenedCookie()
{
using HttpClient client = _backend.NewClient();
using HttpResponseMessage wrong = await client.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = "admin", ["password"] = "nope" }));
using HttpResponseMessage unknown = await client.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = "nobody", ["password"] = "nope" }));
Assert.Equal(HttpStatusCode.Unauthorized, wrong.StatusCode);
Assert.Equal(
JsonNode.Parse(await wrong.Content.ReadAsStringAsync())!["detail"]!.GetValue<string>(),
JsonNode.Parse(await unknown.Content.ReadAsStringAsync())!["detail"]!.GetValue<string>());
using HttpResponseMessage ok = await client.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = BackendFixture.AdminUser, ["password"] = BackendFixture.AdminPassword }));
Assert.Equal(HttpStatusCode.OK, ok.StatusCode);
string cookie = ok.Headers.GetValues("Set-Cookie").Single();
Assert.Contains("httponly", cookie, StringComparison.OrdinalIgnoreCase);
Assert.Contains("samesite=strict", cookie, StringComparison.OrdinalIgnoreCase);
Assert.Equal("no-store", ok.Headers.CacheControl!.ToString());
}
[Fact]
public async Task Login_WithReturnSession_GivesABearerSession_WithoutACookie_AndNoCsrfNeeded()
{
// 密码登录的会话没有任何能力(只能注册 passkey);有能力的会话来自 passkey 登录
(string username, SoftwarePasskey passkey) = await _backend.CreateUserWithPasskeyAsync();
using SoftwarePasskey passkeyScope = passkey;
using HttpClient client = _backend.NewClient();
using HttpResponseMessage login = await _backend.PasskeyLoginAsync(client, passkey);
Assert.Equal(HttpStatusCode.OK, login.StatusCode);
Assert.False(login.Headers.Contains("Set-Cookie"));
string session = JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue<string>();
Assert.False(string.IsNullOrEmpty(session));
// 之后当 API 令牌那样用 Bearer;不是 Cookie,所以不需要 CSRF,也能做非 GET 操作
using var me = new HttpRequestMessage(HttpMethod.Get, "/api/admin/auth/me");
me.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session);
using HttpResponseMessage meResponse = await client.SendAsync(me);
Assert.Equal(HttpStatusCode.OK, meResponse.StatusCode);
Assert.Equal(username, JsonNode.Parse(await meResponse.Content.ReadAsStringAsync())!["username"]!.GetValue<string>());
using var create = new HttpRequestMessage(HttpMethod.Post, "/api/admin/api-tokens") { Content = BackendFixture.Json(new JsonObject { ["name"] = "session-write", ["capabilities"] = new JsonArray("fleet.read") }) };
create.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session);
using HttpResponseMessage created = await client.SendAsync(create);
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
// 退出之后这个会话令牌就失效
using var logout = new HttpRequestMessage(HttpMethod.Post, "/api/admin/auth/logout");
logout.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session);
using HttpResponseMessage loggedOut = await client.SendAsync(logout);
Assert.Equal(HttpStatusCode.NoContent, loggedOut.StatusCode);
using var after = new HttpRequestMessage(HttpMethod.Get, "/api/admin/auth/me");
after.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session);
using HttpResponseMessage afterResponse = await client.SendAsync(after);
Assert.Equal(HttpStatusCode.Unauthorized, afterResponse.StatusCode);
}
[Fact]
public async Task SessionCookie_RequiresCsrfHeaderOnUnsafeMethods()
{
(_, SoftwarePasskey passkey) = await _backend.CreateUserWithPasskeyAsync();
using SoftwarePasskey passkeyScope = passkey;
using HttpClient client = _backend.NewClient();
using HttpResponseMessage login = await _backend.PasskeyLoginAsync(client, passkey, returnSession: false);
string csrf = JsonNode.Parse(await login.Content.ReadAsStringAsync())!["csrf_token"]!.GetValue<string>();
string cookie = login.Headers.GetValues("Set-Cookie").Single().Split(';')[0];
using var get = new HttpRequestMessage(HttpMethod.Get, "/api/admin/auth/me");
get.Headers.Add("Cookie", cookie);
using HttpResponseMessage me = await client.SendAsync(get);
Assert.Equal(HttpStatusCode.OK, me.StatusCode);
using var withoutCsrf = new HttpRequestMessage(HttpMethod.Post, "/api/admin/api-tokens") { Content = BackendFixture.Json(new JsonObject { ["name"] = "session-write", ["capabilities"] = new JsonArray("fleet.read") }) };
withoutCsrf.Headers.Add("Cookie", cookie);
using HttpResponseMessage rejected = await client.SendAsync(withoutCsrf);
Assert.Equal(HttpStatusCode.Unauthorized, rejected.StatusCode);
Assert.Equal("csrf_failed", JsonNode.Parse(await rejected.Content.ReadAsStringAsync())!["code"]!.GetValue<string>());
using var wrongCsrf = new HttpRequestMessage(HttpMethod.Post, "/api/admin/api-tokens") { Content = BackendFixture.Json(new JsonObject { ["name"] = "session-write", ["capabilities"] = new JsonArray("fleet.read") }) };
wrongCsrf.Headers.Add("Cookie", cookie);
wrongCsrf.Headers.Add("X-CSRF-Token", csrf + "x");
using HttpResponseMessage wrong = await client.SendAsync(wrongCsrf);
Assert.Equal(HttpStatusCode.Unauthorized, wrong.StatusCode);
using var good = new HttpRequestMessage(HttpMethod.Post, "/api/admin/api-tokens") { Content = BackendFixture.Json(new JsonObject { ["name"] = "session-write", ["capabilities"] = new JsonArray("fleet.read") }) };
good.Headers.Add("Cookie", cookie);
good.Headers.Add("X-CSRF-Token", csrf);
using HttpResponseMessage created = await client.SendAsync(good);
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
}
[Fact]
public async Task PublicReadToken_CanReadThePublicApiButNothingUnderAdmin()
{
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = "viewer", ["capabilities"] = BackendFixture.Capabilities("public.read") }));
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
Assert.Equal("no-store", created.Headers.CacheControl!.ToString());
string token = JsonNode.Parse(await created.Content.ReadAsStringAsync())!["plaintext"]!.GetValue<string>();
using HttpClient reader = _backend.NewClient();
reader.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
using HttpResponseMessage manage = await reader.GetAsync("/api/admin/agents");
Assert.Equal(HttpStatusCode.Forbidden, manage.StatusCode);
using HttpResponseMessage read = await reader.GetAsync("/api/public/catalog/kinds");
Assert.Equal(HttpStatusCode.OK, read.StatusCode);
using HttpClient bogus = _backend.NewClient();
bogus.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "mos_bogus");
using HttpResponseMessage unknown = await bogus.GetAsync("/api/admin/agents");
Assert.Equal(HttpStatusCode.Unauthorized, unknown.StatusCode);
}
[Fact]
public async Task PrivateSite_HidesThePublicApiFromAnonymousVisitors_UntilMadePublic()
{
using HttpResponseMessage makePrivate = await _backend.Admin.PatchAsync("/api/admin/settings", BackendFixture.Json(new JsonObject { ["site.public"] = false }, "application/merge-patch+json"));
Assert.Equal(HttpStatusCode.OK, makePrivate.StatusCode);
using HttpResponseMessage hidden = await _backend.Anonymous.GetAsync("/api/public/overview");
Assert.Equal(HttpStatusCode.Forbidden, hidden.StatusCode);
Assert.Equal("site_private", JsonNode.Parse(await hidden.Content.ReadAsStringAsync())!["code"]!.GetValue<string>());
using HttpResponseMessage authed = await _backend.Admin.GetAsync("/api/public/overview");
Assert.Equal(HttpStatusCode.OK, authed.StatusCode);
await _backend.SiteMustBePublicAsync();
using HttpResponseMessage visible = await _backend.Anonymous.GetAsync("/api/public/overview");
Assert.Equal(HttpStatusCode.OK, visible.StatusCode);
}
[Fact]
public async Task Settings_RejectUnknownKeysAndOutOfRangeValues()
{
using HttpResponseMessage response = await _backend.Admin.PatchAsync(
"/api/admin/settings", BackendFixture.Json(new JsonObject { ["retention.raw_days"] = 0, ["nope"] = 1 }, "application/merge-patch+json"));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
JsonNode problem = JsonNode.Parse(await response.Content.ReadAsStringAsync())!;
Assert.Equal("validation_failed", problem["code"]!.GetValue<string>());
Assert.NotNull(problem["errors"]!["retention.raw_days"]);
Assert.NotNull(problem["errors"]!["nope"]);
}
[Fact]
public async Task Catalog_IsGeneratedFromTheRegistries_WithCachingHeaders()
{
await _backend.SiteMustBePublicAsync();
using HttpResponseMessage response = await _backend.Anonymous.GetAsync("/api/public/catalog/metrics");
Assert.Equal("public, max-age=300", response.Headers.CacheControl!.ToString());
string etag = response.Headers.ETag!.Tag;
JsonNode metrics = JsonNode.Parse(await response.Content.ReadAsStringAsync())!;
Assert.Contains(metrics["items"]!.AsArray(), m => m!["id"]!.GetValue<string>() == "rtt.p95" && m["unit"]!.GetValue<string>() == "ms");
using var conditional = new HttpRequestMessage(HttpMethod.Get, "/api/public/catalog/metrics");
conditional.Headers.IfNoneMatch.Add(new EntityTagHeaderValue(etag));
using HttpResponseMessage notModified = await _backend.Anonymous.SendAsync(conditional);
Assert.Equal(HttpStatusCode.NotModified, notModified.StatusCode);
JsonNode kinds = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/catalog/kinds");
JsonNode udp = kinds["items"]!.AsArray().Single(k => k!["id"]!.GetValue<string>() == "udp_echo")!;
JsonNode rate = udp["params"]!.AsArray().Single(p => p!["name"]!.GetValue<string>() == "rate_hz")!;
Assert.Equal(1, rate["min"]!.GetValue<int>());
Assert.Equal(200, rate["max"]!.GetValue<int>());
Assert.Equal(10, rate["default"]!.GetValue<int>());
}
[Fact]
public async Task UnknownRoutesAndBadBodies_AreProblemJsonWithCodes()
{
using HttpResponseMessage notFound = await _backend.Admin.GetAsync("/api/admin/nope");
Assert.Equal(HttpStatusCode.NotFound, notFound.StatusCode);
Assert.Equal("not_found", JsonNode.Parse(await notFound.Content.ReadAsStringAsync())!["code"]!.GetValue<string>());
using HttpResponseMessage badBody = await _backend.Admin.PostAsync("/api/admin/tasks", new StringContent("{not json", System.Text.Encoding.UTF8, "application/json"));
Assert.Equal(HttpStatusCode.BadRequest, badBody.StatusCode);
Assert.Equal("validation_failed", JsonNode.Parse(await badBody.Content.ReadAsStringAsync())!["code"]!.GetValue<string>());
}
[Fact]
public async Task Metrics_RequireAnAdminToken_AndExposePrometheusText()
{
using HttpResponseMessage denied = await _backend.Anonymous.GetAsync("/metrics");
Assert.Equal(HttpStatusCode.Unauthorized, denied.StatusCode);
using HttpResponseMessage allowed = await _backend.Admin.GetAsync("/metrics");
string text = await allowed.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.OK, allowed.StatusCode);
Assert.Contains("moshiro_agents_online", text);
Assert.Contains("moshiro_analysis_queue_length", text);
}
}
[CollectionDefinition("backend")]
public sealed class BackendCollection : ICollectionFixture<BackendFixture>;
[CollectionDefinition("probe-e2e")]
public sealed class ProbeEndToEndCollection : ICollectionFixture<BackendFixture>;