Files
MoShiro/backend/tests/Monitor.Backend.Tests/ApiBasicsTests.cs
T

205 lines
11 KiB
C#

using System.Net;
using System.Net.Http.Headers;
using System.Text.Json.Nodes;
namespace Monitor.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 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("monitor_agents_online", text);
Assert.Contains("monitor_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>;