//! Unit tests for the lifecycle module (moved verbatim from the old //! single-file `lifecycle.rs` `#[cfg(test)]` block). use super::*; /// Regression test: `setup_proposed` must seed both agent.nix and flake.nix /// in the initial commit. Before commit 5b5a93e flake.nix was missing from /// the scaffold, requiring manual creation (seen with the damocles agent). #[tokio::test] async fn setup_proposed_seeds_flake_nix() { let dir = tempfile::tempdir().expect("tempdir"); let proposed = dir.path().join("proposed"); setup_proposed(&proposed, "test-agent") .await .expect("setup_proposed"); // Both files must exist on disk. assert!(proposed.join("agent.nix").exists(), "agent.nix missing"); assert!(proposed.join("flake.nix").exists(), "flake.nix missing"); // flake.nix must export nixosModules.default (the meta-flake contract). let flake = std::fs::read_to_string(proposed.join("flake.nix")).unwrap(); assert!( flake.contains("nixosModules.default"), "flake.nix does not export nixosModules.default" ); // Both files must be tracked in the initial git commit. let out = git_command() .current_dir(&proposed) .args(["show", "--name-only", "--format=", "HEAD"]) .output() .await .expect("git show"); let tracked = String::from_utf8_lossy(&out.stdout); assert!(tracked.contains("agent.nix"), "agent.nix not committed"); assert!(tracked.contains("flake.nix"), "flake.nix not committed"); } #[test] fn agent_network_ip_is_in_subnet() { // Default subnet 10.42.0.0/24 — agents get .2 to .254. let ip = agent_network_ip("alice", "10.42.0.0/24").expect("should produce an IP"); let octets: Vec = ip.split('.').map(|o| o.parse().unwrap()).collect(); assert_eq!(&octets[..3], &[10, 42, 0], "wrong /24 prefix"); assert!( octets[3] >= 2 && octets[3] <= 254, "host byte {}", octets[3] ); } #[test] fn agent_network_ip_stable() { // Same name + subnet must always produce the same IP. let a = agent_network_ip("damocles", "10.42.0.0/24"); let b = agent_network_ip("damocles", "10.42.0.0/24"); assert_eq!(a, b); } #[test] fn agent_network_ip_different_agents() { // Different agent names very likely produce different IPs (not guaranteed, // but for these two names the hashes don't collide). let alice = agent_network_ip("alice", "10.42.0.0/24").unwrap(); let bob = agent_network_ip("bob", "10.42.0.0/24").unwrap(); assert_ne!(alice, bob, "alice and bob collide — rename one"); } #[test] fn agent_network_ip_different_subnet() { let ip = agent_network_ip("alice", "192.168.5.0/24").expect("should produce an IP"); let octets: Vec = ip.split('.').map(|o| o.parse().unwrap()).collect(); assert_eq!(&octets[..3], &[192, 168, 5]); } #[test] fn bridge_gateway_ip_extracts_verbatim_address() { // HIVE_NETWORK_SUBNET carries the bridge IP verbatim, not the // canonical network — the gateway is the address before the `/`. assert_eq!( bridge_gateway_ip("10.42.0.1/24").as_deref(), Some("10.42.0.1") ); // Non-`.1` operator override: the gateway is wherever the bridge is. assert_eq!( bridge_gateway_ip("10.42.0.254/24").as_deref(), Some("10.42.0.254") ); assert_eq!( bridge_gateway_ip("172.30.0.1/16").as_deref(), Some("172.30.0.1") ); } #[test] fn bridge_gateway_ip_rejects_bad_input() { assert!(bridge_gateway_ip("notanip/24").is_none()); assert!(bridge_gateway_ip("10.42.0.1").is_none()); // no prefix assert!(bridge_gateway_ip("10.42.0.1/33").is_none()); // prefix > 32 assert!(bridge_gateway_ip("10.42.0.999/24").is_none()); // octet > 255 assert!(bridge_gateway_ip("10.42.0/24").is_none()); // 3 octets } #[test] fn agent_network_ip_rejects_bad_input() { assert!(agent_network_ip("alice", "notanip/24").is_none()); assert!(agent_network_ip("alice", "10.0.0.0/33").is_none()); // prefix > 32 assert!(agent_network_ip("alice", "10.0.0.0/31").is_none()); // too small assert!(agent_network_ip("alice", "10.0.0.0").is_none()); // no prefix } #[test] fn agent_network_ip_normalizes_bridge_ip_subnet() { // HIVE_NETWORK_SUBNET carries the bridge IP (10.42.0.1/24), not // canonical network (10.42.0.0/24). Both must produce the same result // after host-bit masking. let from_bridge = agent_network_ip("alice", "10.42.0.1/24"); let from_canonical = agent_network_ip("alice", "10.42.0.0/24"); assert_eq!( from_bridge, from_canonical, "bridge-IP and canonical-network form should normalize to the same result" ); // Result must still be in .2-.254. let ip = from_bridge.unwrap(); let last: u8 = ip.rsplit('.').next().unwrap().parse().unwrap(); assert!((2..=254).contains(&last), "host byte {last}"); } /// `setup_proposed` is idempotent: calling it on an existing repo is a /// no-op (the fresh guard skips all writes). #[tokio::test] async fn setup_proposed_idempotent() { let dir = tempfile::tempdir().expect("tempdir"); let proposed = dir.path().join("proposed"); setup_proposed(&proposed, "test-agent") .await .expect("first call"); // Second call must not error even though .git already exists. setup_proposed(&proposed, "test-agent") .await .expect("second call"); // Still one commit. let out = git_command() .current_dir(&proposed) .args(["rev-list", "--count", "HEAD"]) .output() .await .expect("git rev-list"); let count = String::from_utf8_lossy(&out.stdout).trim().to_owned(); assert_eq!( count, "1", "expected exactly one commit after idempotent call" ); }