nix: split off monolithic VM test

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ifc0a92ed8b6c7622ae345a21880fd0296a6a6964
This commit is contained in:
raf 2026-02-02 02:54:28 +03:00
commit 7dae114783
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
8 changed files with 2340 additions and 6 deletions

View file

@ -77,29 +77,39 @@
checks = forAllSystems (system: let checks = forAllSystems (system: let
pkgs = nixpkgs.legacyPackages.${system}; pkgs = nixpkgs.legacyPackages.${system};
in { testArgs = {
vm-test = pkgs.callPackage ./nix/vm-test.nix {
nixosModule = self.nixosModules.default; nixosModule = self.nixosModules.default;
fc-packages = { fc-packages = {
inherit (self.packages.${system}) fc-common fc-evaluator fc-migrate-cli fc-queue-runner fc-server; inherit (self.packages.${system}) fc-common fc-evaluator fc-migrate-cli fc-queue-runner fc-server;
}; };
}; };
in {
# Split VM integration tests
service-startup = pkgs.callPackage ./nix/tests/service-startup.nix testArgs;
basic-api = pkgs.callPackage ./nix/tests/basic-api.nix testArgs;
auth-rbac = pkgs.callPackage ./nix/tests/auth-rbac.nix testArgs;
api-crud = pkgs.callPackage ./nix/tests/api-crud.nix testArgs;
features = pkgs.callPackage ./nix/tests/features.nix testArgs;
e2e = pkgs.callPackage ./nix/tests/e2e.nix testArgs;
# Legacy monolithic test (for reference, can be removed after split tests pass)
vm-test = pkgs.callPackage ./nix/vm-test.nix testArgs;
}); });
devShells = forAllSystems (system: let devShells = forAllSystems (system: let
pkgs = nixpkgs.legacyPackages.${system}; pkgs = nixpkgs.legacyPackages.${system};
craneLib = crane.mkLib pkgs;
in { in {
default = craneLib.devShell { default = pkgs.mkShell {
name = "fc"; name = "fc";
inputsFrom = [self.packages.${system}.fc-server]; inputsFrom = [self.packages.${system}.fc-server];
strictDeps = true;
packages = with pkgs; [ packages = with pkgs; [
rust-analyzer
postgresql postgresql
pkg-config pkg-config
openssl openssl
taplo
(rustfmt.override {asNightly = true;})
]; ];
}; };
}); });

768
nix/tests/api-crud.nix Normal file
View file

@ -0,0 +1,768 @@
# API CRUD tests: dashboard content, project/jobset/evaluation/build/channel/builder CRUD, admin endpoints, pagination, search
{
pkgs,
fc-packages,
nixosModule,
}:
pkgs.testers.nixosTest {
name = "fc-api-crud";
nodes.machine = import ./common.nix {inherit pkgs fc-packages nixosModule;};
testScript = ''
import hashlib
import json
machine.start()
machine.wait_for_unit("postgresql.service")
# Ensure PostgreSQL is actually ready to accept connections before fc-server starts
machine.wait_until_succeeds("sudo -u fc psql -U fc -d fc -c 'SELECT 1'", timeout=30)
machine.wait_for_unit("fc-server.service")
# Wait for the server to start listening
machine.wait_until_succeeds("curl -sf http://127.0.0.1:3000/health", timeout=30)
# ---- Seed an API key for write operations ----
# Token: fc_testkey123 -> SHA-256 hash inserted into api_keys table
api_token = "fc_testkey123"
api_hash = hashlib.sha256(api_token.encode()).hexdigest()
machine.succeed(
f"sudo -u fc psql -U fc -d fc -c \"INSERT INTO api_keys (name, key_hash, role) VALUES ('test', '{api_hash}', 'admin')\""
)
auth_header = f"-H 'Authorization: Bearer {api_token}'"
# Seed a read-only key
ro_token = "fc_readonly_key"
ro_hash = hashlib.sha256(ro_token.encode()).hexdigest()
machine.succeed(
f"sudo -u fc psql -U fc -d fc -c \"INSERT INTO api_keys (name, key_hash, role) VALUES ('readonly', '{ro_hash}', 'read-only')\""
)
ro_header = f"-H 'Authorization: Bearer {ro_token}'"
# Create initial project for tests
result = machine.succeed(
"curl -sf -X POST http://127.0.0.1:3000/api/v1/projects "
f"{auth_header} "
"-H 'Content-Type: application/json' "
"-d '{\"name\": \"test-project\", \"repository_url\": \"https://github.com/test/repo\"}' "
"| jq -r .id"
)
project_id = result.strip()
# ========================================================================
# Phase 4: Dashboard Content & Deep Functional Tests
# ========================================================================
# ---- 4A: Dashboard content verification ----
with subtest("Home page contains Dashboard heading"):
body = machine.succeed("curl -sf http://127.0.0.1:3000/")
assert "Dashboard" in body, "Home page missing 'Dashboard' heading"
with subtest("Home page contains stats grid"):
body = machine.succeed("curl -sf http://127.0.0.1:3000/")
assert "stat-card" in body, "Home page missing stats grid"
assert "Completed" in body, "Home page missing 'Completed' stat"
with subtest("Home page shows project overview table"):
body = machine.succeed("curl -sf http://127.0.0.1:3000/")
# We created projects earlier, they should appear
assert "test-project" in body, "Home page should list test-project in overview"
with subtest("Projects page contains created projects"):
body = machine.succeed("curl -sf http://127.0.0.1:3000/projects")
assert "test-project" in body, "Projects page should list test-project"
with subtest("Projects page returns HTML content type"):
ct = machine.succeed(
"curl -s -D - -o /dev/null http://127.0.0.1:3000/projects | grep -i content-type"
)
assert "text/html" in ct.lower(), f"Expected text/html, got: {ct}"
with subtest("Admin page shows system status"):
body = machine.succeed("curl -sf http://127.0.0.1:3000/admin")
assert "Administration" in body, "Admin page missing heading"
assert "System Status" in body, "Admin page missing system status section"
assert "Remote Builders" in body, "Admin page missing remote builders section"
with subtest("Queue page renders"):
body = machine.succeed("curl -sf http://127.0.0.1:3000/queue")
assert "Queue" in body or "Pending" in body or "Running" in body, \
"Queue page missing expected content"
with subtest("Channels page renders"):
body = machine.succeed("curl -sf http://127.0.0.1:3000/channels")
# Page should render even if empty
assert "Channel" in body or "channel" in body, "Channels page missing expected content"
with subtest("Builds page renders with filter params"):
body = machine.succeed(
"curl -sf 'http://127.0.0.1:3000/builds?status=pending&system=x86_64-linux'"
)
assert "Build" in body or "build" in body, "Builds page missing expected content"
with subtest("Evaluations page renders"):
body = machine.succeed("curl -sf http://127.0.0.1:3000/evaluations")
assert "Evaluation" in body or "evaluation" in body, "Evaluations page missing expected content"
with subtest("Login page contains form"):
body = machine.succeed("curl -sf http://127.0.0.1:3000/login")
assert "api_key" in body or "API" in body, "Login page missing API key input"
assert "<form" in body.lower(), "Login page missing form element"
# ---- 4B: Dashboard page for specific entities ----
with subtest("Project detail page renders for existing project"):
body = machine.succeed(
f"curl -sf http://127.0.0.1:3000/project/{project_id}"
)
assert "test-project" in body, "Project detail page should show project name"
with subtest("Project detail page with invalid UUID returns graceful error"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
"http://127.0.0.1:3000/project/00000000-0000-0000-0000-000000000000"
)
# Should return 200 with "not found" message or similar, not crash
assert code.strip() == "200", f"Expected 200 for missing project detail, got {code.strip()}"
# ---- 4C: Project update via PUT ----
with subtest("Update project description via PUT"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
f"-X PUT http://127.0.0.1:3000/api/v1/projects/{project_id} "
f"{auth_header} "
"-H 'Content-Type: application/json' "
"-d '{\"description\": \"Updated description\"}'"
)
assert code.strip() == "200", f"Expected 200 for PUT project, got {code.strip()}"
with subtest("Updated project reflects new description"):
result = machine.succeed(
f"curl -sf http://127.0.0.1:3000/api/v1/projects/{project_id} | jq -r .description"
)
assert result.strip() == "Updated description", f"Expected updated description, got: {result.strip()}"
with subtest("Update project with read-only key returns 403"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
f"-X PUT http://127.0.0.1:3000/api/v1/projects/{project_id} "
f"{ro_header} "
"-H 'Content-Type: application/json' "
"-d '{\"description\": \"Hacked\"}'"
)
assert code.strip() == "403", f"Expected 403 for read-only PUT, got {code.strip()}"
# ---- 4D: Jobset CRUD ----
with subtest("Create jobset for test-project"):
result = machine.succeed(
f"curl -sf -X POST http://127.0.0.1:3000/api/v1/projects/{project_id}/jobsets "
f"{auth_header} "
"-H 'Content-Type: application/json' "
"-d '{\"name\": \"main\", \"nix_expression\": \"packages\"}' "
"| jq -r .id"
)
test_jobset_id = result.strip()
assert len(test_jobset_id) == 36, f"Expected UUID for jobset, got '{test_jobset_id}'"
with subtest("List jobsets for project includes new jobset"):
result = machine.succeed(
f"curl -sf http://127.0.0.1:3000/api/v1/projects/{project_id}/jobsets | jq '.items | length'"
)
assert int(result.strip()) >= 1, f"Expected at least 1 jobset, got {result.strip()}"
with subtest("Jobset detail page renders"):
body = machine.succeed(
f"curl -sf http://127.0.0.1:3000/jobset/{test_jobset_id}"
)
assert "main" in body, "Jobset detail page should show jobset name"
# ---- 4E: Evaluation trigger and lifecycle ----
with subtest("Trigger evaluation via API"):
result = machine.succeed(
"curl -sf -X POST http://127.0.0.1:3000/api/v1/evaluations/trigger "
f"{auth_header} "
"-H 'Content-Type: application/json' "
f"-d '{{\"jobset_id\": \"{test_jobset_id}\", \"commit_hash\": \"abcdef1234567890abcdef1234567890abcdef12\"}}' "
"| jq -r .id"
)
test_eval_id = result.strip()
assert len(test_eval_id) == 36, f"Expected UUID for evaluation, got '{test_eval_id}'"
with subtest("Get evaluation by ID"):
result = machine.succeed(
f"curl -sf http://127.0.0.1:3000/api/v1/evaluations/{test_eval_id} | jq -r .status"
)
assert result.strip().lower() == "pending", f"Expected pending status, got: {result.strip()}"
with subtest("List evaluations includes triggered one"):
result = machine.succeed(
f"curl -sf 'http://127.0.0.1:3000/api/v1/evaluations?jobset_id={test_jobset_id}' | jq '.items | length'"
)
assert int(result.strip()) >= 1, f"Expected at least 1 evaluation, got {result.strip()}"
with subtest("Evaluation detail dashboard page renders"):
body = machine.succeed(
f"curl -sf http://127.0.0.1:3000/evaluation/{test_eval_id}"
)
assert "abcdef123456" in body, "Evaluation page should show commit hash prefix"
with subtest("Trigger evaluation with read-only key returns 403"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
"-X POST http://127.0.0.1:3000/api/v1/evaluations/trigger "
f"{ro_header} "
"-H 'Content-Type: application/json' "
f"-d '{{\"jobset_id\": \"{test_jobset_id}\", \"commit_hash\": \"0000000000000000000000000000000000000000\"}}'"
)
assert code.strip() == "403", f"Expected 403 for read-only eval trigger, got {code.strip()}"
# ---- 4E2: Build lifecycle (restart, bump) ----
# Create a build via SQL since builds are normally created by the evaluator
with subtest("Create test build via SQL"):
machine.succeed(
"sudo -u fc psql -U fc -d fc -c \""
"INSERT INTO builds (id, evaluation_id, job_name, drv_path, status, system, priority, created_at) "
f"VALUES ('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', '{test_eval_id}', 'hello', '/nix/store/fake.drv', 'failed', 'x86_64-linux', 5, NOW())"
"\""
)
test_build_id = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
with subtest("Get build by ID"):
result = machine.succeed(
f"curl -sf http://127.0.0.1:3000/api/v1/builds/{test_build_id} | jq -r .status"
)
assert result.strip().lower() == "failed", f"Expected failed, got: {result.strip()}"
with subtest("Restart failed build"):
result = machine.succeed(
f"curl -sf -X POST http://127.0.0.1:3000/api/v1/builds/{test_build_id}/restart "
f"{auth_header} "
"| jq -r .status"
)
assert result.strip().lower() == "pending", f"Expected pending status for restarted build, got: {result.strip()}"
with subtest("Restart with read-only key returns 403"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
f"-X POST http://127.0.0.1:3000/api/v1/builds/{test_build_id}/restart "
f"{ro_header}"
)
assert code.strip() == "403", f"Expected 403 for read-only restart, got {code.strip()}"
# Create a pending build to test bump
with subtest("Create pending build for bump test"):
machine.succeed(
"sudo -u fc psql -U fc -d fc -c \""
"INSERT INTO builds (id, evaluation_id, job_name, drv_path, status, system, priority, created_at) "
f"VALUES ('bbbbbbbb-cccc-dddd-eeee-ffffffffffff', '{test_eval_id}', 'world', '/nix/store/fake2.drv', 'pending', 'x86_64-linux', 5, NOW())"
"\""
)
bump_build_id = "bbbbbbbb-cccc-dddd-eeee-ffffffffffff"
with subtest("Bump build priority"):
result = machine.succeed(
f"curl -sf -X POST http://127.0.0.1:3000/api/v1/builds/{bump_build_id}/bump "
f"{auth_header} "
"| jq -r .priority"
)
assert int(result.strip()) == 15, f"Expected priority 15 (5+10), got: {result.strip()}"
with subtest("Bump with read-only key returns 403"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
f"-X POST http://127.0.0.1:3000/api/v1/builds/{bump_build_id}/bump "
f"{ro_header}"
)
assert code.strip() == "403", f"Expected 403 for read-only bump, got {code.strip()}"
with subtest("Cancel build"):
result = machine.succeed(
f"curl -sf -X POST http://127.0.0.1:3000/api/v1/builds/{bump_build_id}/cancel "
f"{auth_header} "
"| jq '.[0].status'"
)
assert "cancelled" in result.strip().lower(), f"Expected cancelled, got: {result.strip()}"
# ---- 4E3: Evaluation comparison ----
with subtest("Trigger second evaluation for comparison"):
result = machine.succeed(
"curl -sf -X POST http://127.0.0.1:3000/api/v1/evaluations/trigger "
f"{auth_header} "
"-H 'Content-Type: application/json' "
f"-d '{{\"jobset_id\": \"{test_jobset_id}\", \"commit_hash\": \"deadbeef1234567890abcdef1234567890abcdef\"}}' "
"| jq -r .id"
)
second_eval_id = result.strip()
# Add a build to the second evaluation
machine.succeed(
"sudo -u fc psql -U fc -d fc -c \""
"INSERT INTO builds (id, evaluation_id, job_name, drv_path, status, system, priority, created_at) "
f"VALUES ('cccccccc-dddd-eeee-ffff-aaaaaaaaaaaa', '{second_eval_id}', 'hello', '/nix/store/changed.drv', 'pending', 'x86_64-linux', 5, NOW())"
"\""
)
machine.succeed(
"sudo -u fc psql -U fc -d fc -c \""
"INSERT INTO builds (id, evaluation_id, job_name, drv_path, status, system, priority, created_at) "
f"VALUES ('dddddddd-eeee-ffff-aaaa-bbbbbbbbbbbb', '{second_eval_id}', 'new-pkg', '/nix/store/new.drv', 'pending', 'x86_64-linux', 5, NOW())"
"\""
)
with subtest("Compare evaluations shows diff"):
result = machine.succeed(
f"curl -sf 'http://127.0.0.1:3000/api/v1/evaluations/{test_eval_id}/compare?to={second_eval_id}'"
)
data = json.loads(result)
# hello changed derivation, world was removed, new-pkg was added
assert len(data["changed_jobs"]) >= 1, f"Expected at least 1 changed job, got {data['changed_jobs']}"
assert len(data["new_jobs"]) >= 1, f"Expected at least 1 new job, got {data['new_jobs']}"
assert any(j["job_name"] == "new-pkg" for j in data["new_jobs"]), "new-pkg should be in new_jobs"
# ---- 4F: Channel CRUD lifecycle ----
with subtest("Create channel via API"):
result = machine.succeed(
"curl -sf -X POST http://127.0.0.1:3000/api/v1/channels "
f"{auth_header} "
"-H 'Content-Type: application/json' "
f"-d '{{\"project_id\": \"{project_id}\", \"name\": \"stable\", \"jobset_id\": \"{test_jobset_id}\"}}' "
"| jq -r .id"
)
test_channel_id = result.strip()
assert len(test_channel_id) == 36, f"Expected UUID for channel, got '{test_channel_id}'"
with subtest("List channels includes new channel"):
result = machine.succeed(
"curl -sf http://127.0.0.1:3000/api/v1/channels | jq 'length'"
)
assert int(result.strip()) >= 1, f"Expected at least 1 channel, got {result.strip()}"
with subtest("Get channel by ID"):
result = machine.succeed(
f"curl -sf http://127.0.0.1:3000/api/v1/channels/{test_channel_id} | jq -r .name"
)
assert result.strip() == "stable", f"Expected 'stable', got: {result.strip()}"
with subtest("List project channels"):
result = machine.succeed(
f"curl -sf http://127.0.0.1:3000/api/v1/projects/{project_id}/channels | jq 'length'"
)
assert int(result.strip()) >= 1, f"Expected at least 1 project channel, got {result.strip()}"
with subtest("Promote channel to evaluation"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
f"-X POST http://127.0.0.1:3000/api/v1/channels/{test_channel_id}/promote/{test_eval_id} "
f"{auth_header}"
)
assert code.strip() == "200", f"Expected 200 for channel promote, got {code.strip()}"
with subtest("Channel promote with read-only key returns 403"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
f"-X POST http://127.0.0.1:3000/api/v1/channels/{test_channel_id}/promote/{test_eval_id} "
f"{ro_header}"
)
assert code.strip() == "403", f"Expected 403 for read-only promote, got {code.strip()}"
with subtest("Create channel with read-only key returns 403"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
"-X POST http://127.0.0.1:3000/api/v1/channels "
f"{ro_header} "
"-H 'Content-Type: application/json' "
f"-d '{{\"project_id\": \"{project_id}\", \"name\": \"nightly\", \"jobset_id\": \"{test_jobset_id}\"}}'"
)
assert code.strip() == "403", f"Expected 403 for read-only channel create, got {code.strip()}"
with subtest("Delete channel"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
f"-X DELETE http://127.0.0.1:3000/api/v1/channels/{test_channel_id} "
f"{auth_header}"
)
assert code.strip() == "200", f"Expected 200 for channel delete, got {code.strip()}"
# ---- 4G: Remote builder CRUD lifecycle ----
with subtest("List remote builders"):
result = machine.succeed(
"curl -sf http://127.0.0.1:3000/api/v1/admin/builders | jq 'length'"
)
# We created one earlier in auth tests
assert int(result.strip()) >= 0, f"Expected >= 0 builders, got {result.strip()}"
# Create a builder for testing
machine.succeed(
"curl -sf -X POST http://127.0.0.1:3000/api/v1/admin/builders "
f"{auth_header} "
"-H 'Content-Type: application/json' "
"-d '{\"name\": \"test-builder\", \"ssh_uri\": \"ssh://nix@builder\", \"systems\": [\"x86_64-linux\"], \"max_jobs\": 2}'"
)
with subtest("Get remote builder by ID"):
# Get the first builder's ID
builder_id = machine.succeed(
"curl -sf http://127.0.0.1:3000/api/v1/admin/builders | jq -r '.[0].id'"
).strip()
result = machine.succeed(
f"curl -sf http://127.0.0.1:3000/api/v1/admin/builders/{builder_id} | jq -r .name"
)
assert result.strip() == "test-builder", f"Expected 'test-builder', got: {result.strip()}"
with subtest("Update remote builder (disable)"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
f"-X PUT http://127.0.0.1:3000/api/v1/admin/builders/{builder_id} "
f"{auth_header} "
"-H 'Content-Type: application/json' "
"-d '{\"enabled\": false}'"
)
assert code.strip() == "200", f"Expected 200 for builder update, got {code.strip()}"
with subtest("Updated builder is disabled"):
result = machine.succeed(
f"curl -sf http://127.0.0.1:3000/api/v1/admin/builders/{builder_id} | jq -r .enabled"
)
assert result.strip() == "false", f"Expected false, got: {result.strip()}"
with subtest("Update builder with read-only key returns 403"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
f"-X PUT http://127.0.0.1:3000/api/v1/admin/builders/{builder_id} "
f"{ro_header} "
"-H 'Content-Type: application/json' "
"-d '{\"enabled\": true}'"
)
assert code.strip() == "403", f"Expected 403 for read-only builder update, got {code.strip()}"
with subtest("Delete remote builder with read-only key returns 403"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
f"-X DELETE http://127.0.0.1:3000/api/v1/admin/builders/{builder_id} "
f"{ro_header}"
)
assert code.strip() == "403", f"Expected 403 for read-only builder delete, got {code.strip()}"
with subtest("Delete remote builder with admin key"):
# First clear the builder_id from builds that reference it
machine.succeed(
"sudo -u fc psql -U fc -d fc -c "
f"\"UPDATE builds SET builder_id = NULL WHERE builder_id = '{builder_id}'\""
)
# Now delete the builder
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
f"-X DELETE http://127.0.0.1:3000/api/v1/admin/builders/{builder_id} "
f"{auth_header}"
)
assert code.strip() == "200", f"Expected 200 for builder delete, got {code.strip()}"
# ---- 4H: Admin system status endpoint ----
with subtest("System status endpoint requires admin"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
"http://127.0.0.1:3000/api/v1/admin/system "
f"{ro_header}"
)
assert code.strip() == "403", f"Expected 403 for read-only system status, got {code.strip()}"
with subtest("System status endpoint returns data with admin key"):
result = machine.succeed(
"curl -sf http://127.0.0.1:3000/api/v1/admin/system "
f"{auth_header} "
"| jq .projects_count"
)
assert int(result.strip()) >= 1, f"Expected at least 1 project in system status, got {result.strip()}"
# ---- 4I: API key listing ----
with subtest("List API keys requires admin"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
"http://127.0.0.1:3000/api/v1/api-keys "
f"{ro_header}"
)
assert code.strip() == "403", f"Expected 403 for read-only API key list, got {code.strip()}"
with subtest("List API keys returns array with admin key"):
result = machine.succeed(
"curl -sf http://127.0.0.1:3000/api/v1/api-keys "
f"{auth_header} "
"| jq 'length'"
)
assert int(result.strip()) >= 1, f"Expected at least 1 API key, got {result.strip()}"
# ---- 4J: Badge endpoints ----
with subtest("Badge endpoint returns SVG for unknown project"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
"http://127.0.0.1:3000/api/v1/job/nonexistent/main/hello/shield"
)
# Should return 404 or error since project doesn't exist
assert code.strip() in ("404", "500"), f"Expected 404/500 for unknown badge, got {code.strip()}"
with subtest("Badge endpoint returns SVG for existing project"):
# Create a badge-compatible project name lookup
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
"http://127.0.0.1:3000/api/v1/job/test-project/main/hello/shield"
)
# Should return 200 with SVG (even if no builds, shows "not found" badge)
assert code.strip() == "200", f"Expected 200 for badge, got {code.strip()}"
with subtest("Badge returns SVG content type"):
ct = machine.succeed(
"curl -s -D - -o /dev/null "
"http://127.0.0.1:3000/api/v1/job/test-project/main/hello/shield "
"| grep -i content-type"
)
assert "image/svg+xml" in ct.lower(), f"Expected SVG content type, got: {ct}"
with subtest("Latest build endpoint for unknown project returns 404"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
"http://127.0.0.1:3000/api/v1/job/nonexistent/main/hello/latest"
)
assert code.strip() in ("404", "500"), f"Expected 404/500 for latest build, got {code.strip()}"
# ---- 4K: Pagination tests ----
# Re-verify server is healthy before pagination tests
machine.wait_until_succeeds("curl -sf http://127.0.0.1:3000/health", timeout=15)
with subtest("Projects pagination with limit and offset"):
result = machine.succeed(
"curl -sf 'http://127.0.0.1:3000/api/v1/projects?limit=1&offset=0' | jq '.items | length'"
)
assert int(result.strip()) == 1, f"Expected 1 project with limit=1, got {result.strip()}"
with subtest("Projects pagination returns total count"):
result = machine.succeed(
"curl -sf 'http://127.0.0.1:3000/api/v1/projects?limit=1&offset=0' | jq '.total'"
)
assert int(result.strip()) >= 1, f"Expected at least 1 total projects, got {result.strip()}"
with subtest("Builds pagination with limit"):
result = machine.succeed(
"curl -s 'http://127.0.0.1:3000/api/v1/builds?limit=5'"
)
data = json.loads(result)
assert "limit" in data, f"Expected paginated response with 'limit' field, got: {result[:300]}"
assert data["limit"] == 5, f"Expected limit=5, got {data['limit']}"
with subtest("Evaluations pagination with limit"):
result = machine.succeed(
"curl -s 'http://127.0.0.1:3000/api/v1/evaluations?limit=2'"
)
data = json.loads(result)
assert "limit" in data, f"Expected paginated response with 'limit' field, got: {result[:300]}"
assert data["limit"] == 2, f"Expected limit=2, got {data['limit']}"
# ---- 4L: Build sub-resources ----
with subtest("Build steps endpoint returns empty array for nonexistent build"):
result = machine.succeed(
"curl -sf "
"http://127.0.0.1:3000/api/v1/builds/00000000-0000-0000-0000-000000000000/steps"
" | jq 'length'"
)
assert int(result.strip()) == 0, f"Expected empty steps array, got {result.strip()}"
with subtest("Build products endpoint returns empty array for nonexistent build"):
result = machine.succeed(
"curl -sf "
"http://127.0.0.1:3000/api/v1/builds/00000000-0000-0000-0000-000000000000/products"
" | jq 'length'"
)
assert int(result.strip()) == 0, f"Expected empty products array, got {result.strip()}"
with subtest("Build log endpoint for nonexistent build returns 404"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
"http://127.0.0.1:3000/api/v1/builds/00000000-0000-0000-0000-000000000000/log"
)
assert code.strip() == "404", f"Expected 404 for nonexistent build log, got {code.strip()}"
# ---- 4M: Search functionality ----
with subtest("Search returns matching projects"):
result = machine.succeed(
"curl -sf 'http://127.0.0.1:3000/api/v1/search?q=test-project' | jq '.projects | length'"
)
assert int(result.strip()) >= 1, f"Expected at least 1 matching project, got {result.strip()}"
with subtest("Search returns empty for nonsense query"):
result = machine.succeed(
"curl -sf 'http://127.0.0.1:3000/api/v1/search?q=zzzznonexistent99999' | jq '.projects | length'"
)
assert result.strip() == "0", f"Expected 0, got {result.strip()}"
# ---- 4N: Content-Type verification for API endpoints ----
with subtest("API endpoints return application/json"):
ct = machine.succeed(
"curl -s -D - -o /dev/null http://127.0.0.1:3000/api/v1/projects | grep -i content-type"
)
assert "application/json" in ct.lower(), f"Expected application/json, got: {ct}"
with subtest("Health endpoint returns application/json"):
ct = machine.succeed(
"curl -s -D - -o /dev/null http://127.0.0.1:3000/health | grep -i content-type"
)
assert "application/json" in ct.lower(), f"Expected application/json, got: {ct}"
with subtest("Metrics endpoint returns text/plain"):
ct = machine.succeed(
"curl -s -D - -o /dev/null http://127.0.0.1:3000/metrics | grep -i content-type"
)
assert "text/plain" in ct.lower() or "text/" in ct.lower(), f"Expected text content type for metrics, got: {ct}"
# ---- 4O: Session/Cookie auth for dashboard ----
with subtest("Login with valid API key sets session cookie"):
result = machine.succeed(
"curl -s -D - -o /dev/null "
"-X POST http://127.0.0.1:3000/login "
f"-d 'api_key={api_token}'"
)
assert "fc_session=" in result, f"Expected fc_session cookie in response: {result}"
assert "HttpOnly" in result, "Expected HttpOnly flag on session cookie"
with subtest("Login with invalid API key shows error"):
body = machine.succeed(
"curl -sf -X POST http://127.0.0.1:3000/login "
"-d 'api_key=fc_invalid_key'"
)
assert "Invalid" in body or "invalid" in body or "error" in body.lower(), \
f"Expected error message for invalid login: {body[:200]}"
with subtest("Login with empty API key shows error"):
body = machine.succeed(
"curl -sf -X POST http://127.0.0.1:3000/login "
"-d 'api_key='"
)
assert "required" in body.lower() or "error" in body.lower() or "Invalid" in body, \
f"Expected error message for empty login: {body[:200]}"
with subtest("Session cookie grants admin access on dashboard"):
# Login and capture cookie
cookie = machine.succeed(
"curl -s -D - -o /dev/null "
"-X POST http://127.0.0.1:3000/login "
f"-d 'api_key={api_token}' "
"| grep -i set-cookie | head -1"
)
match = re.search(r'fc_session=([^;]+)', cookie)
if match:
session_val = match.group(1)
body = machine.succeed(
f"curl -sf -H 'Cookie: fc_session={session_val}' http://127.0.0.1:3000/admin"
)
# Admin page with session should show API Keys section and admin controls
assert "API Keys" in body, "Admin page with session should show API Keys section"
with subtest("Logout clears session cookie"):
result = machine.succeed(
"curl -s -D - -o /dev/null -X POST http://127.0.0.1:3000/logout"
)
assert "Max-Age=0" in result or "max-age=0" in result.lower(), \
"Logout should set Max-Age=0 to clear cookie"
# ---- 4P: RBAC with create-projects role ----
cp_token = "fc_createprojects_key"
cp_hash = hashlib.sha256(cp_token.encode()).hexdigest()
machine.succeed(
f"sudo -u fc psql -U fc -d fc -c \"INSERT INTO api_keys (name, key_hash, role) VALUES ('creator', '{cp_hash}', 'create-projects')\""
)
cp_header = f"-H 'Authorization: Bearer {cp_token}'"
with subtest("create-projects role can create project"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
"-X POST http://127.0.0.1:3000/api/v1/projects "
f"{cp_header} "
"-H 'Content-Type: application/json' "
"-d '{\"name\": \"creator-project\", \"repository_url\": \"https://example.com/creator\"}'"
)
assert code.strip() == "200", f"Expected 200 for create-projects role, got {code.strip()}"
with subtest("create-projects role cannot delete project"):
# Get the new project ID
cp_project_id = machine.succeed(
"curl -sf http://127.0.0.1:3000/api/v1/projects | jq -r '.items[] | select(.name==\"creator-project\") | .id'"
).strip()
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
f"-X DELETE http://127.0.0.1:3000/api/v1/projects/{cp_project_id} "
f"{cp_header}"
)
assert code.strip() == "403", f"Expected 403 for create-projects role DELETE, got {code.strip()}"
with subtest("create-projects role cannot update project"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
f"-X PUT http://127.0.0.1:3000/api/v1/projects/{cp_project_id} "
f"{cp_header} "
"-H 'Content-Type: application/json' "
"-d '{\"description\": \"hacked\"}'"
)
assert code.strip() == "403", f"Expected 403 for create-projects PUT, got {code.strip()}"
with subtest("create-projects role cannot access admin endpoints"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
"http://127.0.0.1:3000/api/v1/admin/system "
f"{cp_header}"
)
assert code.strip() == "403", f"Expected 403 for create-projects system status, got {code.strip()}"
# ---- 4Q: Additional security tests ----
with subtest("DELETE project without auth returns 401"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
f"-X DELETE http://127.0.0.1:3000/api/v1/projects/{project_id}"
)
assert code.strip() == "401", f"Expected 401 for unauthenticated DELETE, got {code.strip()}"
with subtest("PUT project without auth returns 401"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
f"-X PUT http://127.0.0.1:3000/api/v1/projects/{project_id} "
"-H 'Content-Type: application/json' "
"-d '{\"description\": \"hacked\"}'"
)
assert code.strip() == "401", f"Expected 401 for unauthenticated PUT, got {code.strip()}"
with subtest("POST channel without auth returns 401"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
"-X POST http://127.0.0.1:3000/api/v1/channels "
"-H 'Content-Type: application/json' "
"-d '{\"project_id\": \"00000000-0000-0000-0000-000000000000\", \"name\": \"x\", \"jobset_id\": \"00000000-0000-0000-0000-000000000000\"}'"
)
assert code.strip() == "401", f"Expected 401 for unauthenticated channel create, got {code.strip()}"
with subtest("API returns JSON error body for 404"):
result = machine.succeed(
"curl -sf http://127.0.0.1:3000/api/v1/projects/00000000-0000-0000-0000-000000000001 2>&1 || "
"curl -s http://127.0.0.1:3000/api/v1/projects/00000000-0000-0000-0000-000000000001"
)
parsed = json.loads(result)
assert "error" in parsed or "error_code" in parsed, f"Expected JSON error body, got: {result}"
with subtest("Nonexistent API route returns 404"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
"http://127.0.0.1:3000/api/v1/nonexistent"
)
# Axum returns 404 for unmatched routes
assert code.strip() in ("404", "405"), f"Expected 404/405 for nonexistent route, got {code.strip()}"
with subtest("HEAD request to health returns 200"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' -I http://127.0.0.1:3000/health"
)
assert code.strip() == "200", f"Expected 200 for HEAD /health, got {code.strip()}"
with subtest("OPTIONS request returns valid response"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
"-X OPTIONS http://127.0.0.1:3000/api/v1/projects"
)
# Axum may return 200, 204, or 405 depending on CORS configuration
assert code.strip() in ("200", "204", "405"), f"Expected 200/204/405 for OPTIONS, got {code.strip()}"
'';
}

298
nix/tests/auth-rbac.nix Normal file
View file

@ -0,0 +1,298 @@
# Authentication and RBAC tests
{
pkgs,
fc-packages,
nixosModule,
}:
pkgs.testers.nixosTest {
name = "fc-auth-rbac";
nodes.machine = import ./common.nix {inherit pkgs fc-packages nixosModule;};
testScript = ''
import hashlib
import json
machine.start()
machine.wait_for_unit("postgresql.service")
# Ensure PostgreSQL is actually ready to accept connections before fc-server starts
machine.wait_until_succeeds("sudo -u fc psql -U fc -d fc -c 'SELECT 1'", timeout=30)
machine.wait_for_unit("fc-server.service")
# Wait for the server to start listening
machine.wait_until_succeeds("curl -sf http://127.0.0.1:3000/health", timeout=30)
# Seed an API key for write operations
# Token: fc_testkey123 -> SHA-256 hash inserted into api_keys table
api_token = "fc_testkey123"
api_hash = hashlib.sha256(api_token.encode()).hexdigest()
machine.succeed(
f"sudo -u fc psql -U fc -d fc -c \"INSERT INTO api_keys (name, key_hash, role) VALUES ('test', '{api_hash}', 'admin')\""
)
auth_header = f"-H 'Authorization: Bearer {api_token}'"
# Authentication tests
with subtest("Unauthenticated POST returns 401"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
"-X POST http://127.0.0.1:3000/api/v1/projects "
"-H 'Content-Type: application/json' "
"-d '{\"name\": \"unauth-test\", \"repository_url\": \"https://example.com/repo\"}'"
)
assert code.strip() == "401", f"Expected 401, got {code.strip()}"
with subtest("Wrong token POST returns 401"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
"-X POST http://127.0.0.1:3000/api/v1/projects "
"-H 'Authorization: Bearer fc_wrong_token_here' "
"-H 'Content-Type: application/json' "
"-d '{\"name\": \"bad-auth-test\", \"repository_url\": \"https://example.com/repo\"}'"
)
assert code.strip() == "401", f"Expected 401, got {code.strip()}"
with subtest("Valid token POST returns 200"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
"-X POST http://127.0.0.1:3000/api/v1/projects "
f"{auth_header} "
"-H 'Content-Type: application/json' "
"-d '{\"name\": \"auth-test-project\", \"repository_url\": \"https://example.com/auth-repo\"}'"
)
assert code.strip() == "200", f"Expected 200, got {code.strip()}"
with subtest("GET without token returns 200"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
"http://127.0.0.1:3000/api/v1/projects"
)
assert code.strip() == "200", f"Expected 200, got {code.strip()}"
## RBAC tests
# Seed a read-only key
ro_token = "fc_readonly_key"
ro_hash = hashlib.sha256(ro_token.encode()).hexdigest()
machine.succeed(
f"sudo -u fc psql -U fc -d fc -c \"INSERT INTO api_keys (name, key_hash, role) VALUES ('readonly', '{ro_hash}', 'read-only')\""
)
ro_header = f"-H 'Authorization: Bearer {ro_token}'"
with subtest("Read-only key POST project returns 403"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
"-X POST http://127.0.0.1:3000/api/v1/projects "
f"{ro_header} "
"-H 'Content-Type: application/json' "
"-d '{\"name\": \"ro-attempt\", \"repository_url\": \"https://example.com/ro\"}'"
)
assert code.strip() == "403", f"Expected 403, got {code.strip()}"
with subtest("Read-only key POST admin/builders returns 403"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
"-X POST http://127.0.0.1:3000/api/v1/admin/builders "
f"{ro_header} "
"-H 'Content-Type: application/json' "
"-d '{\"name\": \"bad-builder\", \"ssh_uri\": \"ssh://x@y\", \"systems\": [\"x86_64-linux\"]}'"
)
assert code.strip() == "403", f"Expected 403, got {code.strip()}"
with subtest("Admin key POST admin/builders returns 200"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
"-X POST http://127.0.0.1:3000/api/v1/admin/builders "
f"{auth_header} "
"-H 'Content-Type: application/json' "
"-d '{\"name\": \"test-builder\", \"ssh_uri\": \"ssh://nix@builder\", \"systems\": [\"x86_64-linux\"], \"max_jobs\": 2}'"
)
assert code.strip() == "200", f"Expected 200, got {code.strip()}"
with subtest("Admin key create and delete API key"):
# Create
result = machine.succeed(
"curl -sf -X POST http://127.0.0.1:3000/api/v1/api-keys "
f"{auth_header} "
"-H 'Content-Type: application/json' "
"-d '{\"name\": \"ephemeral\", \"role\": \"read-only\"}'"
)
key_data = json.loads(result)
assert "id" in key_data, f"Expected id in response: {result}"
key_id = key_data["id"]
# Delete
code = machine.succeed(
f"curl -s -o /dev/null -w '%{{http_code}}' "
f"-X DELETE http://127.0.0.1:3000/api/v1/api-keys/{key_id} "
f"{auth_header}"
)
assert code.strip() == "200", f"Expected 200, got {code.strip()}"
## 3C: API key lifecycle test
with subtest("API key lifecycle: create, use, delete, verify 401"):
# Create a new key via admin API
result = machine.succeed(
"curl -sf -X POST http://127.0.0.1:3000/api/v1/api-keys "
f"{auth_header} "
"-H 'Content-Type: application/json' "
"-d '{\"name\": \"lifecycle-test\", \"role\": \"admin\"}'"
)
lc_data = json.loads(result)
lc_key = lc_data["key"]
lc_id = lc_data["id"]
lc_header = f"-H 'Authorization: Bearer {lc_key}'"
# Use the new key to create a project
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
"-X POST http://127.0.0.1:3000/api/v1/projects "
f"{lc_header} "
"-H 'Content-Type: application/json' "
"-d '{\"name\": \"lifecycle-project\", \"repository_url\": \"https://example.com/lc\"}'"
)
assert code.strip() == "200", f"Expected 200 with new key, got {code.strip()}"
# Delete the key
machine.succeed(
f"curl -sf -X DELETE http://127.0.0.1:3000/api/v1/api-keys/{lc_id} "
f"{auth_header}"
)
# Verify deleted key returns 401
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
"-X POST http://127.0.0.1:3000/api/v1/projects "
f"{lc_header} "
"-H 'Content-Type: application/json' "
"-d '{\"name\": \"should-fail\", \"repository_url\": \"https://example.com/fail\"}'"
)
assert code.strip() == "401", f"Expected 401 after key deletion, got {code.strip()}"
# ---- 3D: CRUD lifecycle test ----
with subtest("CRUD lifecycle: project -> jobset -> list -> delete -> 404"):
# Create project
result = machine.succeed(
"curl -sf -X POST http://127.0.0.1:3000/api/v1/projects "
f"{auth_header} "
"-H 'Content-Type: application/json' "
"-d '{\"name\": \"crud-test\", \"repository_url\": \"https://example.com/crud\"}' "
"| jq -r .id"
)
crud_project_id = result.strip()
# Create jobset
result = machine.succeed(
f"curl -sf -X POST http://127.0.0.1:3000/api/v1/projects/{crud_project_id}/jobsets "
f"{auth_header} "
"-H 'Content-Type: application/json' "
"-d '{\"name\": \"main\", \"nix_expression\": \".\"}' "
"| jq -r .id"
)
jobset_id = result.strip()
assert len(jobset_id) == 36, f"Expected UUID for jobset, got '{jobset_id}'"
# List jobsets (should have at least 1)
result = machine.succeed(
f"curl -sf http://127.0.0.1:3000/api/v1/projects/{crud_project_id}/jobsets | jq '.items | length'"
)
assert int(result.strip()) >= 1, f"Expected at least 1 jobset, got {result.strip()}"
# Delete project (cascades)
machine.succeed(
f"curl -sf -X DELETE http://127.0.0.1:3000/api/v1/projects/{crud_project_id} "
f"{auth_header}"
)
# Verify project returns 404
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
f"http://127.0.0.1:3000/api/v1/projects/{crud_project_id}"
)
assert code.strip() == "404", f"Expected 404 after deletion, got {code.strip()}"
# ---- 3E: Edge case tests ----
with subtest("Duplicate project name returns 409"):
machine.succeed(
"curl -sf -X POST http://127.0.0.1:3000/api/v1/projects "
f"{auth_header} "
"-H 'Content-Type: application/json' "
"-d '{\"name\": \"dup-test\", \"repository_url\": \"https://example.com/dup\"}'"
)
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
"-X POST http://127.0.0.1:3000/api/v1/projects "
f"{auth_header} "
"-H 'Content-Type: application/json' "
"-d '{\"name\": \"dup-test\", \"repository_url\": \"https://example.com/dup2\"}'"
)
assert code.strip() == "409", f"Expected 409 for duplicate, got {code.strip()}"
with subtest("Invalid UUID path returns 400"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
"http://127.0.0.1:3000/api/v1/projects/not-a-uuid"
)
assert code.strip() == "400", f"Expected 400 for invalid UUID, got {code.strip()}"
with subtest("XSS in project name returns 400"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
"-X POST http://127.0.0.1:3000/api/v1/projects "
f"{auth_header} "
"-H 'Content-Type: application/json' "
"-d '{\"name\": \"<script>alert(1)</script>\", \"repository_url\": \"https://example.com/xss\"}'"
)
assert code.strip() == "400", f"Expected 400 for XSS name, got {code.strip()}"
# ---- 3F: Security fuzzing ----
with subtest("SQL injection in search query returns 0 results"):
result = machine.succeed(
"curl -sf 'http://127.0.0.1:3000/api/v1/search?q=test%27%20OR%201%3D1%20--' | jq '.projects | length'"
)
assert result.strip() == "0", f"Expected 0, got {result.strip()}"
# Verify projects table is intact
count = machine.succeed(
"sudo -u fc psql -U fc -d fc -t -c 'SELECT COUNT(*) FROM projects'"
)
assert int(count.strip()) > 0, "Projects table seems damaged"
with subtest("Path traversal in cache returns 404"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
"'http://127.0.0.1:3000/nix-cache/nar/../../../etc/passwd.nar'"
)
# Should be 404 (not 200)
assert code.strip() in ("400", "404"), f"Expected 400/404 for path traversal, got {code.strip()}"
with subtest("Oversized request body returns 413"):
# Generate a payload larger than 10MB (the default max_body_size)
code = machine.succeed(
"dd if=/dev/zero bs=1M count=12 2>/dev/null | "
"curl -s -o /dev/null -w '%{http_code}' "
"-X POST http://127.0.0.1:3000/api/v1/projects "
f"{auth_header} "
"-H 'Content-Type: application/json' "
"--data-binary @-"
)
assert code.strip() == "413", f"Expected 413 for oversized body, got {code.strip()}"
with subtest("NULL bytes in project name returns 400"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
"-X POST http://127.0.0.1:3000/api/v1/projects "
f"{auth_header} "
"-H 'Content-Type: application/json' "
"-d '{\"name\": \"null\\u0000byte\", \"repository_url\": \"https://example.com/null\"}'"
)
assert code.strip() == "400", f"Expected 400 for null bytes, got {code.strip()}"
# ---- 3G: Dashboard page smoke tests ----
with subtest("All dashboard pages return 200"):
pages = ["/", "/projects", "/evaluations", "/builds", "/queue", "/channels", "/admin", "/login"]
for page in pages:
code = machine.succeed(
f"curl -s -o /dev/null -w '%{{http_code}}' http://127.0.0.1:3000{page}"
)
assert code.strip() == "200", f"Page {page} returned {code.strip()}, expected 200"
'';
}

181
nix/tests/basic-api.nix Normal file
View file

@ -0,0 +1,181 @@
{
pkgs,
fc-packages,
nixosModule,
}:
pkgs.testers.nixosTest {
name = "fc-basic-api";
nodes.machine = import ./common.nix {inherit pkgs fc-packages nixosModule;};
testScript = ''
import hashlib
machine.start()
machine.wait_for_unit("postgresql.service")
# Ensure PostgreSQL is actually ready to accept connections before fc-server starts
machine.wait_until_succeeds("sudo -u fc psql -U fc -d fc -c 'SELECT 1'", timeout=30)
machine.wait_for_unit("fc-server.service")
# Wait for the server to start listening
machine.wait_until_succeeds("curl -sf http://127.0.0.1:3000/health", timeout=30)
# ---- Seed an API key for write operations ----
# Token: fc_testkey123 -> SHA-256 hash inserted into api_keys table
api_token = "fc_testkey123"
api_hash = hashlib.sha256(api_token.encode()).hexdigest()
machine.succeed(
f"sudo -u fc psql -U fc -d fc -c \"INSERT INTO api_keys (name, key_hash, role) VALUES ('test', '{api_hash}', 'admin')\""
)
auth_header = f"-H 'Authorization: Bearer {api_token}'"
# ---- Health endpoint ----
with subtest("Health endpoint returns OK"):
result = machine.succeed("curl -sf http://127.0.0.1:3000/health | jq -r .status")
assert result.strip() == "ok", f"Expected 'ok', got '{result.strip()}'"
with subtest("Health endpoint reports database healthy"):
result = machine.succeed("curl -sf http://127.0.0.1:3000/health | jq -r .database")
assert result.strip() == "true", f"Expected 'true', got '{result.strip()}'"
# ---- Cache endpoint: nix-cache-info ----
with subtest("Cache info endpoint returns correct data"):
result = machine.succeed("curl -sf http://127.0.0.1:3000/nix-cache/nix-cache-info")
assert "StoreDir: /nix/store" in result, f"Missing StoreDir in: {result}"
assert "WantMassQuery: 1" in result, f"Missing WantMassQuery in: {result}"
# ---- Cache endpoint: invalid hash rejection ----
with subtest("Cache rejects short hash"):
machine.succeed("curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/nix-cache/tooshort.narinfo | grep -q 404")
with subtest("Cache rejects uppercase hash"):
machine.succeed("curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/nix-cache/ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEF.narinfo | grep -q 404")
with subtest("Cache rejects special chars in hash"):
machine.succeed("curl -s -o /dev/null -w '%{http_code}' 'http://127.0.0.1:3000/nix-cache/abcdefghijklmnop____abcde.narinfo' | grep -q 404")
with subtest("Cache returns 404 for valid but nonexistent hash"):
machine.succeed("curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/nix-cache/abcdefghijklmnopqrstuvwxyz012345.narinfo | grep -q 404")
# ---- NAR endpoints: invalid hash rejection ----
with subtest("NAR zst rejects invalid hash"):
machine.succeed("curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/nix-cache/nar/INVALID.nar.zst | grep -q 404")
with subtest("NAR plain rejects invalid hash"):
machine.succeed("curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/nix-cache/nar/INVALID.nar | grep -q 404")
# ---- Search endpoint: length validation ----
with subtest("Search rejects empty query"):
result = machine.succeed("curl -sf 'http://127.0.0.1:3000/api/v1/search?q=' | jq '.projects | length'")
assert result.strip() == "0", f"Expected 0 projects, got {result.strip()}"
with subtest("Search rejects overly long query"):
long_q = "a" * 300
result = machine.succeed(f"curl -sf 'http://127.0.0.1:3000/api/v1/search?q={long_q}' | jq '.projects | length'")
assert result.strip() == "0", f"Expected 0 projects for long query, got {result.strip()}"
# ---- Error response format ----
with subtest("404 error response includes error_code field"):
json_result = machine.succeed("curl -s http://127.0.0.1:3000/api/v1/projects/00000000-0000-0000-0000-000000000000 | jq -r .error_code")
assert json_result.strip() == "NOT_FOUND", f"Expected NOT_FOUND, got {json_result.strip()}"
# ---- Empty page states (before any data is created) ----
with subtest("Empty evaluations page has proper empty state"):
body = machine.succeed("curl -sf http://127.0.0.1:3000/evaluations")
assert "Page 1 of 0" not in body, \
"Evaluations page should NOT show 'Page 1 of 0' when empty"
assert "No evaluations yet" in body, \
"Empty evaluations page should show helpful empty state message"
with subtest("Empty builds page has proper empty state"):
body = machine.succeed("curl -sf http://127.0.0.1:3000/builds")
assert "Page 1 of 0" not in body, \
"Builds page should NOT show 'Page 1 of 0' when empty"
assert "No builds match" in body, \
"Empty builds page should show helpful empty state message"
with subtest("Empty channels page has proper empty state"):
body = machine.succeed("curl -sf http://127.0.0.1:3000/channels")
assert "No channels configured" in body, \
"Empty channels page should show helpful empty state"
with subtest("Tables use table-wrap containers on projects page"):
body = machine.succeed("curl -sf http://127.0.0.1:3000/projects")
# Projects page should have at least one project (from bootstrap)
assert "table-wrap" in body, \
"Projects page should wrap tables in .table-wrap class"
# ---- API CRUD: create and list projects ----
with subtest("Create a project via API"):
result = machine.succeed(
"curl -sf -X POST http://127.0.0.1:3000/api/v1/projects "
f"{auth_header} "
"-H 'Content-Type: application/json' "
"-d '{\"name\": \"test-project\", \"repository_url\": \"https://github.com/test/repo\"}' "
"| jq -r .id"
)
project_id = result.strip()
assert len(project_id) == 36, f"Expected UUID, got '{project_id}'"
with subtest("List projects includes created project"):
result = machine.succeed("curl -sf http://127.0.0.1:3000/api/v1/projects | jq '.items[0].name'")
assert "test-project" in result, f"Expected test-project in: {result}"
# ---- Builds list with filters ----
with subtest("Builds list with system filter returns 200"):
machine.succeed("curl -sf 'http://127.0.0.1:3000/api/v1/builds?system=x86_64-linux' | jq '.items'")
with subtest("Builds list with job_name filter returns 200"):
machine.succeed("curl -sf 'http://127.0.0.1:3000/api/v1/builds?job_name=hello' | jq '.items'")
with subtest("Builds list with combined filters returns 200"):
machine.succeed("curl -sf 'http://127.0.0.1:3000/api/v1/builds?system=x86_64-linux&status=pending&job_name=test' | jq '.items'")
# ---- Metrics endpoint ----
with subtest("Metrics endpoint returns prometheus format"):
result = machine.succeed("curl -sf http://127.0.0.1:3000/metrics")
assert "fc_builds_total" in result, "Missing fc_builds_total in metrics"
assert "fc_projects_total" in result, "Missing fc_projects_total in metrics"
assert "fc_evaluations_total" in result, "Missing fc_evaluations_total in metrics"
# ---- CORS: default restrictive (no Access-Control-Allow-Origin for cross-origin) ----
with subtest("Default CORS does not allow arbitrary origins"):
result = machine.succeed(
"curl -s -D - "
"-H 'Origin: http://evil.example.com' "
"http://127.0.0.1:3000/health "
"2>&1"
)
# With restrictive CORS, there should be no access-control-allow-origin header
# for an arbitrary origin
assert "access-control-allow-origin: http://evil.example.com" not in result.lower(), \
f"CORS should not allow arbitrary origins: {result}"
# ---- Systemd hardening ----
with subtest("fc-server runs as fc user"):
result = machine.succeed("systemctl show fc-server --property=User --value")
assert result.strip() == "fc", f"Expected fc user, got '{result.strip()}'"
with subtest("fc-server has NoNewPrivileges"):
result = machine.succeed("systemctl show fc-server --property=NoNewPrivileges --value")
assert result.strip() == "yes", f"Expected NoNewPrivileges, got '{result.strip()}'"
with subtest("fc user home directory exists"):
machine.succeed("test -d /var/lib/fc")
with subtest("Log directory exists"):
machine.succeed("test -d /var/lib/fc/logs || mkdir -p /var/lib/fc/logs")
# ---- Stats endpoint ----
with subtest("Build stats endpoint returns data"):
result = machine.succeed("curl -sf http://127.0.0.1:3000/api/v1/builds/stats | jq '.total_builds'")
# Should be a number (possibly 0)
int(result.strip())
with subtest("Recent builds endpoint returns array"):
result = machine.succeed("curl -sf http://127.0.0.1:3000/api/v1/builds/recent | jq 'type'")
assert result.strip() == '"array"', f"Expected array, got {result.strip()}"
'';
}

80
nix/tests/common.nix Normal file
View file

@ -0,0 +1,80 @@
# Common machine configuration for all FC integration tests
{
pkgs,
fc-packages,
nixosModule,
}: {
imports = [nixosModule];
programs.git.enable = true;
security.sudo.enable = true;
# Ensure nix and zstd are available for cache endpoints
environment.systemPackages = with pkgs; [nix nix-eval-jobs zstd curl jq openssl];
services.fc = {
enable = true;
package = fc-packages.fc-server;
evaluatorPackage = fc-packages.fc-evaluator;
queueRunnerPackage = fc-packages.fc-queue-runner;
migratePackage = fc-packages.fc-migrate-cli;
server.enable = true;
evaluator.enable = true;
queueRunner.enable = true;
settings = {
database.url = "postgresql:///fc?host=/run/postgresql";
server = {
host = "127.0.0.1";
port = 3000;
cors_permissive = false;
};
gc.enabled = false;
logs.log_dir = "/var/lib/fc/logs";
cache.enabled = true;
signing.enabled = false;
tracing = {
level = "info";
format = "compact";
show_targets = true;
show_timestamps = true;
};
evaluator.poll_interval = 5;
evaluator.work_dir = "/var/lib/fc/evaluator";
queue_runner.poll_interval = 3;
queue_runner.work_dir = "/var/lib/fc/queue-runner";
# Declarative bootstrap: project + API key created on startup
declarative = {
projects = [
{
name = "declarative-project";
repository_url = "https://github.com/test/declarative";
description = "Bootstrap test project";
jobsets = [
{
name = "packages";
nix_expression = "packages";
enabled = true;
flake_mode = true;
check_interval = 300;
}
];
}
];
api_keys = [
{
name = "bootstrap-admin";
key = "fc_bootstrap_key";
role = "admin";
}
];
};
};
};
}

716
nix/tests/e2e.nix Normal file
View file

@ -0,0 +1,716 @@
# End-to-end tests: flake creation, evaluation, queue runner, notification, signing, GC, declarative, webhooks
{
pkgs,
fc-packages,
nixosModule,
}:
pkgs.testers.nixosTest {
name = "fc-e2e";
nodes.machine = import ./common.nix {inherit pkgs fc-packages nixosModule;};
testScript = ''
import hashlib
import json
machine.start()
machine.wait_for_unit("postgresql.service")
# Ensure PostgreSQL is actually ready to accept connections before fc-server starts
machine.wait_until_succeeds("sudo -u fc psql -U fc -d fc -c 'SELECT 1'", timeout=30)
machine.wait_for_unit("fc-server.service")
# Wait for the server to start listening
machine.wait_until_succeeds("curl -sf http://127.0.0.1:3000/health", timeout=30)
# ---- Seed an API key for write operations ----
# Token: fc_testkey123 -> SHA-256 hash inserted into api_keys table
api_token = "fc_testkey123"
api_hash = hashlib.sha256(api_token.encode()).hexdigest()
machine.succeed(
f"sudo -u fc psql -U fc -d fc -c \"INSERT INTO api_keys (name, key_hash, role) VALUES ('test', '{api_hash}', 'admin')\""
)
auth_header = f"-H 'Authorization: Bearer {api_token}'"
# Seed a read-only key
ro_token = "fc_readonly_key"
ro_hash = hashlib.sha256(ro_token.encode()).hexdigest()
machine.succeed(
f"sudo -u fc psql -U fc -d fc -c \"INSERT INTO api_keys (name, key_hash, role) VALUES ('readonly', '{ro_hash}', 'read-only')\""
)
ro_header = f"-H 'Authorization: Bearer {ro_token}'"
# ========================================================================
# Phase E2E-1: End-to-End Evaluator Integration Test
# ========================================================================
# ---- Create a test flake inside the VM ----
with subtest("Create bare git repo with test flake"):
machine.succeed("mkdir -p /var/lib/fc/test-repos")
machine.succeed("git init --bare /var/lib/fc/test-repos/test-flake.git")
# Create a working copy, write the flake, commit, push
machine.succeed("mkdir -p /tmp/test-flake-work")
machine.succeed("cd /tmp/test-flake-work && git init")
machine.succeed("cd /tmp/test-flake-work && git config user.email 'test@fc' && git config user.name 'FC Test'")
# Write a minimal flake.nix that builds a simple derivation
machine.succeed("""
cat > /tmp/test-flake-work/flake.nix << 'FLAKE'
{
description = "FC CI test flake";
outputs = { self, ... }: {
packages.x86_64-linux.hello = derivation {
name = "fc-test-hello";
system = "x86_64-linux";
builder = "/bin/sh";
args = [ "-c" "echo hello > $out" ];
};
};
}
FLAKE
""")
machine.succeed("cd /tmp/test-flake-work && git add -A && git commit -m 'initial flake'")
machine.succeed("cd /tmp/test-flake-work && git remote add origin /var/lib/fc/test-repos/test-flake.git")
machine.succeed("cd /tmp/test-flake-work && git push origin HEAD:refs/heads/main")
# Set ownership for fc user
machine.succeed("chown -R fc:fc /var/lib/fc/test-repos")
# ---- Create project + jobset pointing to the local repo via API ----
with subtest("Create E2E project and jobset via API"):
result = machine.succeed(
"curl -sf -X POST http://127.0.0.1:3000/api/v1/projects "
f"{auth_header} "
"-H 'Content-Type: application/json' "
"-d '{\"name\": \"e2e-test\", \"repository_url\": \"https://github.com/nixos/nixpkgs\"}' "
"| jq -r .id"
)
e2e_project_id = result.strip()
assert len(e2e_project_id) == 36, f"Expected UUID, got '{e2e_project_id}'"
result = machine.succeed(
f"curl -sf -X POST http://127.0.0.1:3000/api/v1/projects/{e2e_project_id}/jobsets "
f"{auth_header} "
"-H 'Content-Type: application/json' "
"-d '{\"name\": \"packages\", \"nix_expression\": \"packages\", \"flake_mode\": true, \"enabled\": true, \"check_interval\": 5, \"branch\": null, \"scheduling_shares\": 100}' "
"| jq -r .id"
)
e2e_jobset_id = result.strip()
assert len(e2e_jobset_id) == 36, f"Expected UUID for jobset, got '{e2e_jobset_id}'"
# ---- Wait for evaluator to pick it up and create an evaluation ----
with subtest("Evaluator discovers and evaluates the flake"):
# The evaluator is already running (started in Phase 1)
# Poll for evaluation to appear with status "completed"
machine.wait_until_succeeds(
f"curl -sf 'http://127.0.0.1:3000/api/v1/evaluations?jobset_id={e2e_jobset_id}' "
"| jq -e '.items[] | select(.status==\"completed\")'",
timeout=90
)
with subtest("Evaluation created builds with valid drv_path"):
# Get evaluation ID
e2e_eval_id = machine.succeed(
f"curl -sf 'http://127.0.0.1:3000/api/v1/evaluations?jobset_id={e2e_jobset_id}' "
"| jq -r '.items[] | select(.status==\"completed\") | .id' | head -1"
).strip()
assert len(e2e_eval_id) == 36, f"Expected UUID for evaluation, got '{e2e_eval_id}'"
# Verify builds were created
result = machine.succeed(
f"curl -sf 'http://127.0.0.1:3000/api/v1/builds?evaluation_id={e2e_eval_id}' | jq '.items | length'"
)
build_count = int(result.strip())
assert build_count >= 1, f"Expected >= 1 build, got {build_count}"
# Verify build has valid drv_path
drv_path = machine.succeed(
f"curl -sf 'http://127.0.0.1:3000/api/v1/builds?evaluation_id={e2e_eval_id}' | jq -r '.items[0].drv_path'"
).strip()
assert drv_path.startswith("/nix/store/"), f"Expected /nix/store/ drv_path, got '{drv_path}'"
# Get the build ID for later
e2e_build_id = machine.succeed(
f"curl -sf 'http://127.0.0.1:3000/api/v1/builds?evaluation_id={e2e_eval_id}' | jq -r '.items[0].id'"
).strip()
# ---- Test evaluation caching ----
with subtest("Same commit does not trigger a new evaluation"):
# Get current evaluation count
before_count = machine.succeed(
f"curl -sf 'http://127.0.0.1:3000/api/v1/evaluations?jobset_id={e2e_jobset_id}' | jq '.items | length'"
).strip()
# Wait a poll cycle
time.sleep(10)
after_count = machine.succeed(
f"curl -sf 'http://127.0.0.1:3000/api/v1/evaluations?jobset_id={e2e_jobset_id}' | jq '.items | length'"
).strip()
assert before_count == after_count, f"Evaluation count changed from {before_count} to {after_count} (should be cached)"
# ---- Test new commit triggers new evaluation ----
with subtest("New commit triggers new evaluation"):
before_count_int = int(machine.succeed(
f"curl -sf 'http://127.0.0.1:3000/api/v1/evaluations?jobset_id={e2e_jobset_id}' | jq '.items | length'"
).strip())
# Push a new commit
machine.succeed("""
cd /tmp/test-flake-work && \
cat > flake.nix << 'FLAKE'
{
description = "FC CI test flake v2";
outputs = { self, ... }: {
packages.x86_64-linux.hello = derivation {
name = "fc-test-hello-v2";
system = "x86_64-linux";
builder = "/bin/sh";
args = [ "-c" "echo hello-v2 > $out" ];
};
};
}
FLAKE
""")
machine.succeed("cd /tmp/test-flake-work && git add -A && git commit -m 'v2 update'")
machine.succeed("cd /tmp/test-flake-work && git push origin HEAD:refs/heads/main")
# Wait for evaluator to detect and create new evaluation
machine.wait_until_succeeds(
f"test $(curl -sf 'http://127.0.0.1:3000/api/v1/evaluations?jobset_id={e2e_jobset_id}' | jq '.items | length') -gt {before_count_int}",
timeout=60
)
# ========================================================================
# Phase E2E-2: End-to-End Queue Runner Integration Test
# ========================================================================
with subtest("Queue runner builds pending derivation"):
# Poll the E2E build until completed (queue-runner is already running)
machine.wait_until_succeeds(
f"curl -sf http://127.0.0.1:3000/api/v1/builds/{e2e_build_id} | jq -e 'select(.status==\"completed\")'",
timeout=120
)
with subtest("Completed build has output path"):
result = machine.succeed(
f"curl -sf http://127.0.0.1:3000/api/v1/builds/{e2e_build_id} | jq -r .build_output_path"
).strip()
assert result != "null" and result.startswith("/nix/store/"), \
f"Expected /nix/store/ output path, got '{result}'"
with subtest("Build steps recorded"):
result = machine.succeed(
f"curl -sf http://127.0.0.1:3000/api/v1/builds/{e2e_build_id}/steps | jq 'length'"
)
assert int(result.strip()) >= 1, f"Expected >= 1 build step, got {result.strip()}"
# Verify exit_code = 0
exit_code = machine.succeed(
f"curl -sf http://127.0.0.1:3000/api/v1/builds/{e2e_build_id}/steps | jq '.[0].exit_code'"
).strip()
assert exit_code == "0", f"Expected exit_code 0, got {exit_code}"
with subtest("Build products created"):
result = machine.succeed(
f"curl -sf http://127.0.0.1:3000/api/v1/builds/{e2e_build_id}/products | jq 'length'"
)
assert int(result.strip()) >= 1, f"Expected >= 1 build product, got {result.strip()}"
# Verify product has valid path
product_path = machine.succeed(
f"curl -sf http://127.0.0.1:3000/api/v1/builds/{e2e_build_id}/products | jq -r '.[0].path'"
).strip()
assert product_path.startswith("/nix/store/"), f"Expected /nix/store/ product path, got '{product_path}'"
with subtest("Build log exists"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
f"http://127.0.0.1:3000/api/v1/builds/{e2e_build_id}/log"
).strip()
assert code == "200", f"Expected 200 for build log, got {code}"
# ========================================================================
# Phase E2E-3: Jobset Input Management API
# ========================================================================
with subtest("Create jobset input via API"):
result = machine.succeed(
f"curl -sf -X POST http://127.0.0.1:3000/api/v1/projects/{e2e_project_id}/jobsets/{e2e_jobset_id}/inputs "
f"{auth_header} "
"-H 'Content-Type: application/json' "
"-d '{\"name\": \"nixpkgs\", \"input_type\": \"git\", \"value\": \"https://github.com/NixOS/nixpkgs\"}'"
)
input_data = json.loads(result)
assert "id" in input_data, f"Expected id in response: {result}"
e2e_input_id = input_data["id"]
with subtest("List jobset inputs"):
result = machine.succeed(
f"curl -sf http://127.0.0.1:3000/api/v1/projects/{e2e_project_id}/jobsets/{e2e_jobset_id}/inputs | jq 'length'"
)
assert int(result.strip()) >= 1, f"Expected >= 1 input, got {result.strip()}"
with subtest("Read-only key cannot create jobset input"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
f"-X POST http://127.0.0.1:3000/api/v1/projects/{e2e_project_id}/jobsets/{e2e_jobset_id}/inputs "
f"{ro_header} "
"-H 'Content-Type: application/json' "
"-d '{\"name\": \"test\", \"input_type\": \"string\", \"value\": \"hello\"}'"
).strip()
assert code == "403", f"Expected 403 for read-only input create, got {code}"
with subtest("Delete jobset input"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
f"-X DELETE http://127.0.0.1:3000/api/v1/projects/{e2e_project_id}/jobsets/{e2e_jobset_id}/inputs/{e2e_input_id} "
f"{auth_header}"
).strip()
assert code == "200", f"Expected 200 for input delete, got {code}"
with subtest("Read-only key cannot delete jobset input"):
# Re-create first
result = machine.succeed(
f"curl -sf -X POST http://127.0.0.1:3000/api/v1/projects/{e2e_project_id}/jobsets/{e2e_jobset_id}/inputs "
f"{auth_header} "
"-H 'Content-Type: application/json' "
"-d '{\"name\": \"test-ro\", \"input_type\": \"string\", \"value\": \"test\"}'"
)
tmp_input_id = json.loads(result)["id"]
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
f"-X DELETE http://127.0.0.1:3000/api/v1/projects/{e2e_project_id}/jobsets/{e2e_jobset_id}/inputs/{tmp_input_id} "
f"{ro_header}"
).strip()
assert code == "403", f"Expected 403 for read-only input delete, got {code}"
# ========================================================================
# Phase E2E-4: Notification Dispatch
# ========================================================================
# Notifications are dispatched after builds complete (already tested above).
# Verify run_command notifications work:
with subtest("Notification run_command is invoked on build completion"):
# This tests that the notification system dispatches properly.
# The actual run_command config is not set in this VM, so we just verify
# the build status was updated correctly after notification dispatch.
result = machine.succeed(
f"curl -sf http://127.0.0.1:3000/api/v1/builds/{e2e_build_id} | jq -r .status"
).strip()
assert result == "completed", f"Expected completed after notification, got {result}"
# ========================================================================
# Phase E2E-5: Channel Auto-Promotion
# ========================================================================
with subtest("Channel auto-promotion after all builds complete"):
# Create a channel tracking the E2E jobset
result = machine.succeed(
"curl -sf -X POST http://127.0.0.1:3000/api/v1/channels "
f"{auth_header} "
"-H 'Content-Type: application/json' "
f"-d '{{\"project_id\": \"{e2e_project_id}\", \"name\": \"e2e-channel\", \"jobset_id\": \"{e2e_jobset_id}\"}}' "
"| jq -r .id"
)
e2e_channel_id = result.strip()
# Auto-promotion happens when all builds in an evaluation complete.
# The first evaluation's builds should already be complete.
# Check channel's current_evaluation_id
machine.wait_until_succeeds(
f"curl -sf http://127.0.0.1:3000/api/v1/channels/{e2e_channel_id} "
"| jq -e 'select(.current_evaluation_id != null)'",
timeout=30
)
# ========================================================================
# Phase E2E-6: Binary Cache NARinfo Test
# ========================================================================
with subtest("Binary cache serves NARinfo for built output"):
# Get the build output path
output_path = machine.succeed(
f"curl -sf http://127.0.0.1:3000/api/v1/builds/{e2e_build_id} | jq -r .build_output_path"
).strip()
# Extract the hash from /nix/store/<hash>-<name>
hash_match = re.match(r'/nix/store/([a-z0-9]+)-', output_path)
assert hash_match, f"Could not extract hash from output path: {output_path}"
store_hash = hash_match.group(1)
# Request NARinfo
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
f"http://127.0.0.1:3000/nix-cache/{store_hash}.narinfo"
).strip()
assert code == "200", f"Expected 200 for NARinfo, got {code}"
# Verify NARinfo content has StorePath and NarHash
narinfo = machine.succeed(
f"curl -sf http://127.0.0.1:3000/nix-cache/{store_hash}.narinfo"
)
assert "StorePath:" in narinfo, f"NARinfo missing StorePath: {narinfo}"
assert "NarHash:" in narinfo, f"NARinfo missing NarHash: {narinfo}"
# ========================================================================
# Phase E2E-7: Build Retry on Failure
# ========================================================================
with subtest("Build with invalid drv_path fails and retries"):
# Insert a build with an invalid drv_path via SQL
machine.succeed(
"sudo -u postgres psql -d fc -c \""
"INSERT INTO builds (id, evaluation_id, job_name, drv_path, status, priority, retry_count, max_retries, is_aggregate, signed) "
f"VALUES (gen_random_uuid(), '{e2e_eval_id}', 'bad-build', '/nix/store/invalid-does-not-exist.drv', 'pending', 0, 0, 3, false, false);\""
)
# Wait for queue-runner to attempt the build and fail it
machine.wait_until_succeeds(
"curl -sf 'http://127.0.0.1:3000/api/v1/builds?job_name=bad-build' "
"| jq -e '.items[] | select(.status==\"failed\")'",
timeout=60
)
# Verify status is failed
result = machine.succeed(
"curl -sf 'http://127.0.0.1:3000/api/v1/builds?job_name=bad-build' | jq -r '.items[0].status'"
).strip()
assert result == "failed", f"Expected failed for bad build, got '{result}'"
# ========================================================================
# Phase E2E-8: Notification Dispatch (run_command)
# ========================================================================
with subtest("Notification run_command invoked on build completion"):
# Write a notification script
machine.succeed("mkdir -p /var/lib/fc")
machine.succeed("""
cat > /var/lib/fc/notify.sh << 'SCRIPT'
#!/bin/sh
echo "BUILD_STATUS=$FC_BUILD_STATUS" >> /var/lib/fc/notify-output
echo "BUILD_ID=$FC_BUILD_ID" >> /var/lib/fc/notify-output
echo "BUILD_JOB=$FC_BUILD_JOB" >> /var/lib/fc/notify-output
SCRIPT
""")
machine.succeed("chmod +x /var/lib/fc/notify.sh")
machine.succeed("chown -R fc:fc /var/lib/fc")
# Update fc.toml to enable notifications
machine.succeed("""
cat >> /etc/fc.toml << 'CONFIG'
[notifications]
run_command = "/var/lib/fc/notify.sh"
CONFIG
""")
# Restart queue-runner to pick up new config
machine.succeed("systemctl restart fc-queue-runner")
machine.wait_for_unit("fc-queue-runner.service", timeout=30)
# Create a new simple build to trigger notification
# Push a trivial change to trigger a new evaluation
machine.succeed("""
cd /tmp/test-flake-work && \
cat > flake.nix << 'FLAKE'
{
description = "FC CI test flake notify";
outputs = { self, ... }: {
packages.x86_64-linux.notify-test = derivation {
name = "fc-notify-test";
system = "x86_64-linux";
builder = "/bin/sh";
args = [ "-c" "echo notify-test > $out" ];
};
};
}
FLAKE
""")
machine.succeed("cd /tmp/test-flake-work && git add -A && git commit -m 'trigger notification test'")
machine.succeed("cd /tmp/test-flake-work && git push origin HEAD:refs/heads/main")
# Wait for evaluator to create new evaluation
machine.wait_until_succeeds(
f"curl -sf 'http://127.0.0.1:3000/api/v1/evaluations?jobset_id={e2e_jobset_id}' "
"| jq '.items | length' | grep -v '^2$'",
timeout=60
)
# Get the new build ID
notify_build_id = machine.succeed(
"curl -sf 'http://127.0.0.1:3000/api/v1/builds?job_name=notify-test' | jq -r '.items[0].id'"
).strip()
# Wait for the build to complete
machine.wait_until_succeeds(
f"curl -sf http://127.0.0.1:3000/api/v1/builds/{notify_build_id} | jq -e 'select(.status==\"completed\")'",
timeout=120
)
# Wait a bit for notification to dispatch
time.sleep(5)
# Verify the notification script was executed
machine.wait_for_file("/var/lib/fc/notify-output")
output = machine.succeed("cat /var/lib/fc/notify-output")
assert "BUILD_STATUS=success" in output or "BUILD_STATUS=completed" in output, \
f"Expected BUILD_STATUS in notification output, got: {output}"
assert notify_build_id in output, f"Expected build ID {notify_build_id} in output, got: {output}"
# ========================================================================
# Phase E2E-9: Nix Signing
# ========================================================================
with subtest("Generate signing key and configure signing"):
# Generate a Nix signing key
machine.succeed("mkdir -p /var/lib/fc/keys")
machine.succeed("nix-store --generate-binary-cache-key fc-test /var/lib/fc/keys/signing-key /var/lib/fc/keys/signing-key.pub")
machine.succeed("chown -R fc:fc /var/lib/fc/keys")
machine.succeed("chmod 600 /var/lib/fc/keys/signing-key")
# Update fc.toml to enable signing
machine.succeed("""
cat >> /etc/fc.toml << 'CONFIG'
[signing]
enabled = true
key_file = "/var/lib/fc/keys/signing-key"
CONFIG
""")
# Restart queue-runner to pick up signing config
machine.succeed("systemctl restart fc-queue-runner")
machine.wait_for_unit("fc-queue-runner.service", timeout=30)
with subtest("Signed builds have valid signatures"):
# Create a new build to test signing
machine.succeed("""
cd /tmp/test-flake-work && \
cat > flake.nix << 'FLAKE'
{
description = "FC CI test flake signing";
outputs = { self, ... }: {
packages.x86_64-linux.sign-test = derivation {
name = "fc-sign-test";
system = "x86_64-linux";
builder = "/bin/sh";
args = [ "-c" "echo signed-build > $out" ];
};
};
}
FLAKE
""")
machine.succeed("cd /tmp/test-flake-work && git add -A && git commit -m 'trigger signing test'")
machine.succeed("cd /tmp/test-flake-work && git push origin HEAD:refs/heads/main")
# Wait for evaluation
machine.wait_until_succeeds(
f"curl -sf 'http://127.0.0.1:3000/api/v1/evaluations?jobset_id={e2e_jobset_id}' "
"| jq '.items | length' | grep -v '^[23]$'",
timeout=60
)
# Get the sign-test build
sign_build_id = machine.succeed(
"curl -sf 'http://127.0.0.1:3000/api/v1/builds?job_name=sign-test' | jq -r '.items[0].id'"
).strip()
# Wait for build to complete
machine.wait_until_succeeds(
f"curl -sf http://127.0.0.1:3000/api/v1/builds/{sign_build_id} | jq -e 'select(.status==\"completed\")'",
timeout=120
)
# Verify the build has signed=true
signed = machine.succeed(
f"curl -sf http://127.0.0.1:3000/api/v1/builds/{sign_build_id} | jq -r .signed"
).strip()
assert signed == "true", f"Expected signed=true, got {signed}"
# Get the output path and verify it with nix store verify
output_path = machine.succeed(
f"curl -sf http://127.0.0.1:3000/api/v1/builds/{sign_build_id} | jq -r .build_output_path"
).strip()
# Verify the path is signed with our key
# The verify command should succeed (exit 0) if signatures are valid
machine.succeed(f"nix store verify --sigs-needed 1 {output_path}")
# ========================================================================
# Phase E2E-10: GC Roots
# ========================================================================
with subtest("GC roots are created for build products"):
# Enable GC in config
machine.succeed("""
cat >> /etc/fc.toml << 'CONFIG'
[gc]
enabled = true
gc_roots_dir = "/nix/var/nix/gcroots/per-user/fc"
max_age_days = 30
cleanup_interval = 3600
CONFIG
""")
# Restart queue-runner to enable GC
machine.succeed("systemctl restart fc-queue-runner")
machine.wait_for_unit("fc-queue-runner.service", timeout=30)
# Ensure the gc roots directory exists
machine.succeed("mkdir -p /nix/var/nix/gcroots/per-user/fc")
machine.succeed("chown -R fc:fc /nix/var/nix/gcroots/per-user/fc")
# Create a new build to test GC root creation
machine.succeed("""
cd /tmp/test-flake-work && \
cat > flake.nix << 'FLAKE'
{
description = "FC CI test flake gc";
outputs = { self, ... }: {
packages.x86_64-linux.gc-test = derivation {
name = "fc-gc-test";
system = "x86_64-linux";
builder = "/bin/sh";
args = [ "-c" "echo gc-test > $out" ];
};
};
}
FLAKE
""")
machine.succeed("cd /tmp/test-flake-work && git add -A && git commit -m 'trigger gc test'")
machine.succeed("cd /tmp/test-flake-work && git push origin HEAD:refs/heads/main")
# Wait for evaluation and build
machine.wait_until_succeeds(
"curl -sf 'http://127.0.0.1:3000/api/v1/builds?job_name=gc-test' | jq -e '.items[] | select(.status==\"completed\")'",
timeout=120
)
# Get the build output path
gc_build_output = machine.succeed(
"curl -sf 'http://127.0.0.1:3000/api/v1/builds?job_name=gc-test' | jq -r '.items[0].build_output_path'"
).strip()
# Verify GC root symlink was created
# The symlink should be in /nix/var/nix/gcroots/per-user/fc/ and point to the build output
gc_roots = machine.succeed("find /nix/var/nix/gcroots/per-user/fc -type l 2>/dev/null || true").strip()
# Check if any symlink points to our build output
if gc_roots:
found_root = False
for root in gc_roots.split('\n'):
if root:
target = machine.succeed(f"readlink -f {root} 2>/dev/null || true").strip()
if target == gc_build_output:
found_root = True
break
# We might have GC roots - this is expected behavior
# The key is that the build output exists and is protected from GC
machine.succeed(f"test -e {gc_build_output}")
else:
# If no GC roots yet, at least verify the build output exists
# GC roots might be created asynchronously
machine.succeed(f"test -e {gc_build_output}")
# ========================================================================
# Phase E2E-11: Declarative In-Repo Config
# ========================================================================
with subtest("Declarative .fc.toml in repo auto-creates jobset"):
# Add .fc.toml to the test repo with a new jobset definition
machine.succeed("""
cd /tmp/test-flake-work && \
cat > .fc.toml << 'FCTOML'
[[jobsets]]
name = "declarative-checks"
nix_expression = "checks"
flake_mode = true
enabled = true
FCTOML
""")
machine.succeed("cd /tmp/test-flake-work && git add -A && git commit -m 'add declarative config'")
machine.succeed("cd /tmp/test-flake-work && git push origin HEAD:refs/heads/main")
# Wait for evaluator to pick up the new commit and process declarative config
machine.wait_until_succeeds(
f"curl -sf 'http://127.0.0.1:3000/api/v1/projects/{e2e_project_id}/jobsets' "
"| jq -e '.items[] | select(.name==\"declarative-checks\")'",
timeout=60
)
# ========================================================================
# Phase E2E-12: Webhook Endpoint
# ========================================================================
with subtest("Webhook endpoint accepts valid GitHub push"):
# Create a webhook config via SQL (no REST endpoint for creation)
machine.succeed(
"sudo -u postgres psql -d fc -c \""
"INSERT INTO webhook_configs (id, project_id, forge_type, secret_hash, enabled) "
f"VALUES (gen_random_uuid(), '{e2e_project_id}', 'github', 'test-secret', true);\""
)
# Get the current evaluation count
before_evals = int(machine.succeed(
f"curl -sf 'http://127.0.0.1:3000/api/v1/evaluations?jobset_id={e2e_jobset_id}' | jq '.items | length'"
).strip())
# Compute HMAC-SHA256 of the payload
payload = '{"ref":"refs/heads/main","after":"abcdef1234567890abcdef1234567890abcdef12","repository":{"clone_url":"file:///var/lib/fc/test-repos/test-flake.git"}}'
# Generate HMAC with the secret
hmac_sig = machine.succeed(
f"echo -n '{payload}' | openssl dgst -sha256 -hmac 'test-secret' -hex | awk '{{print $2}}'"
).strip()
# Send webhook
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
f"-X POST http://127.0.0.1:3000/api/v1/webhooks/{e2e_project_id}/github "
"-H 'Content-Type: application/json' "
f"-H 'X-Hub-Signature-256: sha256={hmac_sig}' "
f"-d '{payload}'"
).strip()
assert code == "200", f"Expected 200 for webhook, got {code}"
# Verify the webhook response accepted the push
result = machine.succeed(
"curl -sf "
f"-X POST http://127.0.0.1:3000/api/v1/webhooks/{e2e_project_id}/github "
"-H 'Content-Type: application/json' "
f"-H 'X-Hub-Signature-256: sha256={hmac_sig}' "
f"-d '{payload}' | jq -r .accepted"
).strip()
assert result == "true", f"Expected webhook accepted=true, got {result}"
with subtest("Webhook rejects invalid signature"):
payload = '{"ref":"refs/heads/main","after":"deadbeef","repository":{}}'
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
f"-X POST http://127.0.0.1:3000/api/v1/webhooks/{e2e_project_id}/github "
"-H 'Content-Type: application/json' "
"-H 'X-Hub-Signature-256: sha256=0000000000000000000000000000000000000000000000000000000000000000' "
f"-d '{payload}'"
).strip()
assert code == "401", f"Expected 401 for invalid webhook signature, got {code}"
# ---- Cleanup: Delete project ----
with subtest("Delete E2E project"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
f"-X DELETE http://127.0.0.1:3000/api/v1/projects/{e2e_project_id} "
f"{auth_header}"
)
assert code.strip() == "200", f"Expected 200 for project delete, got {code.strip()}"
with subtest("Deleted E2E project returns 404"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
f"http://127.0.0.1:3000/api/v1/projects/{e2e_project_id}"
)
assert code.strip() == "404", f"Expected 404 for deleted project, got {code.strip()}"
'';
}

200
nix/tests/features.nix Normal file
View file

@ -0,0 +1,200 @@
# Feature tests: logging, CSS, setup wizard, probe, metrics improvements
{
pkgs,
fc-packages,
nixosModule,
}:
pkgs.testers.nixosTest {
name = "fc-features";
nodes.machine = import ./common.nix {inherit pkgs fc-packages nixosModule;};
testScript = ''
import hashlib
machine.start()
machine.wait_for_unit("postgresql.service")
# Ensure PostgreSQL is actually ready to accept connections before fc-server starts
machine.wait_until_succeeds("sudo -u fc psql -U fc -d fc -c 'SELECT 1'", timeout=30)
machine.wait_for_unit("fc-server.service")
# Wait for the server to start listening
machine.wait_until_succeeds("curl -sf http://127.0.0.1:3000/health", timeout=30)
# ---- Seed an API key for write operations ----
# Token: fc_testkey123 -> SHA-256 hash inserted into api_keys table
api_token = "fc_testkey123"
api_hash = hashlib.sha256(api_token.encode()).hexdigest()
machine.succeed(
f"sudo -u fc psql -U fc -d fc -c \"INSERT INTO api_keys (name, key_hash, role) VALUES ('test', '{api_hash}', 'admin')\""
)
auth_header = f"-H 'Authorization: Bearer {api_token}'"
# Seed a read-only key
ro_token = "fc_readonly_key"
ro_hash = hashlib.sha256(ro_token.encode()).hexdigest()
machine.succeed(
f"sudo -u fc psql -U fc -d fc -c \"INSERT INTO api_keys (name, key_hash, role) VALUES ('readonly', '{ro_hash}', 'read-only')\""
)
ro_header = f"-H 'Authorization: Bearer {ro_token}'"
# ========================================================================
# Phase 5: New Feature Tests (Structured Logging, Flake Probe, Setup Wizard, Dashboard)
# ========================================================================
# ---- 5A: Structured logging ----
with subtest("Server produces structured log output"):
# The server should log via tracing with the configured format
result = machine.succeed("journalctl -u fc-server --no-pager -n 50 2>&1")
# With compact/full format, tracing outputs level and target info
assert "INFO" in result or "info" in result, \
"Expected structured log lines with INFO level in journalctl output"
# ---- 5B: Static CSS serving ----
with subtest("Static CSS endpoint returns 200 with correct content type"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/static/style.css"
)
assert code.strip() == "200", f"Expected 200 for /static/style.css, got {code.strip()}"
ct = machine.succeed(
"curl -s -D - -o /dev/null http://127.0.0.1:3000/static/style.css | grep -i content-type"
)
assert "text/css" in ct.lower(), f"Expected text/css, got: {ct}"
# ---- 5C: Setup wizard page ----
with subtest("Setup wizard page returns 200"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/projects/new"
)
assert code.strip() == "200", f"Expected 200 for /projects/new, got {code.strip()}"
with subtest("Setup wizard page contains wizard steps"):
body = machine.succeed("curl -sf http://127.0.0.1:3000/projects/new")
assert "Step 1" in body, "Setup wizard should contain Step 1"
assert "Repository URL" in body, "Setup wizard should contain URL input"
assert "probeRepo" in body, "Setup wizard should contain probe JS function"
with subtest("Projects page links to setup wizard"):
# Login first to get admin view
cookie = machine.succeed(
"curl -s -D - -o /dev/null "
"-X POST http://127.0.0.1:3000/login "
f"-d 'api_key={api_token}' "
"| grep -i set-cookie | head -1"
)
match = re.search(r'fc_session=([^;]+)', cookie)
if match:
session_val = match.group(1)
body = machine.succeed(
f"curl -sf -H 'Cookie: fc_session={session_val}' http://127.0.0.1:3000/projects"
)
assert '/projects/new' in body, "Projects page should link to /projects/new wizard"
# ---- 5D: Flake probe endpoint ----
with subtest("Probe endpoint exists and requires POST"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
"http://127.0.0.1:3000/api/v1/projects/probe"
)
# GET should return 405 (Method Not Allowed)
assert code.strip() in ("404", "405"), f"Expected 404/405 for GET /probe, got {code.strip()}"
with subtest("Probe endpoint accepts POST with auth"):
# This will likely fail since the VM has no network access to github,
# but we can verify the endpoint exists and returns a proper error
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
"-X POST http://127.0.0.1:3000/api/v1/projects/probe "
f"{auth_header} "
"-H 'Content-Type: application/json' "
"-d '{\"repository_url\": \"https://github.com/nonexistent/repo\"}'"
)
# Should return 408 (timeout), 422 (nix eval error), 500, or 200 with is_flake=false
# Any non-crash response is acceptable
assert code.strip() in ("200", "408", "422", "500"), \
f"Expected 200/408/422/500 for probe of unreachable repo, got {code.strip()}"
# ---- 5E: Setup endpoint ----
with subtest("Setup endpoint exists and requires POST"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
"http://127.0.0.1:3000/api/v1/projects/setup"
)
assert code.strip() in ("404", "405"), f"Expected 404/405 for GET /setup, got {code.strip()}"
with subtest("Setup endpoint creates project with jobsets"):
result = machine.succeed(
"curl -sf -X POST http://127.0.0.1:3000/api/v1/projects/setup "
f"{auth_header} "
"-H 'Content-Type: application/json' "
"-d '{\"repository_url\": \"https://github.com/test/setup-test\", \"name\": \"setup-test\", \"description\": \"Created via setup\", \"jobsets\": [{\"name\": \"packages\", \"nix_expression\": \"packages\"}]}' "
"| jq -r .project.id"
)
setup_project_id = result.strip()
assert len(setup_project_id) == 36, f"Expected UUID from setup, got '{setup_project_id}'"
with subtest("Setup-created project has jobsets"):
result = machine.succeed(
f"curl -sf http://127.0.0.1:3000/api/v1/projects/{setup_project_id}/jobsets | jq '.items | length'"
)
assert int(result.strip()) == 1, f"Expected 1 jobset from setup, got {result.strip()}"
with subtest("Setup endpoint with read-only key returns 403"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
"-X POST http://127.0.0.1:3000/api/v1/projects/setup "
f"{ro_header} "
"-H 'Content-Type: application/json' "
"-d '{\"repository_url\": \"https://github.com/test/ro\", \"name\": \"ro-setup\", \"jobsets\": []}'"
)
assert code.strip() == "403", f"Expected 403 for read-only setup, got {code.strip()}"
# Clean up setup-test project
machine.succeed(
f"curl -sf -X DELETE http://127.0.0.1:3000/api/v1/projects/{setup_project_id} "
f"{auth_header}"
)
# ---- 5F: Dashboard improvements ----
with subtest("Home page has dashboard-grid two-column layout"):
body = machine.succeed("curl -sf http://127.0.0.1:3000/")
assert "dashboard-grid" in body, "Home page should have dashboard-grid class"
with subtest("Home page has colored stat values"):
body = machine.succeed("curl -sf http://127.0.0.1:3000/")
assert "stat-value-green" in body, "Home page should have green stat value for completed"
assert "stat-value-red" in body, "Home page should have red stat value for failed"
with subtest("Home page has escapeHtml utility"):
body = machine.succeed("curl -sf http://127.0.0.1:3000/")
assert "escapeHtml" in body, "Home page should include escapeHtml function"
with subtest("Admin page JS uses escapeHtml for error handling"):
# Login to get admin view
if match:
body = machine.succeed(
f"curl -sf -H 'Cookie: fc_session={session_val}' http://127.0.0.1:3000/admin"
)
assert "escapeHtml" in body, "Admin page JS should use escapeHtml"
# ---- 4R: Metrics reflect actual data ----
with subtest("Metrics fc_projects_total reflects created projects"):
result = machine.succeed("curl -sf http://127.0.0.1:3000/metrics")
for line in result.split("\n"):
if line.startswith("fc_projects_total"):
val = int(line.split()[-1])
assert val >= 1, f"Expected fc_projects_total >= 1, got {val}"
break
with subtest("Metrics fc_evaluations_total reflects triggered evaluation"):
result = machine.succeed("curl -sf http://127.0.0.1:3000/metrics")
for line in result.split("\n"):
if line.startswith("fc_evaluations_total"):
val = int(line.split()[-1])
# Might be 0 if no evaluations created yet
assert val >= 0, f"Expected fc_evaluations_total >= 0, got {val}"
break
'';
}

View file

@ -0,0 +1,81 @@
{
pkgs,
fc-packages,
nixosModule,
}:
pkgs.testers.nixosTest {
name = "fc-service-startup";
nodes.machine = import ./common.nix {inherit pkgs fc-packages nixosModule;};
testScript = ''
machine.start()
machine.wait_for_unit("postgresql.service")
# Ensure PostgreSQL is actually ready to accept connections
# before fc-server starts. Not actually implied by the wait_for_unit
machine.wait_until_succeeds("sudo -u fc psql -U fc -d fc -c 'SELECT 1'", timeout=30)
machine.wait_for_unit("fc-server.service")
# Wait for the server to start listening
machine.wait_until_succeeds("curl -sf http://127.0.0.1:3000/health", timeout=30)
# Verify all three services start
with subtest("fc-evaluator.service starts without crash"):
machine.wait_for_unit("fc-evaluator.service", timeout=30)
result = machine.succeed("journalctl -u fc-evaluator --no-pager -n 20 2>&1")
assert "binary not found" not in result.lower(), f"Evaluator has 'binary not found' error: {result}"
assert "No such file" not in result, f"Evaluator has 'No such file' error: {result}"
with subtest("fc-queue-runner.service starts without crash"):
machine.wait_for_unit("fc-queue-runner.service", timeout=30)
result = machine.succeed("journalctl -u fc-queue-runner --no-pager -n 20 2>&1")
assert "binary not found" not in result.lower(), f"Queue runner has 'binary not found' error: {result}"
assert "No such file" not in result, f"Queue runner has 'No such file' error: {result}"
with subtest("All three FC services are active"):
for svc in ["fc-server", "fc-evaluator", "fc-queue-runner"]:
result = machine.succeed(f"systemctl is-active {svc}")
assert result.strip() == "active", f"Expected {svc} to be active, got '{result.strip()}'"
with subtest("Declarative project was bootstrapped"):
result = machine.succeed(
"curl -sf http://127.0.0.1:3000/api/v1/projects | jq -r '.items[] | select(.name==\"declarative-project\") | .name'"
)
assert result.strip() == "declarative-project", f"Expected declarative-project, got '{result.strip()}'"
with subtest("Declarative project has correct repository URL"):
result = machine.succeed(
"curl -sf http://127.0.0.1:3000/api/v1/projects | jq -r '.items[] | select(.name==\"declarative-project\") | .repository_url'"
)
assert result.strip() == "https://github.com/test/declarative", f"Expected declarative repo URL, got '{result.strip()}'"
with subtest("Declarative project has bootstrapped jobset"):
# Get the declarative project ID
decl_project_id = machine.succeed(
"curl -sf http://127.0.0.1:3000/api/v1/projects | jq -r '.items[] | select(.name==\"declarative-project\") | .id'"
).strip()
result = machine.succeed(
f"curl -sf http://127.0.0.1:3000/api/v1/projects/{decl_project_id}/jobsets | jq '.items[0].name' -r"
)
assert result.strip() == "packages", f"Expected packages jobset, got '{result.strip()}'"
with subtest("Declarative API key works for authentication"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
"-H 'Authorization: Bearer fc_bootstrap_key' "
"http://127.0.0.1:3000/api/v1/projects"
)
assert code.strip() == "200", f"Expected 200 with bootstrap key, got {code.strip()}"
with subtest("Bootstrap is idempotent (server restarted successfully with same config)"):
# The server already started successfully with declarative config. That
# proves the bootstrap ran. Now we should check that only one declarative
# project exists, and not any duplicates.
result = machine.succeed(
"curl -sf http://127.0.0.1:3000/api/v1/projects | jq '.items | map(select(.name==\"declarative-project\")) | length'"
)
assert result.strip() == "1", f"Expected exactly 1 declarative-project, got {result.strip()}"
'';
}