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