AkurAI Build
Menu

AkurAI-Build

public

Latest change e16ed955e37bc7f7b00de000a208ef91006cf60e - Adopt the AkurAI ID SSO contract and inject pipeline build context by AkurAI Build

use std::{fs, path::Path, process::Command};

use anyhow::{Context, Result};

mod support;
use support::ProcessServer;

/// Verify that the server serves static pages, assets, and security headers
/// on public-facing routes.
#[test]
fn static_pages_and_assets() -> Result<()> {
    let work = tempfile::tempdir()?;
    let data = work.path().join("data");
    let key_file = work.path().join("key.bin");
    let token_file = work.path().join("token.txt");
    let webhook_secret_file = work.path().join("webhook.txt");

    run_cli(&data, &key_file, &["keygen", "--out", path(&key_file)?])?;
    run_cli(&data, &key_file, &["migrate"])?;
    write_secret(
        &token_file,
        "0000111122223333444455556666777788889999aaaabbbbccccddddeeeeffff",
    )?;
    write_secret(
        &webhook_secret_file,
        "aaaabbbbccccddddeeeeffff00001111aaaabbbbccccddddeeeeffff00001111",
    )?;

    let mut command = Command::new(env!("CARGO_BIN_EXE_akurai"));
    command.args([
        "--data",
        path(&data)?,
        "--key-file",
        path(&key_file)?,
        "serve",
        "--address",
        "127.0.0.1:0",
        "--token-file",
        path(&token_file)?,
        "--webhook-secret-file",
        path(&webhook_secret_file)?,
        "--public-url",
        "http://localhost",
        "--allow-native",
        "--worker-count",
        "1",
    ]);
    command.env_remove("AKURAI_DATA");
    command.env_remove("AKURAI_KEY_FILE");
    command.env_remove("AKURAI_ADDRESS");
    command.env_remove("AKURAI_TOKEN_FILE");
    command.env_remove("AKURAI_WEBHOOK_SECRET_FILE");
    command.env_remove("AKURAI_PUBLIC_URL");
    command.env_remove("AKURAI_ALLOW_NATIVE");
    command.env_remove("AKURAI_WORKERS");
    // Strip ambient AkurAI ID (OIDC) configuration so the login contract is
    // deterministic regardless of the shell that invoked the test (e.g. the
    // deploy gate sources a production env file that would otherwise leak).
    command.env_remove("AKURAI_IDP_ISSUER");
    command.env_remove("AKURAI_IDP_INTERNAL_URL");
    command.env_remove("AKURAI_IDP_CLIENT_ID");
    command.env_remove("AKURAI_IDP_CLIENT_SECRET");
    command.env_remove("AKURAI_IDP_REDIRECT_URI");
    command.env_remove("AKURAI_IDP_ALLOWED_GROUPS");

    let mut server = ProcessServer::start_dynamic(command);

    // Health check.
    let health = server.request("GET", "/api/health");
    health.assert_status(200);

    // State with auth.
    let state = server.request_with_headers(
        "GET",
        "/api/state",
        &[(
            "Authorization",
            "Bearer 0000111122223333444455556666777788889999aaaabbbbccccddddeeeeffff",
        )],
    );
    state.assert_status(200);

    // Landing page with retry for startup race.
    let landing = retry_landing(&server)?;
    landing.assert_body_contains("AkurAI Build");
    landing.assert_security_headers();

    // Public documentation.
    let docs = server.request("GET", "/docs");
    docs.assert_status(200);
    docs.assert_body_contains("AkurAI Build documentation");
    docs.assert_body_contains("akurai_run_queue");
    docs.assert_security_headers();

    // Browser sign-in is gated on AkurAI ID configuration. With no client
    // configured in this hermetic test, /auth/login must fail closed with the
    // internal "not configured" error rather than render a broken page.
    let login = server.request("GET", "/auth/login");
    login.assert_status(500);
    login.assert_body_contains("internal");

    // Protected browser routes redirect to the login route, never render.
    let app = server.request("GET", "/app/runs");
    app.assert_status(303);
    app.assert_header_contains("location", "/auth/login?return_to=%2Fapp%2Fruns");

    // Protected JSON routes answer 401 JSON, never a redirect.
    let api = server.request("GET", "/api/state");
    api.assert_status(401);
    api.assert_body_contains("\"unauthenticated\"");
    api.assert_body_contains("\"/auth/login\"");

    // CSS asset.
    let css = server.request("GET", "/assets/app.css");
    css.assert_status(200);
    css.assert_header_contains("content-type", "text/css");

    // JS asset.
    let js = server.request("GET", "/assets/app.js");
    js.assert_status(200);
    js.assert_header_contains("content-type", "text/javascript");
    assert!(!js.body.is_empty(), "JS asset should not be empty");

    // Security headers on landing page.
    let landing2 = server.request("GET", "/");
    landing2.assert_header_contains("x-content-type-options", "nosniff");
    landing2.assert_header_contains("x-frame-options", "deny");

    server.shutdown_with_sigterm();

    let mut auth_command = Command::new(env!("CARGO_BIN_EXE_akurai"));
    auth_command
        .args([
            "--data",
            path(&data)?,
            "--key-file",
            path(&key_file)?,
            "serve",
            "--address",
            "127.0.0.1:0",
            "--token-file",
            path(&token_file)?,
            "--webhook-secret-file",
            path(&webhook_secret_file)?,
            "--public-url",
            "https://build.example",
            "--worker-count",
            "1",
        ])
        .env("AKURAI_IDP_ISSUER", "https://auth.example")
        .env_remove("AKURAI_IDP_INTERNAL_URL")
        .env("AKURAI_IDP_CLIENT_ID", "build client")
        .env("AKURAI_IDP_CLIENT_SECRET", "test secret")
        .env(
            "AKURAI_IDP_REDIRECT_URI",
            "https://build.example/auth/callback",
        )
        .env_remove("AKURAI_IDP_ALLOWED_GROUPS");
    let mut auth_server = ProcessServer::start_dynamic(auth_command);
    let login = auth_server.request("GET", "/auth/login");
    login.assert_status(303);
    for fragment in [
        "https://auth.example/authorize?response_type=code",
        "client_id=build%20client",
        "redirect_uri=https%3A%2F%2Fbuild.example%2Fauth%2Fcallback",
        "scope=openid%20profile%20email%20groups",
        "&state=",
        "&nonce=",
        "&code_challenge=",
        "code_challenge_method=S256",
    ] {
        login.assert_header_contains("location", fragment);
    }
    login.assert_header_contains("set-cookie", "akurai_oauth_state=");
    login.assert_header_contains("set-cookie", "HttpOnly");
    login.assert_header_contains("set-cookie", "Max-Age=600");

    // A callback whose state matches nothing in the server-side store fails
    // closed with 400 before any back-channel call is attempted.
    let mismatch = auth_server.request("GET", "/auth/callback?code=x&state=y");
    mismatch.assert_status(400);
    assert!(
        login.body.is_empty(),
        "login must not render an intermediate page"
    );
    auth_server.shutdown_with_sigterm();
    Ok(())
}

fn retry_landing(server: &ProcessServer) -> Result<support::HttpResponse> {
    let mut response = server.request("GET", "/");
    if response.status == 200 {
        return Ok(response);
    }
    // Single retry after a brief pause for async runtime to settle.
    std::thread::sleep(std::time::Duration::from_millis(500));
    response = server.request("GET", "/");
    anyhow::ensure!(
        response.status == 200,
        "landing page returned {} after retry",
        response.status
    );
    Ok(response)
}

fn write_secret(path: &Path, content: &str) -> Result<()> {
    fs::write(path, content)?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
    }
    Ok(())
}

fn run_cli(data: &Path, key: &Path, arguments: &[&str]) -> Result<()> {
    let output = Command::new(env!("CARGO_BIN_EXE_akurai"))
        .args(["--data", path(data)?, "--key-file", path(key)?])
        .args(arguments)
        .output()?;
    anyhow::ensure!(
        output.status.success(),
        "CLI {} failed: {}",
        arguments.join(" "),
        String::from_utf8_lossy(&output.stderr)
    );
    Ok(())
}

fn path(path: &Path) -> Result<&str> {
    path.to_str().context("test path is not UTF-8")
}