1use crate::client::{AxonFlowClient, PATH_SEGMENT};
16use crate::error::AxonFlowError;
17use crate::types::decisions::{
18 DecisionExplanation, DecisionSummary, ListDecisionsOptions, RateLimitEnvelope,
19};
20use percent_encoding::utf8_percent_encode;
21use serde::Deserialize;
22
23impl AxonFlowClient {
24 pub async fn explain_decision(
44 &self,
45 decision_id: &str,
46 ) -> Result<DecisionExplanation, AxonFlowError> {
47 if decision_id.is_empty() {
48 return Err(AxonFlowError::ConfigError(
49 "decision_id is required".to_string(),
50 ));
51 }
52
53 let encoded = utf8_percent_encode(decision_id, PATH_SEGMENT).to_string();
57 let url = format!("{}/api/v1/decisions/{}/explain", self.endpoint(), encoded);
58
59 let resp = self.checked_get(&url).await?;
60 let body = resp.text().await?;
61 let parsed: DecisionExplanation = serde_json::from_str(&body)?;
62 Ok(parsed)
63 }
64
65 pub async fn list_decisions(
97 &self,
98 opts: ListDecisionsOptions,
99 ) -> Result<Vec<DecisionSummary>, AxonFlowError> {
100 let mut url = format!("{}/api/v1/decisions", self.endpoint());
101 let qs = build_decisions_query(&opts);
102 if !qs.is_empty() {
103 url.push('?');
104 url.push_str(&qs);
105 }
106
107 let resp = self.raw_get(&url).await?;
111 if resp.status().as_u16() == 429 {
112 let body = resp.text().await?;
113 return match serde_json::from_str::<RateLimitEnvelope>(&body) {
114 Ok(envelope) => Err(AxonFlowError::RateLimited {
115 envelope: Box::new(envelope),
116 }),
117 Err(_) => Err(AxonFlowError::ApiError {
118 status: 429,
119 message: body,
120 }),
121 };
122 }
123 if !resp.status().is_success() {
124 let status = resp.status().as_u16();
125 let message = resp.text().await?;
126 return Err(AxonFlowError::ApiError { status, message });
127 }
128 let body = resp.text().await?;
129 #[derive(Deserialize)]
130 struct ListResponse {
131 #[serde(default)]
132 decisions: Vec<DecisionSummary>,
133 }
134 let parsed: ListResponse = serde_json::from_str(&body)?;
135 Ok(parsed.decisions)
136 }
137}
138
139fn build_decisions_query(opts: &ListDecisionsOptions) -> String {
143 let mut pairs: Vec<(&str, String)> = Vec::with_capacity(5);
144 if let Some(since) = &opts.since {
145 pairs.push((
150 "since",
151 since.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
152 ));
153 }
154 if let Some(decision) = &opts.decision {
155 pairs.push(("decision", decision.clone()));
156 }
157 if let Some(policy_id) = &opts.policy_id {
158 pairs.push(("policy_id", policy_id.clone()));
159 }
160 if let Some(tool_signature) = &opts.tool_signature {
161 pairs.push(("tool_signature", tool_signature.clone()));
162 }
163 if let Some(limit) = opts.limit {
164 pairs.push(("limit", limit.to_string()));
165 }
166 pairs
167 .into_iter()
168 .map(|(k, v)| {
169 let v = utf8_percent_encode(&v, PATH_SEGMENT).to_string();
170 format!("{k}={v}")
171 })
172 .collect::<Vec<_>>()
173 .join("&")
174}
175
176#[cfg(test)]
177mod tests {
178 use crate::types::decisions::DecisionExplanation;
179 use crate::{AxonFlowClient, AxonFlowConfig};
180 use chrono::{TimeZone, Utc};
181 use serde_json::json;
182 use std::time::Duration;
183 use wiremock::matchers::{method, path, query_param};
184 use wiremock::{Mock, MockServer, ResponseTemplate};
185
186 fn make_client(endpoint: String) -> AxonFlowClient {
187 let config = AxonFlowConfig {
188 endpoint,
189 timeout: Duration::from_secs(2),
190 ..Default::default()
191 };
192 AxonFlowClient::new(config).expect("client init")
193 }
194
195 #[tokio::test]
196 async fn empty_decision_id_returns_config_error() {
197 let client = make_client("http://127.0.0.1:1".into());
199 let err = client.explain_decision("").await.unwrap_err();
200 assert!(
201 err.to_string().contains("decision_id is required"),
202 "unexpected error: {err}"
203 );
204 }
205
206 #[tokio::test]
207 async fn happy_path_parses_full_payload() {
208 let server = MockServer::start().await;
209 let want = json!({
210 "decision_id": "dec_wf1_step2",
211 "timestamp": "2026-04-17T12:00:00Z",
212 "decision": "deny",
213 "reason": "SQL injection detected",
214 "risk_level": "high",
215 "policy_matches": [{
216 "policy_id": "pol-sqli",
217 "policy_name": "SQL Injection Detector",
218 "action": "deny",
219 "risk_level": "high",
220 "allow_override": true
221 }],
222 "override_available": true,
223 "historical_hit_count_session": 3
224 });
225
226 Mock::given(method("GET"))
227 .and(path("/api/v1/decisions/dec_wf1_step2/explain"))
228 .respond_with(
229 ResponseTemplate::new(200)
230 .insert_header("content-type", "application/json")
231 .set_body_json(want),
232 )
233 .expect(1)
234 .mount(&server)
235 .await;
236
237 let client = make_client(server.uri());
238 let got = client.explain_decision("dec_wf1_step2").await.unwrap();
239
240 assert_eq!(got.decision_id, "dec_wf1_step2");
241 assert_eq!(got.decision, "deny");
242 assert_eq!(got.reason, "SQL injection detected");
243 assert_eq!(got.risk_level.as_deref(), Some("high"));
244 assert_eq!(got.policy_matches.len(), 1);
245 assert_eq!(got.policy_matches[0].policy_id, "pol-sqli");
246 assert!(got.policy_matches[0].allow_override);
247 assert!(got.override_available);
248 assert_eq!(got.historical_hit_count_session, 3);
249 assert_eq!(
250 got.timestamp,
251 Utc.with_ymd_and_hms(2026, 4, 17, 12, 0, 0).unwrap()
252 );
253 }
254
255 #[tokio::test]
256 async fn decision_id_is_url_encoded() {
257 let server = MockServer::start().await;
261 Mock::given(method("GET"))
262 .and(path("/api/v1/decisions/a%2Fb/explain"))
263 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
264 "decision_id": "a/b",
265 "timestamp": "2026-04-17T12:00:00Z",
266 "decision": "allow",
267 "reason": "",
268 "policy_matches": []
269 })))
270 .expect(1)
271 .mount(&server)
272 .await;
273
274 let client = make_client(server.uri());
275 client.explain_decision("a/b").await.unwrap();
276 }
277
278 #[tokio::test]
279 async fn http_404_surfaces_as_api_error() {
280 let server = MockServer::start().await;
281 Mock::given(method("GET"))
282 .and(path("/api/v1/decisions/dec-missing/explain"))
283 .respond_with(
284 ResponseTemplate::new(404)
285 .set_body_json(json!({"error": "Decision not found or past retention window"})),
286 )
287 .mount(&server)
288 .await;
289
290 let client = make_client(server.uri());
291 let err = client.explain_decision("dec-missing").await.unwrap_err();
292 match err {
293 crate::error::AxonFlowError::ApiError { status, .. } => assert_eq!(status, 404),
294 other => panic!("expected ApiError(404), got: {other}"),
295 }
296 }
297
298 #[tokio::test]
299 async fn http_401_surfaces_as_api_error() {
300 let server = MockServer::start().await;
305 Mock::given(method("GET"))
306 .and(path("/api/v1/decisions/dec-x/explain"))
307 .respond_with(
308 ResponseTemplate::new(401)
309 .set_body_json(json!({"error": "X-Tenant-ID header is required"})),
310 )
311 .mount(&server)
312 .await;
313
314 let client = make_client(server.uri());
315 let err = client.explain_decision("dec-x").await.unwrap_err();
316 match err {
317 crate::error::AxonFlowError::ApiError { status, .. } => assert_eq!(status, 401),
318 other => panic!("expected ApiError(401), got: {other}"),
319 }
320 }
321
322 #[tokio::test]
323 async fn malformed_json_response_is_serde_error() {
324 let server = MockServer::start().await;
325 Mock::given(method("GET"))
326 .and(path("/api/v1/decisions/dec-x/explain"))
327 .respond_with(
328 ResponseTemplate::new(200)
329 .insert_header("content-type", "application/json")
330 .set_body_string("{not valid json"),
331 )
332 .mount(&server)
333 .await;
334
335 let client = make_client(server.uri());
336 let err = client.explain_decision("dec-x").await.unwrap_err();
337 match err {
338 crate::error::AxonFlowError::SerdeError(_) => {}
339 other => panic!("expected SerdeError, got: {other}"),
340 }
341 }
342
343 #[tokio::test]
344 async fn additive_unknown_fields_are_ignored() {
345 let server = MockServer::start().await;
353 Mock::given(method("GET"))
354 .and(path("/api/v1/decisions/dec-x/explain"))
355 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
356 "decision_id": "dec-x",
357 "timestamp": "2026-04-17T12:00:00Z",
358 "decision": "allow",
359 "reason": "",
360 "policy_matches": [],
361 "policy_version_at_decision": "v3", "latest_policy_version": "v5", "yet_another_future_field": "shrug" })))
365 .mount(&server)
366 .await;
367
368 let client = make_client(server.uri());
369 let got: DecisionExplanation = client.explain_decision("dec-x").await.unwrap();
370 assert_eq!(got.decision_id, "dec-x");
371 }
372
373 use crate::decisions::build_decisions_query;
379 use crate::error::AxonFlowError;
380 use crate::types::decisions::{DecisionSummary, ListDecisionsOptions};
381
382 #[tokio::test]
383 async fn list_decisions_happy_path_parses_three_rows() {
384 let server = MockServer::start().await;
385 let want = json!({
386 "decisions": [
387 {
388 "decision_id": "dec-1",
389 "timestamp": "2026-05-07T12:00:00Z",
390 "decision": "deny",
391 "policy_id": "pol-sqli",
392 "tool_signature": "postgres.query"
393 },
394 {
395 "decision_id": "dec-2",
396 "timestamp": "2026-05-07T11:00:00Z",
397 "decision": "allow",
398 "policy_id": "pol-default",
399 "tool_signature": "github.status"
400 },
401 {
402 "decision_id": "dec-3",
403 "timestamp": "2026-05-07T10:00:00Z",
404 "decision": "require_approval",
405 "policy_id": "pol-amount",
406 "tool_signature": "stripe.charge"
407 }
408 ]
409 });
410
411 Mock::given(method("GET"))
412 .and(path("/api/v1/decisions"))
413 .respond_with(
414 ResponseTemplate::new(200)
415 .insert_header("content-type", "application/json")
416 .set_body_json(want),
417 )
418 .mount(&server)
419 .await;
420
421 let client = make_client(server.uri());
422 let got = client
423 .list_decisions(ListDecisionsOptions::default())
424 .await
425 .unwrap();
426
427 assert_eq!(got.len(), 3);
428 assert_eq!(got[0].decision_id, "dec-1");
429 assert_eq!(got[0].decision, "deny");
430 assert_eq!(got[0].policy_id.as_deref(), Some("pol-sqli"));
431 assert_eq!(got[0].tool_signature.as_deref(), Some("postgres.query"));
432 assert_eq!(got[2].decision, "require_approval");
433 }
434
435 #[tokio::test]
436 async fn list_decisions_serializes_every_filter_into_url() {
437 let server = MockServer::start().await;
438 Mock::given(method("GET"))
443 .and(path("/api/v1/decisions"))
444 .and(query_param("since", "2026-05-07T00:00:00Z"))
445 .and(query_param("decision", "deny"))
446 .and(query_param("policy_id", "pol-sqli"))
447 .and(query_param("tool_signature", "postgres.query"))
448 .and(query_param("limit", "25"))
449 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"decisions": []})))
450 .expect(1)
451 .mount(&server)
452 .await;
453
454 let client = make_client(server.uri());
455 let opts = ListDecisionsOptions {
456 since: Some(Utc.with_ymd_and_hms(2026, 5, 7, 0, 0, 0).unwrap()),
457 decision: Some("deny".into()),
458 policy_id: Some("pol-sqli".into()),
459 tool_signature: Some("postgres.query".into()),
460 limit: Some(25),
461 };
462 let _ = client.list_decisions(opts).await.unwrap();
463 }
464
465 #[tokio::test]
466 async fn list_decisions_429_surfaces_typed_rate_limit_envelope() {
467 let server = MockServer::start().await;
468 let envelope = json!({
469 "error": "Free tier shows the last 5 decisions in 24h. Pro raises this to 100 decisions in the last 30 days.",
470 "limit_type": "decision_list_size",
471 "tier": "Community",
472 "limit": 5,
473 "remaining": 0,
474 "upgrade": {
475 "tier": "Pro",
476 "wording": "Free tier shows the last 5 decisions in 24h. Pro raises this to 100 decisions in the last 30 days.",
477 "compare_url": "https://getaxonflow.com/pricing/",
478 "buy_url": "https://buy.stripe.com/bJe28qbztcdVchjdkw8k800"
479 }
480 });
481
482 Mock::given(method("GET"))
483 .and(path("/api/v1/decisions"))
484 .respond_with(
485 ResponseTemplate::new(429)
486 .insert_header("content-type", "application/json")
487 .insert_header("X-Axonflow-Tier-Limit", "decision_list_size")
488 .set_body_json(envelope),
489 )
490 .mount(&server)
491 .await;
492
493 let client = make_client(server.uri());
494 let err = client
495 .list_decisions(ListDecisionsOptions {
496 limit: Some(10),
497 ..Default::default()
498 })
499 .await
500 .expect_err("must reject with RateLimited");
501
502 match err {
503 AxonFlowError::RateLimited { envelope } => {
504 assert_eq!(envelope.tier, "Community");
505 assert_eq!(envelope.limit_type, "decision_list_size");
506 assert_eq!(envelope.limit, 5);
507 assert_eq!(envelope.upgrade.tier, "Pro");
508 assert_eq!(
509 envelope.upgrade.compare_url,
510 "https://getaxonflow.com/pricing/"
511 );
512 assert_eq!(
513 envelope.upgrade.buy_url,
514 "https://buy.stripe.com/bJe28qbztcdVchjdkw8k800"
515 );
516 }
517 other => panic!("expected RateLimited, got {other:?}"),
518 }
519 }
520
521 #[tokio::test]
522 async fn list_decisions_429_with_malformed_body_falls_back_to_apierror() {
523 let server = MockServer::start().await;
527 Mock::given(method("GET"))
528 .and(path("/api/v1/decisions"))
529 .respond_with(
530 ResponseTemplate::new(429)
531 .insert_header("content-type", "application/json")
532 .set_body_string("not a json envelope"),
533 )
534 .mount(&server)
535 .await;
536
537 let client = make_client(server.uri());
538 let err = client
539 .list_decisions(ListDecisionsOptions::default())
540 .await
541 .expect_err("must reject");
542 match err {
543 AxonFlowError::ApiError { status, .. } => assert_eq!(status, 429),
544 other => panic!("expected ApiError{{status=429}}, got {other:?}"),
545 }
546 }
547
548 #[tokio::test]
549 async fn list_decisions_401_surfaces_as_apierror() {
550 let server = MockServer::start().await;
551 Mock::given(method("GET"))
552 .and(path("/api/v1/decisions"))
553 .respond_with(
554 ResponseTemplate::new(401)
555 .insert_header("content-type", "application/json")
556 .set_body_json(json!({"error": "X-Tenant-ID header is required"})),
557 )
558 .mount(&server)
559 .await;
560
561 let client = make_client(server.uri());
562 let err = client
563 .list_decisions(ListDecisionsOptions::default())
564 .await
565 .expect_err("must reject");
566 match err {
567 AxonFlowError::ApiError { status, message } => {
568 assert_eq!(status, 401);
569 assert!(message.contains("X-Tenant-ID"), "msg = {message}");
570 }
571 other => panic!("expected ApiError{{status=401}}, got {other:?}"),
572 }
573 }
574
575 #[tokio::test]
576 async fn list_decisions_forward_compat_unknown_fields_ignored() {
577 let server = MockServer::start().await;
578 let want = json!({
579 "decisions": [{
580 "decision_id": "dec-fwd",
581 "timestamp": "2026-05-07T12:00:00Z",
582 "decision": "deny",
583 "policy_id": "pol-x",
584 "tool_signature": "tool-x",
585 "policy_version": 7, "latest_policy_version": 9, "arbitrary_unknown": "ignored" }],
589 "next_cursor": "future_cursor_pagination" });
591
592 Mock::given(method("GET"))
593 .and(path("/api/v1/decisions"))
594 .respond_with(ResponseTemplate::new(200).set_body_json(want))
595 .mount(&server)
596 .await;
597
598 let client = make_client(server.uri());
599 let got = client
600 .list_decisions(ListDecisionsOptions::default())
601 .await
602 .unwrap();
603 assert_eq!(got.len(), 1);
604 assert_eq!(got[0].decision_id, "dec-fwd");
605 }
606
607 #[test]
608 fn build_decisions_query_omits_none_fields() {
609 let qs = build_decisions_query(&ListDecisionsOptions::default());
610 assert_eq!(qs, "");
611
612 let qs = build_decisions_query(&ListDecisionsOptions {
613 decision: Some("deny".into()),
614 limit: Some(7),
615 ..Default::default()
616 });
617 assert_eq!(qs, "decision=deny&limit=7");
618 }
619
620 #[test]
621 fn decision_summary_optional_fields_round_trip() {
622 let raw = json!({
626 "decision_id": "dec-min",
627 "timestamp": "2026-05-07T12:00:00Z",
628 "decision": "deny"
629 });
630 let parsed: DecisionSummary = serde_json::from_value(raw).unwrap();
631 assert_eq!(parsed.decision_id, "dec-min");
632 assert_eq!(parsed.policy_id, None);
633 assert_eq!(parsed.tool_signature, None);
634 let s = serde_json::to_string(&parsed).unwrap();
636 assert!(!s.contains("policy_id"), "policy_id must be omitted: {s}");
637 assert!(
638 !s.contains("tool_signature"),
639 "tool_signature must be omitted: {s}"
640 );
641 }
642}