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()); Assert.True(health["database"]!.GetValue()); Assert.True(health["raw_directory"]!.GetValue()); 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()); } [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()); Assert.Equal(401, problem["status"]!.GetValue()); Assert.False(string.IsNullOrEmpty(problem["trace_id"]!.GetValue())); } [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(), JsonNode.Parse(await unknown.Content.ReadAsStringAsync())!["detail"]!.GetValue()); 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(); 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()); 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 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()); 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(); 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()); 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()); 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() == "rtt.p95" && m["unit"]!.GetValue() == "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() == "udp_echo")!; JsonNode rate = udp["params"]!.AsArray().Single(p => p!["name"]!.GetValue() == "rate_hz")!; Assert.Equal(1, rate["min"]!.GetValue()); Assert.Equal(200, rate["max"]!.GetValue()); Assert.Equal(10, rate["default"]!.GetValue()); } [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()); 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()); } [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()); } } [CollectionDefinition("backend")] public sealed class BackendCollection : ICollectionFixture; [CollectionDefinition("probe-e2e")] public sealed class ProbeEndToEndCollection : ICollectionFixture;