ruzor 0.1.2

Ruzor, a 1:1-compatible Rust port of the Pyzor UDP client and server
Documentation
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex};

use ruzor::Result;
use ruzor::account::{Account, sign_for_account};
use ruzor::engines::{DigestDatabase, Record};
use ruzor::error::PyzorError;
use ruzor::message::Message;
use ruzor::server::handle_packet;

const DIGEST: &str = "2aedaac999d71421c9ee49b9d81f627a7bc570aa";

#[derive(Default)]
struct MemoryDatabase {
    records: HashMap<String, Record>,
}

impl DigestDatabase for MemoryDatabase {
    fn get(&mut self, digest: &str) -> Result<Record> {
        Ok(self.records.get(digest).cloned().unwrap_or_default())
    }

    fn set(&mut self, digest: &str, record: Record) -> Result<()> {
        self.records.insert(digest.to_string(), record);
        Ok(())
    }
}

struct FailingDatabase;

impl DigestDatabase for FailingDatabase {
    fn get(&mut self, _digest: &str) -> Result<Record> {
        Err(PyzorError::Comm("test".to_string()))
    }

    fn set(&mut self, _digest: &str, _record: Record) -> Result<()> {
        Err(PyzorError::Comm("test".to_string()))
    }
}

#[test]
fn ping_pong_check_and_info_match_reference_handler() {
    let db = Arc::new(Mutex::new(MemoryDatabase::default()));
    db.lock().unwrap().records.insert(
        DIGEST.to_string(),
        Record {
            r_count: 24,
            wl_count: 42,
            r_entered: Some(1_400_221_786),
            r_updated: Some(1_400_221_794),
            wl_entered: Some(1_400_221_800),
            wl_updated: Some(1_400_221_900),
        },
    );
    let accounts = HashMap::new();
    let acl = acl_for(
        "anonymous",
        &["check", "report", "ping", "pong", "info", "whitelist"],
    );

    let response = handle_packet(request("ping", None, 3597).as_bytes(), &db, &accounts, &acl);
    assert_ok_head(&response, "3597");

    let response = handle_packet(
        request("pong", Some(DIGEST), 3598).as_bytes(),
        &db,
        &accounts,
        &acl,
    );
    assert_ok_head(&response, "3598");
    assert_eq!(response.get("Count"), Some(isize::MAX.to_string().as_str()));
    assert_eq!(response.get("WL-Count"), Some("0"));

    let response = handle_packet(
        request("check", Some(DIGEST), 3599).as_bytes(),
        &db,
        &accounts,
        &acl,
    );
    assert_ok_head(&response, "3599");
    assert_eq!(response.get("Count"), Some("24"));
    assert_eq!(response.get("WL-Count"), Some("42"));

    let response = handle_packet(
        request("info", Some(DIGEST), 3600).as_bytes(),
        &db,
        &accounts,
        &acl,
    );
    assert_ok_head(&response, "3600");
    assert_eq!(response.get("Count"), Some("24"));
    assert_eq!(response.get("WL-Count"), Some("42"));
    assert_eq!(response.get("Entered"), Some("1400221786"));
    assert_eq!(response.get("Updated"), Some("1400221794"));
    assert_eq!(response.get("WL-Entered"), Some("1400221800"));
    assert_eq!(response.get("WL-Updated"), Some("1400221900"));
}

#[test]
fn check_and_info_for_new_digest_match_reference_blank_record() {
    let db = Arc::new(Mutex::new(MemoryDatabase::default()));
    let accounts = HashMap::new();
    let acl = acl_for("anonymous", &["check", "info"]);

    let response = handle_packet(
        request("check", Some(DIGEST), 3601).as_bytes(),
        &db,
        &accounts,
        &acl,
    );
    assert_ok_head(&response, "3601");
    assert_eq!(response.get("Count"), Some("0"));
    assert_eq!(response.get("WL-Count"), Some("0"));

    let response = handle_packet(
        request("info", Some(DIGEST), 3602).as_bytes(),
        &db,
        &accounts,
        &acl,
    );
    assert_ok_head(&response, "3602");
    assert_eq!(response.get("Count"), Some("0"));
    assert_eq!(response.get("WL-Count"), Some("0"));
    assert_eq!(response.get("Entered"), Some("0"));
    assert_eq!(response.get("Updated"), Some("0"));
    assert_eq!(response.get("WL-Entered"), Some("0"));
    assert_eq!(response.get("WL-Updated"), Some("0"));
}

#[test]
fn digest_operations_without_op_digest_match_reference_plain_ok_response() {
    let db = Arc::new(Mutex::new(MemoryDatabase::default()));
    let accounts = HashMap::new();
    let acl = acl_for("anonymous", &["pong", "check", "info"]);

    for (op, thread) in [("pong", 3700), ("check", 3701), ("info", 3702)] {
        let response = handle_packet(request(op, None, thread).as_bytes(), &db, &accounts, &acl);
        assert_ok_head(&response, &thread.to_string());
        assert_eq!(response.get("Count"), None);
        assert_eq!(response.get("WL-Count"), None);
        assert_eq!(response.get("Entered"), None);
        assert_eq!(response.get("Updated"), None);
        assert_eq!(response.get("WL-Entered"), None);
        assert_eq!(response.get("WL-Updated"), None);
    }
}

#[test]
fn report_and_whitelist_mutate_records_like_reference_handler() {
    let db = Arc::new(Mutex::new(MemoryDatabase::default()));
    db.lock().unwrap().records.insert(
        DIGEST.to_string(),
        Record {
            r_count: 24,
            wl_count: 42,
            ..Record::default()
        },
    );
    let accounts = HashMap::new();
    let acl = acl_for("anonymous", &["report", "whitelist"]);

    let response = handle_packet(
        request("report", Some(DIGEST), 3603).as_bytes(),
        &db,
        &accounts,
        &acl,
    );
    assert_ok_head(&response, "3603");
    assert_eq!(db.lock().unwrap().records[DIGEST].r_count, 25);

    let response = handle_packet(
        request("whitelist", Some(DIGEST), 3604).as_bytes(),
        &db,
        &accounts,
        &acl,
    );
    assert_ok_head(&response, "3604");
    assert_eq!(db.lock().unwrap().records[DIGEST].wl_count, 43);

    let fresh = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
    let response = handle_packet(
        request("report", Some(fresh), 3605).as_bytes(),
        &db,
        &accounts,
        &acl,
    );
    assert_ok_head(&response, "3605");
    assert_eq!(db.lock().unwrap().records[fresh].r_count, 1);
}

#[test]
fn reference_error_statuses_are_preserved() {
    let db = Arc::new(Mutex::new(MemoryDatabase::default()));
    let accounts = HashMap::new();
    let acl = acl_for("anonymous", &["ping", "notimplemented"]);

    let response = handle_packet(
        b"Op: ping\nThread: 3606\nUser: anonymous\n\n",
        &db,
        &accounts,
        &acl,
    );
    assert_error_prefix(&response, "3606", "400", "Bad request:");

    let response = handle_packet(
        b"Op: ping\nThread: 3607\nPV: 4.1\nUser: anonymous\n\n",
        &db,
        &accounts,
        &acl,
    );
    assert_error_prefix(&response, "3607", "505", "Version Not Supported:");

    let response = handle_packet(
        b"Op: ping\nThread: 3608\nPV: ab2.13\nUser: anonymous\n\n",
        &db,
        &accounts,
        &acl,
    );
    assert_error_prefix(&response, "3608", "400", "Bad request:");

    let response = handle_packet(
        b"Op: notimplemented\nThread: 3609\nPV: 2.1\nUser: anonymous\n\n",
        &db,
        &accounts,
        &acl,
    );
    assert_error_prefix(&response, "3609", "501", "Not implemented:");

    let restricted_acl = acl_for("anonymous", &["ping", "check"]);
    let response = handle_packet(
        b"Op: report\nThread: 3610\nPV: 2.1\nUser: anonymous\n\n",
        &db,
        &accounts,
        &restricted_acl,
    );
    assert_error_prefix(&response, "3610", "403", "Forbidden:");
}

#[test]
fn authenticated_account_success_and_failures_match_reference_handler() {
    let db = Arc::new(Mutex::new(MemoryDatabase::default()));
    let mut accounts = HashMap::new();
    accounts.insert("testuser".to_string(), "testkey".to_string());
    let acl = acl_for("testuser", &["ping", "check"]);

    let signed = signed_request("ping", 3611, "testuser", "testkey");
    let response = handle_packet(signed.as_bytes(), &db, &accounts, &acl);
    assert_ok_head(&response, "3611");

    let unknown = signed_request("ping", 3612, "unknown", "testkey");
    let response = handle_packet(unknown.as_bytes(), &db, &accounts, &acl);
    assert_error_prefix(&response, "3612", "401", "Unauthorized:");

    let bad_signature = signed_request("ping", 3613, "testuser", "wrongkey");
    let response = handle_packet(bad_signature.as_bytes(), &db, &accounts, &acl);
    assert_error_prefix(&response, "3613", "401", "Unauthorized:");
}

#[test]
fn database_errors_become_internal_server_errors_after_thread_is_set() {
    let db = Arc::new(Mutex::new(FailingDatabase));
    let accounts = HashMap::new();
    let acl = acl_for("anonymous", &["check"]);

    let response = handle_packet(
        request("check", Some(DIGEST), 3614).as_bytes(),
        &db,
        &accounts,
        &acl,
    );
    assert_error_prefix(&response, "3614", "500", "Internal Server Error:");
}

fn request(op: &str, digest: Option<&str>, thread: u16) -> String {
    let mut request = format!("Op: {op}\nThread: {thread}\nPV: 2.1\nUser: anonymous\n");
    if let Some(digest) = digest {
        request.push_str(&format!("Op-Digest: {digest}\n"));
    }
    request.push('\n');
    request
}

fn signed_request(op: &str, thread: u16, user: &str, key: &str) -> String {
    let mut msg = Message::new();
    msg.add_header("Op", op);
    msg.add_header("Thread", thread.to_string());
    msg.add_header("PV", "2.1");
    sign_for_account(
        &mut msg,
        &Account::new(user, None, key),
        ruzor::account::now_timestamp(),
    );
    msg.as_string()
}

fn acl_for(user: &str, ops: &[&str]) -> HashMap<String, HashSet<String>> {
    let mut acl = HashMap::new();
    acl.insert(
        user.to_string(),
        ops.iter().map(|op| (*op).to_string()).collect(),
    );
    acl
}

fn assert_ok_head(response: &Message, thread: &str) {
    assert_eq!(response.get("Code"), Some("200"));
    assert_eq!(response.get("Diag"), Some("OK"));
    assert_eq!(response.get("PV"), Some("2.1"));
    assert_eq!(response.get("Thread"), Some(thread));
}

fn assert_error_prefix(response: &Message, thread: &str, code: &str, diag_prefix: &str) {
    assert_eq!(response.get("Code"), Some(code));
    assert_eq!(response.get("Thread"), Some(thread));
    assert!(
        response.get("Diag").unwrap_or("").starts_with(diag_prefix),
        "unexpected diagnostic: {:?}",
        response.get("Diag")
    );
}