challenge 0.1.0

A lightweight CLI for ALTCHA Proof-of-Work v2 challenges.
Documentation
use std::{fs, process::Command};

use serde_json::{Value, json};
use tempfile::tempdir;

const SECRET: &str = "test-hmac-secret";

fn bin() -> Command {
    Command::new(env!("CARGO_BIN_EXE_challenge"))
}

fn run_ok(args: &[&str]) -> String {
    let output = bin()
        .args(args)
        .output()
        .unwrap_or_else(|error| panic!("failed to run challenge {args:?}: {error}"));

    assert!(
        output.status.success(),
        "challenge {args:?} failed\nstatus: {}\nstdout:\n{}\nstderr:\n{}",
        output.status,
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );

    String::from_utf8(output.stdout).expect("stdout should be UTF-8")
}

fn run_fail(args: &[&str]) -> String {
    let output = bin()
        .args(args)
        .output()
        .unwrap_or_else(|error| panic!("failed to run challenge {args:?}: {error}"));

    assert!(
        !output.status.success(),
        "challenge {args:?} unexpectedly succeeded\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );

    String::from_utf8(output.stderr).expect("stderr should be UTF-8")
}

#[test]
fn help_prints_usage() {
    let stdout = run_ok(&["--help"]);
    assert!(stdout.contains("A lightweight CLI for ALTCHA"));
    assert!(stdout.contains("create"));
    assert!(stdout.contains("verify-payload"));
}

#[test]
fn create_requires_signed_challenges_by_default() {
    let stderr = run_fail(&["create", "--cost", "1"]);
    assert!(stderr.contains("--hmac-secret is required"));
}

#[test]
fn create_rejects_conflicting_counter_options() {
    let stderr = run_fail(&[
        "create",
        "--cost",
        "1",
        "--counter",
        "1",
        "--random-counter",
        "--hmac-secret",
        SECRET,
        "--hmac-key-secret",
        "test-key-secret",
    ]);
    assert!(stderr.contains("use either --counter or --random-counter"));
}

#[test]
fn create_solve_verify_and_verify_payload_roundtrip() {
    let temp = tempdir().unwrap();
    let challenge_path = temp.path().join("challenge.json");
    let solution_path = temp.path().join("solution.json");
    let register_path = temp.path().join("register-request.json");

    let challenge_json = run_ok(&[
        "--compact",
        "create",
        "--cost",
        "1",
        "--key-prefix",
        "00",
        "--expires-in",
        "600",
        "--hmac-secret",
        SECRET,
        "--data",
        "action=register",
    ]);
    fs::write(&challenge_path, &challenge_json).unwrap();

    let challenge: Value = serde_json::from_str(&challenge_json).unwrap();
    assert!(challenge.get("signature").is_some());

    let solution_json = run_ok(&[
        "--compact",
        "solve",
        "--challenge",
        challenge_path.to_str().unwrap(),
        "--timeout-ms",
        "30000",
    ]);
    fs::write(&solution_path, &solution_json).unwrap();

    let solution: Value = serde_json::from_str(&solution_json).unwrap();
    assert!(solution.get("counter").is_some());
    assert!(solution.get("derivedKey").is_some());

    let verify_json = run_ok(&[
        "--compact",
        "verify",
        "--challenge",
        challenge_path.to_str().unwrap(),
        "--solution",
        solution_path.to_str().unwrap(),
        "--secret",
        SECRET,
        "--fail-on-invalid",
    ]);
    let verify: Value = serde_json::from_str(&verify_json).unwrap();
    assert_eq!(verify.get("verified"), Some(&json!(true)));
    assert_eq!(verify.get("expired"), Some(&json!(false)));

    let register_request = json!({
        "username": "alice",
        "password": "not-a-real-password",
        "altcha": {
            "challenge": challenge,
            "solution": solution,
        }
    });
    fs::write(
        &register_path,
        serde_json::to_string_pretty(&register_request).unwrap(),
    )
    .unwrap();

    let verify_payload_json = run_ok(&[
        "--compact",
        "verify-payload",
        "--payload",
        register_path.to_str().unwrap(),
        "--secret",
        SECRET,
        "--fail-on-invalid",
    ]);
    let verify_payload: Value = serde_json::from_str(&verify_payload_json).unwrap();
    assert_eq!(verify_payload.get("verified"), Some(&json!(true)));
}