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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
This commit is contained in:
1 parent
00b2685cf4
commit
85fdff606d
5 files changed
+110
-9
No files matched your search
@@ -15,5 +15,5 @@ jobs:
|
|||||||
- name: Build
|
- name: Build
|
||||||
run: dotnet build PrivaPub.sln -c Release
|
run: dotnet build PrivaPub.sln -c Release
|
||||||
|
|
||||||
- name: Test
|
- name: Test (unit and integration, on a throwaway mongod)
|
||||||
run: dotnet test PrivaPub.sln -c Release --no-build
|
run: tools/ci/with-test-mongod.sh dotnet test PrivaPub.sln -c Release --no-build
|
||||||
@@ -28,8 +28,8 @@ jobs:
|
|||||||
echo "BUILD_REF=${GITHUB_REF_NAME:-master}" >> "$GITHUB_ENV"
|
echo "BUILD_REF=${GITHUB_REF_NAME:-master}" >> "$GITHUB_ENV"
|
||||||
echo "BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_ENV"
|
echo "BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Test
|
- name: Test (unit and integration, on a throwaway mongod)
|
||||||
run: dotnet test PrivaPub.sln -c Release
|
run: tools/ci/with-test-mongod.sh dotnet test PrivaPub.sln -c Release
|
||||||
|
|
||||||
- name: Publish
|
- name: Publish
|
||||||
run: |
|
run: |
|
||||||
@@ -90,6 +90,8 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Verify what is being served
|
- name: Verify what is being served
|
||||||
|
env:
|
||||||
|
SMOKE_TOKEN: ${{ secrets.PRIVAPUB_SMOKE_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
served=$(curl -fsS "$PUBLIC_URL/build.json" | python3 -c "import json,sys; print(json.load(sys.stdin).get('commit',''))")
|
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; }
|
[ "$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' \
|
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")
|
--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; }
|
[ "$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"
|
echo "::notice::serving $served"
|
||||||
@@ -124,6 +124,7 @@ dotnet build PrivaPub.sln -c Release
|
|||||||
dotnet test PrivaPub.sln # unit tests; integration tests skip
|
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_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)
|
# (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 \
|
cd PrivaPub && ASPNETCORE_ENVIRONMENT=Development \
|
||||||
Kestrel__Endpoints__Http__Url=http://127.0.0.1:6970 Kestrel__Endpoints__Http__Protocols=Http1AndHttp2 \
|
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 \
|
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
|
## Testing
|
||||||
|
|
||||||
`PrivaPub.Tests` (xUnit v3). Unit tests need nothing; tests marked `Category=Integration` need a mongod and skip
|
`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
|
- `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.
|
can be tested; `Support/RemoteActor` signs real deliveries with its own key.
|
||||||
|
|||||||
@@ -13,15 +13,24 @@ namespace PrivaPub.Tests.Support
|
|||||||
public sealed class MongoFixture : IAsyncLifetime
|
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";
|
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";
|
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()
|
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)
|
if (!Enabled)
|
||||||
return;
|
return;
|
||||||
|
var settings = MongoClientSettings.FromConnectionString(Connection);
|
||||||
|
Refuse(settings);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard));
|
BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard));
|
||||||
@@ -29,8 +38,8 @@ namespace PrivaPub.Tests.Support
|
|||||||
catch (BsonSerializationException)
|
catch (BsonSerializationException)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
var connection = Environment.GetEnvironmentVariable("PRIVAPUB_TEST_MONGO") ?? "mongodb://127.0.0.1:27017";
|
await DB.InitAsync(Database, settings);
|
||||||
await DB.InitAsync(Database, MongoClientSettings.FromConnectionString(connection));
|
await RefuseProductionData();
|
||||||
EntityMaps.Warm();
|
EntityMaps.Warm();
|
||||||
await Indexes.Create();
|
await Indexes.Create();
|
||||||
}
|
}
|
||||||
@@ -40,5 +49,32 @@ namespace PrivaPub.Tests.Support
|
|||||||
if (Enabled)
|
if (Enabled)
|
||||||
await DB.Default.Database().Client.DropDatabaseAsync(Database);
|
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<BsonDocument>(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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Executable
+57
@@ -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"
|
||||||
|
"$@"
|
||||||
Reference in new issue
Block a user