rustio-admin-cli 0.31.0

Command-line tools for rustio-admin: project scaffolding, migrations, user management.
//! Generated by `rustio-admin test-init` — first-line CI check for your
//! Rustio project. Boots the project binary via `cargo run`, polls
//! the documented port, sends a raw HTTP request to `/admin/`, and
//! asserts the response is a 302 / 303 redirect to `/admin/login`.
//!
//! Stdlib-only — no extra deps, drop-in. Replace with `reqwest` or
//! similar once your project warrants richer checks; this skeleton
//! exists to catch the "server doesn't start" / "auth middleware
//! doesn't redirect anonymous traffic" class of regression that
//! kills new deployments silently.
//!
//! Run with: `cargo test --test smoke`
//!
//! Prerequisites:
//!   - `DATABASE_URL` exported in your shell (the binary reads it).
//!   - The framework's auth tables present (run `rustio-admin migrate
//!     apply` once first).

use std::io::{Read, Write};
use std::net::TcpStream;
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};

/// Port the project binary is expected to bind. Override via
/// `RUSTIO_PORT` so this test doesn't collide with a running
/// admin on the default 3000.
fn target_port() -> u16 {
    std::env::var("RUSTIO_PORT")
        .ok()
        .and_then(|s| s.parse::<u16>().ok())
        .unwrap_or(3000)
}

/// Cargo run cancellation guard. Sends SIGKILL on drop so a panic
/// in the test body doesn't leak a server process onto the host.
struct ServerGuard {
    child: Child,
}

impl Drop for ServerGuard {
    fn drop(&mut self) {
        let _ = self.child.kill();
        let _ = self.child.wait();
    }
}

/// Spawn `cargo run --quiet` in the background and wait for the
/// target port to start accepting TCP connections. Times out after
/// 60 s — first-build warm-ups can be slow, retries are not.
fn boot_server(port: u16) -> ServerGuard {
    let child = Command::new("cargo")
        .args(["run", "--quiet"])
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .spawn()
        .expect("cargo run failed to spawn");
    let guard = ServerGuard { child };

    let deadline = Instant::now() + Duration::from_secs(60);
    let addr = format!("127.0.0.1:{port}");
    while Instant::now() < deadline {
        if TcpStream::connect_timeout(&addr.parse().unwrap(), Duration::from_millis(200)).is_ok() {
            return guard;
        }
        std::thread::sleep(Duration::from_millis(250));
    }
    panic!("server didn't bind {addr} within 60s — check `cargo run` locally");
}

/// Send a raw GET to `/admin/` and read enough of the response to
/// inspect the status line + the `Location:` header. Doesn't follow
/// redirects (that's the whole point of the assertion).
fn fetch_admin_root(port: u16) -> String {
    let addr = format!("127.0.0.1:{port}");
    let mut stream = TcpStream::connect(&addr).expect("connect to running server");
    stream
        .set_read_timeout(Some(Duration::from_secs(5)))
        .unwrap();
    let req = "GET /admin/ HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n";
    stream.write_all(req.as_bytes()).expect("write request");
    let mut response = String::new();
    let _ = stream.read_to_string(&mut response);
    response
}

#[test]
fn admin_root_redirects_to_login() {
    let port = target_port();
    let _guard = boot_server(port);
    let response = fetch_admin_root(port);

    // First line is the status line: `HTTP/1.1 302 Found` or
    // `HTTP/1.1 303 See Other`. Either redirect status is fine for
    // the anonymous-traffic-redirected-to-login policy.
    let status_line = response.lines().next().unwrap_or("");
    assert!(
        status_line.contains(" 302 ") || status_line.contains(" 303 "),
        "expected redirect, got status line: {status_line:?}\n\nfull response:\n{response}"
    );

    // The Location header should point at the login page. Match
    // case-insensitively so a future header-rewrite refactor
    // doesn't trip the test.
    let location: Option<&str> = response
        .lines()
        .find_map(|l| {
            let lower = l.to_ascii_lowercase();
            lower.strip_prefix("location:").map(|_| l)
        });
    let location = location.expect("missing Location header");
    assert!(
        location.contains("/admin/login"),
        "Location should point at /admin/login, got: {location:?}"
    );
}