1use serde_json::Value;
29use sysknife_types::{PreviewEnvelope, RequestEnvelope, RiskLevel};
30
31#[derive(Clone, Debug, PartialEq, Eq)]
32struct PreviewProfile {
33 expected_side_effects: Vec<String>,
34 reboot_required: bool,
35 rollback_available: bool,
36 warnings: Vec<String>,
37}
38
39pub fn preview_action(
40 request: &RequestEnvelope,
41 current_state: Value,
42 proposed_change: Value,
43) -> PreviewEnvelope {
44 let profile = preview_profile(&request.action_name);
45 let risk_level = crate::actions::spec_meta(&request.action_name)
50 .map(|m| m.risk_level)
51 .unwrap_or_else(|| fallback_risk(&request.action_name));
52
53 PreviewEnvelope {
54 summary: preview_summary(&request.action_name, &risk_level),
55 risk_level,
56 current_state,
57 proposed_change,
58 expected_side_effects: profile.expected_side_effects,
59 reboot_required: profile.reboot_required,
60 rollback_available: profile.rollback_available,
61 warnings: profile.warnings,
62 request_hash: request.request_hash.clone(),
63 }
64}
65
66fn fallback_risk(action_name: &str) -> RiskLevel {
71 match action_name {
72 "ListJobHistory" => RiskLevel::Low,
73 _ => RiskLevel::High,
74 }
75}
76
77fn preview_profile(action_name: &str) -> PreviewProfile {
78 match action_name {
79 "GetSystemState"
80 | "CollectDiagnostics"
81 | "GetDeploymentHistory"
82 | "ListDeployments"
83 | "GetKernelArguments"
84 | "GetPendingUpdates"
85 | "SearchFlatpakApps"
86 | "ListFlatpakRemotes"
87 | "ListInstalledFlatpaks"
88 | "GetFlatpakAppInfo"
89 | "ListToolboxes"
90 | "ListServices"
91 | "GetServiceLogs"
92 | "GetServiceStatus"
93 | "GetServiceResourceLimits"
94 | "ListTimers"
95 | "GetFirewallState"
96 | "ListUsers"
97 | "ListGroups"
98 | "ListPackageRepositories"
99 | "ListContainers"
100 | "GetContainerInfo"
101 | "GetLayeredPackages"
102 | "GetDiskUsage"
103 | "ListProcesses"
104 | "GetMemoryInfo"
105 | "GetNetworkStatus"
106 | "GetListeningPorts"
107 | "GetJournalLog"
108 | "GetLvmReport"
109 | "GetSysctl"
110 | "GetMounts"
111 | "GetSudoGrants"
112 | "GetLogrotateStatus"
113 | "GetPasswordAging"
114 | "GetAuditRules"
115 | "GetCertificates"
116 | "GetAuthorizedKeys"
117 | "GetDateTime"
118 | "ListJobHistory"
119 | "AptSearch"
121 | "AptListInstalled"
122 | "AptShow"
123 | "GetAptPins"
124 | "SnapList"
126 | "SnapInfo"
127 | "UfwStatus"
129 | "DistroboxList"
131 | "NetplanGetConfig"
133 | "ProStatus"
135 | "LivepatchStatus"
136 | "MultipassList" => PreviewProfile {
137 expected_side_effects: Vec::new(),
138 reboot_required: false,
139 rollback_available: false,
140 warnings: Vec::new(),
141 },
142 "AptInstall" | "AptRemove" | "AptPurge" | "AptHold" | "AptUnhold" => PreviewProfile {
148 expected_side_effects: vec![
149 "package state will change".to_string(),
150 "services may be restarted by needrestart".to_string(),
151 ],
152 reboot_required: false,
153 rollback_available: false,
154 warnings: vec!["approval required".to_string()],
155 },
156
157 "SnapInstall" | "SnapRemove" | "SnapRefresh" | "SnapHold" | "SnapUnhold" => {
159 PreviewProfile {
160 expected_side_effects: vec!["snap state will change".to_string()],
161 reboot_required: false,
162 rollback_available: false,
163 warnings: vec![
164 "approval required".to_string(),
165 "snap auto-refresh: install is paired with --hold by default".to_string(),
166 ],
167 }
168 }
169
170 "DistroboxCreate" | "DistroboxRemove" => PreviewProfile {
172 expected_side_effects: vec!["container lifecycle will change".to_string()],
173 reboot_required: false,
174 rollback_available: false,
175 warnings: vec!["approval required".to_string()],
176 },
177
178 "AptUpgrade" => PreviewProfile {
183 expected_side_effects: vec![
184 "all packages will be upgraded".to_string(),
185 "packages may be removed to resolve dependency conflicts".to_string(),
186 "services may be restarted by needrestart".to_string(),
187 ],
188 reboot_required: false,
189 rollback_available: false,
190 warnings: vec![
191 "dist-upgrade may remove packages".to_string(),
192 "exact approval required".to_string(),
193 ],
194 },
195
196 "UfwEnable" | "UfwDisable" | "UfwAllow" | "UfwDeny" | "UfwReset" => PreviewProfile {
198 expected_side_effects: vec![
199 "firewall rules will change".to_string(),
200 "network access may be immediately affected".to_string(),
201 ],
202 reboot_required: false,
203 rollback_available: false,
204 warnings: vec![
205 "misconfigured rules can lock out SSH access".to_string(),
206 "exact approval required".to_string(),
207 ],
208 },
209
210 "NetplanApply" => PreviewProfile {
212 expected_side_effects: vec![
213 "network interfaces will be reconfigured immediately".to_string(),
214 "SSH session may be disconnected".to_string(),
215 ],
216 reboot_required: false,
217 rollback_available: false,
218 warnings: vec![
219 "can disconnect the current SSH session if config is wrong".to_string(),
220 "exact approval required".to_string(),
221 ],
222 },
223
224 "NetplanGenerate" => PreviewProfile {
226 expected_side_effects: vec![
227 "backend network config files will be regenerated".to_string(),
228 ],
229 reboot_required: false,
230 rollback_available: false,
231 warnings: vec!["approval required".to_string()],
232 },
233
234 "NetplanSet" => PreviewProfile {
236 expected_side_effects: vec![
237 "netplan configuration will be modified".to_string(),
238 "network may be affected when NetplanApply is run".to_string(),
239 ],
240 reboot_required: false,
241 rollback_available: true,
242 warnings: vec![
243 "run NetplanApply to activate the change".to_string(),
244 "exact approval required".to_string(),
245 ],
246 },
247
248 "UfwDeleteRule" | "UfwLimit" => PreviewProfile {
250 expected_side_effects: vec![
251 "firewall rules will change".to_string(),
252 "network access may be immediately affected".to_string(),
253 ],
254 reboot_required: false,
255 rollback_available: false,
256 warnings: vec![
257 "misconfigured rules can lock out SSH access".to_string(),
258 "exact approval required".to_string(),
259 ],
260 },
261
262 "ProAttach" => PreviewProfile {
264 expected_side_effects: vec![
265 "Ubuntu Pro subscription will be attached".to_string(),
266 "Pro services (ESM, Livepatch, FIPS) may be enabled".to_string(),
267 ],
268 reboot_required: false,
269 rollback_available: true,
270 warnings: vec![
271 "exact approval required".to_string(),
272 "token is redacted from the preview, audit log, and diagnostic output".to_string(),
273 ],
274 },
275
276 "ProDetach" => PreviewProfile {
278 expected_side_effects: vec![
279 "Ubuntu Pro subscription will be released".to_string(),
280 "ESM, Livepatch, and FIPS services will be disabled".to_string(),
281 ],
282 reboot_required: false,
283 rollback_available: true,
284 warnings: vec![
285 "exact approval required".to_string(),
286 "after detach, this machine no longer receives Pro security patches".to_string(),
287 ],
288 },
289
290 "AddAuditRule" | "RemoveAuditRule" => PreviewProfile {
292 expected_side_effects: vec![
293 "a persistent audit rule will be written/removed and reloaded".to_string(),
294 ],
295 reboot_required: false,
296 rollback_available: false,
297 warnings: vec![
298 "requires the auditd package installed to take effect".to_string(),
299 "exact approval required".to_string(),
300 ],
301 },
302
303 "ObtainCertificate" => PreviewProfile {
305 expected_side_effects: vec![
306 "certbot will obtain a TLS certificate (ACME challenge)".to_string(),
307 "TLS material will be written under /etc/letsencrypt".to_string(),
308 ],
309 reboot_required: false,
310 rollback_available: false,
311 warnings: vec![
312 "contacts a public ACME CA over the network".to_string(),
313 "requires certbot installed + a reachable DNS/HTTP challenge".to_string(),
314 "exact approval required".to_string(),
315 ],
316 },
317 "RenewCertificates" => PreviewProfile {
318 expected_side_effects: vec!["certbot will renew due certificates".to_string()],
319 reboot_required: false,
320 rollback_available: false,
321 warnings: vec![
322 "contacts a public ACME CA over the network".to_string(),
323 "exact approval required".to_string(),
324 ],
325 },
326
327 "ConfigureFail2banJail" => PreviewProfile {
329 expected_side_effects: vec![
330 "a fail2ban jail override will be written and the daemon reloaded".to_string(),
331 ],
332 reboot_required: false,
333 rollback_available: false,
334 warnings: vec![
335 "changes who gets banned (too strict can lock out real users)".to_string(),
336 "requires the fail2ban package installed to take effect".to_string(),
337 "exact approval required".to_string(),
338 ],
339 },
340
341 "EnableProService" | "DisableProService" => PreviewProfile {
343 expected_side_effects: vec![
344 "a single Ubuntu Pro service will be enabled/disabled".to_string(),
345 ],
346 reboot_required: false,
347 rollback_available: false,
348 warnings: vec![
349 "exact approval required".to_string(),
350 "requires an attached Pro subscription + network to take effect".to_string(),
351 ],
352 },
353
354 "UbuntuReleaseUpgrade" => PreviewProfile {
356 expected_side_effects: vec![
357 "entire OS will be upgraded to the next Ubuntu release".to_string(),
358 "takes 20–45 minutes; system will be rebooted to complete".to_string(),
359 "third-party PPAs may be disabled or break during upgrade".to_string(),
360 ],
361 reboot_required: true,
362 rollback_available: false,
363 warnings: vec![
364 "reboot required to complete the upgrade".to_string(),
365 "long-running operation — configure timeout >= 3600 seconds".to_string(),
366 "exact approval required".to_string(),
367 ],
368 },
369
370 "ReloadService" => PreviewProfile {
371 expected_side_effects: vec!["service config will be reloaded".to_string()],
372 reboot_required: false,
373 rollback_available: false,
374 warnings: vec![
375 "approval required".to_string(),
376 "requires ExecReload= to be defined in the unit file; \
377 if not defined, use RestartService instead"
378 .to_string(),
379 ],
380 },
381 "RestartService"
382 | "ReloadDaemon"
383 | "SetServiceEnabled"
384 | "StartService"
385 | "StopService"
386 | "ConfigureWifi"
387 | "SetDnsServers"
388 | "ConfigureFirewall"
389 | "CreateToolbox"
390 | "RemoveToolbox"
391 | "InstallFlatpak"
392 | "RemoveFlatpak"
393 | "UpdateFlatpak"
394 | "AddFlatpakRemote"
395 | "RemoveFlatpakRemote"
396 | "MaskService"
397 | "UnmaskService"
398 | "SetHostname"
399 | "SetTimezone"
400 | "SetLocale"
401 | "SetNtp"
402 | "CreateUser" => PreviewProfile {
403 expected_side_effects: vec!["service interruption".to_string()],
404 reboot_required: false,
405 rollback_available: false,
406 warnings: vec!["approval required".to_string()],
407 },
408 "VacuumJournal" => PreviewProfile {
410 expected_side_effects: vec![
411 "old journal entries will be permanently deleted".to_string(),
412 "disk space will be reclaimed".to_string(),
413 ],
414 reboot_required: false,
415 rollback_available: false,
416 warnings: vec!["deleted log history cannot be recovered".to_string()],
417 },
418
419 "ExtendLogicalVolume" => PreviewProfile {
421 expected_side_effects: vec![
422 "the logical volume and its filesystem will be grown".to_string(),
423 ],
424 reboot_required: false,
425 rollback_available: false,
426 warnings: vec![
427 "resizes a live filesystem; a wrong volume target risks data".to_string(),
428 "exact approval required".to_string(),
429 ],
430 },
431 "CreateLogicalVolume" | "CreateLvSnapshot" => PreviewProfile {
432 expected_side_effects: vec![
433 "volume-group free space will be consumed".to_string(),
434 ],
435 reboot_required: false,
436 rollback_available: false,
437 warnings: vec![
438 "consumes VG capacity; snapshots fill as the origin changes".to_string(),
439 "exact approval required".to_string(),
440 ],
441 },
442
443 "SetServiceResourceLimits" => PreviewProfile {
445 expected_side_effects: vec![
446 "the service's memory/CPU/task limits will change immediately".to_string(),
447 "a persistent drop-in will be written (undo: systemctl revert)".to_string(),
448 ],
449 reboot_required: false,
450 rollback_available: false,
451 warnings: vec![
452 "too tight a MemoryMax can OOM-kill the service".to_string(),
453 "exact approval required".to_string(),
454 ],
455 },
456
457 "ConfigureLogRotation" | "RemoveLogRotation" => PreviewProfile {
459 expected_side_effects: vec![
460 "a logrotate drop-in will be written/removed".to_string(),
461 ],
462 reboot_required: false,
463 rollback_available: false,
464 warnings: vec!["approval required".to_string()],
465 },
466 "ConfigureRemoteSyslog" | "RemoveRemoteSyslog" => PreviewProfile {
467 expected_side_effects: vec![
468 "rsyslog will start/stop forwarding all logs to a remote host".to_string(),
469 "rsyslog will be restarted".to_string(),
470 ],
471 reboot_required: false,
472 rollback_available: false,
473 warnings: vec![
474 "forwarding sends log data off-host (exfiltration surface)".to_string(),
475 "exact approval required".to_string(),
476 ],
477 },
478
479 "SetPasswordAging" => PreviewProfile {
481 expected_side_effects: vec![
482 "the target account's password-aging limits will change".to_string(),
483 ],
484 reboot_required: false,
485 rollback_available: false,
486 warnings: vec!["exact approval required".to_string()],
487 },
488 "SetPasswordPolicy" | "SetAccountLockout" => PreviewProfile {
489 expected_side_effects: vec![
490 "a system-wide PAM policy file will be written".to_string(),
491 ],
492 reboot_required: false,
493 rollback_available: false,
494 warnings: vec![
495 "affects password/lockout rules for all accounts".to_string(),
496 "takes effect only if the PAM module is enabled in the auth stack".to_string(),
497 "exact approval required".to_string(),
498 ],
499 },
500
501 "SetAptPin" | "RemoveAptPin" => PreviewProfile {
503 expected_side_effects: vec![
504 "apt version/origin preferences will change".to_string(),
505 "a /etc/apt/preferences.d drop-in will be written/removed".to_string(),
506 ],
507 reboot_required: false,
508 rollback_available: false,
509 warnings: vec!["approval required".to_string()],
510 },
511
512 "GrantSudoAccess" | "RevokeSudoAccess" => PreviewProfile {
514 expected_side_effects: vec![
515 "a sudoers.d drop-in will be created/removed".to_string(),
516 "the target user's sudo privileges will change".to_string(),
517 ],
518 reboot_required: false,
519 rollback_available: false,
520 warnings: vec![
521 "this configures privilege escalation — review the rule carefully".to_string(),
522 "exact approval required".to_string(),
523 ],
524 },
525
526 "AddMount" | "RemoveMount" => PreviewProfile {
528 expected_side_effects: vec![
529 "a filesystem will be (un)mounted".to_string(),
530 "/etc/fstab will be updated (managed entries carry nofail)".to_string(),
531 ],
532 reboot_required: false,
533 rollback_available: false,
534 warnings: vec![
535 "a wrong device or mountpoint risks data or availability".to_string(),
536 "exact approval required".to_string(),
537 ],
538 },
539 "AddSwap" | "RemoveSwap" => PreviewProfile {
540 expected_side_effects: vec![
541 "swap will be enabled/disabled".to_string(),
542 "a swap file will be created/removed and /etc/fstab updated".to_string(),
543 ],
544 reboot_required: false,
545 rollback_available: false,
546 warnings: vec!["exact approval required".to_string()],
547 },
548
549 "SetSysctl" => PreviewProfile {
551 expected_side_effects: vec![
552 "a kernel parameter will change immediately".to_string(),
553 "the value will persist across reboots (/etc/sysctl.d)".to_string(),
554 ],
555 reboot_required: false,
556 rollback_available: false,
557 warnings: vec![
558 "a wrong net.*/vm.*/kernel.* value can degrade or lock the host".to_string(),
559 "exact approval required".to_string(),
560 ],
561 },
562
563 "SignalProcess" => PreviewProfile {
564 expected_side_effects: vec!["the target process will be terminated".to_string()],
565 reboot_required: false,
566 rollback_available: false,
567 warnings: vec![
568 "the process and its in-flight work will stop".to_string(),
569 "exact approval required".to_string(),
570 ],
571 },
572 "ConfigureUnattendedUpgrades" => PreviewProfile {
573 expected_side_effects: vec![
574 "the automatic security-update policy will change".to_string()
575 ],
576 reboot_required: false,
577 rollback_available: false,
578 warnings: vec!["approval required".to_string()],
579 },
580 "CreateScheduledJob" => PreviewProfile {
581 expected_side_effects: vec![
582 "a systemd timer will run the command on a recurring schedule".to_string(),
583 ],
584 reboot_required: false,
585 rollback_available: false,
586 warnings: vec![
587 "persistent root-scheduled execution".to_string(),
588 "exact approval required".to_string(),
589 ],
590 },
591 "AddUserToGroup"
592 | "RemoveUserFromGroup"
593 | "CreateGroup"
594 | "DeleteGroup"
595 | "LockUserAccount"
596 | "UnlockUserAccount"
597 | "SetSshdOption"
598 | "DeleteUser"
599 | "AddAuthorizedKey"
600 | "RemoveAuthorizedKey" => PreviewProfile {
601 expected_side_effects: vec!["access control will change".to_string()],
604 reboot_required: false,
605 rollback_available: false,
606 warnings: vec![
607 "privilege change".to_string(),
608 "exact approval required".to_string(),
609 ],
610 },
611 "AddPackageRepository"
612 | "RemovePackageRepository"
613 | "EnablePackageRepository"
614 | "DisablePackageRepository" => PreviewProfile {
615 expected_side_effects: vec!["package repository configuration will change".to_string()],
616 reboot_required: false,
617 rollback_available: false,
618 warnings: vec!["approval required".to_string()],
619 },
620 "CreateContainer" | "StartContainer" | "StopContainer" | "RemoveContainer" => {
621 PreviewProfile {
622 expected_side_effects: vec!["container lifecycle will change".to_string()],
623 reboot_required: false,
624 rollback_available: false,
625 warnings: vec!["approval required".to_string()],
626 }
627 }
628 "UpdateSystem"
629 | "InstallPackages"
630 | "RemovePackages"
631 | "RebaseSystem"
632 | "RollbackDeployment"
633 | "AddLayeredPackage"
634 | "RemoveLayeredPackage"
635 | "ReplaceLayeredPackage"
636 | "ResetLayeredPackageOverride"
637 | "RemoveBasePackage" => PreviewProfile {
638 expected_side_effects: vec![
639 "system deployment will change".to_string(),
640 "reboot may be required".to_string(),
641 ],
642 reboot_required: true,
643 rollback_available: true,
644 warnings: vec![
645 "reboot required".to_string(),
646 "exact approval required".to_string(),
647 ],
648 },
649 "SetKernelArguments" => PreviewProfile {
650 expected_side_effects: vec!["boot arguments will change".to_string()],
651 reboot_required: true,
652 rollback_available: true,
653 warnings: vec![
654 "reboot required".to_string(),
655 "exact approval required".to_string(),
656 ],
657 },
658 "RebootSystem" => PreviewProfile {
659 expected_side_effects: vec!["system reboot will interrupt running work".to_string()],
660 reboot_required: true,
661 rollback_available: false,
662 warnings: vec![
663 "reboot required".to_string(),
664 "exact approval required".to_string(),
665 ],
666 },
667 "CleanupDeployments" => PreviewProfile {
668 expected_side_effects: vec!["old deployments may be removed".to_string()],
669 reboot_required: false,
670 rollback_available: false,
671 warnings: vec!["exact approval required".to_string()],
672 },
673 "PinDeployment" | "UnpinDeployment" => PreviewProfile {
674 expected_side_effects: vec!["deployment pin state will change".to_string()],
675 reboot_required: false,
676 rollback_available: false,
677 warnings: vec!["exact approval required".to_string()],
678 },
679 _ => PreviewProfile {
680 expected_side_effects: vec!["unclassified action".to_string()],
681 reboot_required: false,
682 rollback_available: false,
683 warnings: vec!["action profile not recognized".to_string()],
684 },
685 }
686}
687
688fn preview_summary(action_name: &str, risk_level: &RiskLevel) -> String {
689 let risk = match risk_level {
690 RiskLevel::Low => "low-risk",
691 RiskLevel::Medium => "medium-risk",
692 RiskLevel::High => "high-risk",
693 };
694
695 format!("{action_name} preview ({risk})")
696}
697
698#[cfg(test)]
699mod tests {
700 use super::*;
701 use sysknife_types::{CallerRole, RiskLevel};
702
703 fn req(action: &str) -> RequestEnvelope {
704 RequestEnvelope {
705 action_name: action.to_string(),
706 request_id: "test-req".to_string(),
707 params: serde_json::Value::Null,
708 caller_role: CallerRole::Dev,
709 request_hash: sysknife_types::RequestHash::new("hash".to_string()),
710 }
711 }
712
713 #[test]
714 fn all_low_risk_actions() {
715 let actions = [
716 "GetSystemState",
717 "CollectDiagnostics",
718 "GetDeploymentHistory",
719 "ListDeployments",
720 "GetKernelArguments",
721 "GetPendingUpdates",
722 "SearchFlatpakApps",
723 "ListFlatpakRemotes",
724 "ListInstalledFlatpaks",
725 "GetFlatpakAppInfo",
726 "ListToolboxes",
727 "ListServices",
728 "GetServiceLogs",
729 "GetServiceStatus",
730 "ListTimers",
731 "GetFirewallState",
732 "ListUsers",
733 "ListGroups",
734 "ListPackageRepositories",
735 "ListContainers",
736 "GetContainerInfo",
737 "GetLayeredPackages",
738 "GetDiskUsage",
739 "ListProcesses",
740 "GetMemoryInfo",
741 "GetNetworkStatus",
742 "GetAuthorizedKeys",
743 "GetDateTime",
744 "ListJobHistory",
745 ];
746
747 for action in &actions {
748 let envelope = preview_action(
749 &req(action),
750 serde_json::Value::Null,
751 serde_json::Value::Null,
752 );
753 assert_eq!(
754 envelope.risk_level,
755 RiskLevel::Low,
756 "{action} should be Low risk"
757 );
758 assert!(
759 !envelope.reboot_required,
760 "{action} should not require reboot"
761 );
762 assert!(
763 !envelope.rollback_available,
764 "{action} should not have rollback available"
765 );
766 }
767 }
768
769 #[test]
770 fn reload_service_is_medium_risk() {
771 let envelope = preview_action(
772 &req("ReloadService"),
773 serde_json::Value::Null,
774 serde_json::Value::Null,
775 );
776 assert_eq!(envelope.risk_level, RiskLevel::Medium);
777 assert_eq!(envelope.warnings.len(), 2);
778 assert!(!envelope.reboot_required);
779 assert!(!envelope.rollback_available);
780 }
781
782 #[test]
783 fn medium_risk_actions() {
784 let actions = [
785 "RestartService",
786 "StopService",
787 "StartService",
788 "SetHostname",
789 "SetTimezone",
790 "SetLocale",
791 "SetNtp",
792 "InstallFlatpak",
793 "RemoveFlatpak",
794 "CreateToolbox",
795 "RemoveToolbox",
796 "CreateContainer",
797 "StartContainer",
798 "StopContainer",
799 "RemoveContainer",
800 "RemovePackageRepository",
801 ];
802
803 for action in &actions {
804 let envelope = preview_action(
805 &req(action),
806 serde_json::Value::Null,
807 serde_json::Value::Null,
808 );
809 assert_eq!(
810 envelope.risk_level,
811 RiskLevel::Medium,
812 "{action} should be Medium risk"
813 );
814 }
815 }
816
817 #[test]
818 fn high_risk_access_control_actions() {
819 let actions = [
820 "AddUserToGroup",
821 "RemoveUserFromGroup",
822 "DeleteUser",
823 "AddAuthorizedKey",
824 "RemoveAuthorizedKey",
825 ];
826
827 for action in &actions {
828 let envelope = preview_action(
829 &req(action),
830 serde_json::Value::Null,
831 serde_json::Value::Null,
832 );
833 assert_eq!(
834 envelope.risk_level,
835 RiskLevel::High,
836 "{action} should be High risk"
837 );
838 assert!(
839 envelope
840 .expected_side_effects
841 .iter()
842 .any(|e| e.contains("access control will change")),
843 "{action} should have 'access control will change' in expected_side_effects"
844 );
845 }
846 }
847
848 #[test]
849 fn high_risk_system_reboot_actions() {
850 let actions = [
851 "UpdateSystem",
852 "InstallPackages",
853 "RemovePackages",
854 "RebaseSystem",
855 "RollbackDeployment",
856 "AddLayeredPackage",
857 "RemoveLayeredPackage",
858 "SetKernelArguments",
859 ];
860
861 for action in &actions {
862 let envelope = preview_action(
863 &req(action),
864 serde_json::Value::Null,
865 serde_json::Value::Null,
866 );
867 assert_eq!(
868 envelope.risk_level,
869 RiskLevel::High,
870 "{action} should be High risk"
871 );
872 assert!(envelope.reboot_required, "{action} should require reboot");
873 assert!(
874 envelope.rollback_available,
875 "{action} should have rollback available"
876 );
877 }
878 }
879
880 #[test]
881 fn reboot_system_no_rollback() {
882 let envelope = preview_action(
883 &req("RebootSystem"),
884 serde_json::Value::Null,
885 serde_json::Value::Null,
886 );
887 assert_eq!(envelope.risk_level, RiskLevel::High);
888 assert!(envelope.reboot_required);
889 assert!(!envelope.rollback_available);
890 }
891
892 #[test]
893 fn cleanup_deployments_is_high_no_reboot() {
894 let envelope = preview_action(
895 &req("CleanupDeployments"),
896 serde_json::Value::Null,
897 serde_json::Value::Null,
898 );
899 assert_eq!(envelope.risk_level, RiskLevel::High);
900 assert!(!envelope.reboot_required);
901 }
902
903 #[test]
904 fn pin_unpin_deployment_are_high() {
905 for action in &["PinDeployment", "UnpinDeployment"] {
906 let envelope = preview_action(
907 &req(action),
908 serde_json::Value::Null,
909 serde_json::Value::Null,
910 );
911 assert_eq!(
912 envelope.risk_level,
913 RiskLevel::High,
914 "{action} should be High risk"
915 );
916 }
917 }
918
919 #[test]
920 fn unknown_action_defaults_to_high() {
921 let envelope = preview_action(
922 &req("DefinitelyNotRealAction"),
923 serde_json::Value::Null,
924 serde_json::Value::Null,
925 );
926 assert_eq!(envelope.risk_level, RiskLevel::High);
927 assert!(
928 envelope
929 .warnings
930 .iter()
931 .any(|w| w.contains("action profile not recognized")),
932 "warnings should contain 'action profile not recognized'"
933 );
934 assert!(
935 envelope
936 .expected_side_effects
937 .iter()
938 .any(|e| e.contains("unclassified action")),
939 "expected_side_effects should contain 'unclassified action'"
940 );
941 }
942
943 #[test]
944 fn preview_action_summary_format() {
945 let envelope = preview_action(
946 &req("GetDiskUsage"),
947 serde_json::Value::Null,
948 serde_json::Value::Null,
949 );
950 assert_eq!(envelope.summary, "GetDiskUsage preview (low-risk)");
951 }
952
953 #[test]
954 fn preview_action_passes_current_and_proposed_state() {
955 let current = serde_json::json!({"disk": "80%"});
956 let proposed = serde_json::json!({"action": "cleanup"});
957 let envelope = preview_action(
958 &req("CleanupDeployments"),
959 current.clone(),
960 proposed.clone(),
961 );
962 assert_eq!(envelope.current_state, current);
963 assert_eq!(envelope.proposed_change, proposed);
964 }
965
966 #[test]
967 fn preview_action_preserves_request_hash() {
968 let mut r = req("GetDiskUsage");
969 r.request_hash = sysknife_types::RequestHash::new("deadbeef".to_string());
970 let envelope = preview_action(&r, serde_json::Value::Null, serde_json::Value::Null);
971 assert_eq!(envelope.request_hash.as_str(), "deadbeef");
972 }
973}