upcloud-api 0.1.3

The UpCloud API 1.3 surface the nordisk estates use, as ONE trait (`UpCloudApi`) with ONE wire implementation. Which cloud a run talks to (the account, or a mock-upcloud on loopback) is an `Endpoint` decided once at the edge, and a mock endpoint cannot be pointed off this machine. The fake that answers the trait in-process lives beside mock-upcloud's state machine.
Documentation
//! **The ONE wire, on a real socket.** What goes over TCP for a mock endpoint
//! is what goes over TCP for the account — only the base differs — so these
//! are properties of the account's requests too: the versioned path, the
//! bearer on API calls, NO bearer at the upload host, a declared
//! Content-Length on the medium (the direct-upload endpoint answers a chunked
//! PUT with 411), and the stop timeout as a string.

use std::io::{BufRead, BufReader, Read, Write};
use std::net::TcpListener;
use std::sync::mpsc;

use upcloud_api::{connect, Credential, Endpoint, Options, Stop, UpCloudApi};

#[derive(Debug)]
struct Seen {
    line: String,
    headers: Vec<(String, String)>,
    body: Vec<u8>,
}

impl Seen {
    fn header(&self, k: &str) -> Option<&str> {
        self.headers.iter().find(|(h, _)| h.eq_ignore_ascii_case(k)).map(|(_, v)| v.as_str())
    }
}

/// A loopback server that answers `n` requests with `{}` and hands back what it saw.
fn serve(n: usize) -> (String, mpsc::Receiver<Seen>) {
    serve_with(n, "{}")
}

fn serve_with(n: usize, reply: &'static str) -> (String, mpsc::Receiver<Seen>) {
    let l = TcpListener::bind("127.0.0.1:0").unwrap();
    let base = format!("http://127.0.0.1:{}", l.local_addr().unwrap().port());
    let (tx, rx) = mpsc::channel();
    std::thread::spawn(move || {
        for _ in 0..n {
            let (s, _) = l.accept().unwrap();
            let mut r = BufReader::new(s.try_clone().unwrap());
            let mut line = String::new();
            r.read_line(&mut line).unwrap();
            let mut headers = Vec::new();
            loop {
                let mut h = String::new();
                r.read_line(&mut h).unwrap();
                let h = h.trim_end().to_string();
                if h.is_empty() {
                    break;
                }
                let (k, v) = h.split_once(':').unwrap();
                headers.push((k.trim().to_string(), v.trim().to_string()));
            }
            let len: usize = headers.iter().find(|(k, _)| k.eq_ignore_ascii_case("content-length")).map(|(_, v)| v.parse().unwrap()).unwrap_or(0);
            let mut body = vec![0u8; len];
            r.read_exact(&mut body).unwrap();
            let mut s = s;
            s.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{reply}", reply.len()).as_bytes()).unwrap();
            tx.send(Seen { line: line.trim_end().to_string(), headers, body }).unwrap();
        }
    });
    (base, rx)
}

#[test]
fn an_api_call_carries_the_versioned_path_and_the_bearer() {
    let (base, rx) = serve(2);
    let api = connect(&Endpoint::mock(&base).unwrap(), Credential::Token("tok".into()), Options::default());
    assert!(api.server("abc").unwrap().ok());
    let seen = rx.recv().unwrap();
    assert_eq!(seen.line, "GET /1.3/server/abc HTTP/1.1");
    assert_eq!(seen.header("authorization"), Some("Bearer tok"));

    api.stop_server("abc", Stop::Soft { timeout_s: 60 }).unwrap();
    let seen = rx.recv().unwrap();
    assert_eq!(seen.line, "POST /1.3/server/abc/stop HTTP/1.1");
    let v: serde_json::Value = serde_json::from_slice(&seen.body).unwrap();
    assert_eq!(v["stop_server"]["timeout"], serde_json::json!("60"), "a number is accepted and IGNORED by UpCloud");
}

#[test]
fn the_medium_goes_up_with_a_declared_length_and_no_bearer() {
    let (base, rx) = serve(1);
    let api = connect(&Endpoint::mock(&base).unwrap(), Credential::Token("tok".into()), Options::default());
    let dir = std::env::temp_dir().join(format!("upcloud-api-wire-{}", std::process::id()));
    std::fs::create_dir_all(&dir).unwrap();
    let f = dir.join("medium.iso");
    std::fs::write(&f, b"0123456789abcdef").unwrap();
    let url = format!("{base}/uploader/session/SECRETSESSION");
    assert!(api.upload_direct(&url, &f).unwrap().ok());
    let seen = rx.recv().unwrap();
    assert_eq!(seen.line, "PUT /uploader/session/SECRETSESSION HTTP/1.1");
    assert_eq!(seen.header("authorization"), None, "the API bearer has no business at the upload host");
    assert_eq!(seen.header("content-length"), Some("16"), "a chunked PUT is answered 411 by the real upload host");
    assert_eq!(seen.header("transfer-encoding"), None);
    assert_eq!(seen.header("content-type"), Some("application/octet-stream"));
    assert_eq!(seen.body, b"0123456789abcdef");
}

#[test]
fn a_mock_wire_refuses_an_upload_url_that_is_not_its_own() {
    let api = connect(&Endpoint::mock("http://127.0.0.1:1").unwrap(), Credential::Token("t".into()), Options::default());
    let f = std::env::temp_dir().join("upcloud-api-wire-never-read");
    let e = api.upload_direct("https://fi-hel1.img.upcloud.com/uploader/session/SECRET", &f).unwrap_err();
    assert!(e.contains("upload-url-foreign") && !e.contains("SECRET"), "{e}");
}

/// **Behaviour 63's guard.** A mock that never heard this run's token — the
/// provider spoke to somebody else — refuses the apply by name; one that heard
/// a `GET /1.3/account` from it lets it through. The digest, never the token,
/// is what crosses the wire.
#[test]
fn a_mock_that_never_heard_this_run_refuses_the_apply() {
    let token = upcloud_api::mock_door::mint_token();
    assert!(token.starts_with("ucat_mock_"));

    let (base, rx) = serve_with(1, r#"{"token_sha256":"x","requests":3,"account_calls":0}"#);
    let e = upcloud_api::mock_door::require_heard(&Endpoint::mock(&base).unwrap(), &token, "apply").unwrap_err();
    assert!(e.contains("mock-never-heard-this-run") && e.contains("apply"), "{e}");
    let seen = rx.recv().unwrap();
    assert!(seen.line.starts_with("GET /mock/heard?token_sha256="), "{}", seen.line);
    assert!(!seen.line.contains(&token), "the token itself never leaves this process");

    let (base, _rx) = serve_with(1, r#"{"token_sha256":"x","requests":9,"account_calls":1}"#);
    let h = upcloud_api::mock_door::require_heard(&Endpoint::mock(&base).unwrap(), &token, "destroy").unwrap();
    assert_eq!((h.requests, h.account_calls), (9, 1));

    assert!(upcloud_api::mock_door::heard(&Endpoint::Account, &token).unwrap_err().contains("heard-asked-of-the-account"));
}