tandem-server 0.6.5

HTTP server for Tandem engine APIs
#[tokio::test]
#[serial_test::serial(incident_monitor_http)]
async fn incident_monitor_assessment_probes_detect_approval_and_mcp_gaps() {
    let state = test_state().await;
    state.set_api_token(Some("tk_admin".to_string())).await;
    let workspace = tempfile::tempdir().expect("assessment workspace");
    let mut automation =
        sample_authority_inventory_automation(workspace.path().display().to_string());
    automation.automation_id = "auto-assessment-risk".to_string();
    automation.set_tenant_context(&tandem_types::TenantContext::explicit_user_workspace(
        "org-assessment",
        "workspace-assessment",
        None,
        "security-admin",
    ));
    automation.agents[0].approval_policy = None;
    automation.agents[0].mcp_policy.allowed_tools = None;
    automation.flow.nodes[0].gate = None;
    state
        .put_automation_v2(automation)
        .await
        .expect("assessment automation");
    let app = app_router(state);

    let payload = run_incident_monitor_assessment_probes(
        app,
        "/incident-monitor/security/assessment-probes",
        json!({
            "probes": ["approval_required_tool_policy", "mcp_tool_allowlist"]
        }),
        Some((
            "org-assessment",
            "workspace-assessment",
            "security-admin",
            "tk_admin",
        )),
    )
    .await;
    let results = payload["results"].as_array().expect("probe results");

    assert!(results.iter().any(|result| {
        result["probe_id"].as_str() == Some("approval_required_tool_policy")
            && result["status"].as_str() == Some("fail")
            && result["incident_draft_suggestion"].is_object()
    }));
    assert!(results.iter().any(|result| {
        result["probe_id"].as_str() == Some("mcp_tool_allowlist")
            && result["status"].as_str() == Some("fail")
    }));
    assert_eq!(payload["scope"]["dry_run"], json!(true));
    assert_eq!(payload["evidence_pack"]["persisted"], json!(true));
    let artifact_path = payload["evidence_pack"]["path"]
        .as_str()
        .expect("assessment artifact path");
    assert!(std::fs::read_to_string(artifact_path)
        .expect("assessment artifact")
        .contains("approval_required_tool_policy"));
}

#[tokio::test]
#[serial_test::serial(incident_monitor_http)]
async fn incident_monitor_assessment_probes_reject_scoped_intake_key_and_report_scope() {
    let state = test_state().await;
    state.set_api_token(Some("tk_admin".to_string())).await;
    let workspace = tempfile::tempdir().expect("assessment intake workspace");
    state
        .put_incident_monitor_config(crate::IncidentMonitorConfig {
            enabled: true,
            repo: Some("acme/platform".to_string()),
            workspace_root: Some(workspace.path().display().to_string()),
            monitored_projects: vec![crate::IncidentMonitorMonitoredProject {
                project_id: "payments".to_string(),
                name: "Payments".to_string(),
                repo: "acme/payments".to_string(),
                workspace_root: workspace.path().display().to_string(),
                tenant_id: Some("tenant-a".to_string()),
                workspace_id: Some("workspace-a".to_string()),
                ..Default::default()
            }],
            ..Default::default()
        })
        .await
        .expect("assessment intake config");
    let raw_key = "tim_assessment_intake_report_only";
    state
        .put_incident_monitor_intake_key(crate::IncidentMonitorProjectIntakeKey {
            key_id: "intake-assessment-report".to_string(),
            project_id: "payments".to_string(),
            name: "Assessment report key".to_string(),
            key_hash: crate::sha256_hex(&[raw_key]),
            enabled: true,
            scopes: vec!["incident_monitor:report".to_string()],
            created_at_ms: Some(crate::now_ms()),
            last_used_at_ms: None,
        })
        .await
        .expect("assessment intake key");
    let app = app_router(state);

    let blocked_resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/incident-monitor/security/assessment-probes")
                .header("content-type", "application/json")
                .header("x-tandem-token", "tk_admin")
                .header("x-tandem-incident-monitor-intake-key", raw_key)
                .body(Body::from(
                    json!({"probes": ["scoped_intake_restriction"]}).to_string(),
                ))
                .expect("assessment intake rejected request"),
        )
        .await
        .expect("assessment intake rejected response");
    assert_eq!(blocked_resp.status(), StatusCode::FORBIDDEN);

    let payload = run_incident_monitor_assessment_probes(
        app,
        "/incident-monitor/security/assessment-probes",
        json!({
            "probes": ["scoped_intake_restriction"]
        }),
        Some(("tenant-a", "workspace-a", "security-admin", "tk_admin")),
    )
    .await;
    assert!(payload["results"]
        .as_array()
        .expect("results")
        .iter()
        .any(
            |result| result["probe_id"].as_str() == Some("scoped_intake_restriction")
                && result["status"].as_str() == Some("pass")
        ));
}

#[tokio::test]
#[serial_test::serial(incident_monitor_http)]
async fn incident_monitor_assessment_probes_accept_bearer_admin_token() {
    let state = test_state().await;
    state.set_api_token(Some("tk_admin".to_string())).await;
    let app = app_router(state);

    let resp = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/incident-monitor/security/assessment-probes")
                .header("content-type", "application/json")
                .header("authorization", "Bearer tk_admin")
                .body(Body::from(
                    json!({"probes": ["destination_readiness_fail_closed"]}).to_string(),
                ))
                .expect("assessment bearer admin request"),
        )
        .await
        .expect("assessment bearer admin response");
    let status = resp.status();
    let body = to_bytes(resp.into_body(), usize::MAX)
        .await
        .expect("assessment bearer admin body");

    assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body));
    let payload: Value = serde_json::from_slice(&body).expect("assessment bearer admin json");
    assert_eq!(payload["scope"]["dry_run"], json!(true));
}

#[tokio::test]
#[serial_test::serial(incident_monitor_http)]
async fn incident_monitor_assessment_probes_detect_destination_readiness_and_webhook_policy() {
    let state = test_state().await;
    state.set_api_token(Some("tk_admin".to_string())).await;
    state
        .put_incident_monitor_config(crate::IncidentMonitorConfig {
            enabled: true,
            repo: Some("acme/platform".to_string()),
            destinations: vec![crate::IncidentMonitorDestinationConfig {
                destination_id: "private-webhook".to_string(),
                name: "Private webhook".to_string(),
                kind: crate::IncidentMonitorDestinationKind::Webhook,
                enabled: true,
                require_approval: true,
                webhook_url: Some("http://127.0.0.1:9/incidents".to_string()),
                webhook_secret_ref: Some("env:INCIDENT_MONITOR_ASSESSMENT_WEBHOOK_SECRET".to_string()),
                ..Default::default()
            }],
            default_destination_ids: vec!["private-webhook".to_string()],
            safety_defaults: crate::IncidentMonitorSafetyDefaults {
                block_unready_destinations: false,
                ..Default::default()
            },
            ..Default::default()
        })
        .await
        .expect("assessment webhook config");
    let app = app_router(state);

    let payload = run_incident_monitor_assessment_probes(
        app,
        "/incident-monitor/security/assessment-probes",
        json!({
            "probes": ["destination_readiness_fail_closed", "webhook_url_policy"]
        }),
        Some(("local", "local", "security-admin", "tk_admin")),
    )
    .await;
    let results = payload["results"].as_array().expect("probe results");

    assert!(results.iter().any(|result| {
        result["probe_id"].as_str() == Some("destination_readiness_fail_closed")
            && result["status"].as_str() == Some("fail")
            && result["observed_behavior"]
                .as_str()
                .is_some_and(|value| value.contains("block_unready_destinations is disabled"))
    }));
    assert!(results.iter().any(|result| {
        result["probe_id"].as_str() == Some("webhook_url_policy")
            && result["status"].as_str() == Some("fail")
            && result["observed_behavior"].as_str().is_some_and(|value| {
                value.contains("localhost/private network")
                    || value.contains("Webhook URL must use https")
            })
    }));
    let artifact_path = payload["evidence_pack"]["path"]
        .as_str()
        .expect("assessment artifact path");
    let artifact = std::fs::read_to_string(artifact_path).expect("assessment artifact");
    assert!(!artifact.contains("127.0.0.1"));
    assert!(!artifact.contains("INCIDENT_MONITOR_ASSESSMENT_WEBHOOK_SECRET"));
}

async fn run_incident_monitor_assessment_probes(
    app: axum::Router,
    uri: &str,
    input: Value,
    tenant: Option<(&str, &str, &str, &str)>,
) -> Value {
    let mut builder = Request::builder()
        .method("POST")
        .uri(uri)
        .header("content-type", "application/json");
    if let Some((org, workspace, actor, token)) = tenant {
        builder = builder
            .header("x-tandem-org-id", org)
            .header("x-tandem-workspace-id", workspace)
            .header("x-tandem-actor-id", actor)
            .header("x-tandem-token", token);
    }
    let resp = app
        .oneshot(
            builder
                .body(Body::from(input.to_string()))
                .expect("assessment probes request"),
        )
        .await
        .expect("assessment probes response");
    let status = resp.status();
    let body = to_bytes(resp.into_body(), usize::MAX)
        .await
        .expect("assessment probes body");
    assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body));
    serde_json::from_slice(&body).expect("assessment probes json")
}