From 85fdff606d9ce57b624c3884eb1eba67626c8f95 Mon Sep 17 00:00:00 2001 From: thepra Date: Sat, 3 Oct 2026 10:37:42 +0200 Subject: [PATCH] T2: CI runs every test against a throwaway mongod tools/ci/with-test-mongod.sh starts mongod (or podman's mongo:8) on a random localhost port with a temporary data directory, runs the command, and removes both. build.yml and deploy.yml test through it, so the ~85 integration tests stop being skipped in CI. MongoFixture refuses production's port and data directory, and in CI anything but the wrapper's mongod; with PRIVAPUB_TEST_REQUIRE_MONGOD=1 a missing mongod fails instead of skipping. The deploy now passes the PRIVAPUB_SMOKE_TOKEN secret to the Mastodon smoke check, so its signed-in half runs once the owner creates the persona and the secret. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2 --- .gitea/workflows/build.yml | 4 +- .gitea/workflows/deploy.yml | 9 ++-- CLAUDE.md | 7 +++- PrivaPub.Tests/Support/MongoFixture.cs | 42 +++++++++++++++++-- tools/ci/with-test-mongod.sh | 57 ++++++++++++++++++++++++++ 5 files changed, 110 insertions(+), 9 deletions(-) create mode 100755 tools/ci/with-test-mongod.sh diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 9298f96..651b0b6 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -15,5 +15,5 @@ jobs: - name: Build run: dotnet build PrivaPub.sln -c Release - - name: Test - run: dotnet test PrivaPub.sln -c Release --no-build + - name: Test (unit and integration, on a throwaway mongod) + run: tools/ci/with-test-mongod.sh dotnet test PrivaPub.sln -c Release --no-build diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 387c14d..2793c3a 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -28,8 +28,8 @@ jobs: echo "BUILD_REF=${GITHUB_REF_NAME:-master}" >> "$GITHUB_ENV" echo "BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_ENV" - - name: Test - run: dotnet test PrivaPub.sln -c Release + - name: Test (unit and integration, on a throwaway mongod) + run: tools/ci/with-test-mongod.sh dotnet test PrivaPub.sln -c Release - name: Publish run: | @@ -90,6 +90,8 @@ jobs: fi - name: Verify what is being served + env: + SMOKE_TOKEN: ${{ secrets.PRIVAPUB_SMOKE_TOKEN }} run: | served=$(curl -fsS "$PUBLIC_URL/build.json" | python3 -c "import json,sys; print(json.load(sys.stdin).get('commit',''))") [ "$served" = "$BUILD_COMMIT" ] || { echo "::error::served build is '$served', expected '$BUILD_COMMIT'"; exit 1; } @@ -104,5 +106,6 @@ jobs: code=$(curl -s -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/activity+json' \ --data '{"type":"Follow","actor":"https://example.org/users/x","object":"'"$PUBLIC_URL"'/peasants/privapub"}' "$PUBLIC_URL/human-centipede") [ "$code" = "401" ] || { echo "::error::an unsigned inbox POST answered $code"; exit 1; } - tools/smoke/mastodon-api.sh "$PUBLIC_URL" + tools/smoke/mastodon-api.sh "$PUBLIC_URL" "$SMOKE_TOKEN" + [ -n "$SMOKE_TOKEN" ] || echo "::warning::PRIVAPUB_SMOKE_TOKEN is not set, so the signed-in API was not checked" echo "::notice::serving $served" diff --git a/CLAUDE.md b/CLAUDE.md index cabe2bf..63167ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -124,6 +124,7 @@ dotnet build PrivaPub.sln -c Release dotnet test PrivaPub.sln # unit tests; integration tests skip PRIVAPUB_TEST_MONGOD=1 dotnet test PrivaPub.sln # all of them, against mongod on 127.0.0.1:27017 # (PRIVAPUB_TEST_MONGO overrides; a fresh database per run, dropped after) +tools/ci/with-test-mongod.sh dotnet test PrivaPub.sln # all of them, on a throwaway mongod, as CI runs them cd PrivaPub && ASPNETCORE_ENVIRONMENT=Development \ Kestrel__Endpoints__Http__Url=http://127.0.0.1:6970 Kestrel__Endpoints__Http__Protocols=Http1AndHttp2 \ AppConfiguration__BackendBaseAddress=http://127.0.0.1:6970 MongoSettings__Database=PrivaPubTest \ @@ -323,7 +324,11 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ ## Testing `PrivaPub.Tests` (xUnit v3). Unit tests need nothing; tests marked `Category=Integration` need a mongod and skip -without `PRIVAPUB_TEST_MONGOD=1`. CI runs the unit tests only (the box's mongods are production). +without `PRIVAPUB_TEST_MONGOD=1`. CI (`build.yml` and `deploy.yml`) runs all of them through +`tools/ci/with-test-mongod.sh`, which starts a throwaway mongod on a random localhost port and deletes it afterwards. +The box's own mongods are production, so `MongoFixture` refuses port 27022, a data directory under `/var/lib/privapub`, +and in CI anything but the wrapper's mongod. With `PRIVAPUB_TEST_REQUIRE_MONGOD=1`, which the wrapper sets, a missing +mongod fails the run instead of silently skipping half the tests. - `Support/Peer` is an in-process HTTP server answering on two origins (`127.0.0.1` and `localhost`), so origin rules can be tested; `Support/RemoteActor` signs real deliveries with its own key. diff --git a/PrivaPub.Tests/Support/MongoFixture.cs b/PrivaPub.Tests/Support/MongoFixture.cs index 72f065c..23352c1 100644 --- a/PrivaPub.Tests/Support/MongoFixture.cs +++ b/PrivaPub.Tests/Support/MongoFixture.cs @@ -13,15 +13,24 @@ namespace PrivaPub.Tests.Support public sealed class MongoFixture : IAsyncLifetime { public const string Skip = "set PRIVAPUB_TEST_MONGOD=1 (and optionally PRIVAPUB_TEST_MONGO) to run the tests that need a mongod"; + const int ProductionPort = 27022; + const int DefaultPort = 27017; public static bool Enabled => Environment.GetEnvironmentVariable("PRIVAPUB_TEST_MONGOD") == "1"; + static bool Required => Environment.GetEnvironmentVariable("PRIVAPUB_TEST_REQUIRE_MONGOD") == "1"; + static bool InCi => !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")) || !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITEA_ACTIONS")); - public string Database { get; } = $"PrivaPubTests_{Guid.NewGuid():N}"; + public static string Connection { get; } = Environment.GetEnvironmentVariable("PRIVAPUB_TEST_MONGO") ?? $"mongodb://127.0.0.1:{DefaultPort}"; + public static string Database { get; } = $"PrivaPubTests_{Guid.NewGuid():N}"; public async ValueTask InitializeAsync() { + if (Required && !Enabled) + throw new InvalidOperationException("PRIVAPUB_TEST_REQUIRE_MONGOD=1 but PRIVAPUB_TEST_MONGOD is not 1: the integration tests would be skipped"); if (!Enabled) return; + var settings = MongoClientSettings.FromConnectionString(Connection); + Refuse(settings); try { BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard)); @@ -29,8 +38,8 @@ namespace PrivaPub.Tests.Support catch (BsonSerializationException) { } - var connection = Environment.GetEnvironmentVariable("PRIVAPUB_TEST_MONGO") ?? "mongodb://127.0.0.1:27017"; - await DB.InitAsync(Database, MongoClientSettings.FromConnectionString(connection)); + await DB.InitAsync(Database, settings); + await RefuseProductionData(); EntityMaps.Warm(); await Indexes.Create(); } @@ -40,5 +49,32 @@ namespace PrivaPub.Tests.Support if (Enabled) await DB.Default.Database().Client.DropDatabaseAsync(Database); } + + static void Refuse(MongoClientSettings settings) + { + var ports = settings.Servers.Select(s => s.Port).ToList(); + if (ports.Contains(ProductionPort)) + throw new InvalidOperationException($"the tests never run against port {ProductionPort}: that is production's mongod"); + if (InCi && (Environment.GetEnvironmentVariable("PRIVAPUB_TEST_MONGO") == default || ports.Contains(DefaultPort))) + throw new InvalidOperationException("in CI the tests run only against a throwaway mongod: use tools/ci/with-test-mongod.sh"); + } + + static async Task RefuseProductionData() + { + BsonDocument options; + try + { + options = await DB.Default.Database().Client.GetDatabase("admin").RunCommandAsync(new BsonDocument("getCmdLineOpts", 1)); + } + catch (MongoCommandException) + { + return; + } + var dbPath = options.GetValue("parsed", new BsonDocument()).AsBsonDocument + .GetValue("storage", new BsonDocument()).AsBsonDocument + .GetValue("dbPath", BsonNull.Value); + if (dbPath.IsString && dbPath.AsString.StartsWith("/var/lib/privapub", StringComparison.Ordinal)) + throw new InvalidOperationException($"the mongod at {Connection} keeps its data in {dbPath.AsString}: that is production's"); + } } } diff --git a/tools/ci/with-test-mongod.sh b/tools/ci/with-test-mongod.sh new file mode 100755 index 0000000..b07825e --- /dev/null +++ b/tools/ci/with-test-mongod.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Runs a command (normally `dotnet test`) with a throwaway mongod: a fresh data directory on a random localhost port, +# stopped and deleted when the command ends. The box's own mongods (27017 shared, 27022 PrivaPub's) are never touched. +# usage: tools/ci/with-test-mongod.sh dotnet test PrivaPub.sln -c Release +set -euo pipefail + +root="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/privapub-test-mongod" +mkdir -p "$root" + +# Leftovers of a cancelled run: anything here older than two hours. +find "$root" -mindepth 1 -maxdepth 1 -type d -mmin +120 -print0 2>/dev/null | while IFS= read -r -d '' old; do + [ -f "$old/pid" ] && kill "$(cat "$old/pid")" 2>/dev/null || true + [ -f "$old/container" ] && podman rm -f "$(cat "$old/container")" >/dev/null 2>&1 || true + rm -rf "$old" +done + +listening() { (exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null; } + +port="" +for _ in $(seq 1 20); do + candidate=$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()') + case "$candidate" in 27017|27022) continue ;; esac + listening "$candidate" && continue + port=$candidate; break +done +[ -n "$port" ] || { echo "::error::no free port for the test mongod"; exit 1; } + +dir=$(mktemp -d "$root/run-XXXXXX") +cleanup() { + status=$? + [ -f "$dir/pid" ] && kill "$(cat "$dir/pid")" 2>/dev/null || true + [ -f "$dir/container" ] && podman rm -f "$(cat "$dir/container")" >/dev/null 2>&1 || true + for _ in $(seq 1 20); do listening "$port" || break; sleep 0.5; done + rm -rf "$dir" + exit $status +} +trap cleanup EXIT INT TERM + +if command -v mongod >/dev/null 2>&1; then + mkdir -p "$dir/db" + timeout 7200 mongod --dbpath "$dir/db" --port "$port" --bind_ip 127.0.0.1 --noauth \ + --wiredTigerCacheSizeGB 0.25 --quiet --logpath "$dir/mongod.log" & + echo $! > "$dir/pid" + how="mongod $(mongod --version | head -1)" +elif command -v podman >/dev/null 2>&1; then + podman run -d --rm -p "127.0.0.1:$port:27017" docker.io/library/mongo:8 --quiet > "$dir/container" + how="podman mongo:8" +else + echo "::error::neither mongod nor podman is available for the test mongod"; exit 1 +fi + +for _ in $(seq 1 60); do listening "$port" && break; sleep 0.5; done +listening "$port" || { echo "::error::the test mongod did not start"; cat "$dir/mongod.log" 2>/dev/null | tail -20; exit 1; } +echo "test mongod: $how on 127.0.0.1:$port" + +export PRIVAPUB_TEST_MONGOD=1 PRIVAPUB_TEST_REQUIRE_MONGOD=1 PRIVAPUB_TEST_MONGO="mongodb://127.0.0.1:$port" +"$@"