diff --git a/flake.lock b/flake.lock index 5ecabf5..4462231 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "crane": { "locked": { - "lastModified": 1760924934, - "narHash": "sha256-tuuqY5aU7cUkR71sO2TraVKK2boYrdW3gCSXUkF4i44=", + "lastModified": 1769737823, + "narHash": "sha256-DrBaNpZ+sJ4stXm+0nBX7zqZT9t9P22zbk6m5YhQxS4=", "owner": "ipetkov", "repo": "crane", - "rev": "c6b4d5308293d0d04fcfeee92705017537cad02f", + "rev": "b2f45c3830aa96b7456a4c4bc327d04d7a43e1ba", "type": "github" }, "original": { @@ -17,11 +17,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1761880412, - "narHash": "sha256-QoJjGd4NstnyOG4mm4KXF+weBzA2AH/7gn1Pmpfcb0A=", + "lastModified": 1769740369, + "narHash": "sha256-xKPyJoMoXfXpDM5DFDZDsi9PHArf2k5BJjvReYXoFpM=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "a7fc11be66bdfb5cdde611ee5ce381c183da8386", + "rev": "6308c3b21396534d8aaeac46179c14c439a89b8a", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index aca0c48..5e085d1 100644 --- a/flake.nix +++ b/flake.nix @@ -1,77 +1,107 @@ { inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + nixpkgs.url = "github:NixOS/nixpkgs?ref=nixpkgs-unstable"; crane.url = "github:ipetkov/crane"; }; outputs = { nixpkgs, crane, + self, ... }: let - # FIXME: allow multi-system when I can be arsed to write the abstractions - system = "x86_64-linux"; - pkgs = nixpkgs.legacyPackages.${system}; - craneLib = crane.mkLib pkgs; - src = craneLib.cleanCargoSource ./.; - - commonArgs = { - pname = "feel-ci"; - inherit src; - strictDeps = true; - }; - - cargoArtifacts = craneLib.buildDepsOnly commonArgs; - - # Build individual workspace members - server = craneLib.buildPackage (commonArgs - // { - inherit cargoArtifacts; - pname = "server"; - cargoExtraArgs = "--package server"; - }); - - evaluator = craneLib.buildPackage (commonArgs - // { - inherit cargoArtifacts; - pname = "evaluator"; - cargoExtraArgs = "--package evaluator"; - }); - - queue-runner = craneLib.buildPackage (commonArgs - // { - inherit cargoArtifacts; - pname = "queue-runner"; - cargoExtraArgs = "--package queue-runner"; - }); - - common = craneLib.buildPackage (commonArgs - // { - inherit cargoArtifacts; - pname = "common"; - cargoExtraArgs = "--package common"; - }); - - migrate-cli = craneLib.buildPackage (commonArgs - // { - inherit cargoArtifacts; - pname = "migrate-cli"; - cargoExtraArgs = "--package migrate-cli"; - }); + inherit (nixpkgs) lib; + forAllSystems = lib.genAttrs ["x86_64-linux" "aarch64-linux"]; in { - packages.${system} = { - inherit server evaluator queue-runner common migrate-cli; + # NixOS module for feel-ci + nixosModules = { + fc-ci = ./nix/modules/nixos.nix; + default = self.nixosModules.fc-ci; }; - devShells.${system}.default = craneLib.devShell { - name = "fc"; - inputsFrom = [server]; - packages = with pkgs; [ - rust-analyzer - postgresql - pkg-config - openssl - ]; - }; + packages = forAllSystems (system: let + pkgs = nixpkgs.legacyPackages.${system}; + craneLib = crane.mkLib pkgs; + + src = let + fs = lib.fileset; + s = ./.; + in + fs.toSource { + root = s; + fileset = fs.unions [ + (s + /crates) + (s + /Cargo.lock) + (s + /Cargo.toml) + ]; + }; + + commonArgs = { + pname = "feel-ci"; + inherit src; + strictDeps = true; + nativeBuildInputs = with pkgs; [pkg-config]; + buildInputs = with pkgs; [openssl]; + }; + + cargoArtifacts = craneLib.buildDepsOnly commonArgs; + in { + demo-vm = pkgs.callPackage ./nix/demo-vm.nix { + nixosModule = self.nixosModules.default; + fc-packages = { + inherit (self.packages.${system}) fc-common fc-evaluator fc-migrate-cli fc-queue-runner fc-server; + }; + }; + + # FC Packages + fc-common = pkgs.callPackage ./nix/packages/fc-common.nix { + inherit craneLib commonArgs cargoArtifacts; + }; + + fc-evaluator = pkgs.callPackage ./nix/packages/fc-evaluator.nix { + inherit craneLib commonArgs cargoArtifacts; + }; + + fc-migrate-cli = pkgs.callPackage ./nix/packages/fc-migrate-cli.nix { + inherit craneLib commonArgs cargoArtifacts; + }; + + fc-queue-runner = pkgs.callPackage ./nix/packages/fc-queue-runner.nix { + inherit craneLib commonArgs cargoArtifacts; + }; + + fc-server = pkgs.callPackage ./nix/packages/fc-server.nix { + inherit craneLib commonArgs cargoArtifacts; + }; + }); + + checks = forAllSystems (system: let + pkgs = nixpkgs.legacyPackages.${system}; + in { + vm-test = pkgs.callPackage ./nix/vm-test.nix { + nixosModule = self.nixosModules.default; + fc-packages = { + inherit (self.packages.${system}) fc-common fc-evaluator fc-migrate-cli fc-queue-runner fc-server; + }; + }; + }); + + devShells = forAllSystems (system: let + pkgs = nixpkgs.legacyPackages.${system}; + craneLib = crane.mkLib pkgs; + in { + default = craneLib.devShell { + name = "fc"; + inputsFrom = [self.packages.${system}.fc-server]; + + strictDeps = true; + packages = with pkgs; [ + rust-analyzer + postgresql + pkg-config + openssl + ]; + }; + }); }; } diff --git a/nix/demo-vm.nix b/nix/demo-vm.nix new file mode 100644 index 0000000..e994cae --- /dev/null +++ b/nix/demo-vm.nix @@ -0,0 +1,153 @@ +{ + pkgs, + fc-packages, + nixosModule, +}: let + nixos = pkgs.nixos ({ + modulesPath, + pkgs, + ... + }: { + imports = [ + nixosModule + (modulesPath + "/virtualisation/qemu-vm.nix") + ]; + + ## VM hardware + virtualisation = { + memorySize = 2048; + cores = 2; + diskSize = 4096; + graphics = false; + + # Forward guest:3000 -> host:3000 so the dashboard is reachable + forwardPorts = [ + { + from = "host"; + host.port = 3000; + guest.port = 3000; + } + ]; + }; + + 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"; + gc.enabled = false; + logs.log_dir = "/var/lib/fc/logs"; + cache.enabled = true; + signing.enabled = false; + server = { + # Bind to all interfaces so port forwarding works + host = "0.0.0.0"; + port = 3000; + cors_permissive = true; + }; + }; + }; + + ## Seed an admin API key on first boot + # Token: fc_demo_admin_key, SHA-256 hash inserted into api_keys + # A read-only key is also seeded for testing RBAC. + systemd.services.fc-seed-keys = { + description = "Seed demo API keys"; + after = ["fc-server.service"]; + requires = ["fc-server.service"]; + wantedBy = ["multi-user.target"]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + User = "fc"; + Group = "fc"; + }; + path = [pkgs.postgresql pkgs.curl]; + script = '' + # Wait for server to be ready + for i in $(seq 1 30); do + if curl -sf http://127.0.0.1:3000/health >/dev/null 2>&1; then + break + fi + sleep 1 + done + + # Admin key: fc_demo_admin_key + ADMIN_HASH="$(echo -n 'fc_demo_admin_key' | sha256sum | cut -d' ' -f1)" + psql -U fc -d fc -c "INSERT INTO api_keys (name, key_hash, role) VALUES ('demo-admin', '$ADMIN_HASH', 'admin') ON CONFLICT DO NOTHING" 2>/dev/null || true + + # Read-only key: fc_demo_readonly_key + RO_HASH="$(echo -n 'fc_demo_readonly_key' | sha256sum | cut -d' ' -f1)" + psql -U fc -d fc -c "INSERT INTO api_keys (name, key_hash, role) VALUES ('demo-readonly', '$RO_HASH', 'read-only') ON CONFLICT DO NOTHING" 2>/dev/null || true + + echo "" + echo "===========================================" + echo "" + echo " Dashboard: http://localhost:3000" + echo " Health: http://localhost:3000/health" + echo " API base: http://localhost:3000/api/v1" + echo "" + echo " Admin key: fc_demo_admin_key" + echo " Read-only key: fc_demo_readonly_key" + echo "" + echo " Login at http://localhost:3000/login" + echo " using the admin key above." + echo "===========================================" + ''; + }; + + # --- Useful tools inside the VM --- + environment.systemPackages = with pkgs; [ + curl + jq + htop + nix + nix-eval-jobs + git + zstd + ]; + + # --- Misc VM settings --- + networking.hostName = "fc-demo"; + networking.firewall.allowedTCPPorts = [3000]; + services.getty.autologinUser = "root"; + + # Show a helpful MOTD + environment.etc."motd".text = '' + ┌──────────────────────────────────────────────┐ + │ Dashboard: http://localhost:3000 │ + │ API: http://localhost:3000/api/v1 │ + │ │ + │ Admin API key: fc_demo_admin_key │ + │ Read-only API key: fc_demo_readonly_key │ + │ │ + │ Useful commands: │ + │ $ systemctl status fc-server │ + │ $ journalctl -u fc-server -f │ + │ $ curl -sf localhost:3000/health | jq │ + │ $ curl -sf localhost:3000/metrics │ + │ │ + │ Press Ctrl-a x to quit QEMU. │ + └──────────────────────────────────────────────┘ + ''; + + system.stateVersion = "26.11"; + }); +in + pkgs.writeShellApplication { + name = "run-fc-demo-vm"; + text = '' + echo "Starting FC CI demo VM..." + echo "Dashboard will be available at http://localhost:3000" + echo "Press Ctrl-a x to quit." + echo "" + exec ${nixos.config.system.build.vm}/bin/run-fc-demo-vm + ''; + } diff --git a/nix/modules/nixos.nix b/nix/modules/nixos.nix new file mode 100644 index 0000000..b394e3c --- /dev/null +++ b/nix/modules/nixos.nix @@ -0,0 +1,365 @@ +{ + config, + pkgs, + lib, + ... +}: let + inherit (lib.options) mkOption mkEnableOption; + inherit (lib.types) bool str int package listOf submodule nullOr; + cfg = config.services.fc; + + settingsFormat = pkgs.formats.toml {}; + settingsType = settingsFormat.type; + + # Build the final settings by merging declarative config into settings + finalSettings = lib.recursiveUpdate cfg.settings (lib.optionalAttrs (cfg.declarative.projects != [] || cfg.declarative.apiKeys != []) { + declarative = { + projects = map (p: { + name = p.name; + repository_url = p.repositoryUrl; + description = p.description or null; + jobsets = map (j: { + name = j.name; + nix_expression = j.nixExpression; + enabled = j.enabled; + flake_mode = j.flakeMode; + check_interval = j.checkInterval; + }) p.jobsets; + }) cfg.declarative.projects; + api_keys = map (k: { + name = k.name; + key = k.key; + role = k.role; + }) cfg.declarative.apiKeys; + }; + }); + + settingsFile = settingsFormat.generate "fc.toml" finalSettings; + + inherit (builtins) map; + + jobsetOpts = { + options = { + name = mkOption { + type = str; + description = "Jobset name."; + }; + nixExpression = mkOption { + type = str; + description = "Nix expression to evaluate (e.g. 'packages', 'checks', 'hydraJobs')."; + }; + enabled = mkOption { + type = bool; + default = true; + description = "Whether this jobset is enabled for evaluation."; + }; + flakeMode = mkOption { + type = bool; + default = true; + description = "Whether to evaluate as a flake."; + }; + checkInterval = mkOption { + type = int; + default = 60; + description = "Seconds between evaluation checks."; + }; + }; + }; + + projectOpts = { + options = { + name = mkOption { + type = str; + description = "Project name (unique identifier)."; + }; + repositoryUrl = mkOption { + type = str; + description = "Git repository URL."; + }; + description = mkOption { + type = nullOr str; + default = null; + description = "Optional project description."; + }; + jobsets = mkOption { + type = listOf (submodule jobsetOpts); + default = []; + description = "Jobsets to create for this project."; + }; + }; + }; + + apiKeyOpts = { + options = { + name = mkOption { + type = str; + description = "Human-readable name for this API key."; + }; + key = mkOption { + type = str; + description = '' + The raw API key value (e.g. "fc_mykey123"). + Will be hashed before storage. Consider using a secrets manager. + ''; + }; + role = mkOption { + type = str; + default = "admin"; + description = "Role: admin, read-only, create-projects, eval-jobset, cancel-build, restart-jobs, bump-to-front."; + }; + }; + }; +in { + options.services.fc = { + enable = mkEnableOption "FC CI system"; + + package = mkOption { + type = package; + description = "The FC server package."; + }; + + evaluatorPackage = mkOption { + type = package; + default = cfg.package; + description = "The FC evaluator package. Defaults to cfg.package."; + }; + + queueRunnerPackage = mkOption { + type = package; + default = cfg.package; + description = "The FC queue runner package. Defaults to cfg.package."; + }; + + migratePackage = mkOption { + type = package; + description = "The FC migration CLI package."; + }; + + settings = mkOption { + type = settingsType; + default = {}; + description = '' + FC configuration as a Nix attribute set. + Will be converted to TOML and written to fc.toml. + ''; + }; + + declarative = { + projects = mkOption { + type = listOf (submodule projectOpts); + default = []; + description = '' + Declarative project definitions. These are upserted on every + server startup, ensuring the database matches this configuration. + ''; + example = lib.literalExpression '' + [ + { + name = "my-project"; + repositoryUrl = "https://github.com/user/repo"; + description = "My Nix project"; + jobsets = [ + { name = "packages"; nixExpression = "packages"; } + { name = "checks"; nixExpression = "checks"; } + ]; + } + ] + ''; + }; + + apiKeys = mkOption { + type = listOf (submodule apiKeyOpts); + default = []; + description = '' + Declarative API key definitions. Keys are upserted on every + server startup. Use a secrets manager for production deployments. + ''; + example = lib.literalExpression '' + [ + { name = "admin"; key = "fc_admin_secret"; role = "admin"; } + { name = "ci-bot"; key = "fc_ci_bot_key"; role = "eval-jobset"; } + ] + ''; + }; + }; + + database = { + createLocally = mkOption { + type = bool; + default = true; + description = "Whether to create the PostgreSQL database locally."; + }; + }; + + server = { + enable = mkEnableOption "FC server (REST API)"; + }; + + evaluator = { + enable = mkEnableOption "FC evaluator (git polling and nix evaluation)"; + }; + + queueRunner = { + enable = mkEnableOption "FC queue runner (build dispatch)"; + }; + }; + + config = lib.mkIf cfg.enable { + users.users.fc = { + isSystemUser = true; + group = "fc"; + home = "/var/lib/fc"; + createHome = true; + }; + + users.groups.fc = {}; + + services.postgresql = lib.mkIf cfg.database.createLocally { + enable = true; + ensureDatabases = ["fc"]; + ensureUsers = [ + { + name = "fc"; + ensureDBOwnership = true; + } + ]; + }; + + services.fc.settings = lib.mkDefault { + database.url = "postgresql:///fc?host=/run/postgresql"; + server.host = "127.0.0.1"; + server.port = 3000; + gc.gc_roots_dir = "/nix/var/nix/gcroots/per-user/fc/fc-roots"; + gc.enabled = true; + gc.max_age_days = 30; + gc.cleanup_interval = 3600; + logs.log_dir = "/var/lib/fc/logs"; + cache.enabled = true; + evaluator.restrict_eval = true; + evaluator.allow_ifd = false; + signing.enabled = false; + }; + + systemd.tmpfiles.rules = [ + (lib.mkIf cfg.server.enable "d /var/lib/fc/logs 0750 fc fc -") + (lib.mkIf cfg.queueRunner.enable "d /nix/var/nix/gcroots/per-user/fc 0755 fc fc -") + ]; + + systemd.services.fc-server = lib.mkIf cfg.server.enable { + description = "FC CI Server"; + wantedBy = ["multi-user.target"]; + after = ["network.target"] ++ lib.optional cfg.database.createLocally "postgresql.target"; + requires = lib.optional cfg.database.createLocally "postgresql.target"; + + path = with pkgs; [nix zstd]; + + serviceConfig = { + ExecStartPre = "${cfg.migratePackage}/bin/fc-migrate up ${finalSettings.database.url or "postgresql:///fc?host=/run/postgresql"}"; + ExecStart = "${cfg.package}/bin/fc-server"; + Restart = "on-failure"; + RestartSec = 5; + User = "fc"; + Group = "fc"; + StateDirectory = "fc"; + LogsDirectory = "fc"; + WorkingDirectory = "/var/lib/fc"; + ReadWritePaths = ["/var/lib/fc"]; + + # Hardening + ProtectSystem = "strict"; + ProtectHome = true; + NoNewPrivileges = true; + PrivateTmp = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + RestrictSUIDSGID = true; + }; + + environment = { + FC_CONFIG_FILE = "${settingsFile}"; + }; + }; + + systemd.services.fc-evaluator = lib.mkIf cfg.evaluator.enable { + description = "FC CI Evaluator"; + wantedBy = ["multi-user.target"]; + after = ["network.target" "fc-server.service"] ++ lib.optional cfg.database.createLocally "postgresql.target"; + requires = ["fc-server.service"] ++ lib.optional cfg.database.createLocally "postgresql.target"; + + path = with pkgs; [ + nix + git + nix-eval-jobs + ]; + + serviceConfig = { + ExecStart = "${cfg.evaluatorPackage}/bin/fc-evaluator"; + Restart = "on-failure"; + RestartSec = 10; + User = "fc"; + Group = "fc"; + StateDirectory = "fc"; + WorkingDirectory = "/var/lib/fc"; + ReadWritePaths = ["/var/lib/fc"]; + + # Hardening + ProtectSystem = "strict"; + ProtectHome = true; + NoNewPrivileges = true; + PrivateTmp = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + RestrictSUIDSGID = true; + }; + + environment = { + FC_CONFIG_FILE = "${settingsFile}"; + FC_EVALUATOR__WORK_DIR = "/var/lib/fc/evaluator"; + FC_EVALUATOR__RESTRICT_EVAL = "true"; + }; + }; + + systemd.services.fc-queue-runner = lib.mkIf cfg.queueRunner.enable { + description = "FC CI Queue Runner"; + wantedBy = ["multi-user.target"]; + after = ["network.target" "fc-server.service"] ++ lib.optional cfg.database.createLocally "postgresql.target"; + requires = ["fc-server.service"] ++ lib.optional cfg.database.createLocally "postgresql.target"; + + path = with pkgs; [ + nix + ]; + + serviceConfig = { + ExecStart = "${cfg.queueRunnerPackage}/bin/fc-queue-runner"; + Restart = "on-failure"; + RestartSec = 10; + User = "fc"; + Group = "fc"; + StateDirectory = "fc"; + LogsDirectory = "fc"; + WorkingDirectory = "/var/lib/fc"; + ReadWritePaths = [ + "/var/lib/fc" + "/nix/var/nix/gcroots/per-user/fc" + ]; + + # Hardening + ProtectSystem = "strict"; + ProtectHome = true; + NoNewPrivileges = true; + PrivateTmp = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + RestrictSUIDSGID = true; + }; + + environment = { + FC_CONFIG_FILE = "${settingsFile}"; + FC_QUEUE_RUNNER__WORK_DIR = "/var/lib/fc/queue-runner"; + }; + }; + }; +} diff --git a/nix/packages/fc-common.nix b/nix/packages/fc-common.nix new file mode 100644 index 0000000..c622ad5 --- /dev/null +++ b/nix/packages/fc-common.nix @@ -0,0 +1,11 @@ +{ + craneLib, + commonArgs, + cargoArtifacts, +}: +craneLib.buildPackage (commonArgs + // { + inherit cargoArtifacts; + pname = "fc-common"; + cargoExtraArgs = "--package fc-common"; + }) diff --git a/nix/packages/fc-evaluator.nix b/nix/packages/fc-evaluator.nix new file mode 100644 index 0000000..3c1ea4d --- /dev/null +++ b/nix/packages/fc-evaluator.nix @@ -0,0 +1,11 @@ +{ + craneLib, + commonArgs, + cargoArtifacts, +}: +craneLib.buildPackage (commonArgs + // { + inherit cargoArtifacts; + pname = "fc-evaluator"; + cargoExtraArgs = "--package fc-evaluator"; + }) diff --git a/nix/packages/fc-migrate-cli.nix b/nix/packages/fc-migrate-cli.nix new file mode 100644 index 0000000..3fb69b6 --- /dev/null +++ b/nix/packages/fc-migrate-cli.nix @@ -0,0 +1,11 @@ +{ + craneLib, + commonArgs, + cargoArtifacts, +}: +craneLib.buildPackage (commonArgs + // { + inherit cargoArtifacts; + pname = "fc-migrate-cli"; + cargoExtraArgs = "--package fc-migrate-cli"; + }) diff --git a/nix/packages/fc-queue-runner.nix b/nix/packages/fc-queue-runner.nix new file mode 100644 index 0000000..173fa3b --- /dev/null +++ b/nix/packages/fc-queue-runner.nix @@ -0,0 +1,11 @@ +{ + craneLib, + commonArgs, + cargoArtifacts, +}: +craneLib.buildPackage (commonArgs + // { + inherit cargoArtifacts; + pname = "fc-queue-runner"; + cargoExtraArgs = "--package fc-queue-runner"; + }) diff --git a/nix/packages/fc-server.nix b/nix/packages/fc-server.nix new file mode 100644 index 0000000..05ca503 --- /dev/null +++ b/nix/packages/fc-server.nix @@ -0,0 +1,11 @@ +{ + craneLib, + commonArgs, + cargoArtifacts, +}: +craneLib.buildPackage (commonArgs + // { + inherit cargoArtifacts; + pname = "fc-server"; + cargoExtraArgs = "--package fc-server"; + }) diff --git a/nix/vm-test.nix b/nix/vm-test.nix new file mode 100644 index 0000000..05920ce --- /dev/null +++ b/nix/vm-test.nix @@ -0,0 +1,2216 @@ +{ + 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 "