circus/nix/tests/auth-rbac.nix
NotAShelf 7dae114783
nix: split off monolithic VM test
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ifc0a92ed8b6c7622ae345a21880fd0296a6a6964
2026-02-05 22:45:07 +03:00

298 lines
13 KiB
Nix

# 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"
'';
}