{ config, lib, pkgs, ... }: let baseSiteConfig = import ../site-config.nix; baseServiceConfigs = import ../hosts/muffin/service-configs.nix { site_config = baseSiteConfig; }; testServiceConfigs = lib.recursiveUpdate baseServiceConfigs { zpool_ssds = ""; gitea = { dir = "/var/lib/gitea"; # `:80` makes Caddy bind all hosts on HTTP port 80 with no Host-header # matching — simplest path to a reachable vhost inside the test VM # where there is no ACME / DNS and no TLS terminator. domain = ":80"; }; ports.private.gitea = { port = 3000; proto = "tcp"; }; }; testLib = lib.extend ( final: prev: { serviceMountWithZpool = serviceName: zpool: dirs: { ... }: { }; serviceFilePerms = serviceName: tmpfilesRules: { ... }: { }; } ); giteaModule = { config, pkgs, ... }: { imports = [ (import ../services/gitea/gitea.nix { inherit config pkgs; lib = testLib; service_configs = testServiceConfigs; }) ]; }; in pkgs.testers.runNixOSTest { name = "gitea-hide-actions"; nodes = { server = { config, lib, pkgs, ... }: { imports = [ ../modules/server-security.nix giteaModule ]; # The shared gitea.nix module derives DOMAIN/ROOT_URL from the # `service_configs.gitea.domain` string, which here is the full URL # `http://server`. Override to valid bare values so Gitea doesn't # get a malformed ROOT_URL like `https://http://server`. services.gitea.settings = { server = { DOMAIN = lib.mkForce "server"; ROOT_URL = lib.mkForce "http://server/"; }; # Tests talk HTTP, so drop the Secure flag — otherwise curl's cookie # jar holds the session cookie but never sends it back. session.COOKIE_SECURE = lib.mkForce false; }; services.caddy = { enable = true; # No DNS / ACME in the VM test network — serve plain HTTP. globalConfig = '' auto_https off ''; }; services.postgresql.enable = true; # Stub out zfs/mount ordering added by the real serviceMountWithZpool. systemd.services."gitea-mounts".enable = lib.mkForce false; systemd.services.gitea = { wants = lib.mkForce [ ]; after = lib.mkForce [ "postgresql.service" ]; requires = lib.mkForce [ ]; }; networking.firewall.allowedTCPPorts = [ 80 3000 ]; }; client = { pkgs, ... }: { environment.systemPackages = [ pkgs.curl ]; }; }; testScript = '' import re start_all() server.wait_for_unit("postgresql.service") server.wait_for_unit("gitea.service") server.wait_for_unit("caddy.service") server.wait_for_open_port(3000) server.wait_for_open_port(80) server.succeed( "su -l gitea -s /bin/sh -c '${pkgs.gitea}/bin/gitea admin user create " "--username testuser --password testpassword " "--email test@test.local --must-change-password=false " "--work-path /var/lib/gitea'" ) def curl(args, cookies=None): cookie_args = f"-b {cookies} " if cookies else "" cmd = ( "curl -4 -s -o /dev/null " f"-w '%{{http_code}}|%{{redirect_url}}' {cookie_args}{args}" ) return client.succeed(cmd).strip() def login(): # Gitea's POST /user/login requires a _csrf token and expects the # matching session cookie already set. Fetch the login form first # to harvest both, then submit credentials with the same cookie jar. client.succeed("rm -f /tmp/cookies.txt") html = client.succeed( "curl -4 -s -c /tmp/cookies.txt http://server/user/login" ) match = re.search(r'name="_csrf"\s+value="([^"]+)"', html) assert match, f"CSRF token not found in login form: {html[:500]!r}" csrf = match.group(1) # -L so we follow the post-login redirect; the session cookie is # rewritten by Gitea on successful login to carry uid. client.succeed( "curl -4 -s -L -o /dev/null " "-b /tmp/cookies.txt -c /tmp/cookies.txt " f"--data-urlencode '_csrf={csrf}' " "--data-urlencode 'user_name=testuser' " "--data-urlencode 'password=testpassword' " "http://server/user/login" ) # Sanity-check the session by hitting the gated probe directly — # the post-login cookie jar MUST drive /user/stopwatches to 200. probe = client.succeed( "curl -4 -s -o /dev/null -w '%{http_code}' " "-b /tmp/cookies.txt http://server/user/stopwatches" ).strip() assert probe == "200", f"session auth probe expected 200, got {probe!r}" return "/tmp/cookies.txt" with subtest("Anonymous /{user}/{repo}/actions redirects to login"): result = curl("http://server/foo/bar/actions") code, _, redir = result.partition("|") print(f"anon /foo/bar/actions -> {result!r}") assert code == "302", f"expected 302, got {code!r} (full: {result!r})" assert "/user/login" in redir, f"expected login redirect, got {redir!r}" assert "redirect_to=" in redir, f"expected redirect_to param, got {redir!r}" assert "/foo/bar/actions" in redir, ( f"expected original URL preserved in redirect_to, got {redir!r}" ) with subtest("Anonymous deep /actions paths also redirect"): for path in ["/foo/bar/actions/", "/foo/bar/actions/runs/1", "/foo/bar/actions/workflows/build.yaml"]: result = curl(f"http://server{path}") code, _, redir = result.partition("|") print(f"anon {path} -> {result!r}") assert code == "302", f"{path}: expected 302, got {code!r}" assert "/user/login" in redir, f"{path}: expected login redirect, got {redir!r}" with subtest("Anonymous workflow badge stays public"): result = curl("http://server/foo/bar/actions/workflows/ci.yaml/badge.svg") code, _, redir = result.partition("|") print(f"anon badge -> {result!r}") assert code != "302" or "/user/login" not in redir, ( f"badge path should not redirect to login, got {result!r}" ) cookies = login() with subtest("Session-authenticated /{user}/{repo}/actions reaches Gitea"): result = curl( "http://server/testuser/nonexistent/actions", cookies=cookies ) code, _, redir = result.partition("|") print(f"auth /testuser/nonexistent/actions -> {result!r}") # Gitea returns 404 for the missing repo — the key assertion is that # Caddy's gate forwarded the request instead of redirecting to login. assert not (code == "302" and "/user/login" in redir), ( f"session-authed actions request was intercepted by login gate: {result!r}" ) with subtest("Anonymous /explore/repos is served without gating"): result = curl("http://server/explore/repos") code, _, _ = result.partition("|") print(f"anon /explore/repos -> {result!r}") assert code == "200", f"expected 200 for public explore page, got {result!r}" with subtest("Anonymous /{user}/{repo} (non-actions) is not login-gated"): result = curl("http://server/foo/bar") code, _, redir = result.partition("|") print(f"anon /foo/bar -> {result!r}") assert not (code == "302" and "/user/login" in redir), ( f"non-actions repo path should not redirect to login: {result!r}" ) ''; }