wsb-daemon 0.248.6265

wsb's daemon: the workbench, the GitHub mirror's schedule and peer continuity, for every workspace on the machine
//! Who may reach the control API.
//!
//! Driven through a real router with the real middleware, using requests a
//! browser actually sends. The guard is a claim about HTTP headers, so a test
//! that called the function with a struct it built itself would be a test of
//! the test.

use super::*;

use axum::{body::Body, http::Request, routing::get, Router};
use tower::ServiceExt;

/// The guard, in front of something that answers.
fn guarded() -> Router {
    Router::new()
        .route("/api/daemon/info", get(|| async { "reached" }))
        .layer(axum::middleware::from_fn(not_a_browser))
}

async fn status_of(request: Request<Body>) -> axum::http::StatusCode {
    guarded()
        .oneshot(request)
        .await
        .expect("the router answers")
        .status()
}

/// TEST9880: the command line gets through.
///
/// It sends neither `Origin` nor `Sec-Fetch-Site`, because it is not a
/// browser. If this stops being true the whole daemon stops working, so it is
/// asserted first.
#[tokio::test]
async fn test9880_a_command_line_reaches_the_control_api() {
    let request = Request::builder()
        .uri("/api/daemon/info")
        .header("user-agent", "wsb")
        .body(Body::empty())
        .expect("a request");
    assert_eq!(status_of(request).await, StatusCode::OK);
}

/// TEST9881: a page in the operator's browser does not.
///
/// A page that could reach these routes could register workspaces and stop the
/// daemon. `Sec-Fetch-Site` is set by the browser itself and page script cannot
/// suppress it — including on a same-origin request from the workbench's own
/// page, which has its own routes and no business here.
#[tokio::test]
async fn test9881_a_browser_is_refused_whatever_it_claims() {
    for (header, value) in [
        ("sec-fetch-site", "cross-site"),
        ("sec-fetch-site", "same-origin"),
        ("sec-fetch-site", "none"),
    ] {
        let request = Request::builder()
            .uri("/api/daemon/info")
            .header(header, value)
            .body(Body::empty())
            .expect("a request");
        assert_eq!(
            status_of(request).await,
            StatusCode::FORBIDDEN,
            "a request carrying {header}: {value} came from a browser"
        );
    }
}

/// TEST9882: an origin is a browser too.
///
/// A cross-origin `fetch` always carries one, and a client that sets it by
/// hand is claiming to be a page — so it is taken at its word.
#[tokio::test]
async fn test9882_a_request_carrying_an_origin_is_refused() {
    let request = Request::builder()
        .uri("/api/daemon/info")
        .header("origin", "http://127.0.0.1:3000")
        .body(Body::empty())
        .expect("a request");
    assert_eq!(status_of(request).await, StatusCode::FORBIDDEN);
}

/// TEST9883: a refusal says who the API is for, in the shape a command line
/// reads.
#[tokio::test]
async fn test9883_a_refusal_is_stated_as_one() {
    let response = guarded()
        .oneshot(
            Request::builder()
                .uri("/api/daemon/info")
                .header("origin", "http://evil.example")
                .body(Body::empty())
                .expect("a request"),
        )
        .await
        .expect("the router answers");
    let body = hyper::body::to_bytes(response.into_body())
        .await
        .expect("the body is readable");
    let refusal: wsb::daemon::ApiError =
        serde_json::from_slice(&body).expect("a refusal is stated as one");

    assert!(
        refusal.error.contains("command line"),
        "the refusal must say who this API is for: {}",
        refusal.error
    );
}

/// A machine with one workspace open, in scratch directories of its own.
async fn machine_with_a_workspace() -> (tempfile::TempDir, tempfile::TempDir, Arc<MachineState>) {
    let machine_dir = tempfile::tempdir().expect("a scratch directory");
    let repository = tempfile::tempdir().expect("a scratch repository");
    let root = wsb::platform::canonicalize(&repository.path()).expect("the path resolves");

    for arguments in [
        vec!["init", "--quiet"],
        vec!["-c", "user.email=t@t", "-c", "user.name=t", "commit", "--allow-empty", "-qm", "first"],
    ] {
        let done = wsb::git_backend::git_command()
            .arg("-C")
            .arg(&root)
            .args(&arguments)
            .status()
            .expect("git runs");
        assert!(done.success(), "git {arguments:?} failed");
    }
    wsb::workspace::exclude_state_dir(&root).expect("the state dir is excluded from git");
    wsb::workspace_state::WorkspaceState::initialize(&root, None).expect("wsb is set up here, as `wsb init` does");

    let machine = MachineState::new(
        machine_dir.path().to_path_buf(),
        std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
        0,
        std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
        0,
    )
    .expect("a machine");
    machine.resolve(&root).await.expect("the workspace opens");
    (machine_dir, repository, machine)
}

/// TEST11496: stopping takes the control secret, and nothing else does.
///
/// What stopping has to establish is that the caller is not a passer-by: the
/// workbench answers every interface under `--listen`. The daemon's rendezvous
/// file is 0600, so a secret published only in there says exactly that.
#[tokio::test]
async fn test11496_stopping_takes_the_control_secret() {
    let (_machine_dir, _repository, machine) = machine_with_a_workspace().await;

    assert!(
        !presents_control_secret(&machine, &HeaderMap::new()),
        "a caller with nothing to show must not be able to stop this daemon"
    );

    // The secret this daemon published in its own 0600 file.
    assert!(!machine.control_token.is_empty(), "a daemon minted no control secret");
    let mut presented = HeaderMap::new();
    presented.insert(
        wsb::daemon::protocol::route::CONTROL_HEADER,
        machine.control_token.parse().expect("a header value"),
    );
    assert!(
        presents_control_secret(&machine, &presented),
        "the operator's own command line, which read the rendezvous file to \
         find the port, could not stop the daemon"
    );

    // And nothing else does.
    for wrong in ["", " ", "0", &machine.control_token[..machine.control_token.len() - 1],
                  &format!("{}0", machine.control_token)] {
        let mut headers = HeaderMap::new();
        if let Ok(value) = wrong.parse() {
            headers.insert(wsb::daemon::protocol::route::CONTROL_HEADER, value);
        }
        assert!(
            !presents_control_secret(&machine, &headers),
            "a caller presenting {wrong:?} was allowed to stop this daemon"
        );
    }
}

/// TEST11497: the control secret is this daemon's, and only this one's.
///
/// It is minted at startup and lives in that process's memory and its 0600
/// rendezvous file. A token from a daemon that has been replaced opens
/// nothing.
#[tokio::test]
async fn test11497_each_daemon_mints_its_own_control_secret() {
    let (_one_dir, _one_repo, one) = machine_with_a_workspace().await;
    let (_two_dir, _two_repo, two) = machine_with_a_workspace().await;

    assert_ne!(
        one.control_token, two.control_token,
        "two daemons minted the same control secret, so either can stop the other"
    );
    assert_eq!(one.control_token.len(), 64, "32 bytes, hex");
    assert!(one.control_token.chars().all(|c| c.is_ascii_hexdigit()));

    let mut headers = HeaderMap::new();
    headers.insert(
        wsb::daemon::protocol::route::CONTROL_HEADER,
        one.control_token.parse().expect("a header value"),
    );
    assert!(
        !presents_control_secret(&two, &headers),
        "one daemon's control secret stopped another"
    );
}