tandem-server 0.6.8

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

use serial_test::serial;
use std::collections::HashMap;
use tandem_enterprise_contract::authority::fixtures;

struct EnvRestore {
    provider: Option<String>,
    key_file: Option<String>,
    required: Option<String>,
    principal: Option<String>,
}

impl EnvRestore {
    fn capture() -> Self {
        Self {
            provider: std::env::var("TANDEM_MEMORY_DECRYPT_PROVIDER").ok(),
            key_file: std::env::var("TANDEM_MEMORY_LOCAL_KEY_FILE").ok(),
            required: std::env::var("TANDEM_MEMORY_ENCRYPTION_REQUIRED").ok(),
            principal: std::env::var("TANDEM_MEMORY_DECRYPT_PRINCIPAL_ID").ok(),
        }
    }
}

impl Drop for EnvRestore {
    fn drop(&mut self) {
        restore_var("TANDEM_MEMORY_DECRYPT_PROVIDER", self.provider.as_deref());
        restore_var("TANDEM_MEMORY_LOCAL_KEY_FILE", self.key_file.as_deref());
        restore_var(
            "TANDEM_MEMORY_ENCRYPTION_REQUIRED",
            self.required.as_deref(),
        );
        restore_var(
            "TANDEM_MEMORY_DECRYPT_PRINCIPAL_ID",
            self.principal.as_deref(),
        );
    }
}

fn restore_var(key: &str, value: Option<&str>) {
    match value {
        Some(value) => std::env::set_var(key, value),
        None => std::env::remove_var(key),
    }
}

fn enable_local_file_encryption(dir: &tempfile::TempDir) -> EnvRestore {
    let restore = EnvRestore::capture();
    std::env::set_var("TANDEM_MEMORY_DECRYPT_PROVIDER", "local-file");
    std::env::set_var(
        "TANDEM_MEMORY_LOCAL_KEY_FILE",
        dir.path().join("local_memory.key"),
    );
    std::env::remove_var("TANDEM_MEMORY_ENCRYPTION_REQUIRED");
    std::env::remove_var("TANDEM_MEMORY_DECRYPT_PRINCIPAL_ID");
    restore
}

fn tenant() -> TenantContext {
    TenantContext::explicit_user_workspace("org-sec", "workspace-sec", None, "user-sec")
}

fn policy_decision(decision_id: &str, tenant_context: TenantContext) -> PolicyDecisionRecord {
    PolicyDecisionRecord {
        decision_id: decision_id.to_string(),
        tenant_context,
        requester_context: None,
        actor_id: Some("agent-encrypted-store-test".to_string()),
        session_id: Some("session-encrypted-store-test".to_string()),
        message_id: Some("message-encrypted-store-test".to_string()),
        run_id: Some("run-encrypted-store-test".to_string()),
        automation_id: Some("automation-encrypted-store-test".to_string()),
        node_id: None,
        tool: Some("mcp.secure.release".to_string()),
        resource: None,
        data_classes: Vec::new(),
        risk_tier: Some("privileged".to_string()),
        decision: PolicyDecisionEffect::ApprovalRequired,
        reason_code: "encrypted_file_store_required".to_string(),
        reason: "finance-decision-secret should not be plaintext".to_string(),
        policy_id: Some("policy-encrypted-store".to_string()),
        grant_id: None,
        approval_id: None,
        audit_event_id: None,
        created_at_ms: 42,
        metadata: json!({"secret_marker": "finance-decision-secret"}),
    }
}

#[tokio::test]
#[serial]
async fn protected_audit_hash_chain_verifies_with_encrypted_rows() {
    let state = crate::test_support::test_state().await;
    let _env_lock = crate::test_support::TEST_STATE_ENV_LOCK.lock().await;
    let crypto_dir = tempfile::tempdir().expect("crypto tempdir");
    let _restore = enable_local_file_encryption(&crypto_dir);
    let tenant_context = tenant();

    crate::audit::append_protected_audit_event(
        &state,
        "governance.secret_allowed",
        &tenant_context,
        Some("agent-encrypted-store-test".to_string()),
        json!({"secret_marker": "audit-chain-secret-one"}),
    )
    .await
    .expect("append first audit row");
    crate::audit::append_protected_audit_event(
        &state,
        "governance.secret_denied",
        &tenant_context,
        Some("agent-encrypted-store-test".to_string()),
        json!({"secret_marker": "audit-chain-secret-two"}),
    )
    .await
    .expect("append second audit row");

    let raw = tokio::fs::read_to_string(&state.protected_audit_path)
        .await
        .expect("raw audit file");
    assert!(raw
        .lines()
        .all(crate::encrypted_file_store::is_encrypted_payload));
    assert!(!raw.contains("audit-chain-secret"));

    let result = crate::audit::verify_protected_audit_ledger(&state.protected_audit_path).await;
    assert!(result.valid, "unexpected violation: {:?}", result.violation);
    assert_eq!(result.record_count, 2);
    assert_eq!(result.hashed_record_count, 2);

    let loaded =
        crate::audit::load_protected_audit_events_for_tenant(&state, &tenant_context).await;
    assert_eq!(loaded.len(), 2);
    assert_eq!(loaded[0].seq, 1);
    assert_eq!(loaded[1].seq, 2);
}

#[tokio::test]
#[serial]
async fn policy_and_org_unit_files_round_trip_encrypted() {
    let state = crate::test_support::test_state().await;
    let _env_lock = crate::test_support::TEST_STATE_ENV_LOCK.lock().await;
    let crypto_dir = tempfile::tempdir().expect("crypto tempdir");
    let _restore = enable_local_file_encryption(&crypto_dir);

    state
        .record_policy_decision(policy_decision("decision-encrypted", tenant()))
        .await
        .expect("record encrypted policy decision");
    let raw_policy = tokio::fs::read_to_string(&state.policy_decisions_path)
        .await
        .expect("raw policy decisions");
    assert!(crate::encrypted_file_store::is_encrypted_payload(
        &raw_policy
    ));
    assert!(!raw_policy.contains("finance-decision-secret"));

    state.policy_decisions.write().await.clear();
    state
        .load_policy_decisions()
        .await
        .expect("reload encrypted policy decisions");
    assert!(state
        .get_policy_decision("decision-encrypted")
        .await
        .is_some());

    let fixture = fixtures::acme_company();
    let units = fixture
        .graph
        .units
        .iter()
        .map(|unit| {
            (
                format!("{}/{}", unit.taxonomy_id, unit.unit_id),
                unit.clone(),
            )
        })
        .collect::<HashMap<_, _>>();
    let memberships = fixture
        .graph
        .memberships
        .iter()
        .map(|membership| (membership.membership_id.clone(), membership.clone()))
        .collect::<HashMap<_, _>>();
    let grants = fixture
        .graph
        .unit_access_grants
        .iter()
        .map(|grant| (grant.grant_id.clone(), grant.clone()))
        .collect::<HashMap<_, _>>();

    crate::encrypted_file_store::write_json_file(&state.enterprise.org_units_path, &units)
        .await
        .expect("write encrypted org units");
    crate::encrypted_file_store::write_json_file(
        &state.enterprise.org_unit_memberships_path,
        &memberships,
    )
    .await
    .expect("write encrypted memberships");
    crate::encrypted_file_store::write_json_file(
        &state.enterprise.org_unit_access_grants_path,
        &grants,
    )
    .await
    .expect("write encrypted grants");

    let raw_units = tokio::fs::read_to_string(&state.enterprise.org_units_path)
        .await
        .expect("raw org units");
    assert!(crate::encrypted_file_store::is_encrypted_payload(
        &raw_units
    ));
    assert!(!raw_units.contains("Engineering"));

    state.load_enterprise_org_units().await.expect("load units");
    state
        .load_enterprise_org_unit_memberships()
        .await
        .expect("load memberships");
    state
        .load_enterprise_org_unit_access_grants()
        .await
        .expect("load grants");

    assert_eq!(state.enterprise.org_units.read().await.len(), units.len());
    assert_eq!(
        state.enterprise.org_unit_memberships.read().await.len(),
        memberships.len()
    );
    assert_eq!(
        state.enterprise.org_unit_access_grants.read().await.len(),
        grants.len()
    );
}