Menu
AkurAI-Build
publicLatest 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")
}