1use chio_kernel::receipt_query::{ReceiptQuery, ReceiptQueryResult};
2use chio_kernel::ReceiptStoreError;
3
4use crate::receipt_store::SqliteReceiptStore;
5
6impl SqliteReceiptStore {
7 pub fn query_receipts(
15 &self,
16 query: &ReceiptQuery,
17 ) -> Result<ReceiptQueryResult, ReceiptStoreError> {
18 self.query_receipts_impl(query)
20 }
21}
22
23#[cfg(test)]
24#[allow(clippy::expect_used, clippy::unwrap_used)]
25mod tests {
26 use std::time::{SystemTime, UNIX_EPOCH};
27
28 use chio_core::crypto::Keypair;
29 use chio_core::receipt::{ChioReceipt, ChioReceiptBody, Decision, ToolCallAction};
30 use chio_kernel::{ReceiptStore, MAX_QUERY_LIMIT};
31
32 use super::*;
33 use crate::receipt_store::SqliteReceiptStore;
34
35 fn unique_db_path(prefix: &str) -> std::path::PathBuf {
36 let nonce = SystemTime::now()
37 .duration_since(UNIX_EPOCH)
38 .expect("time before epoch")
39 .as_nanos();
40 std::env::temp_dir().join(format!("{prefix}-{nonce}.sqlite3"))
41 }
42
43 fn valid_tool_action(parameters: serde_json::Value) -> ToolCallAction {
44 ToolCallAction::from_parameters(parameters).unwrap()
45 }
46
47 fn make_receipt_with_metadata(
48 id: &str,
49 capability_id: &str,
50 tool_server: &str,
51 tool_name: &str,
52 decision: Decision,
53 timestamp: u64,
54 metadata: Option<serde_json::Value>,
55 ) -> ChioReceipt {
56 let keypair = Keypair::generate();
57 ChioReceipt::sign(
58 ChioReceiptBody {
59 id: id.to_string(),
60 timestamp,
61 capability_id: capability_id.to_string(),
62 tool_server: tool_server.to_string(),
63 tool_name: tool_name.to_string(),
64 action: valid_tool_action(serde_json::json!({})),
65 decision,
66 content_hash: "content-hash".to_string(),
67 policy_hash: "policy-hash".to_string(),
68 evidence: Vec::new(),
69 metadata,
70 trust_level: chio_core::TrustLevel::default(),
71 tenant_id: None,
72 kernel_key: keypair.public_key(),
73 },
74 &keypair,
75 )
76 .unwrap()
77 }
78
79 fn make_receipt(
81 id: &str,
82 capability_id: &str,
83 tool_server: &str,
84 tool_name: &str,
85 decision: Decision,
86 timestamp: u64,
87 cost: Option<u64>,
88 ) -> ChioReceipt {
89 let metadata = cost.map(|c| {
90 serde_json::json!({
91 "financial": {
92 "grant_index": 0u32,
93 "cost_charged": c,
94 "currency": "USD",
95 "budget_remaining": 1000u64,
96 "budget_total": 2000u64,
97 "delegation_depth": 0u32,
98 "root_budget_holder": "root-agent",
99 "settlement_status": "pending"
100 }
101 })
102 });
103 make_receipt_with_metadata(
104 id,
105 capability_id,
106 tool_server,
107 tool_name,
108 decision,
109 timestamp,
110 metadata,
111 )
112 }
113
114 #[test]
115 fn test_query_no_filters() {
116 let path = unique_db_path("rq-no-filters");
117 let mut store = SqliteReceiptStore::open(&path).unwrap();
118
119 for i in 0..5usize {
120 let r = make_receipt(
121 &format!("r-{i}"),
122 "cap-1",
123 "shell",
124 "bash",
125 Decision::Allow,
126 100 + i as u64,
127 None,
128 );
129 store.append_chio_receipt(&r).unwrap();
130 }
131
132 let result = store
133 .query_receipts(&ReceiptQuery {
134 limit: 10,
135 ..Default::default()
136 })
137 .unwrap();
138
139 assert_eq!(result.receipts.len(), 5);
140 assert_eq!(result.total_count, 5);
141 let seqs: Vec<u64> = result.receipts.iter().map(|r| r.seq).collect();
143 let mut sorted = seqs.clone();
144 sorted.sort();
145 assert_eq!(seqs, sorted, "receipts should be ordered by seq ASC");
146
147 let _ = std::fs::remove_file(path);
148 }
149
150 #[test]
151 fn test_query_filter_capability_id() {
152 let path = unique_db_path("rq-cap-id");
153 let mut store = SqliteReceiptStore::open(&path).unwrap();
154
155 store
156 .append_chio_receipt(&make_receipt(
157 "r-1",
158 "cap-A",
159 "s",
160 "t",
161 Decision::Allow,
162 100,
163 None,
164 ))
165 .unwrap();
166 store
167 .append_chio_receipt(&make_receipt(
168 "r-2",
169 "cap-B",
170 "s",
171 "t",
172 Decision::Allow,
173 101,
174 None,
175 ))
176 .unwrap();
177 store
178 .append_chio_receipt(&make_receipt(
179 "r-3",
180 "cap-A",
181 "s",
182 "t",
183 Decision::Allow,
184 102,
185 None,
186 ))
187 .unwrap();
188
189 let result = store
190 .query_receipts(&ReceiptQuery {
191 capability_id: Some("cap-A".to_string()),
192 limit: 10,
193 ..Default::default()
194 })
195 .unwrap();
196
197 assert_eq!(result.receipts.len(), 2);
198 assert_eq!(result.total_count, 2);
199 for r in &result.receipts {
200 assert_eq!(r.receipt.capability_id, "cap-A");
201 }
202
203 let _ = std::fs::remove_file(path);
204 }
205
206 #[test]
207 fn test_query_filter_tool_server() {
208 let path = unique_db_path("rq-tool-server");
209 let mut store = SqliteReceiptStore::open(&path).unwrap();
210
211 store
212 .append_chio_receipt(&make_receipt(
213 "r-1",
214 "cap-1",
215 "shell",
216 "bash",
217 Decision::Allow,
218 100,
219 None,
220 ))
221 .unwrap();
222 store
223 .append_chio_receipt(&make_receipt(
224 "r-2",
225 "cap-1",
226 "files",
227 "read",
228 Decision::Allow,
229 101,
230 None,
231 ))
232 .unwrap();
233 store
234 .append_chio_receipt(&make_receipt(
235 "r-3",
236 "cap-1",
237 "shell",
238 "ls",
239 Decision::Allow,
240 102,
241 None,
242 ))
243 .unwrap();
244
245 let result = store
246 .query_receipts(&ReceiptQuery {
247 tool_server: Some("shell".to_string()),
248 limit: 10,
249 ..Default::default()
250 })
251 .unwrap();
252
253 assert_eq!(result.receipts.len(), 2);
254 assert_eq!(result.total_count, 2);
255 for r in &result.receipts {
256 assert_eq!(r.receipt.tool_server, "shell");
257 }
258
259 let _ = std::fs::remove_file(path);
260 }
261
262 #[test]
263 fn test_query_filter_tool_name() {
264 let path = unique_db_path("rq-tool-name");
265 let mut store = SqliteReceiptStore::open(&path).unwrap();
266
267 store
268 .append_chio_receipt(&make_receipt(
269 "r-1",
270 "cap-1",
271 "shell",
272 "bash",
273 Decision::Allow,
274 100,
275 None,
276 ))
277 .unwrap();
278 store
279 .append_chio_receipt(&make_receipt(
280 "r-2",
281 "cap-1",
282 "shell",
283 "ls",
284 Decision::Allow,
285 101,
286 None,
287 ))
288 .unwrap();
289 store
290 .append_chio_receipt(&make_receipt(
291 "r-3",
292 "cap-1",
293 "shell",
294 "bash",
295 Decision::Allow,
296 102,
297 None,
298 ))
299 .unwrap();
300
301 let result = store
302 .query_receipts(&ReceiptQuery {
303 tool_name: Some("bash".to_string()),
304 limit: 10,
305 ..Default::default()
306 })
307 .unwrap();
308
309 assert_eq!(result.receipts.len(), 2);
310 assert_eq!(result.total_count, 2);
311 for r in &result.receipts {
312 assert_eq!(r.receipt.tool_name, "bash");
313 }
314
315 let _ = std::fs::remove_file(path);
316 }
317
318 #[test]
319 fn test_query_filter_outcome() {
320 let path = unique_db_path("rq-outcome");
321 let mut store = SqliteReceiptStore::open(&path).unwrap();
322
323 store
324 .append_chio_receipt(&make_receipt(
325 "r-1",
326 "cap-1",
327 "s",
328 "t",
329 Decision::Allow,
330 100,
331 None,
332 ))
333 .unwrap();
334 store
335 .append_chio_receipt(&make_receipt(
336 "r-2",
337 "cap-1",
338 "s",
339 "t",
340 Decision::Deny {
341 reason: "no".to_string(),
342 guard: "G".to_string(),
343 },
344 101,
345 None,
346 ))
347 .unwrap();
348 store
349 .append_chio_receipt(&make_receipt(
350 "r-3",
351 "cap-1",
352 "s",
353 "t",
354 Decision::Allow,
355 102,
356 None,
357 ))
358 .unwrap();
359
360 let result = store
361 .query_receipts(&ReceiptQuery {
362 outcome: Some("allow".to_string()),
363 limit: 10,
364 ..Default::default()
365 })
366 .unwrap();
367
368 assert_eq!(result.receipts.len(), 2);
369 assert_eq!(result.total_count, 2);
370 for r in &result.receipts {
371 assert!(r.receipt.is_allowed());
372 }
373
374 let _ = std::fs::remove_file(path);
375 }
376
377 #[test]
378 fn test_query_filter_time_range_since() {
379 let path = unique_db_path("rq-since");
380 let mut store = SqliteReceiptStore::open(&path).unwrap();
381
382 store
383 .append_chio_receipt(&make_receipt(
384 "r-1",
385 "cap-1",
386 "s",
387 "t",
388 Decision::Allow,
389 100,
390 None,
391 ))
392 .unwrap();
393 store
394 .append_chio_receipt(&make_receipt(
395 "r-2",
396 "cap-1",
397 "s",
398 "t",
399 Decision::Allow,
400 200,
401 None,
402 ))
403 .unwrap();
404 store
405 .append_chio_receipt(&make_receipt(
406 "r-3",
407 "cap-1",
408 "s",
409 "t",
410 Decision::Allow,
411 300,
412 None,
413 ))
414 .unwrap();
415
416 let result = store
417 .query_receipts(&ReceiptQuery {
418 since: Some(200),
419 limit: 10,
420 ..Default::default()
421 })
422 .unwrap();
423
424 assert_eq!(
425 result.receipts.len(),
426 2,
427 "since 200 should include timestamps 200 and 300"
428 );
429 assert_eq!(result.total_count, 2);
430 for r in &result.receipts {
431 assert!(r.receipt.timestamp >= 200);
432 }
433
434 let _ = std::fs::remove_file(path);
435 }
436
437 #[test]
438 fn test_query_filter_time_range_until() {
439 let path = unique_db_path("rq-until");
440 let mut store = SqliteReceiptStore::open(&path).unwrap();
441
442 store
443 .append_chio_receipt(&make_receipt(
444 "r-1",
445 "cap-1",
446 "s",
447 "t",
448 Decision::Allow,
449 100,
450 None,
451 ))
452 .unwrap();
453 store
454 .append_chio_receipt(&make_receipt(
455 "r-2",
456 "cap-1",
457 "s",
458 "t",
459 Decision::Allow,
460 200,
461 None,
462 ))
463 .unwrap();
464 store
465 .append_chio_receipt(&make_receipt(
466 "r-3",
467 "cap-1",
468 "s",
469 "t",
470 Decision::Allow,
471 300,
472 None,
473 ))
474 .unwrap();
475
476 let result = store
477 .query_receipts(&ReceiptQuery {
478 until: Some(200),
479 limit: 10,
480 ..Default::default()
481 })
482 .unwrap();
483
484 assert_eq!(
485 result.receipts.len(),
486 2,
487 "until 200 should include timestamps 100 and 200"
488 );
489 assert_eq!(result.total_count, 2);
490 for r in &result.receipts {
491 assert!(r.receipt.timestamp <= 200);
492 }
493
494 let _ = std::fs::remove_file(path);
495 }
496
497 #[test]
498 fn test_query_filter_time_range_both() {
499 let path = unique_db_path("rq-time-both");
500 let mut store = SqliteReceiptStore::open(&path).unwrap();
501
502 store
503 .append_chio_receipt(&make_receipt(
504 "r-1",
505 "cap-1",
506 "s",
507 "t",
508 Decision::Allow,
509 100,
510 None,
511 ))
512 .unwrap();
513 store
514 .append_chio_receipt(&make_receipt(
515 "r-2",
516 "cap-1",
517 "s",
518 "t",
519 Decision::Allow,
520 200,
521 None,
522 ))
523 .unwrap();
524 store
525 .append_chio_receipt(&make_receipt(
526 "r-3",
527 "cap-1",
528 "s",
529 "t",
530 Decision::Allow,
531 300,
532 None,
533 ))
534 .unwrap();
535 store
536 .append_chio_receipt(&make_receipt(
537 "r-4",
538 "cap-1",
539 "s",
540 "t",
541 Decision::Allow,
542 400,
543 None,
544 ))
545 .unwrap();
546
547 let result = store
548 .query_receipts(&ReceiptQuery {
549 since: Some(200),
550 until: Some(300),
551 limit: 10,
552 ..Default::default()
553 })
554 .unwrap();
555
556 assert_eq!(result.receipts.len(), 2);
557 assert_eq!(result.total_count, 2);
558 for r in &result.receipts {
559 assert!(r.receipt.timestamp >= 200 && r.receipt.timestamp <= 300);
560 }
561
562 let _ = std::fs::remove_file(path);
563 }
564
565 #[test]
566 fn test_query_filter_cost_range_min() {
567 let path = unique_db_path("rq-min-cost");
568 let mut store = SqliteReceiptStore::open(&path).unwrap();
569
570 store
572 .append_chio_receipt(&make_receipt(
573 "r-1",
574 "cap-1",
575 "s",
576 "t",
577 Decision::Allow,
578 100,
579 None,
580 ))
581 .unwrap();
582 store
584 .append_chio_receipt(&make_receipt(
585 "r-2",
586 "cap-1",
587 "s",
588 "t",
589 Decision::Allow,
590 101,
591 Some(50),
592 ))
593 .unwrap();
594 store
596 .append_chio_receipt(&make_receipt(
597 "r-3",
598 "cap-1",
599 "s",
600 "t",
601 Decision::Allow,
602 102,
603 Some(150),
604 ))
605 .unwrap();
606
607 let result = store
608 .query_receipts(&ReceiptQuery {
609 min_cost: Some(100),
610 limit: 10,
611 ..Default::default()
612 })
613 .unwrap();
614
615 assert_eq!(
617 result.receipts.len(),
618 1,
619 "only r-3 with cost=150 should match min_cost=100"
620 );
621 assert_eq!(result.total_count, 1);
622
623 let _ = std::fs::remove_file(path);
624 }
625
626 #[test]
627 fn test_query_filter_cost_range_max() {
628 let path = unique_db_path("rq-max-cost");
629 let mut store = SqliteReceiptStore::open(&path).unwrap();
630
631 store
633 .append_chio_receipt(&make_receipt(
634 "r-1",
635 "cap-1",
636 "s",
637 "t",
638 Decision::Allow,
639 100,
640 None,
641 ))
642 .unwrap();
643 store
645 .append_chio_receipt(&make_receipt(
646 "r-2",
647 "cap-1",
648 "s",
649 "t",
650 Decision::Allow,
651 101,
652 Some(50),
653 ))
654 .unwrap();
655 store
657 .append_chio_receipt(&make_receipt(
658 "r-3",
659 "cap-1",
660 "s",
661 "t",
662 Decision::Allow,
663 102,
664 Some(150),
665 ))
666 .unwrap();
667
668 let result = store
669 .query_receipts(&ReceiptQuery {
670 max_cost: Some(100),
671 limit: 10,
672 ..Default::default()
673 })
674 .unwrap();
675
676 assert_eq!(
678 result.receipts.len(),
679 1,
680 "only r-2 with cost=50 should match max_cost=100"
681 );
682 assert_eq!(result.total_count, 1);
683
684 let _ = std::fs::remove_file(path);
685 }
686
687 #[test]
688 fn test_query_filter_cost_range_both() {
689 let path = unique_db_path("rq-cost-both");
690 let mut store = SqliteReceiptStore::open(&path).unwrap();
691
692 store
693 .append_chio_receipt(&make_receipt(
694 "r-1",
695 "cap-1",
696 "s",
697 "t",
698 Decision::Allow,
699 100,
700 None,
701 ))
702 .unwrap();
703 store
704 .append_chio_receipt(&make_receipt(
705 "r-2",
706 "cap-1",
707 "s",
708 "t",
709 Decision::Allow,
710 101,
711 Some(50),
712 ))
713 .unwrap();
714 store
715 .append_chio_receipt(&make_receipt(
716 "r-3",
717 "cap-1",
718 "s",
719 "t",
720 Decision::Allow,
721 102,
722 Some(100),
723 ))
724 .unwrap();
725 store
726 .append_chio_receipt(&make_receipt(
727 "r-4",
728 "cap-1",
729 "s",
730 "t",
731 Decision::Allow,
732 103,
733 Some(200),
734 ))
735 .unwrap();
736
737 let result = store
738 .query_receipts(&ReceiptQuery {
739 min_cost: Some(75),
740 max_cost: Some(150),
741 limit: 10,
742 ..Default::default()
743 })
744 .unwrap();
745
746 assert_eq!(
748 result.receipts.len(),
749 1,
750 "only r-3 with cost=100 should match 75..=150 window"
751 );
752 assert_eq!(result.total_count, 1);
753
754 let _ = std::fs::remove_file(path);
755 }
756
757 #[test]
758 fn test_query_cursor_pagination() {
759 let path = unique_db_path("rq-cursor");
760 let mut store = SqliteReceiptStore::open(&path).unwrap();
761
762 for i in 0..5usize {
763 let r = make_receipt(
764 &format!("r-{i}"),
765 "cap-1",
766 "s",
767 "t",
768 Decision::Allow,
769 100 + i as u64,
770 None,
771 );
772 store.append_chio_receipt(&r).unwrap();
773 }
774
775 let page1 = store
777 .query_receipts(&ReceiptQuery {
778 cursor: None,
779 limit: 2,
780 ..Default::default()
781 })
782 .unwrap();
783 assert_eq!(page1.receipts.len(), 2);
784
785 let cursor = page1.next_cursor.expect("should have next cursor");
786
787 let page2 = store
789 .query_receipts(&ReceiptQuery {
790 cursor: Some(cursor),
791 limit: 2,
792 ..Default::default()
793 })
794 .unwrap();
795
796 for r in &page2.receipts {
798 assert!(
799 r.seq > cursor,
800 "page2 receipt seq {} should be > cursor {}",
801 r.seq,
802 cursor
803 );
804 }
805
806 let _ = std::fs::remove_file(path);
807 }
808
809 #[test]
810 fn test_query_cursor_pagination_pages() {
811 let path = unique_db_path("rq-cursor-pages");
812 let mut store = SqliteReceiptStore::open(&path).unwrap();
813
814 for i in 0..7usize {
815 let r = make_receipt(
816 &format!("r-{i}"),
817 "cap-1",
818 "s",
819 "t",
820 Decision::Allow,
821 100 + i as u64,
822 None,
823 );
824 store.append_chio_receipt(&r).unwrap();
825 }
826
827 let mut all_seqs = Vec::new();
829 let mut cursor = None;
830
831 loop {
832 let page = store
833 .query_receipts(&ReceiptQuery {
834 cursor,
835 limit: 3,
836 ..Default::default()
837 })
838 .unwrap();
839
840 for r in &page.receipts {
841 all_seqs.push(r.seq);
842 }
843
844 cursor = page.next_cursor;
845 if cursor.is_none() {
846 break;
847 }
848 }
849
850 assert_eq!(
851 all_seqs.len(),
852 7,
853 "all 7 receipts should be seen across pages"
854 );
855
856 let mut unique = all_seqs.clone();
858 unique.dedup();
859 assert_eq!(all_seqs, unique, "no duplicate seqs");
860
861 let _ = std::fs::remove_file(path);
862 }
863
864 #[test]
865 fn test_query_next_cursor_some_when_more() {
866 let path = unique_db_path("rq-next-cursor-some");
867 let mut store = SqliteReceiptStore::open(&path).unwrap();
868
869 for i in 0..5usize {
870 let r = make_receipt(
871 &format!("r-{i}"),
872 "cap-1",
873 "s",
874 "t",
875 Decision::Allow,
876 100 + i as u64,
877 None,
878 );
879 store.append_chio_receipt(&r).unwrap();
880 }
881
882 let result = store
883 .query_receipts(&ReceiptQuery {
884 limit: 3,
885 ..Default::default()
886 })
887 .unwrap();
888
889 assert_eq!(result.receipts.len(), 3);
891 assert!(
892 result.next_cursor.is_some(),
893 "next_cursor should be Some when results.len() == limit"
894 );
895 assert_eq!(result.next_cursor.unwrap(), result.receipts[2].seq);
896
897 let _ = std::fs::remove_file(path);
898 }
899
900 #[test]
901 fn test_query_next_cursor_none_when_last_page() {
902 let path = unique_db_path("rq-next-cursor-none");
903 let mut store = SqliteReceiptStore::open(&path).unwrap();
904
905 for i in 0..3usize {
906 let r = make_receipt(
907 &format!("r-{i}"),
908 "cap-1",
909 "s",
910 "t",
911 Decision::Allow,
912 100 + i as u64,
913 None,
914 );
915 store.append_chio_receipt(&r).unwrap();
916 }
917
918 let result = store
919 .query_receipts(&ReceiptQuery {
920 limit: 5,
921 ..Default::default()
922 })
923 .unwrap();
924
925 assert_eq!(result.receipts.len(), 3);
927 assert!(
928 result.next_cursor.is_none(),
929 "next_cursor should be None when results.len() < limit"
930 );
931
932 let _ = std::fs::remove_file(path);
933 }
934
935 #[test]
936 fn test_query_total_count() {
937 let path = unique_db_path("rq-total-count");
938 let mut store = SqliteReceiptStore::open(&path).unwrap();
939
940 for i in 0..10usize {
941 let r = make_receipt(
942 &format!("r-{i}"),
943 "cap-1",
944 "s",
945 "t",
946 Decision::Allow,
947 100 + i as u64,
948 None,
949 );
950 store.append_chio_receipt(&r).unwrap();
951 }
952
953 let result = store
955 .query_receipts(&ReceiptQuery {
956 limit: 3,
957 ..Default::default()
958 })
959 .unwrap();
960
961 assert_eq!(result.receipts.len(), 3);
962 assert_eq!(
963 result.total_count, 10,
964 "total_count should reflect all matching receipts"
965 );
966
967 let _ = std::fs::remove_file(path);
968 }
969
970 #[test]
971 fn test_query_limit_capped() {
972 let path = unique_db_path("rq-limit-capped");
973 let mut store = SqliteReceiptStore::open(&path).unwrap();
974
975 for i in 0..(MAX_QUERY_LIMIT + 10) {
977 let r = make_receipt(
978 &format!("r-{i}"),
979 "cap-1",
980 "s",
981 "t",
982 Decision::Allow,
983 100 + i as u64,
984 None,
985 );
986 store.append_chio_receipt(&r).unwrap();
987 }
988
989 let result = store
991 .query_receipts(&ReceiptQuery {
992 limit: MAX_QUERY_LIMIT + 100,
993 ..Default::default()
994 })
995 .unwrap();
996
997 assert_eq!(
998 result.receipts.len(),
999 MAX_QUERY_LIMIT,
1000 "limit above MAX_QUERY_LIMIT should be capped to MAX_QUERY_LIMIT"
1001 );
1002
1003 let _ = std::fs::remove_file(path);
1004 }
1005
1006 #[test]
1011 fn test_query_agent_subject_filter() {
1012 let path = unique_db_path("rq-agent-subject");
1013 let mut store = SqliteReceiptStore::open(&path).unwrap();
1014
1015 use chio_core::capability::{
1016 CapabilityToken, CapabilityTokenBody, ChioScope, Operation, ToolGrant,
1017 };
1018 use chio_core::crypto::Keypair;
1019
1020 let kp_agent1 = Keypair::generate();
1021 let kp_agent2 = Keypair::generate();
1022 let kp_issuer = Keypair::generate();
1023
1024 let make_token_local = |id: &str, subject_kp: &Keypair, issuer_kp: &Keypair| {
1025 let body = CapabilityTokenBody {
1026 id: id.to_string(),
1027 issuer: issuer_kp.public_key(),
1028 subject: subject_kp.public_key(),
1029 scope: ChioScope {
1030 grants: vec![ToolGrant {
1031 server_id: "shell".to_string(),
1032 tool_name: "bash".to_string(),
1033 operations: vec![Operation::Invoke],
1034 constraints: vec![],
1035 max_invocations: None,
1036 max_cost_per_invocation: None,
1037 max_total_cost: None,
1038 dpop_required: None,
1039 }],
1040 resource_grants: vec![],
1041 prompt_grants: vec![],
1042 },
1043 issued_at: 1000,
1044 expires_at: 9000,
1045 delegation_chain: vec![],
1046 };
1047 CapabilityToken::sign(body, issuer_kp).expect("sign failed")
1048 };
1049
1050 let token1 = make_token_local("cap-agent1", &kp_agent1, &kp_issuer);
1051 let token2 = make_token_local("cap-agent2", &kp_agent2, &kp_issuer);
1052
1053 store.record_capability_snapshot(&token1, None).unwrap();
1054 store.record_capability_snapshot(&token2, None).unwrap();
1055
1056 store
1058 .append_chio_receipt(&make_receipt(
1059 "r-1",
1060 "cap-agent1",
1061 "s",
1062 "t",
1063 Decision::Allow,
1064 100,
1065 None,
1066 ))
1067 .unwrap();
1068 store
1069 .append_chio_receipt(&make_receipt(
1070 "r-2",
1071 "cap-agent1",
1072 "s",
1073 "t",
1074 Decision::Allow,
1075 101,
1076 None,
1077 ))
1078 .unwrap();
1079 store
1080 .append_chio_receipt(&make_receipt(
1081 "r-3",
1082 "cap-agent2",
1083 "s",
1084 "t",
1085 Decision::Allow,
1086 102,
1087 None,
1088 ))
1089 .unwrap();
1090
1091 let agent1_key = kp_agent1.public_key().to_hex();
1092 let result = store
1093 .query_receipts(&ReceiptQuery {
1094 agent_subject: Some(agent1_key),
1095 limit: 10,
1096 ..Default::default()
1097 })
1098 .unwrap();
1099
1100 assert_eq!(
1101 result.receipts.len(),
1102 2,
1103 "only agent1 receipts should match"
1104 );
1105 assert_eq!(result.total_count, 2);
1106 for r in &result.receipts {
1107 assert_eq!(r.receipt.capability_id, "cap-agent1");
1108 }
1109
1110 let _ = std::fs::remove_file(path);
1111 }
1112
1113 #[test]
1114 fn test_query_agent_subject_none_returns_all() {
1115 let path = unique_db_path("rq-agent-none");
1116 let mut store = SqliteReceiptStore::open(&path).unwrap();
1117
1118 use chio_core::capability::{
1119 CapabilityToken, CapabilityTokenBody, ChioScope, Operation, ToolGrant,
1120 };
1121 use chio_core::crypto::Keypair;
1122
1123 let kp_agent1 = Keypair::generate();
1124 let kp_agent2 = Keypair::generate();
1125 let kp_issuer = Keypair::generate();
1126
1127 let make_token_local = |id: &str, subject_kp: &Keypair, issuer_kp: &Keypair| {
1128 let body = CapabilityTokenBody {
1129 id: id.to_string(),
1130 issuer: issuer_kp.public_key(),
1131 subject: subject_kp.public_key(),
1132 scope: ChioScope {
1133 grants: vec![ToolGrant {
1134 server_id: "shell".to_string(),
1135 tool_name: "bash".to_string(),
1136 operations: vec![Operation::Invoke],
1137 constraints: vec![],
1138 max_invocations: None,
1139 max_cost_per_invocation: None,
1140 max_total_cost: None,
1141 dpop_required: None,
1142 }],
1143 resource_grants: vec![],
1144 prompt_grants: vec![],
1145 },
1146 issued_at: 1000,
1147 expires_at: 9000,
1148 delegation_chain: vec![],
1149 };
1150 CapabilityToken::sign(body, issuer_kp).expect("sign failed")
1151 };
1152
1153 let token1 = make_token_local("cap-none-1", &kp_agent1, &kp_issuer);
1154 let token2 = make_token_local("cap-none-2", &kp_agent2, &kp_issuer);
1155
1156 store.record_capability_snapshot(&token1, None).unwrap();
1157 store.record_capability_snapshot(&token2, None).unwrap();
1158
1159 store
1160 .append_chio_receipt(&make_receipt(
1161 "r-1",
1162 "cap-none-1",
1163 "s",
1164 "t",
1165 Decision::Allow,
1166 100,
1167 None,
1168 ))
1169 .unwrap();
1170 store
1171 .append_chio_receipt(&make_receipt(
1172 "r-2",
1173 "cap-none-2",
1174 "s",
1175 "t",
1176 Decision::Allow,
1177 101,
1178 None,
1179 ))
1180 .unwrap();
1181 store
1182 .append_chio_receipt(&make_receipt(
1183 "r-3",
1184 "cap-none-1",
1185 "s",
1186 "t",
1187 Decision::Allow,
1188 102,
1189 None,
1190 ))
1191 .unwrap();
1192
1193 let result = store
1195 .query_receipts(&ReceiptQuery {
1196 agent_subject: None,
1197 limit: 10,
1198 ..Default::default()
1199 })
1200 .unwrap();
1201
1202 assert_eq!(
1203 result.receipts.len(),
1204 3,
1205 "no agent_subject filter should return all receipts"
1206 );
1207 assert_eq!(result.total_count, 3);
1208
1209 let _ = std::fs::remove_file(path);
1210 }
1211
1212 #[test]
1213 fn test_query_agent_subject_no_match() {
1214 let path = unique_db_path("rq-agent-no-match");
1215 let mut store = SqliteReceiptStore::open(&path).unwrap();
1216
1217 store
1218 .append_chio_receipt(&make_receipt(
1219 "r-1",
1220 "cap-1",
1221 "s",
1222 "t",
1223 Decision::Allow,
1224 100,
1225 None,
1226 ))
1227 .unwrap();
1228
1229 let result = store
1231 .query_receipts(&ReceiptQuery {
1232 agent_subject: Some("deadbeef".to_string()),
1233 limit: 10,
1234 ..Default::default()
1235 })
1236 .unwrap();
1237
1238 assert_eq!(result.receipts.len(), 0, "no match should return empty");
1239 assert_eq!(result.total_count, 0);
1240
1241 let _ = std::fs::remove_file(path);
1242 }
1243
1244 #[test]
1245 fn test_query_agent_subject_with_outcome_filter() {
1246 let path = unique_db_path("rq-agent-outcome");
1247 let mut store = SqliteReceiptStore::open(&path).unwrap();
1248
1249 use chio_core::capability::{
1250 CapabilityToken, CapabilityTokenBody, ChioScope, Operation, ToolGrant,
1251 };
1252 use chio_core::crypto::Keypair;
1253
1254 let kp_agent = Keypair::generate();
1255 let kp_issuer = Keypair::generate();
1256
1257 let body = CapabilityTokenBody {
1258 id: "cap-outcome-1".to_string(),
1259 issuer: kp_issuer.public_key(),
1260 subject: kp_agent.public_key(),
1261 scope: ChioScope {
1262 grants: vec![ToolGrant {
1263 server_id: "shell".to_string(),
1264 tool_name: "bash".to_string(),
1265 operations: vec![Operation::Invoke],
1266 constraints: vec![],
1267 max_invocations: None,
1268 max_cost_per_invocation: None,
1269 max_total_cost: None,
1270 dpop_required: None,
1271 }],
1272 resource_grants: vec![],
1273 prompt_grants: vec![],
1274 },
1275 issued_at: 1000,
1276 expires_at: 9000,
1277 delegation_chain: vec![],
1278 };
1279 let token = CapabilityToken::sign(body, &kp_issuer).expect("sign failed");
1280 store.record_capability_snapshot(&token, None).unwrap();
1281
1282 store
1284 .append_chio_receipt(&make_receipt(
1285 "r-1",
1286 "cap-outcome-1",
1287 "s",
1288 "t",
1289 Decision::Allow,
1290 100,
1291 None,
1292 ))
1293 .unwrap();
1294 store
1295 .append_chio_receipt(&make_receipt(
1296 "r-2",
1297 "cap-outcome-1",
1298 "s",
1299 "t",
1300 Decision::Deny {
1301 reason: "no".to_string(),
1302 guard: "G".to_string(),
1303 },
1304 101,
1305 None,
1306 ))
1307 .unwrap();
1308
1309 let agent_key = kp_agent.public_key().to_hex();
1310 let result = store
1311 .query_receipts(&ReceiptQuery {
1312 agent_subject: Some(agent_key),
1313 outcome: Some("allow".to_string()),
1314 limit: 10,
1315 ..Default::default()
1316 })
1317 .unwrap();
1318
1319 assert_eq!(
1321 result.receipts.len(),
1322 1,
1323 "intersection of agent and outcome=allow should be 1"
1324 );
1325 assert_eq!(result.total_count, 1);
1326 assert!(result.receipts[0].receipt.is_allowed());
1327
1328 let _ = std::fs::remove_file(path);
1329 }
1330
1331 #[test]
1332 fn test_query_agent_subject_cursor_pagination() {
1333 let path = unique_db_path("rq-agent-cursor");
1334 let mut store = SqliteReceiptStore::open(&path).unwrap();
1335
1336 use chio_core::capability::{
1337 CapabilityToken, CapabilityTokenBody, ChioScope, Operation, ToolGrant,
1338 };
1339 use chio_core::crypto::Keypair;
1340
1341 let kp_agent = Keypair::generate();
1342 let kp_issuer = Keypair::generate();
1343
1344 let body = CapabilityTokenBody {
1345 id: "cap-page-1".to_string(),
1346 issuer: kp_issuer.public_key(),
1347 subject: kp_agent.public_key(),
1348 scope: ChioScope {
1349 grants: vec![ToolGrant {
1350 server_id: "shell".to_string(),
1351 tool_name: "bash".to_string(),
1352 operations: vec![Operation::Invoke],
1353 constraints: vec![],
1354 max_invocations: None,
1355 max_cost_per_invocation: None,
1356 max_total_cost: None,
1357 dpop_required: None,
1358 }],
1359 resource_grants: vec![],
1360 prompt_grants: vec![],
1361 },
1362 issued_at: 1000,
1363 expires_at: 9000,
1364 delegation_chain: vec![],
1365 };
1366 let token = CapabilityToken::sign(body, &kp_issuer).expect("sign failed");
1367 store.record_capability_snapshot(&token, None).unwrap();
1368
1369 for i in 0..7usize {
1371 store
1372 .append_chio_receipt(&make_receipt(
1373 &format!("r-page-{i}"),
1374 "cap-page-1",
1375 "s",
1376 "t",
1377 Decision::Allow,
1378 100 + i as u64,
1379 None,
1380 ))
1381 .unwrap();
1382 }
1383
1384 let agent_key = kp_agent.public_key().to_hex();
1385
1386 let mut all_seqs = Vec::new();
1388 let mut cursor = None;
1389
1390 loop {
1391 let page = store
1392 .query_receipts(&ReceiptQuery {
1393 agent_subject: Some(agent_key.clone()),
1394 cursor,
1395 limit: 3,
1396 ..Default::default()
1397 })
1398 .unwrap();
1399
1400 for r in &page.receipts {
1401 all_seqs.push(r.seq);
1402 }
1403
1404 cursor = page.next_cursor;
1405 if cursor.is_none() {
1406 break;
1407 }
1408 }
1409
1410 assert_eq!(
1411 all_seqs.len(),
1412 7,
1413 "all 7 receipts should be seen across pages with agent filter"
1414 );
1415
1416 let mut unique = all_seqs.clone();
1418 unique.dedup();
1419 assert_eq!(all_seqs, unique, "no duplicate seqs");
1420
1421 let _ = std::fs::remove_file(path);
1422 }
1423
1424 #[test]
1425 fn test_query_cursor_u64_max_returns_empty() {
1426 let path = unique_db_path("rq-cursor-u64max");
1429 let mut store = SqliteReceiptStore::open(&path).unwrap();
1430
1431 for i in 0..5usize {
1433 let r = make_receipt(
1434 &format!("r-max-{i}"),
1435 "cap-1",
1436 "s",
1437 "t",
1438 Decision::Allow,
1439 100 + i as u64,
1440 None,
1441 );
1442 store.append_chio_receipt(&r).unwrap();
1443 }
1444
1445 let result = store
1446 .query_receipts(&ReceiptQuery {
1447 cursor: Some(u64::MAX),
1448 limit: 10,
1449 ..Default::default()
1450 })
1451 .unwrap();
1452
1453 assert_eq!(
1454 result.receipts.len(),
1455 0,
1456 "cursor=u64::MAX should return no receipts (no seq can exceed u64::MAX)"
1457 );
1458 assert!(
1459 result.next_cursor.is_none(),
1460 "next_cursor should be None when result is empty"
1461 );
1462
1463 let _ = std::fs::remove_file(path);
1464 }
1465
1466 #[test]
1467 fn test_query_combined_filters() {
1468 let path = unique_db_path("rq-combined");
1469 let mut store = SqliteReceiptStore::open(&path).unwrap();
1470
1471 store
1473 .append_chio_receipt(&make_receipt(
1474 "r-1",
1475 "cap-A",
1476 "s",
1477 "t",
1478 Decision::Allow,
1479 200,
1480 None,
1481 ))
1482 .unwrap();
1483 store
1485 .append_chio_receipt(&make_receipt(
1486 "r-2",
1487 "cap-A",
1488 "s",
1489 "t",
1490 Decision::Deny {
1491 reason: "no".to_string(),
1492 guard: "G".to_string(),
1493 },
1494 300,
1495 None,
1496 ))
1497 .unwrap();
1498 store
1500 .append_chio_receipt(&make_receipt(
1501 "r-3",
1502 "cap-B",
1503 "s",
1504 "t",
1505 Decision::Allow,
1506 200,
1507 None,
1508 ))
1509 .unwrap();
1510 store
1512 .append_chio_receipt(&make_receipt(
1513 "r-4",
1514 "cap-A",
1515 "s",
1516 "t",
1517 Decision::Allow,
1518 100,
1519 None,
1520 ))
1521 .unwrap();
1522 store
1524 .append_chio_receipt(&make_receipt(
1525 "r-5",
1526 "cap-A",
1527 "s",
1528 "t",
1529 Decision::Allow,
1530 250,
1531 None,
1532 ))
1533 .unwrap();
1534
1535 let result = store
1536 .query_receipts(&ReceiptQuery {
1537 capability_id: Some("cap-A".to_string()),
1538 outcome: Some("allow".to_string()),
1539 since: Some(150),
1540 limit: 10,
1541 ..Default::default()
1542 })
1543 .unwrap();
1544
1545 assert_eq!(result.receipts.len(), 2);
1547 assert_eq!(result.total_count, 2);
1548 for r in &result.receipts {
1549 assert_eq!(r.receipt.capability_id, "cap-A");
1550 assert!(r.receipt.is_allowed());
1551 assert!(r.receipt.timestamp >= 150);
1552 }
1553
1554 let _ = std::fs::remove_file(path);
1555 }
1556}