From 666614a3b211aba954fefe0c26f18728b316e45f Mon Sep 17 00:00:00 2001 From: tleininger Date: Wed, 30 Sep 2026 21:07:40 +0200 Subject: [PATCH] Smoke-test /health for the reachable (200) and unreachable (503) cases --- Elternbeirat.PocketBase/PocketBaseClient.cs | 37 +++++++++++++++ Elternbeirat.Web.Tests/RouteSmokeTests.cs | 31 +++++++++++- Elternbeirat.Web/Program.cs | 52 ++++++++++++--------- compose.yaml | 18 ++++--- 4 files changed, 110 insertions(+), 28 deletions(-) diff --git a/Elternbeirat.PocketBase/PocketBaseClient.cs b/Elternbeirat.PocketBase/PocketBaseClient.cs index 7b13dac..dd72ccd 100644 --- a/Elternbeirat.PocketBase/PocketBaseClient.cs +++ b/Elternbeirat.PocketBase/PocketBaseClient.cs @@ -108,6 +108,43 @@ public sealed class PocketBaseClient(HttpClient httpClient) public Task> GetFaqTopicsAsync(CancellationToken token = default) => GetRecordsAsync("faq_topics", "order", token, expand: "faqs_via_topic"); + /// + /// Checks whether PocketBase answers its health endpoint. + /// + /// + /// Backs the app's own /health endpoint, which an external monitor (Uptime + /// Kuma) polls: the app answering at all proves the app is up, and this probe adds + /// whether the content source behind it is reachable, so one monitor covers both. + /// This calls GET /api/health, PocketBase's own liveness endpoint, which + /// needs no auth and touches no collection. + /// + /// + /// A token to cancel the probe. + /// + /// + /// if PocketBase answered with a success status within the + /// client timeout; if it was unreachable, timed out, or + /// answered with an error status. + /// + public async Task IsHealthyAsync(CancellationToken token = default) + { + try + { + using var response = await httpClient.GetAsync( + new Uri("/api/health", UriKind.Relative), token); + return response.IsSuccessStatusCode; + } + // Same failure shapes as a record read: unreachable (HttpRequestException) or the + // client's own timeout (a TaskCanceledException wrapping a TimeoutException). A + // plain TaskCanceledException is the caller's cancellation and propagates. + catch (Exception exception) when ( + exception is HttpRequestException + or TaskCanceledException { InnerException: TimeoutException }) + { + return false; + } + } + /// /// Gets all public records of a collection in a single request. /// diff --git a/Elternbeirat.Web.Tests/RouteSmokeTests.cs b/Elternbeirat.Web.Tests/RouteSmokeTests.cs index c52c5f7..152c80b 100644 --- a/Elternbeirat.Web.Tests/RouteSmokeTests.cs +++ b/Elternbeirat.Web.Tests/RouteSmokeTests.cs @@ -1,5 +1,4 @@ using System.Net; -using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; namespace Elternbeirat.Web.Tests; @@ -140,4 +139,34 @@ public sealed class RouteSmokeTests : IDisposable html.ShouldContain("Förderverein"); // the page body, rendered from Markdown } + + [Fact] + public async Task Health_returns_200_when_PocketBase_is_reachable() + { + // The monitor's happy path: the app answers and PocketBase (the seeded + // fixture) is up, so /health is 200. This mostly guards the wiring -- that the + // endpoint exists and reaches the client -- since the fixture keeps PocketBase + // alive; the unreachable case is covered separately below. + var client = _factory.CreateClient(); + + var response = await client.GetAsync(new Uri("/health", UriKind.Relative)); + + response.StatusCode.ShouldBe(HttpStatusCode.OK); + } + + [Fact] + public async Task Health_returns_503_when_PocketBase_is_unreachable() + { + // The case the monitor exists for: the app is up but PocketBase is not. Point a + // throwaway app at a dead address (nothing listens on port 1, so the probe is + // refused at once, no timeout wait) and assert /health reports 503 rather than + // claiming healthy. + await using var factory = _baseFactory.WithWebHostBuilder(builder => + builder.UseSetting("PocketBase:BaseUrl", "http://localhost:1")); + var client = factory.CreateClient(); + + var response = await client.GetAsync(new Uri("/health", UriKind.Relative)); + + response.StatusCode.ShouldBe(HttpStatusCode.ServiceUnavailable); + } } diff --git a/Elternbeirat.Web/Program.cs b/Elternbeirat.Web/Program.cs index 185408c..fbe49a8 100644 --- a/Elternbeirat.Web/Program.cs +++ b/Elternbeirat.Web/Program.cs @@ -6,52 +6,50 @@ using Microsoft.AspNetCore.HttpOverrides; var builder = WebApplication.CreateBuilder(args); -// Add services to the container. builder.Services.AddRazorComponents(); // The typed client reads content from PocketBase over its REST API. The base URL -// comes from configuration: appsettings for local dev, the PocketBase__BaseUrl -// env var on the server (see compose.yaml). +// comes from config: appsettings for local dev, the PocketBase__BaseUrl env var +// on the server (see compose.yaml). var pocketBaseUrl = builder.Configuration["PocketBase:BaseUrl"] ?? throw new InvalidOperationException("PocketBase:BaseUrl is not configured."); builder.Services.AddHttpClient(client => - client.BaseAddress = new Uri(pocketBaseUrl)); +{ + client.BaseAddress = new Uri(pocketBaseUrl); + // Every page waits for PocketBase. The default wait is 100 seconds, so if + // PocketBase is down the page would hang that long. 5 seconds fails fast instead. + client.Timeout = TimeSpan.FromSeconds(5); +}); var app = builder.Build(); -// NPM terminates TLS and is the only way to reach the container (no port -// mapping in production). Without UseForwardedHeaders the app sees every request -// as HTTP and with the proxy IP instead of the client IP. -// KnownNetworks/KnownProxies are deliberately empty because only NPM reaches -// the container. +// NPM sits in front of the app and handles HTTPS. Without this line the app would +// think every request is plain HTTP and would see NPM's IP, not the visitor's. +// KnownNetworks/KnownProxies are left empty on purpose: only NPM reaches the app. app.UseForwardedHeaders( new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto }); -// Configure the HTTP request pipeline. if (app.Environment.IsDevelopment() is false) { app.UseExceptionHandler("/Error", createScopeForErrors: true); - // No UseHsts() and no UseHttpsRedirection(): NPM sets HSTS and terminates - // TLS. Both here would create a redirect loop behind the proxy. + // No UseHsts() and no UseHttpsRedirection() here: NPM already handles HTTPS. + // Adding them behind NPM would send the browser into a redirect loop. } -// No UseStatusCodePagesWithReExecute: the Router renders the NotFoundPage -// (Pages.NotFound) in process for both an unmatched route and an explicit -// NavigationManager.NotFound() from a component (see Routes.razor). Re-executing -// every error code to /not-found would also turn a 503 (content source down) into -// a "not found" page; leaving it out lets those components keep their own -// "temporarily unavailable" markup and 503 status. +// Don't add UseStatusCodePagesWithReExecute here. Blazor already shows its own +// not-found page. That middleware would catch *every* error code, so when PocketBase +// is down (a 503) it would wrongly show "not found" instead of our own message. app.UseAntiforgery(); app.MapStaticAssets(); app.MapRazorComponents(); -// Subscribable calendar feed of all events. A minimal API endpoint rather than -// a Razor page because it returns text/calendar, not HTML. Reads from PocketBase -// per request; an unreachable source yields an empty calendar, not a 500. +// Calendar feed people can subscribe to. It returns calendar data, not a web page, +// so it is a plain endpoint, not a Razor page. If PocketBase is down it returns an +// empty calendar rather than an error. app.MapGet("/events.ics", async (PocketBaseClient pocketBase, CancellationToken token) => { IReadOnlyList events; @@ -67,4 +65,16 @@ app.MapGet("/events.ics", async (PocketBaseClient pocketBase, CancellationToken return Results.Text(IcsCalendar.Build(events), "text/calendar; charset=utf-8"); }); +// Status page for an outside monitor (Uptime Kuma) to check. One check tells all +// three states apart: no reply = the app is down, 503 = the app runs but PocketBase +// is down, 200 = both are fine. It talks to PocketBase directly, not through the +// page-rendering code, so it can still report when that code is the thing failing. +app.MapGet("/health", async (PocketBaseClient pocketBase, CancellationToken token) => + await pocketBase.IsHealthyAsync(token) + ? Results.Text("healthy", "text/plain; charset=utf-8") + : Results.Text( + "PocketBase unreachable", + "text/plain; charset=utf-8", + statusCode: StatusCodes.Status503ServiceUnavailable)); + app.Run(); diff --git a/compose.yaml b/compose.yaml index b207665..4574716 100644 --- a/compose.yaml +++ b/compose.yaml @@ -3,8 +3,12 @@ # # The two `ports:` blocks below are TEMPORARY. The target state is NPM in front: # NPM terminates TLS and is the only path to the web app, and the editors reach -# PocketBase through an NPM subdomain -- then both port mappings go away (#9). While -# NPM is not set up yet, the ports are the only way onto the site and the admin UI. +# PocketBase through an NPM subdomain (#9). NPM still needs a target address and +# port, so a port stays reachable either way -- the plan is to reach the containers +# by name over a shared Docker network (e.g. eb-blazor:8080) instead of publishing +# them on the LAN, and to drop these host mappings that expose the site and admin UI +# to anyone on the LAN. While NPM is not set up yet, these mappings are the only way +# onto the site and the admin UI. # # For local development use the dev overlay on top, which adds host ports and builds # the web image from source: @@ -31,8 +35,9 @@ services: - /mnt/user/appdata/pocketbase/pb_data:/pb_data ports: # TEMPORARY: exposes the admin UI on the LAN (http://:8090/_/) while - # NPM is not in front yet. Remove once the editors reach PocketBase through an - # NPM subdomain (#9) -- until then the admin has no other way in. + # NPM is not in front yet. Replace once the editors reach PocketBase through an + # NPM subdomain (#9): NPM points at eb-pocketbase:8090 over a shared network, so + # this host mapping can go -- until then the admin has no other way in. - "8090:8090" healthcheck: # The image ships this endpoint; the web app waits for it to pass. @@ -55,6 +60,7 @@ services: PocketBase__BaseUrl: http://eb-pocketbase:8090 ports: # TEMPORARY: reach the site on the LAN (http://:5000) while NPM is not - # in front yet. Replace with the external npm network once NPM terminates TLS - # and is the only path in (#9). + # in front yet. Replace once NPM terminates TLS and fronts the site (#9): join + # the external NPM network and let NPM point at eb-blazor:8080 by name, so this + # host mapping can go and the site is no longer open on the LAN. - "5000:8080"