Files
MoShiro/backend/tests/MoShiro.Backend.Tests/ApiBasicsTests.cs
T
2026-10-07 20:02:38 +08:00

239 lines
14 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
{
using HttpClient client = _backend.NewClient();
using HttpResponseMessage login = await client.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = BackendFixture.AdminUser, ["password"] = BackendFixture.AdminPassword, ["return_session"] = true }));
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(BackendFixture.AdminUser, JsonNode.Parse(await meResponse.Content.ReadAsStringAsync())!["username"]!.GetValue<string>());
using var create = new HttpRequestMessage(HttpMethod.Post, "/api/admin/join-tokens") { Content = BackendFixture.Json(new JsonObject()) };
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()
{
using HttpClient client = _backend.NewClient();
using HttpResponseMessage login = await client.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = BackendFixture.AdminUser, ["password"] = BackendFixture.AdminPassword }));
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/join-tokens") { Content = BackendFixture.Json(new JsonObject()) };
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/join-tokens") { Content = BackendFixture.Json(new JsonObject()) };
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/join-tokens") { Content = BackendFixture.Json(new JsonObject()) };
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 ReadScopedToken_CanReadButNotManage()
{
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = "viewer", ["scope"] = "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);
}
[Fact]
public async Task InstallScripts_RequireAValidCode()
{
// 一行安装脚本凭一次性代码获取;没有代码或代码不能用是 404(完整行为见 InstallTests)
using HttpResponseMessage response = await _backend.Anonymous.GetAsync("/install.sh");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
Assert.Equal("not_found", JsonNode.Parse(await response.Content.ReadAsStringAsync())!["code"]!.GetValue<string>());
}
}
[CollectionDefinition("backend")]
public sealed class BackendCollection : ICollectionFixture<BackendFixture>;
[CollectionDefinition("probe-e2e")]
public sealed class ProbeEndToEndCollection : ICollectionFixture<BackendFixture>;