tandem-server 0.6.0

HTTP server for Tandem engine APIs
use super::*;

use crate::app::state::governance::UnavailableGovernanceEngine;
use tandem_types::{PolicyDecisionEffect, PolicyDecisionRecord, TenantContext};

fn tenant(org_id: &str, workspace_id: &str, actor_id: &str) -> TenantContext {
    TenantContext::explicit_user_workspace(org_id, workspace_id, None, actor_id)
}

fn policy_decision(
    decision_id: &str,
    tenant_context: TenantContext,
    run_id: &str,
    created_at_ms: u64,
) -> PolicyDecisionRecord {
    PolicyDecisionRecord {
        decision_id: decision_id.to_string(),
        tenant_context,
        actor_id: Some("agent-policy-test".to_string()),
        session_id: Some(format!("session-{decision_id}")),
        message_id: Some(format!("message-{decision_id}")),
        run_id: Some(run_id.to_string()),
        automation_id: Some("automation-policy-test".to_string()),
        node_id: None,
        tool: Some("mcp.bank.release_funds".to_string()),
        resource: None,
        data_classes: Vec::new(),
        risk_tier: Some("money_movement".to_string()),
        decision: PolicyDecisionEffect::ApprovalRequired,
        reason_code: "approval_required_unverified".to_string(),
        reason: "approval required".to_string(),
        policy_id: Some("fintech_strict".to_string()),
        grant_id: None,
        approval_id: None,
        audit_event_id: None,
        created_at_ms,
        metadata: json!({}),
    }
}

async fn response_json(response: axum::response::Response) -> Value {
    let body = to_bytes(response.into_body(), usize::MAX)
        .await
        .expect("response body");
    serde_json::from_slice(&body).expect("response json")
}

#[tokio::test]
async fn policy_decisions_route_filters_tenant_before_limit() {
    let state = test_state().await;
    let tenant_a = tenant("org-a", "workspace-a", "user-a");
    let tenant_b = tenant("org-b", "workspace-b", "user-b");
    state
        .record_policy_decision(policy_decision(
            "decision-a-older",
            tenant_a.clone(),
            "run-shared",
            100,
        ))
        .await
        .expect("record tenant a decision");
    state
        .record_policy_decision(policy_decision(
            "decision-b-newer",
            tenant_b,
            "run-shared",
            300,
        ))
        .await
        .expect("record tenant b decision");

    let response = app_router(state)
        .oneshot(
            Request::builder()
                .method("GET")
                .uri("/governance/policy-decisions?limit=1")
                .header("x-tandem-org-id", tenant_a.org_id.as_str())
                .header("x-tandem-workspace-id", tenant_a.workspace_id.as_str())
                .header("x-tandem-actor-id", "user-a")
                .body(Body::empty())
                .expect("policy decisions list request"),
        )
        .await
        .expect("policy decisions list response");

    assert_eq!(response.status(), StatusCode::OK);
    let payload = response_json(response).await;
    assert_eq!(payload["count"], json!(1));
    assert_eq!(
        payload["policy_decisions"][0]["decision_id"],
        json!("decision-a-older")
    );
}

#[tokio::test]
async fn policy_decisions_route_filters_run_and_tenant_before_limit() {
    let state = test_state().await;
    let tenant_a = tenant("org-a", "workspace-a", "user-a");
    let tenant_b = tenant("org-b", "workspace-b", "user-b");
    state
        .record_policy_decision(policy_decision(
            "decision-a-target-run",
            tenant_a.clone(),
            "run-target",
            100,
        ))
        .await
        .expect("record tenant a decision");
    state
        .record_policy_decision(policy_decision(
            "decision-b-target-run-newer",
            tenant_b,
            "run-target",
            300,
        ))
        .await
        .expect("record tenant b decision");

    let response = app_router(state)
        .oneshot(
            Request::builder()
                .method("GET")
                .uri("/governance/policy-decisions?run_id=run-target&limit=1")
                .header("x-tandem-org-id", tenant_a.org_id.as_str())
                .header("x-tandem-workspace-id", tenant_a.workspace_id.as_str())
                .header("x-tandem-actor-id", "user-a")
                .body(Body::empty())
                .expect("policy decisions run list request"),
        )
        .await
        .expect("policy decisions run list response");

    assert_eq!(response.status(), StatusCode::OK);
    let payload = response_json(response).await;
    assert_eq!(payload["count"], json!(1));
    assert_eq!(
        payload["policy_decisions"][0]["decision_id"],
        json!("decision-a-target-run")
    );
}

#[tokio::test]
async fn policy_decisions_route_requires_premium_governance() {
    let mut state = test_state().await;
    state.governance_engine = Arc::new(UnavailableGovernanceEngine);

    let response = app_router(state)
        .oneshot(
            Request::builder()
                .method("GET")
                .uri("/governance/policy-decisions")
                .body(Body::empty())
                .expect("policy decisions list request"),
        )
        .await
        .expect("policy decisions list response");

    assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
    let payload = response_json(response).await;
    assert_eq!(
        payload.get("code").and_then(Value::as_str),
        Some("PREMIUM_FEATURE_REQUIRED")
    );
}