239 lines
14 KiB
C#
239 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()
|
||
{
|
||
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>;
|