{ pkgs, fc-packages, nixosModule, }: pkgs.testers.nixosTest { name = "fc-integration"; nodes.machine = {pkgs, ...}: { imports = [nixosModule]; 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; } ]; } ]; declarative.api_keys = [ { name = "bootstrap-admin"; key = "fc_bootstrap_key"; role = "admin"; } ]; }; }; # Ensure nix and zstd are available for cache endpoints environment.systemPackages = with pkgs; [nix nix-eval-jobs zstd curl jq sudo git openssl]; }; testScript = '' import hashlib import json import re import time 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) # ---- Verify all three services start ---- with subtest("fc-evaluator.service starts without crash"): machine.wait_for_unit("fc-evaluator.service", timeout=30) # Check journalctl for no "binary not found" errors 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()}'" # ---- 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}'" # ======================================================================== # Phase 0: Declarative Bootstrap Tests # ======================================================================== with subtest("Declarative project was bootstrapped"): result = machine.succeed( "curl -sf http://127.0.0.1:3000/api/v1/projects | jq '.items[] | select(.name==\"declarative-project\") | .name' -r" ) 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 '.items[] | select(.name==\"declarative-project\") | .repository_url' -r" ) 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 '.items[] | select(.name==\"declarative-project\") | .id' -r" ).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}' " "-X POST http://127.0.0.1:3000/api/v1/projects " "-H 'Authorization: Bearer fc_bootstrap_key' " "-H 'Content-Type: application/json' " "-d '{\"name\": \"bootstrap-auth-test\", \"repository_url\": \"https://example.com/bootstrap\"}'" ) 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. We verify no duplicate projects were created. result = machine.succeed( "curl -sf http://127.0.0.1:3000/api/v1/projects | jq '[.items[] | select(.name==\"declarative-project\")] | length'" ) assert result.strip() == "1", f"Expected exactly 1 declarative-project, got {result.strip()}" # ======================================================================== # Phase 0B: Security Headers Tests # ======================================================================== with subtest("X-Content-Type-Options nosniff header present"): result = machine.succeed( "curl -s -D - -o /dev/null http://127.0.0.1:3000/health | grep -i x-content-type-options" ) assert "nosniff" in result.lower(), f"Expected nosniff, got: {result}" with subtest("X-Frame-Options DENY header present"): result = machine.succeed( "curl -s -D - -o /dev/null http://127.0.0.1:3000/health | grep -i x-frame-options" ) assert "deny" in result.lower(), f"Expected DENY, got: {result}" with subtest("Referrer-Policy header present"): result = machine.succeed( "curl -s -D - -o /dev/null http://127.0.0.1:3000/health | grep -i referrer-policy" ) assert "strict-origin-when-cross-origin" in result.lower(), f"Expected strict-origin-when-cross-origin, got: {result}" with subtest("Security headers present on API routes too"): result = machine.succeed( "curl -s -D - -o /dev/null http://127.0.0.1:3000/api/v1/projects 2>&1" ) assert "nosniff" in result.lower(), "API route missing X-Content-Type-Options" assert "deny" in result.lower(), "API route missing X-Frame-Options" # ======================================================================== # Phase 0C: Error Message Quality Tests # ======================================================================== with subtest("404 error returns structured JSON with error_code"): result = machine.succeed( "curl -s http://127.0.0.1:3000/api/v1/projects/00000000-0000-0000-0000-000000000000" ) assert len(result.strip()) > 0, "Expected non-empty response body for 404" parsed = json.loads(result) assert "error" in parsed, f"Missing 'error' field in: {result}" assert "error_code" in parsed, f"Missing 'error_code' field in: {result}" assert parsed["error_code"] == "NOT_FOUND", f"Expected NOT_FOUND, got {parsed['error_code']}" with subtest("409 conflict error includes meaningful message"): # First create a project 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\": \"error-msg-test\", \"repository_url\": \"https://example.com/err\"}'" ) # Try creating duplicate — check status code first 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\": \"error-msg-test\", \"repository_url\": \"https://example.com/err2\"}'" ) assert code.strip() == "409", f"Expected 409 for duplicate, got {code.strip()}" # Verify the response body is structured JSON with error details result = machine.succeed( "curl -s -X POST http://127.0.0.1:3000/api/v1/projects " f"{auth_header} " "-H 'Content-Type: application/json' " "-d '{\"name\": \"error-msg-test\", \"repository_url\": \"https://example.com/err2\"}'" ) parsed = json.loads(result) assert "error" in parsed, f"Missing error field in conflict response: {result}" assert parsed.get("error_code") == "CONFLICT", f"Expected CONFLICT error_code, got: {parsed}" # Error message should not be generic "Internal server error" assert "internal" not in parsed["error"].lower(), \ f"Error message should not be generic 'Internal server error': {parsed['error']}" with subtest("401 error returns structured JSON"): result = machine.succeed( "curl -s -X POST http://127.0.0.1:3000/api/v1/projects " "-H 'Content-Type: application/json' " "-d '{\"name\": \"x\", \"repository_url\": \"https://example.com/x\"}'" ) try: parsed = json.loads(result) assert "error" in parsed, f"Missing error field in 401: {result}" except json.JSONDecodeError: # Auth middleware may return non-JSON 401; verify status code instead 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\": \"x\", \"repository_url\": \"https://example.com/x\"}'" ) assert code.strip() == "401", f"Expected 401, got {code.strip()}" # ---- 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()}" # ======================================================================== # Phase 3: Authentication & RBAC tests # ======================================================================== # ---- 3A: 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()}" # ---- 3B: 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\": \"\", \"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" # ======================================================================== # 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 "