{ pkgs, self, }: let fc-packages = self.packages.${pkgs.stdenv.hostPlatform.system}; # Password files for testing passwordFile option # Passwords must be at least 12 characters with at least one uppercase letter adminPasswordFile = pkgs.writeText "admin-password" "SecretAdmin123!"; userPasswordFile = pkgs.writeText "user-password" "SecretUser123!"; disabledPasswordFile = pkgs.writeText "disabled-password" "DisabledPass123!"; in pkgs.testers.nixosTest { name = "fc-declarative"; nodes.machine = { imports = [self.nixosModules.fc-ci]; _module.args.self = self; programs.git.enable = true; security.sudo.enable = true; environment.systemPackages = with pkgs; [nix nix-eval-jobs zstd curl jq openssl]; services.fc-ci = { 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; }; # Declarative users declarative.users = { # Admin user with passwordFile decl-admin = { email = "admin@test.local"; passwordFile = toString adminPasswordFile; role = "admin"; }; # Regular user with passwordFile decl-user = { email = "user@test.local"; passwordFile = toString userPasswordFile; role = "read-only"; }; # User with passwordFile decl-user2 = { email = "user2@test.local"; passwordFile = toString userPasswordFile; role = "read-only"; }; # Disabled user with passwordFile decl-disabled = { email = "disabled@test.local"; passwordFile = toString disabledPasswordFile; role = "read-only"; enabled = false; }; }; # Declarative API keys declarative.apiKeys = [ { name = "decl-admin-key"; key = "fc_decl_admin"; role = "admin"; } { name = "decl-readonly-key"; key = "fc_decl_readonly"; role = "read-only"; } ]; # Declarative projects with various jobset states declarative.projects = [ { name = "decl-project-1"; repositoryUrl = "https://github.com/test/decl1"; description = "First declarative project"; jobsets = [ { name = "enabled-jobset"; nixExpression = "packages"; enabled = true; flakeMode = true; checkInterval = 300; state = "enabled"; } { name = "disabled-jobset"; nixExpression = "disabled"; state = "disabled"; } { name = "oneshot-jobset"; nixExpression = "oneshot"; state = "one_shot"; } { name = "oneatatime-jobset"; nixExpression = "exclusive"; state = "one_at_a_time"; checkInterval = 60; } ]; } { name = "decl-project-2"; repositoryUrl = "https://github.com/test/decl2"; jobsets = [ { name = "main"; nixExpression = "."; flakeMode = true; } ]; } ]; }; }; testScript = '' machine.start() machine.wait_for_unit("postgresql.service") machine.wait_until_succeeds("sudo -u fc psql -U fc -d fc -c 'SELECT 1'", timeout=30) machine.wait_for_unit("fc-server.service") machine.wait_until_succeeds("curl -sf http://127.0.0.1:3000/health", timeout=30) # DECLARATIVE USERS with subtest("Declarative users are created in database"): result = machine.succeed( "sudo -u fc psql -U fc -d fc -t -c \"SELECT COUNT(*) FROM users WHERE username LIKE 'decl-%'\"" ) count = int(result.strip()) assert count == 4, f"Expected 4 declarative users, got {count}" with subtest("Declarative admin user has admin role"): result = machine.succeed( "sudo -u fc psql -U fc -d fc -t -c \"SELECT role FROM users WHERE username = 'decl-admin'\"" ) assert result.strip() == "admin", f"Expected admin role, got '{result.strip()}'" with subtest("Declarative regular users have read-only role"): result = machine.succeed( "sudo -u fc psql -U fc -d fc -t -c \"SELECT role FROM users WHERE username = 'decl-user'\"" ) assert result.strip() == "read-only", f"Expected read-only role, got '{result.strip()}'" with subtest("Declarative disabled user is disabled"): result = machine.succeed( "sudo -u fc psql -U fc -d fc -t -c \"SELECT enabled FROM users WHERE username = 'decl-disabled'\"" ) assert result.strip() == "f", f"Expected disabled (f), got '{result.strip()}'" with subtest("Declarative enabled users are enabled"): result = machine.succeed( "sudo -u fc psql -U fc -d fc -t -c \"SELECT enabled FROM users WHERE username = 'decl-admin'\"" ) assert result.strip() == "t", f"Expected enabled (t), got '{result.strip()}'" with subtest("Declarative users have password hashes set"): result = machine.succeed( "sudo -u fc psql -U fc -d fc -t -c \"SELECT password_hash FROM users WHERE username = 'decl-admin'\"" ) # Argon2 hashes start with $argon2 assert result.strip().startswith("$argon2"), f"Expected argon2 hash, got '{result.strip()[:20]}...'" with subtest("User with passwordFile has correct password hash"): # The password in the file is 'SecretAdmin123!' result = machine.succeed( "sudo -u fc psql -U fc -d fc -t -c \"SELECT password_hash FROM users WHERE username = 'decl-admin'\"" ) assert len(result.strip()) > 50, "Password hash should be substantial length" with subtest("User with inline password has correct password hash"): result = machine.succeed( "sudo -u fc psql -U fc -d fc -t -c \"SELECT password_hash FROM users WHERE username = 'decl-user'\"" ) assert result.strip().startswith("$argon2"), f"Expected argon2 hash for inline password user, got '{result.strip()[:20]}...'" # DECLARATIVE USER WEB LOGIN with subtest("Web login with declarative admin user succeeds"): # Login via POST to /login with username/password result = machine.succeed( "curl -s -w '\\n%{http_code}' " "-X POST http://127.0.0.1:3000/login " "-d 'username=decl-admin&password=SecretAdmin123!'" ) lines = result.strip().split('\n') code = lines[-1] # Should redirect (302/303) on success assert code in ("200", "302", "303"), f"Expected redirect on login, got {code}" with subtest("Web login with declarative user (passwordFile) succeeds"): result = machine.succeed( "curl -s -w '\\n%{http_code}' " "-X POST http://127.0.0.1:3000/login " "-d 'username=decl-user&password=SecretUser123!'" ) lines = result.strip().split('\n') code = lines[-1] assert code in ("200", "302", "303"), f"Expected redirect on login, got {code}" with subtest("Web login with declarative user2 (passwordFile) succeeds"): result = machine.succeed( "curl -s -w '\\n%{http_code}' " "-X POST http://127.0.0.1:3000/login " "-d 'username=decl-user2&password=SecretUser123!'" ) lines = result.strip().split('\n') code = lines[-1] assert code in ("200", "302", "303"), f"Expected redirect on login, got {code}" with subtest("Web login with wrong password fails"): result = machine.succeed( "curl -s -w '\\n%{http_code}' " "-X POST http://127.0.0.1:3000/login " "-d 'username=decl-admin&password=wrongpassword'" ) lines = result.strip().split('\n') code = lines[-1] # Should return 401 for wrong password assert code in ("401",), f"Expected 401 for wrong password, got {code}" with subtest("Web login with disabled user fails"): result = machine.succeed( "curl -s -w '\\n%{http_code}' " "-X POST http://127.0.0.1:3000/login " "-d 'username=decl-disabled&password=DisabledPass123!'" ) lines = result.strip().split('\n') code = lines[-1] # Disabled user should not be able to login (401 or 403) assert code in ("401", "403"), f"Expected login failure for disabled user, got {code}" # DECLARATIVE API KEYS with subtest("Declarative API keys are created"): result = machine.succeed( "sudo -u fc psql -U fc -d fc -t -c \"SELECT COUNT(*) FROM api_keys WHERE name LIKE 'decl-%'\"" ) count = int(result.strip()) assert count == 2, f"Expected 2 declarative API keys, got {count}" with subtest("Declarative admin API key works"): code = machine.succeed( "curl -s -o /dev/null -w '%{http_code}' " "-H 'Authorization: Bearer fc_decl_admin' " "http://127.0.0.1:3000/api/v1/projects" ) assert code.strip() == "200", f"Expected 200, got {code.strip()}" with subtest("Declarative admin API key can create resources"): 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_decl_admin' " "-H 'Content-Type: application/json' " "-d '{\"name\": \"api-created\", \"repository_url\": \"https://example.com/api\"}'" ) assert code.strip() == "200", f"Expected 200, got {code.strip()}" with subtest("Declarative read-only API key works for GET"): code = machine.succeed( "curl -s -o /dev/null -w '%{http_code}' " "-H 'Authorization: Bearer fc_decl_readonly' " "http://127.0.0.1:3000/api/v1/projects" ) assert code.strip() == "200", f"Expected 200, got {code.strip()}" with subtest("Declarative read-only API key cannot create resources"): 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_decl_readonly' " "-H 'Content-Type: application/json' " "-d '{\"name\": \"should-fail\", \"repository_url\": \"https://example.com/fail\"}'" ) assert code.strip() == "403", f"Expected 403, got {code.strip()}" # DECLARATIVE PROJECTS with subtest("Declarative projects are created"): result = machine.succeed( "curl -sf http://127.0.0.1:3000/api/v1/projects | jq '.items | map(select(.name | startswith(\"decl-project\"))) | length'" ) count = int(result.strip()) assert count == 2, f"Expected 2 declarative projects, got {count}" 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==\"decl-project-1\") | .repository_url'" ) assert result.strip() == "https://github.com/test/decl1", f"Got '{result.strip()}'" with subtest("Declarative project has description"): result = machine.succeed( "curl -sf http://127.0.0.1:3000/api/v1/projects | jq -r '.items[] | select(.name==\"decl-project-1\") | .description'" ) assert result.strip() == "First declarative project", f"Got '{result.strip()}'" # DECLARATIVE JOBSETS WITH STATES with subtest("Declarative project has all jobsets"): project_id = machine.succeed( "curl -sf http://127.0.0.1:3000/api/v1/projects | jq -r '.items[] | select(.name==\"decl-project-1\") | .id'" ).strip() result = machine.succeed( f"curl -sf http://127.0.0.1:3000/api/v1/projects/{project_id}/jobsets | jq '.items | length'" ) count = int(result.strip()) assert count == 4, f"Expected 4 jobsets, got {count}" with subtest("Enabled jobset has state 'enabled'"): project_id = machine.succeed( "curl -sf http://127.0.0.1:3000/api/v1/projects | jq -r '.items[] | select(.name==\"decl-project-1\") | .id'" ).strip() result = machine.succeed( f"curl -sf http://127.0.0.1:3000/api/v1/projects/{project_id}/jobsets | jq -r '.items[] | select(.name==\"enabled-jobset\") | .state'" ) assert result.strip() == "enabled", f"Expected 'enabled', got '{result.strip()}'" with subtest("Disabled jobset has state 'disabled'"): project_id = machine.succeed( "curl -sf http://127.0.0.1:3000/api/v1/projects | jq -r '.items[] | select(.name==\"decl-project-1\") | .id'" ).strip() result = machine.succeed( f"curl -sf http://127.0.0.1:3000/api/v1/projects/{project_id}/jobsets | jq -r '.items[] | select(.name==\"disabled-jobset\") | .state'" ) assert result.strip() == "disabled", f"Expected 'disabled', got '{result.strip()}'" with subtest("One-shot jobset has state 'one_shot'"): project_id = machine.succeed( "curl -sf http://127.0.0.1:3000/api/v1/projects | jq -r '.items[] | select(.name==\"decl-project-1\") | .id'" ).strip() result = machine.succeed( f"curl -sf http://127.0.0.1:3000/api/v1/projects/{project_id}/jobsets | jq -r '.items[] | select(.name==\"oneshot-jobset\") | .state'" ) assert result.strip() == "one_shot", f"Expected 'one_shot', got '{result.strip()}'" with subtest("One-at-a-time jobset has state 'one_at_a_time'"): project_id = machine.succeed( "curl -sf http://127.0.0.1:3000/api/v1/projects | jq -r '.items[] | select(.name==\"decl-project-1\") | .id'" ).strip() result = machine.succeed( f"curl -sf http://127.0.0.1:3000/api/v1/projects/{project_id}/jobsets | jq -r '.items[] | select(.name==\"oneatatime-jobset\") | .state'" ) assert result.strip() == "one_at_a_time", f"Expected 'one_at_a_time', got '{result.strip()}'" with subtest("Disabled jobset is not in active_jobsets view"): result = machine.succeed( "sudo -u fc psql -U fc -d fc -t -c \"SELECT COUNT(*) FROM active_jobsets WHERE name = 'disabled-jobset'\"" ) count = int(result.strip()) assert count == 0, f"Disabled jobset should not be in active_jobsets, got {count}" with subtest("Enabled jobsets are in active_jobsets view"): result = machine.succeed( "sudo -u fc psql -U fc -d fc -t -c \"SELECT COUNT(*) FROM active_jobsets WHERE name = 'enabled-jobset'\"" ) count = int(result.strip()) assert count == 1, f"Enabled jobset should be in active_jobsets, got {count}" with subtest("One-shot jobset is in active_jobsets view"): result = machine.succeed( "sudo -u fc psql -U fc -d fc -t -c \"SELECT COUNT(*) FROM active_jobsets WHERE name = 'oneshot-jobset'\"" ) count = int(result.strip()) assert count == 1, f"One-shot jobset should be in active_jobsets, got {count}" with subtest("One-at-a-time jobset is in active_jobsets view"): result = machine.succeed( "sudo -u fc psql -U fc -d fc -t -c \"SELECT COUNT(*) FROM active_jobsets WHERE name = 'oneatatime-jobset'\"" ) count = int(result.strip()) assert count == 1, f"One-at-a-time jobset should be in active_jobsets, got {count}" with subtest("Jobset check_interval is correctly set"): project_id = machine.succeed( "curl -sf http://127.0.0.1:3000/api/v1/projects | jq -r '.items[] | select(.name==\"decl-project-1\") | .id'" ).strip() result = machine.succeed( f"curl -sf http://127.0.0.1:3000/api/v1/projects/{project_id}/jobsets | jq -r '.items[] | select(.name==\"oneatatime-jobset\") | .check_interval'" ) assert result.strip() == "60", f"Expected check_interval 60, got '{result.strip()}'" # IDEMPOTENCY with subtest("Bootstrap is idempotent - no duplicate users"): result = machine.succeed( "sudo -u fc psql -U fc -d fc -t -c \"SELECT COUNT(*) FROM users WHERE username = 'decl-admin'\"" ) count = int(result.strip()) assert count == 1, f"Expected exactly 1 decl-admin user, got {count}" with subtest("Bootstrap is idempotent - no duplicate projects"): result = machine.succeed( "curl -sf http://127.0.0.1:3000/api/v1/projects | jq '.items | map(select(.name==\"decl-project-1\")) | length'" ) count = int(result.strip()) assert count == 1, f"Expected exactly 1 decl-project-1, got {count}" with subtest("Bootstrap is idempotent - no duplicate API keys"): result = machine.succeed( "sudo -u fc psql -U fc -d fc -t -c \"SELECT COUNT(*) FROM api_keys WHERE name = 'decl-admin-key'\"" ) count = int(result.strip()) assert count == 1, f"Expected exactly 1 decl-admin-key, got {count}" with subtest("Bootstrap is idempotent - no duplicate jobsets"): project_id = machine.succeed( "curl -sf http://127.0.0.1:3000/api/v1/projects | jq -r '.items[] | select(.name==\"decl-project-1\") | .id'" ).strip() result = machine.succeed( f"curl -sf http://127.0.0.1:3000/api/v1/projects/{project_id}/jobsets | jq '.items | map(select(.name==\"enabled-jobset\")) | length'" ) count = int(result.strip()) assert count == 1, f"Expected exactly 1 enabled-jobset, got {count}" # USER MANAGEMENT UI (admin-only) with subtest("Users page requires admin access"): # Test HTML /users endpoint htmlResp = machine.succeed( "curl -sf -H 'Authorization: Bearer fc_decl_admin' http://127.0.0.1:3000/users" ) assert "User Management" in htmlResp or "Users" in htmlResp # Non-admin should be denied access via API machine.fail( "curl -sf -H 'Authorization: Bearer fc_decl_readonly' http://127.0.0.1:3000/api/v1/users | grep 'decl-admin'" ) # Admin should have access via API adminApiResp = machine.succeed( "curl -sf -H 'Authorization: Bearer fc_decl_admin' http://127.0.0.1:3000/api/v1/users" ) assert "decl-admin" in adminApiResp, "Expected decl-admin in API response" assert "decl-user" in adminApiResp, "Expected decl-user in API response" with subtest("Users API shows declarative users for admin"): # Use the admin API key to list users instead of session-based auth result = machine.succeed( "curl -sf -H 'Authorization: Bearer fc_decl_admin' http://127.0.0.1:3000/api/v1/users" ) assert "decl-admin" in result, f"Users API should return decl-admin. Got: {result[:500]}" assert "decl-user" in result, f"Users API should return decl-user. Got: {result[:500]}" # STARRED JOBS PAGE with subtest("Starred page exists and returns 200"): code = machine.succeed( "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/starred" ) assert code.strip() == "200", f"Expected 200, got {code.strip()}" with subtest("Starred page shows login prompt when not logged in"): body = machine.succeed("curl -sf http://127.0.0.1:3000/starred") assert "Login required" in body or "login" in body.lower(), "Starred page should prompt for login" ''; }