plecto 0.3.6

Plecto Proxy — a self-hostable L7 reverse proxy / API gateway extended with sandboxed WASM Component Model filters. This crate is the `plecto` binary and operator CLI.
//! E2E (tdd-workflow Phase 0) for the binary's operator CLI: `plecto --version` and
//! `plecto validate <manifest>` (the `nginx -t` shape — validate a manifest in CI / before a
//! SIGHUP without serving). Drives the real compiled binary (`CARGO_BIN_EXE_plecto`).
//!
//! `validate` is a STATIC config check: parse (strict, `deny_unknown_fields`), reference and
//! range validation (filters / chain / routes / upstreams), and the fail-closed file loads the
//! server would do at startup (trust keys, TLS certs, upstream CA). It must not serve, must not
//! load WASM artifacts (they may not exist where CI runs), and must not create state files.

use std::path::Path;
use std::process::Command;

fn run(args: &[&str], dir: &Path) -> std::process::Output {
    Command::new(env!("CARGO_BIN_EXE_plecto"))
        .args(args)
        .current_dir(dir)
        .output()
        .unwrap()
}

const VALID_MANIFEST: &str = r#"
[[upstream]]
name = "app"
addresses = ["127.0.0.1:9000"]
[upstream.health]
path = "/healthz"

[[route]]
upstream = "app"
[route.match]
path_prefix = "/api"
"#;

#[test]
fn version_flag_prints_the_package_version_and_exits_zero() {
    let dir = tempfile::tempdir().unwrap();
    for flag in ["--version", "-V"] {
        let out = run(&[flag], dir.path());
        assert!(out.status.success(), "{flag} exits 0");
        let stdout = String::from_utf8_lossy(&out.stdout);
        assert!(
            stdout.contains(env!("CARGO_PKG_VERSION")),
            "{flag} prints the package version, got: {stdout:?}"
        );
    }
}

#[test]
fn version_flag_names_the_compiled_capability_profile() {
    // ADR 000079: compile-time inclusion ≠ runtime grant. The binary self-reports which named
    // runtime capability profile it was compiled as, so an operator can tell a minimal binary
    // from a capabilities one on the spot — without digest bookkeeping or reading the
    // cargo-auditable dependency list out of the executable.
    let dir = tempfile::tempdir().unwrap();
    let out = run(&["--version"], dir.path());
    assert!(out.status.success(), "--version exits 0");
    let stdout = String::from_utf8_lossy(&out.stdout);
    // This integration test compiles with the same features as the binary, so cfg! sees the
    // profile under test. CI also runs this suite under `--features capabilities` so the
    // named capabilities self-report path is exercised, not only the default minimal build.
    // Partial feature sets report as `custom (...)` and are not a named release profile.
    let expected = if cfg!(feature = "capabilities") {
        "profile: capabilities (outbound-http, outbound-tcp, fat-guest)"
    } else {
        "profile: minimal"
    };
    assert!(
        stdout.contains(expected),
        "--version names the compiled profile, want {expected:?}, got: {stdout:?}"
    );
}

#[test]
fn schema_emits_a_draft07_json_schema_describing_the_manifest() {
    // `plecto schema` (ADR 000049): the manifest's JSON Schema on stdout, derived from the same
    // serde model `from_toml` parses with. draft-07 is the level taplo / Even Better TOML
    // consume; `deny_unknown_fields` must surface as `additionalProperties: false` so editor
    // validation catches typos exactly like `validate` does.
    let dir = tempfile::tempdir().unwrap();
    let out = run(&["schema"], dir.path());
    assert!(
        out.status.success(),
        "schema exits 0, stderr: {:?}",
        String::from_utf8_lossy(&out.stderr)
    );
    let stdout = String::from_utf8_lossy(&out.stdout);
    let schema: serde_json::Value = serde_json::from_str(&stdout).expect("stdout is JSON");
    assert_eq!(
        schema["$schema"], "http://json-schema.org/draft-07/schema#",
        "draft-07 output (the taplo-compatible level)"
    );
    assert_eq!(
        schema["additionalProperties"],
        serde_json::Value::Bool(false),
        "deny_unknown_fields → additionalProperties: false"
    );
    let props = schema["properties"]
        .as_object()
        .expect("root schema has properties");
    for key in [
        "trust",
        "state",
        "filter",
        "chain",
        "upstream",
        "route",
        "tls",
        "observability",
        "listen",
    ] {
        assert!(props.contains_key(key), "schema describes [{key}]");
    }
}

#[test]
fn validate_accepts_a_good_manifest_and_exits_without_serving() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(dir.path().join("plecto.toml"), VALID_MANIFEST).unwrap();

    let out = run(&["validate", "plecto.toml"], dir.path());

    let stdout = String::from_utf8_lossy(&out.stdout);
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        out.status.success(),
        "a valid manifest validates, stderr: {stderr:?}"
    );
    // The config version is the operator's audit handle (ADR 000008) — surface it.
    assert!(
        stdout.contains("sha256:"),
        "validate reports the config version, got: {stdout:?}"
    );
    // The process EXITED (output() already proves it did not stay up serving).
}

#[test]
fn validate_rejects_an_unknown_field_with_a_nonzero_exit() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(
        dir.path().join("plecto.toml"),
        VALID_MANIFEST.replace("path = \"/healthz\"", "path = \"/healthz\"\ntypo_field = 1"),
    )
    .unwrap();

    let out = run(&["validate", "plecto.toml"], dir.path());

    assert!(!out.status.success(), "an unknown field must fail validate");
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        stderr.contains("typo_field"),
        "the error names the offending field, got: {stderr:?}"
    );
}

#[test]
fn validate_rejects_a_route_referencing_an_unknown_upstream() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(
        dir.path().join("plecto.toml"),
        VALID_MANIFEST.replace("upstream = \"app\"", "upstream = \"nonexistent\""),
    )
    .unwrap();

    let out = run(&["validate", "plecto.toml"], dir.path());

    assert!(
        !out.status.success(),
        "a dangling upstream reference must fail validate"
    );
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        stderr.contains("nonexistent"),
        "the error names the unknown upstream, got: {stderr:?}"
    );
}

#[test]
fn validate_never_creates_the_redb_state_file() {
    // `[state] backend = "redb"` opens (and CREATES) the database at startup; validate must only
    // check the section's coherence, never touch the filesystem — a CI validate run should leave
    // no state files behind.
    let dir = tempfile::tempdir().unwrap();
    let manifest = format!("[state]\nbackend = \"redb\"\npath = \"kv.redb\"\n{VALID_MANIFEST}");
    std::fs::write(dir.path().join("plecto.toml"), manifest).unwrap();

    let out = run(&["validate", "plecto.toml"], dir.path());

    assert!(
        out.status.success(),
        "a coherent [state] section validates, stderr: {:?}",
        String::from_utf8_lossy(&out.stderr)
    );
    assert!(
        !dir.path().join("kv.redb").exists(),
        "validate must not create the redb state file"
    );
}

#[test]
fn validate_rejects_a_bad_upstream_ca_path() {
    // The fail-closed file loads the server would do at startup run under validate too — a
    // missing [upstream.tls] CA is exactly the class of mistake `validate` exists to catch
    // before a deploy (ADR 000042).
    let dir = tempfile::tempdir().unwrap();
    let manifest = VALID_MANIFEST.replace(
        "[upstream.health]",
        "[upstream.tls]\nca_path = \"missing-ca.pem\"\n[upstream.health]",
    );
    std::fs::write(dir.path().join("plecto.toml"), manifest).unwrap();

    let out = run(&["validate", "plecto.toml"], dir.path());

    assert!(
        !out.status.success(),
        "a missing CA file must fail validate"
    );
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        stderr.contains("missing-ca.pem"),
        "the error names the missing CA path, got: {stderr:?}"
    );
}