khive-gate 0.2.8

Pluggable authorization gate trait + default AllowAllGate impl for khive verb dispatch.
Documentation
use std::sync::Arc;

use serde_json::json;

use khive_gate::{
    ActorRef, AllowAllGate, AuditDecision, AuditEvent, Gate, GateContext, GateDecision, GateError,
    GateRef, GateRequest, GateValidationError, Obligation,
};
use khive_types::Namespace;

fn sample_request() -> GateRequest {
    GateRequest::new(
        ActorRef::anonymous(),
        Namespace::local(),
        "search",
        json!({"query": "LoRA"}),
    )
}

fn sample_req_with_session() -> GateRequest {
    GateRequest::new(
        ActorRef::new("user", "ocean"),
        Namespace::local(),
        "create",
        json!({"kind": "concept"}),
    )
    .with_context(GateContext {
        session_id: Some("sess-abc".into()),
        timestamp: None,
        source: Some("mcp".into()),
    })
}

// ---- AllowAllGate ----

#[test]
fn allow_all_gate_allows() {
    let gate = AllowAllGate;
    let decision = gate.check(&sample_request()).unwrap();
    assert!(decision.is_allow());
}

#[test]
fn allow_all_gate_through_dyn() {
    let gate: GateRef = Arc::new(AllowAllGate);
    let decision = gate.check(&sample_request()).unwrap();
    assert!(decision.is_allow());
}

// ---- Decision helpers ----

#[test]
fn decision_helpers() {
    assert!(GateDecision::allow().is_allow());
    assert!(!GateDecision::deny("nope").is_allow());
}

// ---- Wire-shape stability ----

#[test]
fn request_serializes_to_stable_shape() {
    let req = sample_request();
    let v = serde_json::to_value(&req).unwrap();
    assert_eq!(v["actor"]["kind"], "anonymous");
    assert_eq!(v["actor"]["id"], "local");
    assert_eq!(v["namespace"], "local");
    assert_eq!(v["verb"], "search");
    assert_eq!(v["args"]["query"], "LoRA");
}

#[test]
fn decision_roundtrips_through_json() {
    let allow = GateDecision::allow_with(vec![Obligation::Audit {
        tag: "search.attempt".into(),
    }]);
    let s = serde_json::to_string(&allow).unwrap();
    let back: GateDecision = serde_json::from_str(&s).unwrap();
    match back {
        GateDecision::Allow { obligations } => {
            assert_eq!(obligations.len(), 1);
            match &obligations[0] {
                Obligation::Audit { tag } => assert_eq!(tag, "search.attempt"),
                _ => panic!("expected Audit"),
            }
        }
        _ => panic!("expected Allow"),
    }

    let deny = GateDecision::deny("forbidden");
    let s = serde_json::to_string(&deny).unwrap();
    let back: GateDecision = serde_json::from_str(&s).unwrap();
    match back {
        GateDecision::Deny { reason } => assert_eq!(reason, "forbidden"),
        _ => panic!("expected Deny"),
    }
}

#[test]
fn obligation_rate_limit_serializes_with_kind_tag() {
    let o = Obligation::rate_limit(60, 100);
    let v = serde_json::to_value(&o).unwrap();
    assert_eq!(v["kind"], "rate_limit");
    assert_eq!(v["window_secs"], 60);
    assert_eq!(v["max"], 100);
}

// ---- Obligation::Custom round-trips (ADR-018) ----

fn assert_custom_round_trips(value: serde_json::Value) {
    let original = Obligation::Custom {
        value: value.clone(),
    };
    let json = serde_json::to_value(&original).expect("serialize");
    assert_eq!(json["kind"], "custom");
    assert_eq!(json["value"], value);
    let back: Obligation = serde_json::from_value(json).expect("deserialize");
    match back {
        Obligation::Custom { value: got } => assert_eq!(got, value),
        other => panic!("expected Custom, got {other:?}"),
    }
}

#[test]
fn obligation_custom_round_trips_object() {
    assert_custom_round_trips(json!({"audit_tag": "billing", "weight": 1.5}));
}

#[test]
fn obligation_custom_round_trips_string() {
    assert_custom_round_trips(json!("just a string"));
}

#[test]
fn obligation_custom_round_trips_number() {
    assert_custom_round_trips(json!(42));
}

#[test]
fn obligation_custom_round_trips_array() {
    assert_custom_round_trips(json!(["a", "b", 3]));
}

#[test]
fn obligation_custom_round_trips_null() {
    assert_custom_round_trips(serde_json::Value::Null);
}

#[test]
fn obligation_custom_round_trips_bool() {
    assert_custom_round_trips(json!(true));
}

// ---- AuditEvent (ADR-018) ----

#[test]
fn audit_event_roundtrips_through_serde_stable_shape() {
    let req = sample_req_with_session();
    let decision = GateDecision::allow_with(vec![Obligation::Audit {
        tag: "create.attempt".into(),
    }]);
    let ev = AuditEvent::from_check(&req, &decision, "AllowAllGate");

    let json = serde_json::to_value(&ev).unwrap();

    assert_eq!(json["actor"]["kind"], "user");
    assert_eq!(json["actor"]["id"], "ocean");
    assert_eq!(json["namespace"], "local");
    assert_eq!(json["verb"], "create");
    assert_eq!(json["decision"], "allow");
    assert_eq!(json["gate_impl"], "AllowAllGate");
    assert_eq!(json["session_id"], "sess-abc");
    assert!(json.get("deny_reason").is_none() || json["deny_reason"].is_null());
    assert_eq!(json["obligations"][0]["kind"], "audit");
    assert_eq!(json["obligations"][0]["tag"], "create.attempt");
    assert!(json["timestamp"].is_string());

    let back: AuditEvent = serde_json::from_value(json).unwrap();
    assert_eq!(back.verb, "create");
    assert_eq!(back.decision, AuditDecision::Allow);
    assert!(back.deny_reason.is_none());
    assert_eq!(back.obligations.len(), 1);
}

#[test]
fn audit_event_deny_path_carries_reason() {
    let req = sample_request();
    let decision = GateDecision::deny("forbidden: no write for anonymous");
    let ev = AuditEvent::from_check(&req, &decision, "RegoGate");

    let json = serde_json::to_value(&ev).unwrap();

    assert_eq!(json["decision"], "deny");
    assert_eq!(json["deny_reason"], "forbidden: no write for anonymous");
    assert_eq!(json["gate_impl"], "RegoGate");
    assert_eq!(
        json["obligations"],
        serde_json::Value::Array(Vec::new()),
        "obligations must be an empty array on Deny, not omitted"
    );
    assert!(json.get("session_id").is_none() || json["session_id"].is_null());
}

#[test]
fn audit_event_allow_no_obligations() {
    let req = sample_request();
    let decision = GateDecision::allow();
    let ev = AuditEvent::from_check(&req, &decision, "AllowAllGate");
    assert_eq!(ev.decision, AuditDecision::Allow);
    assert!(ev.deny_reason.is_none());
    assert!(ev.obligations.is_empty());
    let json = serde_json::to_value(&ev).unwrap();
    assert_eq!(
        json["obligations"],
        serde_json::Value::Array(Vec::new()),
        "obligations must serialize as an empty array, not be omitted"
    );
}

#[test]
fn audit_decision_serialises_as_snake_case() {
    let allow = serde_json::to_value(AuditDecision::Allow).unwrap();
    assert_eq!(allow, "allow");
    let deny = serde_json::to_value(AuditDecision::Deny).unwrap();
    assert_eq!(deny, "deny");
}

// ---- impl_name() default ----

// A gate that does NOT override impl_name() — default must return type_name.
#[derive(Debug)]
struct CustomTestGate;

impl Gate for CustomTestGate {
    fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
        Ok(GateDecision::allow())
    }
    // impl_name intentionally NOT overridden — tests the default.
}

#[test]
fn impl_name_default_returns_type_name() {
    let gate = CustomTestGate;
    // The default must use std::any::type_name, not the literal "Gate".
    assert_ne!(
        gate.impl_name(),
        "Gate",
        "default impl_name must not return literal \"Gate\""
    );
    assert!(
        gate.impl_name().contains("CustomTestGate"),
        "default impl_name must contain the concrete type name, got: {}",
        gate.impl_name()
    );
}

#[test]
fn allow_all_gate_impl_name_is_overridden() {
    let gate = AllowAllGate;
    assert_eq!(gate.impl_name(), "AllowAllGate");
}

// ---- Validation rejection at deserialization boundary ----

#[test]
fn deserialize_rejects_empty_actor_kind() {
    let json = r#"{"kind":"","id":"x"}"#;
    let err = serde_json::from_str::<ActorRef>(json).unwrap_err();
    assert!(err.to_string().contains("actor kind must not be empty"));
}

#[test]
fn deserialize_rejects_empty_actor_id() {
    let json = r#"{"kind":"user","id":""}"#;
    let err = serde_json::from_str::<ActorRef>(json).unwrap_err();
    assert!(err.to_string().contains("actor id must not be empty"));
}

#[test]
fn deserialize_rejects_empty_verb() {
    let json = r#"{"actor":{"kind":"user","id":"x"},"namespace":"local","verb":"","args":{}}"#;
    let err = serde_json::from_str::<GateRequest>(json).unwrap_err();
    assert!(err.to_string().contains("verb must not be empty"));
}

#[test]
fn deserialize_rejects_empty_deny_reason() {
    let json = r#"{"decision":"deny","reason":""}"#;
    let err = serde_json::from_str::<GateDecision>(json).unwrap_err();
    assert!(err.to_string().contains("deny reason must not be empty"));
}

#[test]
fn deserialize_rejects_zero_rate_limit_window() {
    let json = r#"{"kind":"rate_limit","window_secs":0,"max":10}"#;
    let err = serde_json::from_str::<Obligation>(json).unwrap_err();
    assert!(err
        .to_string()
        .contains("rate limit window_secs must be > 0"));
}

#[test]
fn deserialize_rejects_zero_rate_limit_max() {
    let json = r#"{"kind":"rate_limit","window_secs":60,"max":0}"#;
    let err = serde_json::from_str::<Obligation>(json).unwrap_err();
    assert!(err.to_string().contains("rate limit max must be > 0"));
}

// ---- try_new constructor validation ----

#[test]
fn actor_ref_try_new_rejects_empty_kind() {
    assert_eq!(
        ActorRef::try_new("", "id"),
        Err(GateValidationError::EmptyActorKind)
    );
}

#[test]
fn actor_ref_try_new_rejects_empty_id() {
    assert_eq!(
        ActorRef::try_new("user", ""),
        Err(GateValidationError::EmptyActorId)
    );
}

#[test]
fn gate_request_try_new_rejects_empty_verb() {
    let err =
        GateRequest::try_new(ActorRef::anonymous(), Namespace::local(), "", json!({})).unwrap_err();
    assert_eq!(err, GateValidationError::EmptyVerb);
}

#[test]
fn deny_try_deny_rejects_empty_reason() {
    let err = GateDecision::try_deny("").unwrap_err();
    assert_eq!(err, GateValidationError::EmptyDenyReason);
}

#[test]
fn rate_limit_try_rejects_zero_window() {
    let err = Obligation::try_rate_limit(0, 10).unwrap_err();
    assert_eq!(err, GateValidationError::ZeroRateLimitWindow);
}

#[test]
fn rate_limit_try_rejects_zero_max() {
    let err = Obligation::try_rate_limit(60, 0).unwrap_err();
    assert_eq!(err, GateValidationError::ZeroRateLimitMax);
}

#[test]
fn valid_actor_ref_roundtrips() {
    let a = ActorRef::new("user", "ocean");
    let json = serde_json::to_string(&a).unwrap();
    let back: ActorRef = serde_json::from_str(&json).unwrap();
    assert_eq!(back.kind, "user");
    assert_eq!(back.id, "ocean");
}

#[test]
fn valid_rate_limit_roundtrips() {
    let o = Obligation::rate_limit(60, 100);
    let json = serde_json::to_string(&o).unwrap();
    let back: Obligation = serde_json::from_str(&json).unwrap();
    match back {
        Obligation::RateLimit { window_secs, max } => {
            assert_eq!(window_secs, 60);
            assert_eq!(max, 100);
        }
        _ => panic!("expected RateLimit"),
    }
}