1use std::collections::BTreeMap;
15use std::path::Path;
16
17use crate::error::JoyError;
18use crate::identity::Identity;
19use crate::model::item::{Capability, Status};
20use crate::model::project::{is_ai_member, Member, MemberCapabilities, Project};
21use crate::store;
22
23#[derive(Debug, Clone)]
25pub enum Action {
26 CreateItem,
27 UpdateItem,
28 DeleteItem,
29 ChangeStatus {
30 from: Status,
31 to: Status,
32 },
33 AssignItem,
34 AddComment,
35 ManageProject,
36 ManageMilestone,
37 CreateRelease,
38 StartJob {
39 capability: Capability,
40 estimated_cost: Option<f64>,
41 },
42}
43
44impl Action {
45 pub fn required_capability(&self) -> Capability {
48 match self {
49 Action::CreateItem => Capability::Create,
50 Action::UpdateItem => Capability::Create,
51 Action::DeleteItem => Capability::Delete,
52 Action::AssignItem => Capability::Assign,
53 Action::AddComment => Capability::Create,
54 Action::ManageProject => Capability::Manage,
55 Action::ManageMilestone => Capability::Manage,
56 Action::CreateRelease => Capability::Manage,
57 Action::ChangeStatus { to, .. } => match to {
58 Status::InProgress => Capability::Implement,
59 Status::Review => Capability::Review,
60 Status::Closed => Capability::Review,
61 Status::Deferred => Capability::Plan,
62 Status::Open => Capability::Plan,
63 Status::New => Capability::Create,
64 },
65 Action::StartJob { capability, .. } => *capability,
66 }
67 }
68}
69
70#[derive(Debug, Clone, PartialEq)]
72pub enum Verdict {
73 Allow,
74 Deny(String),
75 Warn(String),
76}
77
78impl Verdict {
79 pub fn is_allowed(&self) -> bool {
80 matches!(self, Verdict::Allow | Verdict::Warn(_))
81 }
82
83 pub fn is_denied(&self) -> bool {
84 matches!(self, Verdict::Deny(_))
85 }
86
87 pub fn enforce(self, root: &Path, target: &str, identity: &Identity) -> Result<(), JoyError> {
91 match self {
92 Verdict::Allow => Ok(()),
93 Verdict::Warn(reason) => {
94 crate::event_log::log_event_as(
95 root,
96 crate::event_log::EventType::GuardWarned,
97 target,
98 Some(&reason),
99 &identity.log_user(),
100 );
101 eprintln!("Warning: {reason}");
102 Ok(())
103 }
104 Verdict::Deny(reason) => {
105 crate::event_log::log_event_as(
106 root,
107 crate::event_log::EventType::GuardDenied,
108 target,
109 Some(&reason),
110 &identity.log_user(),
111 );
112 Err(JoyError::GuardDenied(reason))
113 }
114 }
115 }
116}
117
118pub fn enforce(root: &Path, action: &Action, target: &str) -> Result<(), JoyError> {
120 let identity = crate::identity::resolve_identity(root).unwrap_or(Identity {
121 member: "unknown".into(),
122 delegated_by: None,
123 authenticated: false,
124 });
125 Guard::load(root)?
126 .check(action, &identity)
127 .enforce(root, target, &identity)
128}
129
130#[derive(Debug, Clone)]
132pub struct GateConfig {
133 pub allow_ai: bool,
135}
136
137pub struct Guard {
139 members: BTreeMap<String, Member>,
140 gates: BTreeMap<String, GateConfig>,
141}
142
143impl Guard {
144 pub fn new(project: &Project) -> Self {
146 Self {
147 members: project.members.clone(),
148 gates: BTreeMap::new(),
149 }
150 }
151
152 pub fn with_gates(project: &Project, gates: BTreeMap<String, GateConfig>) -> Self {
154 Self {
155 members: project.members.clone(),
156 gates,
157 }
158 }
159
160 pub fn load(root: &Path) -> Result<Self, JoyError> {
162 let project_path = store::joy_dir(root).join(store::PROJECT_FILE);
163 let project: Project = store::read_yaml(&project_path)?;
164 let gates = load_gates(&project_path)?;
165 Ok(Self::with_gates(&project, gates))
166 }
167
168 pub fn gates(&self) -> &BTreeMap<String, GateConfig> {
170 &self.gates
171 }
172
173 pub fn check(&self, action: &Action, identity: &Identity) -> Verdict {
175 if self.members.is_empty() {
177 return Verdict::Allow;
178 }
179
180 let member = match self.members.get(&identity.member) {
182 Some(m) => m,
183 None => {
184 return Verdict::Deny(format!(
185 "{} is not a registered project member",
186 identity.member
187 ));
188 }
189 };
190
191 if is_ai_member(&identity.member) {
193 let required = action.required_capability();
194
195 if required == Capability::Manage {
197 return Verdict::Deny(format!(
198 "AI member {} cannot perform manage actions",
199 identity.member
200 ));
201 }
202
203 if let Action::ChangeStatus { from, to } = action {
205 let key = format!("{} -> {}", status_str(from), status_str(to));
206 if let Some(gate) = self.gates.get(&key) {
207 if !gate.allow_ai {
208 return Verdict::Deny(format!(
209 "AI member {} blocked by gate on {} (allow_ai: false)",
210 identity.member, key
211 ));
212 }
213 }
214 }
215 }
216
217 let has_ai = self.members.keys().any(|k| is_ai_member(k));
220 if has_ai && !identity.authenticated {
221 return Verdict::Deny(format!(
222 "{} must authenticate to perform this action. Run `joy auth`.",
223 identity.member
224 ));
225 }
226
227 if !has_ai && !identity.authenticated {
230 let auth_active = self.members.values().any(|m| m.public_key.is_some());
231 if auth_active {
232 let required = action.required_capability();
233 if required == Capability::Manage {
234 return Verdict::Deny(format!(
235 "{} must authenticate to perform manage actions. Run `joy auth`.",
236 identity.member
237 ));
238 }
239 }
240 }
241
242 if member.capabilities == MemberCapabilities::All {
244 return Verdict::Allow;
245 }
246
247 let required = action.required_capability();
248
249 if member.has_capability(&required) {
251 if let Action::StartJob {
253 capability,
254 estimated_cost: Some(cost),
255 } = action
256 {
257 if let MemberCapabilities::Specific(ref map) = member.capabilities {
258 if let Some(config) = map.get(capability) {
259 if let Some(max_cost) = config.max_cost_per_job {
260 if *cost > max_cost {
261 return Verdict::Deny(format!(
262 "{} estimated cost {:.2} exceeds max_cost_per_job {:.2} for '{}'",
263 identity.member, cost, max_cost, capability
264 ));
265 }
266 }
267 }
268 }
269 }
270 Verdict::Allow
271 } else if required.is_management() {
272 Verdict::Deny(format!(
274 "{} does not have '{}' capability",
275 identity.member, required
276 ))
277 } else {
278 Verdict::Warn(format!(
279 "{} does not have '{}' capability. \
280 This action may be rejected by Joy Judge.",
281 identity.member, required
282 ))
283 }
284 }
285
286 pub fn is_last_manager(&self, member_id: &str) -> bool {
288 let manager_count = self
289 .members
290 .iter()
291 .filter(|(id, m)| m.has_capability(&Capability::Manage) && !is_ai_member(id))
292 .count();
293 let is_manager = self
294 .members
295 .get(member_id)
296 .map(|m| m.has_capability(&Capability::Manage))
297 .unwrap_or(false);
298 is_manager && manager_count <= 1
299 }
300}
301
302pub fn status_str(s: &Status) -> &'static str {
304 match s {
305 Status::New => "new",
306 Status::Open => "open",
307 Status::InProgress => "in-progress",
308 Status::Review => "review",
309 Status::Closed => "closed",
310 Status::Deferred => "deferred",
311 }
312}
313
314fn load_gates(project_path: &Path) -> Result<BTreeMap<String, GateConfig>, JoyError> {
316 let content = std::fs::read_to_string(project_path).map_err(|e| JoyError::ReadFile {
317 path: project_path.to_path_buf(),
318 source: e,
319 })?;
320 let doc: serde_json::Value = serde_yaml_ng::from_str(&content).map_err(JoyError::Yaml)?;
321
322 let mut gates = BTreeMap::new();
323
324 let Some(rules) = doc.get("status_rules") else {
325 return Ok(gates);
326 };
327 let Some(rules_obj) = rules.as_object() else {
328 return Ok(gates);
329 };
330
331 for (key, value) in rules_obj {
332 let allow_ai = value
333 .get("allow_ai")
334 .and_then(|v| v.as_bool())
335 .unwrap_or(true);
336 gates.insert(key.clone(), GateConfig { allow_ai });
337 }
338
339 Ok(gates)
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345
346 fn setup_log_dir(dir: &Path) {
347 std::fs::create_dir_all(dir.join(".joy").join("logs")).unwrap();
348 }
349
350 fn identity(member: &str) -> Identity {
351 Identity {
352 member: member.into(),
353 delegated_by: None,
354 authenticated: true,
355 }
356 }
357
358 fn unauthenticated_identity(member: &str) -> Identity {
359 Identity {
360 member: member.into(),
361 delegated_by: None,
362 authenticated: false,
363 }
364 }
365
366 fn ai_identity(member: &str, delegated_by: &str) -> Identity {
367 Identity {
368 member: member.into(),
369 delegated_by: Some(delegated_by.into()),
370 authenticated: true,
371 }
372 }
373
374 fn project_with_members(members: Vec<(&str, MemberCapabilities)>) -> Project {
375 let mut project = Project::new("Test".into(), Some("TST".into()));
376 for (name, caps) in members {
377 project.members.insert(name.into(), Member::new(caps));
378 }
379 project
380 }
381
382 fn specific_caps(caps: &[Capability]) -> MemberCapabilities {
383 let map: BTreeMap<Capability, _> = caps
384 .iter()
385 .map(|c| (c.clone(), Default::default()))
386 .collect();
387 MemberCapabilities::Specific(map)
388 }
389
390 #[test]
391 fn no_members_allows_all() {
392 let project = Project::new("Test".into(), Some("TST".into()));
393 let guard = Guard::new(&project);
394 let id = identity("anyone@example.com");
395
396 assert_eq!(guard.check(&Action::CreateItem, &id), Verdict::Allow);
397 assert_eq!(guard.check(&Action::ManageProject, &id), Verdict::Allow);
398 assert_eq!(guard.check(&Action::DeleteItem, &id), Verdict::Allow);
399 }
400
401 #[test]
402 fn member_with_all_caps() {
403 let project = project_with_members(vec![("dev@example.com", MemberCapabilities::All)]);
404 let guard = Guard::new(&project);
405 let id = identity("dev@example.com");
406
407 assert_eq!(guard.check(&Action::CreateItem, &id), Verdict::Allow);
408 assert_eq!(guard.check(&Action::ManageProject, &id), Verdict::Allow);
409 assert_eq!(guard.check(&Action::DeleteItem, &id), Verdict::Allow);
410 assert_eq!(
411 guard.check(
412 &Action::ChangeStatus {
413 from: Status::New,
414 to: Status::InProgress
415 },
416 &id
417 ),
418 Verdict::Allow
419 );
420 }
421
422 #[test]
423 fn member_with_specific_caps() {
424 let project = project_with_members(vec![(
425 "dev@example.com",
426 specific_caps(&[Capability::Implement, Capability::Create]),
427 )]);
428 let guard = Guard::new(&project);
429 let id = identity("dev@example.com");
430
431 assert_eq!(guard.check(&Action::CreateItem, &id), Verdict::Allow);
433
434 assert_eq!(
436 guard.check(
437 &Action::ChangeStatus {
438 from: Status::Open,
439 to: Status::InProgress
440 },
441 &id
442 ),
443 Verdict::Allow
444 );
445
446 assert!(matches!(
448 guard.check(&Action::DeleteItem, &id),
449 Verdict::Deny(_)
450 ));
451
452 assert!(matches!(
454 guard.check(&Action::ManageProject, &id),
455 Verdict::Deny(_)
456 ));
457
458 assert!(matches!(
460 guard.check(
461 &Action::ChangeStatus {
462 from: Status::Review,
463 to: Status::Closed
464 },
465 &id
466 ),
467 Verdict::Warn(_)
468 ));
469 }
470
471 #[test]
472 fn ai_member_blocked_from_manage() {
473 let project = project_with_members(vec![
474 ("dev@example.com", MemberCapabilities::All),
475 (
476 "ai:claude@joy",
477 specific_caps(&[
478 Capability::Implement,
479 Capability::Review,
480 Capability::Create,
481 ]),
482 ),
483 ]);
484 let guard = Guard::new(&project);
485 let id = ai_identity("ai:claude@joy", "dev@example.com");
486
487 assert_eq!(guard.check(&Action::CreateItem, &id), Verdict::Allow);
489
490 assert_eq!(
492 guard.check(
493 &Action::ChangeStatus {
494 from: Status::Open,
495 to: Status::InProgress
496 },
497 &id
498 ),
499 Verdict::Allow
500 );
501
502 assert!(matches!(
504 guard.check(&Action::ManageProject, &id),
505 Verdict::Deny(_)
506 ));
507 assert!(matches!(
508 guard.check(&Action::ManageMilestone, &id),
509 Verdict::Deny(_)
510 ));
511 assert!(matches!(
512 guard.check(&Action::CreateRelease, &id),
513 Verdict::Deny(_)
514 ));
515 }
516
517 #[test]
518 fn gate_blocks_ai_on_configured_transition() {
519 let project = project_with_members(vec![
520 ("dev@example.com", MemberCapabilities::All),
521 (
522 "ai:claude@joy",
523 specific_caps(&[
524 Capability::Implement,
525 Capability::Review,
526 Capability::Create,
527 ]),
528 ),
529 ]);
530 let mut gates = BTreeMap::new();
531 gates.insert(
532 "review -> closed".to_string(),
533 GateConfig { allow_ai: false },
534 );
535 let guard = Guard::with_gates(&project, gates);
536 let ai = ai_identity("ai:claude@joy", "dev@example.com");
537 let human = identity("dev@example.com");
538
539 assert!(matches!(
541 guard.check(
542 &Action::ChangeStatus {
543 from: Status::Review,
544 to: Status::Closed
545 },
546 &ai
547 ),
548 Verdict::Deny(_)
549 ));
550
551 assert_eq!(
553 guard.check(
554 &Action::ChangeStatus {
555 from: Status::Review,
556 to: Status::Closed
557 },
558 &human
559 ),
560 Verdict::Allow
561 );
562
563 assert_eq!(
565 guard.check(
566 &Action::ChangeStatus {
567 from: Status::InProgress,
568 to: Status::Review
569 },
570 &ai
571 ),
572 Verdict::Allow
573 );
574 }
575
576 #[test]
577 fn no_gates_allows_all_transitions() {
578 let project = project_with_members(vec![(
579 "ai:claude@joy",
580 specific_caps(&[Capability::Review, Capability::Create]),
581 )]);
582 let guard = Guard::new(&project); let ai = ai_identity("ai:claude@joy", "dev@example.com");
584
585 assert_eq!(
587 guard.check(
588 &Action::ChangeStatus {
589 from: Status::Review,
590 to: Status::Closed
591 },
592 &ai
593 ),
594 Verdict::Allow
595 );
596 }
597
598 #[test]
599 fn ai_member_can_close_without_gate() {
600 let project = project_with_members(vec![
601 ("dev@example.com", MemberCapabilities::All),
602 (
603 "ai:claude@joy",
604 specific_caps(&[
605 Capability::Implement,
606 Capability::Review,
607 Capability::Create,
608 ]),
609 ),
610 ]);
611 let guard = Guard::new(&project);
612 let ai = ai_identity("ai:claude@joy", "dev@example.com");
613 let human = identity("dev@example.com");
614
615 assert_eq!(
617 guard.check(
618 &Action::ChangeStatus {
619 from: Status::Review,
620 to: Status::Closed
621 },
622 &ai
623 ),
624 Verdict::Allow
625 );
626
627 assert_eq!(
629 guard.check(
630 &Action::ChangeStatus {
631 from: Status::InProgress,
632 to: Status::Review
633 },
634 &ai
635 ),
636 Verdict::Allow
637 );
638
639 assert_eq!(
641 guard.check(
642 &Action::ChangeStatus {
643 from: Status::Review,
644 to: Status::Closed
645 },
646 &human
647 ),
648 Verdict::Allow
649 );
650 }
651
652 #[test]
653 fn unknown_member_denied() {
654 let project = project_with_members(vec![("dev@example.com", MemberCapabilities::All)]);
655 let guard = Guard::new(&project);
656 let id = identity("stranger@example.com");
657
658 assert!(matches!(
659 guard.check(&Action::CreateItem, &id),
660 Verdict::Deny(_)
661 ));
662 }
663
664 #[test]
665 fn status_transitions_require_correct_cap() {
666 let project = project_with_members(vec![(
667 "dev@example.com",
668 specific_caps(&[Capability::Implement, Capability::Create]),
669 )]);
670 let guard = Guard::new(&project);
671 let id = identity("dev@example.com");
672
673 let check_transition = |to: Status| -> Verdict {
674 guard.check(
675 &Action::ChangeStatus {
676 from: Status::New,
677 to,
678 },
679 &id,
680 )
681 };
682
683 assert_eq!(check_transition(Status::InProgress), Verdict::Allow);
685 assert_eq!(check_transition(Status::New), Verdict::Allow);
687 assert!(matches!(check_transition(Status::Review), Verdict::Warn(_)));
689 assert!(matches!(check_transition(Status::Closed), Verdict::Warn(_)));
691 assert!(matches!(check_transition(Status::Open), Verdict::Warn(_)));
693 assert!(matches!(
695 check_transition(Status::Deferred),
696 Verdict::Warn(_)
697 ));
698 }
699
700 #[test]
703 fn team_workflow_gate_enforcement() {
704 let project = project_with_members(vec![
705 ("lead@example.com", MemberCapabilities::All),
707 (
709 "dev@example.com",
710 specific_caps(&[Capability::Implement, Capability::Test, Capability::Create]),
711 ),
712 (
714 "ai:claude@joy",
715 specific_caps(&[
716 Capability::Implement,
717 Capability::Review,
718 Capability::Create,
719 ]),
720 ),
721 ]);
722 let guard = Guard::new(&project);
723
724 let lead = identity("lead@example.com");
725 let dev = identity("dev@example.com");
726 let ai = ai_identity("ai:claude@joy", "lead@example.com");
727
728 assert_eq!(guard.check(&Action::CreateItem, &lead), Verdict::Allow);
731 assert_eq!(guard.check(&Action::CreateItem, &dev), Verdict::Allow);
732 assert_eq!(guard.check(&Action::CreateItem, &ai), Verdict::Allow);
733
734 let start = Action::ChangeStatus {
736 from: Status::Open,
737 to: Status::InProgress,
738 };
739 assert_eq!(guard.check(&start, &lead), Verdict::Allow);
740 assert_eq!(guard.check(&start, &dev), Verdict::Allow);
741 assert_eq!(guard.check(&start, &ai), Verdict::Allow);
742
743 let submit = Action::ChangeStatus {
745 from: Status::InProgress,
746 to: Status::Review,
747 };
748 assert_eq!(guard.check(&submit, &lead), Verdict::Allow);
749 assert!(matches!(guard.check(&submit, &dev), Verdict::Warn(_)));
751 assert_eq!(guard.check(&submit, &ai), Verdict::Allow);
753
754 let close = Action::ChangeStatus {
756 from: Status::Review,
757 to: Status::Closed,
758 };
759 assert_eq!(guard.check(&close, &lead), Verdict::Allow);
761 assert!(matches!(guard.check(&close, &dev), Verdict::Warn(_)));
763 assert_eq!(guard.check(&close, &ai), Verdict::Allow);
765
766 assert_eq!(guard.check(&Action::ManageProject, &lead), Verdict::Allow);
769 assert!(matches!(
771 guard.check(&Action::ManageProject, &dev),
772 Verdict::Deny(_)
773 ));
774 assert!(matches!(
776 guard.check(&Action::ManageProject, &ai),
777 Verdict::Deny(_)
778 ));
779 }
780
781 #[test]
782 fn required_capability_mapping_is_complete() {
783 assert_eq!(Action::CreateItem.required_capability(), Capability::Create);
785 assert_eq!(Action::UpdateItem.required_capability(), Capability::Create);
786 assert_eq!(Action::DeleteItem.required_capability(), Capability::Delete);
787 assert_eq!(Action::AssignItem.required_capability(), Capability::Assign);
788 assert_eq!(Action::AddComment.required_capability(), Capability::Create);
789 assert_eq!(
790 Action::ManageProject.required_capability(),
791 Capability::Manage
792 );
793 assert_eq!(
794 Action::ManageMilestone.required_capability(),
795 Capability::Manage
796 );
797 assert_eq!(
798 Action::CreateRelease.required_capability(),
799 Capability::Manage
800 );
801
802 let cs = |to: Status| Action::ChangeStatus {
804 from: Status::New,
805 to,
806 };
807 assert_eq!(
808 cs(Status::InProgress).required_capability(),
809 Capability::Implement
810 );
811 assert_eq!(cs(Status::Review).required_capability(), Capability::Review);
812 assert_eq!(cs(Status::Closed).required_capability(), Capability::Review);
813 assert_eq!(cs(Status::Deferred).required_capability(), Capability::Plan);
814 assert_eq!(cs(Status::Open).required_capability(), Capability::Plan);
815 assert_eq!(cs(Status::New).required_capability(), Capability::Create);
816
817 assert_eq!(
819 Action::StartJob {
820 capability: Capability::Implement,
821 estimated_cost: None
822 }
823 .required_capability(),
824 Capability::Implement
825 );
826 }
827
828 #[test]
829 fn verdict_enforce_allow() {
830 let dir = tempfile::tempdir().unwrap();
831 setup_log_dir(dir.path());
832 let id = identity("dev@example.com");
833 assert!(Verdict::Allow.enforce(dir.path(), "TST-0001", &id).is_ok());
834 }
835
836 #[test]
837 fn verdict_enforce_deny_logs_event() {
838 let dir = tempfile::tempdir().unwrap();
839 setup_log_dir(dir.path());
840 let id = identity("dev@example.com");
841 let result = Verdict::Deny("blocked".into()).enforce(dir.path(), "TST-0001", &id);
842 assert!(result.is_err());
843 let events = crate::event_log::read_events(dir.path(), None, None, 100).unwrap();
845 assert_eq!(events.len(), 1);
846 assert_eq!(events[0].event_type, "guard.denied");
847 assert_eq!(events[0].target, "TST-0001");
848 assert_eq!(events[0].details.as_deref(), Some("blocked"));
849 }
850
851 #[test]
852 fn verdict_enforce_warn_logs_event() {
853 let dir = tempfile::tempdir().unwrap();
854 setup_log_dir(dir.path());
855 let id = identity("dev@example.com");
856 let result = Verdict::Warn("caution".into()).enforce(dir.path(), "TST-0001", &id);
857 assert!(result.is_ok());
858 let events = crate::event_log::read_events(dir.path(), None, None, 100).unwrap();
860 assert_eq!(events.len(), 1);
861 assert_eq!(events[0].event_type, "guard.warned");
862 assert_eq!(events[0].target, "TST-0001");
863 assert_eq!(events[0].details.as_deref(), Some("caution"));
864 }
865
866 #[test]
867 fn budget_precheck_allows_within_limit() {
868 let mut caps = BTreeMap::new();
869 caps.insert(
870 Capability::Implement,
871 crate::model::project::CapabilityConfig {
872 max_mode: None,
873 max_cost_per_job: Some(5.0),
874 },
875 );
876 let project =
877 project_with_members(vec![("ai:claude@joy", MemberCapabilities::Specific(caps))]);
878 let guard = Guard::new(&project);
879 let ai = ai_identity("ai:claude@joy", "dev@example.com");
880
881 assert_eq!(
883 guard.check(
884 &Action::StartJob {
885 capability: Capability::Implement,
886 estimated_cost: Some(3.0),
887 },
888 &ai
889 ),
890 Verdict::Allow
891 );
892
893 assert_eq!(
895 guard.check(
896 &Action::StartJob {
897 capability: Capability::Implement,
898 estimated_cost: Some(5.0),
899 },
900 &ai
901 ),
902 Verdict::Allow
903 );
904 }
905
906 #[test]
907 fn budget_precheck_denies_over_limit() {
908 let mut caps = BTreeMap::new();
909 caps.insert(
910 Capability::Implement,
911 crate::model::project::CapabilityConfig {
912 max_mode: None,
913 max_cost_per_job: Some(5.0),
914 },
915 );
916 let project =
917 project_with_members(vec![("ai:claude@joy", MemberCapabilities::Specific(caps))]);
918 let guard = Guard::new(&project);
919 let ai = ai_identity("ai:claude@joy", "dev@example.com");
920
921 let verdict = guard.check(
923 &Action::StartJob {
924 capability: Capability::Implement,
925 estimated_cost: Some(7.50),
926 },
927 &ai,
928 );
929 assert!(matches!(verdict, Verdict::Deny(_)));
930 if let Verdict::Deny(reason) = verdict {
931 assert!(reason.contains("7.50"));
932 assert!(reason.contains("5.00"));
933 }
934 }
935
936 #[test]
937 fn budget_precheck_allows_without_cost_limit() {
938 let project = project_with_members(vec![(
939 "ai:claude@joy",
940 specific_caps(&[Capability::Implement]),
941 )]);
942 let guard = Guard::new(&project);
943 let ai = ai_identity("ai:claude@joy", "dev@example.com");
944
945 assert_eq!(
947 guard.check(
948 &Action::StartJob {
949 capability: Capability::Implement,
950 estimated_cost: Some(999.0),
951 },
952 &ai
953 ),
954 Verdict::Allow
955 );
956 }
957
958 #[test]
959 fn budget_precheck_allows_without_estimate() {
960 let mut caps = BTreeMap::new();
961 caps.insert(
962 Capability::Implement,
963 crate::model::project::CapabilityConfig {
964 max_mode: None,
965 max_cost_per_job: Some(5.0),
966 },
967 );
968 let project =
969 project_with_members(vec![("ai:claude@joy", MemberCapabilities::Specific(caps))]);
970 let guard = Guard::new(&project);
971 let ai = ai_identity("ai:claude@joy", "dev@example.com");
972
973 assert_eq!(
975 guard.check(
976 &Action::StartJob {
977 capability: Capability::Implement,
978 estimated_cost: None,
979 },
980 &ai
981 ),
982 Verdict::Allow
983 );
984 }
985
986 #[test]
987 fn is_last_manager_solo() {
988 let project = project_with_members(vec![("lead@example.com", MemberCapabilities::All)]);
989 let guard = Guard::new(&project);
990 assert!(guard.is_last_manager("lead@example.com"));
991 }
992
993 #[test]
994 fn is_last_manager_with_backup() {
995 let project = project_with_members(vec![
996 ("lead@example.com", MemberCapabilities::All),
997 ("backup@example.com", MemberCapabilities::All),
998 ]);
999 let guard = Guard::new(&project);
1000 assert!(!guard.is_last_manager("lead@example.com"));
1001 assert!(!guard.is_last_manager("backup@example.com"));
1002 }
1003
1004 #[test]
1005 fn is_last_manager_ai_not_counted() {
1006 let project = project_with_members(vec![
1007 ("lead@example.com", MemberCapabilities::All),
1008 ("ai:claude@joy", MemberCapabilities::All),
1009 ]);
1010 let guard = Guard::new(&project);
1011 assert!(guard.is_last_manager("lead@example.com"));
1013 }
1014
1015 #[test]
1016 fn is_last_manager_non_manager_member() {
1017 let project = project_with_members(vec![
1018 ("lead@example.com", MemberCapabilities::All),
1019 (
1020 "dev@example.com",
1021 specific_caps(&[Capability::Implement, Capability::Create]),
1022 ),
1023 ]);
1024 let guard = Guard::new(&project);
1025 assert!(guard.is_last_manager("lead@example.com"));
1027 assert!(!guard.is_last_manager("dev@example.com"));
1029 }
1030
1031 #[test]
1032 fn unauthenticated_denied_when_ai_members_exist() {
1033 let project = project_with_members(vec![
1034 ("dev@example.com", MemberCapabilities::All),
1035 (
1036 "ai:claude@joy",
1037 specific_caps(&[Capability::Implement, Capability::Create]),
1038 ),
1039 ]);
1040 let guard = Guard::new(&project);
1041 let unauth = unauthenticated_identity("dev@example.com");
1042
1043 assert!(matches!(
1045 guard.check(&Action::CreateItem, &unauth),
1046 Verdict::Deny(_)
1047 ));
1048 assert!(matches!(
1049 guard.check(&Action::AddComment, &unauth),
1050 Verdict::Deny(_)
1051 ));
1052 assert!(matches!(
1053 guard.check(
1054 &Action::ChangeStatus {
1055 from: Status::Open,
1056 to: Status::InProgress
1057 },
1058 &unauth
1059 ),
1060 Verdict::Deny(_)
1061 ));
1062
1063 let auth = identity("dev@example.com");
1065 assert_eq!(guard.check(&Action::CreateItem, &auth), Verdict::Allow);
1066 }
1067
1068 #[test]
1069 fn unauthenticated_allowed_without_ai_members() {
1070 let project = project_with_members(vec![("dev@example.com", MemberCapabilities::All)]);
1071 let guard = Guard::new(&project);
1072 let unauth = unauthenticated_identity("dev@example.com");
1073
1074 assert_eq!(guard.check(&Action::CreateItem, &unauth), Verdict::Allow);
1076 assert_eq!(guard.check(&Action::AddComment, &unauth), Verdict::Allow);
1077 }
1078
1079 #[test]
1080 fn verdict_helpers() {
1081 assert!(Verdict::Allow.is_allowed());
1082 assert!(!Verdict::Allow.is_denied());
1083
1084 assert!(Verdict::Warn("w".into()).is_allowed());
1085 assert!(!Verdict::Warn("w".into()).is_denied());
1086
1087 assert!(!Verdict::Deny("d".into()).is_allowed());
1088 assert!(Verdict::Deny("d".into()).is_denied());
1089 }
1090}