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 = store::read_project(&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.verify_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.iter().map(|c| (*c, Default::default())).collect();
384 MemberCapabilities::Specific(map)
385 }
386
387 #[test]
388 fn no_members_allows_all() {
389 let project = Project::new("Test".into(), Some("TST".into()));
390 let guard = Guard::new(&project);
391 let id = identity("anyone@example.com");
392
393 assert_eq!(guard.check(&Action::CreateItem, &id), Verdict::Allow);
394 assert_eq!(guard.check(&Action::ManageProject, &id), Verdict::Allow);
395 assert_eq!(guard.check(&Action::DeleteItem, &id), Verdict::Allow);
396 }
397
398 #[test]
399 fn member_with_all_caps() {
400 let project = project_with_members(vec![("dev@example.com", MemberCapabilities::All)]);
401 let guard = Guard::new(&project);
402 let id = identity("dev@example.com");
403
404 assert_eq!(guard.check(&Action::CreateItem, &id), Verdict::Allow);
405 assert_eq!(guard.check(&Action::ManageProject, &id), Verdict::Allow);
406 assert_eq!(guard.check(&Action::DeleteItem, &id), Verdict::Allow);
407 assert_eq!(
408 guard.check(
409 &Action::ChangeStatus {
410 from: Status::New,
411 to: Status::InProgress
412 },
413 &id
414 ),
415 Verdict::Allow
416 );
417 }
418
419 #[test]
420 fn member_with_specific_caps() {
421 let project = project_with_members(vec![(
422 "dev@example.com",
423 specific_caps(&[Capability::Implement, Capability::Create]),
424 )]);
425 let guard = Guard::new(&project);
426 let id = identity("dev@example.com");
427
428 assert_eq!(guard.check(&Action::CreateItem, &id), Verdict::Allow);
430
431 assert_eq!(
433 guard.check(
434 &Action::ChangeStatus {
435 from: Status::Open,
436 to: Status::InProgress
437 },
438 &id
439 ),
440 Verdict::Allow
441 );
442
443 assert!(matches!(
445 guard.check(&Action::DeleteItem, &id),
446 Verdict::Deny(_)
447 ));
448
449 assert!(matches!(
451 guard.check(&Action::ManageProject, &id),
452 Verdict::Deny(_)
453 ));
454
455 assert!(matches!(
457 guard.check(
458 &Action::ChangeStatus {
459 from: Status::Review,
460 to: Status::Closed
461 },
462 &id
463 ),
464 Verdict::Warn(_)
465 ));
466 }
467
468 #[test]
469 fn ai_member_blocked_from_manage() {
470 let project = project_with_members(vec![
471 ("dev@example.com", MemberCapabilities::All),
472 (
473 "ai:claude@joy",
474 specific_caps(&[
475 Capability::Implement,
476 Capability::Review,
477 Capability::Create,
478 ]),
479 ),
480 ]);
481 let guard = Guard::new(&project);
482 let id = ai_identity("ai:claude@joy", "dev@example.com");
483
484 assert_eq!(guard.check(&Action::CreateItem, &id), Verdict::Allow);
486
487 assert_eq!(
489 guard.check(
490 &Action::ChangeStatus {
491 from: Status::Open,
492 to: Status::InProgress
493 },
494 &id
495 ),
496 Verdict::Allow
497 );
498
499 assert!(matches!(
501 guard.check(&Action::ManageProject, &id),
502 Verdict::Deny(_)
503 ));
504 assert!(matches!(
505 guard.check(&Action::ManageMilestone, &id),
506 Verdict::Deny(_)
507 ));
508 assert!(matches!(
509 guard.check(&Action::CreateRelease, &id),
510 Verdict::Deny(_)
511 ));
512 }
513
514 #[test]
515 fn gate_blocks_ai_on_configured_transition() {
516 let project = project_with_members(vec![
517 ("dev@example.com", MemberCapabilities::All),
518 (
519 "ai:claude@joy",
520 specific_caps(&[
521 Capability::Implement,
522 Capability::Review,
523 Capability::Create,
524 ]),
525 ),
526 ]);
527 let mut gates = BTreeMap::new();
528 gates.insert(
529 "review -> closed".to_string(),
530 GateConfig { allow_ai: false },
531 );
532 let guard = Guard::with_gates(&project, gates);
533 let ai = ai_identity("ai:claude@joy", "dev@example.com");
534 let human = identity("dev@example.com");
535
536 assert!(matches!(
538 guard.check(
539 &Action::ChangeStatus {
540 from: Status::Review,
541 to: Status::Closed
542 },
543 &ai
544 ),
545 Verdict::Deny(_)
546 ));
547
548 assert_eq!(
550 guard.check(
551 &Action::ChangeStatus {
552 from: Status::Review,
553 to: Status::Closed
554 },
555 &human
556 ),
557 Verdict::Allow
558 );
559
560 assert_eq!(
562 guard.check(
563 &Action::ChangeStatus {
564 from: Status::InProgress,
565 to: Status::Review
566 },
567 &ai
568 ),
569 Verdict::Allow
570 );
571 }
572
573 #[test]
574 fn no_gates_allows_all_transitions() {
575 let project = project_with_members(vec![(
576 "ai:claude@joy",
577 specific_caps(&[Capability::Review, Capability::Create]),
578 )]);
579 let guard = Guard::new(&project); let ai = ai_identity("ai:claude@joy", "dev@example.com");
581
582 assert_eq!(
584 guard.check(
585 &Action::ChangeStatus {
586 from: Status::Review,
587 to: Status::Closed
588 },
589 &ai
590 ),
591 Verdict::Allow
592 );
593 }
594
595 #[test]
596 fn ai_member_can_close_without_gate() {
597 let project = project_with_members(vec![
598 ("dev@example.com", MemberCapabilities::All),
599 (
600 "ai:claude@joy",
601 specific_caps(&[
602 Capability::Implement,
603 Capability::Review,
604 Capability::Create,
605 ]),
606 ),
607 ]);
608 let guard = Guard::new(&project);
609 let ai = ai_identity("ai:claude@joy", "dev@example.com");
610 let human = identity("dev@example.com");
611
612 assert_eq!(
614 guard.check(
615 &Action::ChangeStatus {
616 from: Status::Review,
617 to: Status::Closed
618 },
619 &ai
620 ),
621 Verdict::Allow
622 );
623
624 assert_eq!(
626 guard.check(
627 &Action::ChangeStatus {
628 from: Status::InProgress,
629 to: Status::Review
630 },
631 &ai
632 ),
633 Verdict::Allow
634 );
635
636 assert_eq!(
638 guard.check(
639 &Action::ChangeStatus {
640 from: Status::Review,
641 to: Status::Closed
642 },
643 &human
644 ),
645 Verdict::Allow
646 );
647 }
648
649 #[test]
650 fn unknown_member_denied() {
651 let project = project_with_members(vec![("dev@example.com", MemberCapabilities::All)]);
652 let guard = Guard::new(&project);
653 let id = identity("stranger@example.com");
654
655 assert!(matches!(
656 guard.check(&Action::CreateItem, &id),
657 Verdict::Deny(_)
658 ));
659 }
660
661 #[test]
662 fn status_transitions_require_correct_cap() {
663 let project = project_with_members(vec![(
664 "dev@example.com",
665 specific_caps(&[Capability::Implement, Capability::Create]),
666 )]);
667 let guard = Guard::new(&project);
668 let id = identity("dev@example.com");
669
670 let check_transition = |to: Status| -> Verdict {
671 guard.check(
672 &Action::ChangeStatus {
673 from: Status::New,
674 to,
675 },
676 &id,
677 )
678 };
679
680 assert_eq!(check_transition(Status::InProgress), Verdict::Allow);
682 assert_eq!(check_transition(Status::New), Verdict::Allow);
684 assert!(matches!(check_transition(Status::Review), Verdict::Warn(_)));
686 assert!(matches!(check_transition(Status::Closed), Verdict::Warn(_)));
688 assert!(matches!(check_transition(Status::Open), Verdict::Warn(_)));
690 assert!(matches!(
692 check_transition(Status::Deferred),
693 Verdict::Warn(_)
694 ));
695 }
696
697 #[test]
700 fn team_workflow_gate_enforcement() {
701 let project = project_with_members(vec![
702 ("lead@example.com", MemberCapabilities::All),
704 (
706 "dev@example.com",
707 specific_caps(&[Capability::Implement, Capability::Test, Capability::Create]),
708 ),
709 (
711 "ai:claude@joy",
712 specific_caps(&[
713 Capability::Implement,
714 Capability::Review,
715 Capability::Create,
716 ]),
717 ),
718 ]);
719 let guard = Guard::new(&project);
720
721 let lead = identity("lead@example.com");
722 let dev = identity("dev@example.com");
723 let ai = ai_identity("ai:claude@joy", "lead@example.com");
724
725 assert_eq!(guard.check(&Action::CreateItem, &lead), Verdict::Allow);
728 assert_eq!(guard.check(&Action::CreateItem, &dev), Verdict::Allow);
729 assert_eq!(guard.check(&Action::CreateItem, &ai), Verdict::Allow);
730
731 let start = Action::ChangeStatus {
733 from: Status::Open,
734 to: Status::InProgress,
735 };
736 assert_eq!(guard.check(&start, &lead), Verdict::Allow);
737 assert_eq!(guard.check(&start, &dev), Verdict::Allow);
738 assert_eq!(guard.check(&start, &ai), Verdict::Allow);
739
740 let submit = Action::ChangeStatus {
742 from: Status::InProgress,
743 to: Status::Review,
744 };
745 assert_eq!(guard.check(&submit, &lead), Verdict::Allow);
746 assert!(matches!(guard.check(&submit, &dev), Verdict::Warn(_)));
748 assert_eq!(guard.check(&submit, &ai), Verdict::Allow);
750
751 let close = Action::ChangeStatus {
753 from: Status::Review,
754 to: Status::Closed,
755 };
756 assert_eq!(guard.check(&close, &lead), Verdict::Allow);
758 assert!(matches!(guard.check(&close, &dev), Verdict::Warn(_)));
760 assert_eq!(guard.check(&close, &ai), Verdict::Allow);
762
763 assert_eq!(guard.check(&Action::ManageProject, &lead), Verdict::Allow);
766 assert!(matches!(
768 guard.check(&Action::ManageProject, &dev),
769 Verdict::Deny(_)
770 ));
771 assert!(matches!(
773 guard.check(&Action::ManageProject, &ai),
774 Verdict::Deny(_)
775 ));
776 }
777
778 #[test]
779 fn required_capability_mapping_is_complete() {
780 assert_eq!(Action::CreateItem.required_capability(), Capability::Create);
782 assert_eq!(Action::UpdateItem.required_capability(), Capability::Create);
783 assert_eq!(Action::DeleteItem.required_capability(), Capability::Delete);
784 assert_eq!(Action::AssignItem.required_capability(), Capability::Assign);
785 assert_eq!(Action::AddComment.required_capability(), Capability::Create);
786 assert_eq!(
787 Action::ManageProject.required_capability(),
788 Capability::Manage
789 );
790 assert_eq!(
791 Action::ManageMilestone.required_capability(),
792 Capability::Manage
793 );
794 assert_eq!(
795 Action::CreateRelease.required_capability(),
796 Capability::Manage
797 );
798
799 let cs = |to: Status| Action::ChangeStatus {
801 from: Status::New,
802 to,
803 };
804 assert_eq!(
805 cs(Status::InProgress).required_capability(),
806 Capability::Implement
807 );
808 assert_eq!(cs(Status::Review).required_capability(), Capability::Review);
809 assert_eq!(cs(Status::Closed).required_capability(), Capability::Review);
810 assert_eq!(cs(Status::Deferred).required_capability(), Capability::Plan);
811 assert_eq!(cs(Status::Open).required_capability(), Capability::Plan);
812 assert_eq!(cs(Status::New).required_capability(), Capability::Create);
813
814 assert_eq!(
816 Action::StartJob {
817 capability: Capability::Implement,
818 estimated_cost: None
819 }
820 .required_capability(),
821 Capability::Implement
822 );
823 }
824
825 #[test]
826 fn verdict_enforce_allow() {
827 let dir = tempfile::tempdir().unwrap();
828 setup_log_dir(dir.path());
829 let id = identity("dev@example.com");
830 assert!(Verdict::Allow.enforce(dir.path(), "TST-0001", &id).is_ok());
831 }
832
833 #[test]
834 fn verdict_enforce_deny_logs_event() {
835 let dir = tempfile::tempdir().unwrap();
836 setup_log_dir(dir.path());
837 let id = identity("dev@example.com");
838 let result = Verdict::Deny("blocked".into()).enforce(dir.path(), "TST-0001", &id);
839 assert!(result.is_err());
840 let events = crate::event_log::read_events(dir.path(), None, None, 100).unwrap();
842 assert_eq!(events.len(), 1);
843 assert_eq!(events[0].event_type, "guard.denied");
844 assert_eq!(events[0].target, "TST-0001");
845 assert_eq!(events[0].details.as_deref(), Some("blocked"));
846 }
847
848 #[test]
849 fn verdict_enforce_warn_logs_event() {
850 let dir = tempfile::tempdir().unwrap();
851 setup_log_dir(dir.path());
852 let id = identity("dev@example.com");
853 let result = Verdict::Warn("caution".into()).enforce(dir.path(), "TST-0001", &id);
854 assert!(result.is_ok());
855 let events = crate::event_log::read_events(dir.path(), None, None, 100).unwrap();
857 assert_eq!(events.len(), 1);
858 assert_eq!(events[0].event_type, "guard.warned");
859 assert_eq!(events[0].target, "TST-0001");
860 assert_eq!(events[0].details.as_deref(), Some("caution"));
861 }
862
863 #[test]
864 fn budget_precheck_allows_within_limit() {
865 let mut caps = BTreeMap::new();
866 caps.insert(
867 Capability::Implement,
868 crate::model::project::CapabilityConfig {
869 max_mode: None,
870 max_cost_per_job: Some(5.0),
871 },
872 );
873 let project =
874 project_with_members(vec![("ai:claude@joy", MemberCapabilities::Specific(caps))]);
875 let guard = Guard::new(&project);
876 let ai = ai_identity("ai:claude@joy", "dev@example.com");
877
878 assert_eq!(
880 guard.check(
881 &Action::StartJob {
882 capability: Capability::Implement,
883 estimated_cost: Some(3.0),
884 },
885 &ai
886 ),
887 Verdict::Allow
888 );
889
890 assert_eq!(
892 guard.check(
893 &Action::StartJob {
894 capability: Capability::Implement,
895 estimated_cost: Some(5.0),
896 },
897 &ai
898 ),
899 Verdict::Allow
900 );
901 }
902
903 #[test]
904 fn budget_precheck_denies_over_limit() {
905 let mut caps = BTreeMap::new();
906 caps.insert(
907 Capability::Implement,
908 crate::model::project::CapabilityConfig {
909 max_mode: None,
910 max_cost_per_job: Some(5.0),
911 },
912 );
913 let project =
914 project_with_members(vec![("ai:claude@joy", MemberCapabilities::Specific(caps))]);
915 let guard = Guard::new(&project);
916 let ai = ai_identity("ai:claude@joy", "dev@example.com");
917
918 let verdict = guard.check(
920 &Action::StartJob {
921 capability: Capability::Implement,
922 estimated_cost: Some(7.50),
923 },
924 &ai,
925 );
926 assert!(matches!(verdict, Verdict::Deny(_)));
927 if let Verdict::Deny(reason) = verdict {
928 assert!(reason.contains("7.50"));
929 assert!(reason.contains("5.00"));
930 }
931 }
932
933 #[test]
934 fn budget_precheck_allows_without_cost_limit() {
935 let project = project_with_members(vec![(
936 "ai:claude@joy",
937 specific_caps(&[Capability::Implement]),
938 )]);
939 let guard = Guard::new(&project);
940 let ai = ai_identity("ai:claude@joy", "dev@example.com");
941
942 assert_eq!(
944 guard.check(
945 &Action::StartJob {
946 capability: Capability::Implement,
947 estimated_cost: Some(999.0),
948 },
949 &ai
950 ),
951 Verdict::Allow
952 );
953 }
954
955 #[test]
956 fn budget_precheck_allows_without_estimate() {
957 let mut caps = BTreeMap::new();
958 caps.insert(
959 Capability::Implement,
960 crate::model::project::CapabilityConfig {
961 max_mode: None,
962 max_cost_per_job: Some(5.0),
963 },
964 );
965 let project =
966 project_with_members(vec![("ai:claude@joy", MemberCapabilities::Specific(caps))]);
967 let guard = Guard::new(&project);
968 let ai = ai_identity("ai:claude@joy", "dev@example.com");
969
970 assert_eq!(
972 guard.check(
973 &Action::StartJob {
974 capability: Capability::Implement,
975 estimated_cost: None,
976 },
977 &ai
978 ),
979 Verdict::Allow
980 );
981 }
982
983 #[test]
984 fn is_last_manager_solo() {
985 let project = project_with_members(vec![("lead@example.com", MemberCapabilities::All)]);
986 let guard = Guard::new(&project);
987 assert!(guard.is_last_manager("lead@example.com"));
988 }
989
990 #[test]
991 fn is_last_manager_with_backup() {
992 let project = project_with_members(vec![
993 ("lead@example.com", MemberCapabilities::All),
994 ("backup@example.com", MemberCapabilities::All),
995 ]);
996 let guard = Guard::new(&project);
997 assert!(!guard.is_last_manager("lead@example.com"));
998 assert!(!guard.is_last_manager("backup@example.com"));
999 }
1000
1001 #[test]
1002 fn is_last_manager_ai_not_counted() {
1003 let project = project_with_members(vec![
1004 ("lead@example.com", MemberCapabilities::All),
1005 ("ai:claude@joy", MemberCapabilities::All),
1006 ]);
1007 let guard = Guard::new(&project);
1008 assert!(guard.is_last_manager("lead@example.com"));
1010 }
1011
1012 #[test]
1013 fn is_last_manager_non_manager_member() {
1014 let project = project_with_members(vec![
1015 ("lead@example.com", MemberCapabilities::All),
1016 (
1017 "dev@example.com",
1018 specific_caps(&[Capability::Implement, Capability::Create]),
1019 ),
1020 ]);
1021 let guard = Guard::new(&project);
1022 assert!(guard.is_last_manager("lead@example.com"));
1024 assert!(!guard.is_last_manager("dev@example.com"));
1026 }
1027
1028 #[test]
1029 fn unauthenticated_denied_when_ai_members_exist() {
1030 let project = project_with_members(vec![
1031 ("dev@example.com", MemberCapabilities::All),
1032 (
1033 "ai:claude@joy",
1034 specific_caps(&[Capability::Implement, Capability::Create]),
1035 ),
1036 ]);
1037 let guard = Guard::new(&project);
1038 let unauth = unauthenticated_identity("dev@example.com");
1039
1040 assert!(matches!(
1042 guard.check(&Action::CreateItem, &unauth),
1043 Verdict::Deny(_)
1044 ));
1045 assert!(matches!(
1046 guard.check(&Action::AddComment, &unauth),
1047 Verdict::Deny(_)
1048 ));
1049 assert!(matches!(
1050 guard.check(
1051 &Action::ChangeStatus {
1052 from: Status::Open,
1053 to: Status::InProgress
1054 },
1055 &unauth
1056 ),
1057 Verdict::Deny(_)
1058 ));
1059
1060 let auth = identity("dev@example.com");
1062 assert_eq!(guard.check(&Action::CreateItem, &auth), Verdict::Allow);
1063 }
1064
1065 #[test]
1066 fn unauthenticated_allowed_without_ai_members() {
1067 let project = project_with_members(vec![("dev@example.com", MemberCapabilities::All)]);
1068 let guard = Guard::new(&project);
1069 let unauth = unauthenticated_identity("dev@example.com");
1070
1071 assert_eq!(guard.check(&Action::CreateItem, &unauth), Verdict::Allow);
1073 assert_eq!(guard.check(&Action::AddComment, &unauth), Verdict::Allow);
1074 }
1075
1076 #[test]
1077 fn verdict_helpers() {
1078 assert!(Verdict::Allow.is_allowed());
1079 assert!(!Verdict::Allow.is_denied());
1080
1081 assert!(Verdict::Warn("w".into()).is_allowed());
1082 assert!(!Verdict::Warn("w".into()).is_denied());
1083
1084 assert!(!Verdict::Deny("d".into()).is_allowed());
1085 assert!(Verdict::Deny("d".into()).is_denied());
1086 }
1087}