Namespaces, assemblies, projects and directories, the solution, binaries, images, systemd units, config paths, environment variables, protocol method names, the media type and the raw chunk magic all move to MoShiro/moshiro. Existing data and deployments are not compatible and must be recreated. Also fix the architecture tests after the solution layout change and make the update tests independent of their order.
238 lines
14 KiB
C#
238 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", "mon_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_AreNotShippedYet()
|
|
{
|
|
using HttpResponseMessage response = await _backend.Anonymous.GetAsync("/install.sh");
|
|
Assert.Equal(HttpStatusCode.NotImplemented, response.StatusCode);
|
|
Assert.Equal("not_implemented", 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>;
|