test: cover .mam.env migration, shadowing, ownership and uninstall preservation
This commit is contained in:
@@ -0,0 +1,282 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Full regression test suite for .mam.env migration (T-1 through T-17)."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
import subprocess
|
||||||
|
import logging
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import sys
|
||||||
|
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../.agents/skills/multi-agent-mux-delegate-job/scripts")))
|
||||||
|
import mqtt_common
|
||||||
|
|
||||||
|
class TestEnvMigrationFull(unittest.TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.test_dir = tempfile.mkdtemp(prefix="mam_env_full_test_")
|
||||||
|
self.old_cwd = os.getcwd()
|
||||||
|
os.chdir(self.test_dir)
|
||||||
|
self.clean_os_env()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
os.chdir(self.old_cwd)
|
||||||
|
shutil.rmtree(self.test_dir, ignore_errors=True)
|
||||||
|
self.clean_os_env()
|
||||||
|
|
||||||
|
def clean_os_env(self):
|
||||||
|
for k in ["MQTT_BROKER", "MQTT_PORT", "MQTT_PASSWORD", "TEST_KEY_A", "TEST_KEY_B", "MAM_ENV_FILE", "MAM_LEGACY_ENV_OWNED"]:
|
||||||
|
os.environ.pop(k, None)
|
||||||
|
mqtt_common._warned_deprecated_env = False
|
||||||
|
mqtt_common._warned_coexistence_env = False
|
||||||
|
|
||||||
|
def test_t1_mam_env_only(self):
|
||||||
|
"""T-1: Loads .mam.env when present."""
|
||||||
|
mam_env = os.path.join(self.test_dir, ".mam.env")
|
||||||
|
with open(mam_env, "w") as f:
|
||||||
|
f.write("TEST_KEY_A=val_mam_env\n")
|
||||||
|
|
||||||
|
mqtt_common._load_dotenv(self.test_dir)
|
||||||
|
self.assertEqual(os.environ.get("TEST_KEY_A"), "val_mam_env")
|
||||||
|
|
||||||
|
def test_t2_legacy_env_only_fallback_and_warning(self):
|
||||||
|
"""T-2: Falls back to .env when .mam.env absent, logs deprecation warning."""
|
||||||
|
legacy_env = os.path.join(self.test_dir, ".env")
|
||||||
|
with open(legacy_env, "w") as f:
|
||||||
|
f.write("TEST_KEY_A=val_legacy_env\n")
|
||||||
|
|
||||||
|
with self.assertLogs("delegate_job.mqtt_common", level="WARNING") as cm:
|
||||||
|
mqtt_common._load_dotenv(self.test_dir)
|
||||||
|
|
||||||
|
self.assertEqual(os.environ.get("TEST_KEY_A"), "val_legacy_env")
|
||||||
|
self.assertTrue(any("deprecated" in log for log in cm.output))
|
||||||
|
|
||||||
|
def test_t3_coexistence_mam_env_precedence_and_warning(self):
|
||||||
|
"""T-3: When both exist, .mam.env wins and coexistence warning logs."""
|
||||||
|
mam_env = os.path.join(self.test_dir, ".mam.env")
|
||||||
|
legacy_env = os.path.join(self.test_dir, ".env")
|
||||||
|
with open(mam_env, "w") as f:
|
||||||
|
f.write("TEST_KEY_A=val_mam\n")
|
||||||
|
with open(legacy_env, "w") as f:
|
||||||
|
f.write("TEST_KEY_A=val_legacy\n")
|
||||||
|
|
||||||
|
with self.assertLogs("delegate_job.mqtt_common", level="WARNING") as cm:
|
||||||
|
mqtt_common._load_dotenv(self.test_dir)
|
||||||
|
|
||||||
|
self.assertEqual(os.environ.get("TEST_KEY_A"), "val_mam")
|
||||||
|
self.assertTrue(any("Both" in log for log in cm.output))
|
||||||
|
|
||||||
|
def test_t4_parent_boundary_stop_walkup_without_arg(self):
|
||||||
|
"""T-4: Walk-up with no argument stops at boundary marker (.agents/.git)."""
|
||||||
|
parent_dir = os.path.join(self.test_dir, "parent")
|
||||||
|
child_repo = os.path.join(parent_dir, "child_repo")
|
||||||
|
nested_script_dir = os.path.join(child_repo, ".agents", "skills", "test", "scripts")
|
||||||
|
os.makedirs(nested_script_dir, exist_ok=True)
|
||||||
|
|
||||||
|
with open(os.path.join(parent_dir, ".env"), "w") as f:
|
||||||
|
f.write("TEST_KEY_A=parent_leaked_secret\n")
|
||||||
|
|
||||||
|
dummy_file = os.path.join(nested_script_dir, "mqtt_common.py")
|
||||||
|
with patch.object(mqtt_common, "__file__", dummy_file):
|
||||||
|
mqtt_common._load_dotenv()
|
||||||
|
|
||||||
|
self.assertIsNone(os.environ.get("TEST_KEY_A"))
|
||||||
|
|
||||||
|
def test_t5_os_env_precedence(self):
|
||||||
|
"""T-5: OS environment variables take precedence over env file values."""
|
||||||
|
os.environ["TEST_KEY_A"] = "os_val"
|
||||||
|
mam_env = os.path.join(self.test_dir, ".mam.env")
|
||||||
|
with open(mam_env, "w") as f:
|
||||||
|
f.write("TEST_KEY_A=file_val\n")
|
||||||
|
|
||||||
|
mqtt_common._load_dotenv(self.test_dir)
|
||||||
|
self.assertEqual(os.environ.get("TEST_KEY_A"), "os_val")
|
||||||
|
|
||||||
|
def test_t6_mam_env_file_override(self):
|
||||||
|
"""T-6: MAM_ENV_FILE takes highest precedence if explicitly provided."""
|
||||||
|
custom_env = os.path.join(self.test_dir, "custom.env")
|
||||||
|
with open(custom_env, "w") as f:
|
||||||
|
f.write("TEST_KEY_A=custom_val\n")
|
||||||
|
os.environ["MAM_ENV_FILE"] = custom_env
|
||||||
|
|
||||||
|
mam_env = os.path.join(self.test_dir, ".mam.env")
|
||||||
|
with open(mam_env, "w") as f:
|
||||||
|
f.write("TEST_KEY_A=mam_val\n")
|
||||||
|
|
||||||
|
mqtt_common._load_dotenv(self.test_dir)
|
||||||
|
self.assertTrue(os.path.exists(custom_env))
|
||||||
|
self.assertEqual(os.environ.get("TEST_KEY_A"), "custom_val")
|
||||||
|
|
||||||
|
def test_t7_wrapper_cwd_isolation(self):
|
||||||
|
"""T-7: Bash wrapper executes cleanly and outputs help/warnings."""
|
||||||
|
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
wrapper_path = os.path.join(repo_root, ".agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job")
|
||||||
|
res = subprocess.run([wrapper_path, "--help"], capture_output=True, text=True)
|
||||||
|
self.assertEqual(res.returncode, 0)
|
||||||
|
|
||||||
|
def test_t8_remove_force_preserves_owned_env(self):
|
||||||
|
"""T-8 [MERGE BLOCKER]: --force MUST back up owned .env, NEVER delete without backup."""
|
||||||
|
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
shutil.copy(os.path.join(repo_root, "deploy/remove.sh"), self.test_dir)
|
||||||
|
|
||||||
|
os.makedirs(".mam", exist_ok=True)
|
||||||
|
with open(".mam/install_manifest.txt", "w") as f:
|
||||||
|
f.write(".env\nremove.sh\n")
|
||||||
|
|
||||||
|
with open(".env", "w") as f:
|
||||||
|
f.write("SECRET_KEY=user_secret_data\n")
|
||||||
|
|
||||||
|
res = subprocess.run(["bash", "remove.sh", "--force"], capture_output=True, text=True)
|
||||||
|
self.assertEqual(res.returncode, 0, f"remove.sh failed: {res.stderr}")
|
||||||
|
self.assertTrue(os.path.exists(".env.mam-backup"), "MAM-owned .env MUST be backed up under --force")
|
||||||
|
with open(".env.mam-backup") as f:
|
||||||
|
self.assertIn("user_secret_data", f.read())
|
||||||
|
|
||||||
|
def test_t8b_purge_env_is_sole_delete_authority(self):
|
||||||
|
"""T-8b: --purge-env is the ONLY flag authorized to delete without backup."""
|
||||||
|
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
shutil.copy(os.path.join(repo_root, "deploy/remove.sh"), self.test_dir)
|
||||||
|
|
||||||
|
os.makedirs(".mam", exist_ok=True)
|
||||||
|
with open(".mam/install_manifest.txt", "w") as f:
|
||||||
|
f.write(".env\nremove.sh\n")
|
||||||
|
|
||||||
|
with open(".env", "w") as f:
|
||||||
|
f.write("SECRET_KEY=user_secret_data\n")
|
||||||
|
|
||||||
|
res = subprocess.run(["bash", "remove.sh", "--force", "--purge-env"], capture_output=True, text=True)
|
||||||
|
self.assertEqual(res.returncode, 0)
|
||||||
|
self.assertFalse(os.path.exists(".env"))
|
||||||
|
self.assertFalse(os.path.exists(".env.mam-backup"))
|
||||||
|
|
||||||
|
def test_t9_git_check_ignore(self):
|
||||||
|
"""T-9 [MERGE BLOCKER]: Verifies gitignore rules ignore .mam.env variants but track .mam.env.example."""
|
||||||
|
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
check_cmd = ["git", "check-ignore", "-v", ".mam.env", ".mam.env.bak", ".mam.env.update-tmp", ".mam.env.mam-backup"]
|
||||||
|
res = subprocess.run(check_cmd, cwd=repo_root, capture_output=True, text=True)
|
||||||
|
self.assertEqual(res.returncode, 0, f"git check-ignore failed: {res.stderr}")
|
||||||
|
|
||||||
|
ex_cmd = ["git", "check-ignore", ".mam.env.example"]
|
||||||
|
res_ex = subprocess.run(ex_cmd, cwd=repo_root, capture_output=True, text=True)
|
||||||
|
self.assertNotEqual(res_ex.returncode, 0, ".mam.env.example should NOT be ignored by git")
|
||||||
|
|
||||||
|
def test_t10_shadowing_prevention_guard(self):
|
||||||
|
"""T-10 [MERGE BLOCKER]: install.sh does NOT create new .mam.env if .env.update-tmp exists."""
|
||||||
|
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
shutil.copy(os.path.join(repo_root, "deploy/install.sh"), self.test_dir)
|
||||||
|
os.makedirs("deploy", exist_ok=True)
|
||||||
|
shutil.copy(os.path.join(repo_root, "deploy/install.sh"), "deploy/install.sh")
|
||||||
|
if os.path.exists(os.path.join(repo_root, ".mam.env.example")):
|
||||||
|
shutil.copy(os.path.join(repo_root, ".mam.env.example"), ".mam.env.example")
|
||||||
|
|
||||||
|
with open(".env.update-tmp", "w") as f:
|
||||||
|
f.write("MQTT_PASSWORD=s3cr3t_user_pass\n")
|
||||||
|
|
||||||
|
res = subprocess.run(["bash", "deploy/install.sh", self.test_dir], capture_output=True, text=True)
|
||||||
|
self.assertEqual(res.returncode, 0, f"install.sh failed: {res.stderr}")
|
||||||
|
self.assertIn("Preserved without shadowing", res.stdout)
|
||||||
|
self.assertFalse(os.path.exists(".mam.env"), ".mam.env MUST NOT be created when .env.update-tmp is waiting for restore")
|
||||||
|
|
||||||
|
def test_t12_unowned_legacy_env_preservation(self):
|
||||||
|
"""T-12 [MERGE BLOCKER]: install.sh leaves unowned .env untouched without heuristic hijacking."""
|
||||||
|
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
os.makedirs("deploy", exist_ok=True)
|
||||||
|
shutil.copy(os.path.join(repo_root, "deploy/install.sh"), "deploy/install.sh")
|
||||||
|
if os.path.exists(os.path.join(repo_root, ".mam.env.example")):
|
||||||
|
shutil.copy(os.path.join(repo_root, ".mam.env.example"), ".mam.env.example")
|
||||||
|
|
||||||
|
with open(".env", "w") as f:
|
||||||
|
f.write("MQTT_BROKER=my-private-iot-broker.com\nSTRIPE_SECRET=sk_live_123\n")
|
||||||
|
|
||||||
|
res = subprocess.run(["bash", "deploy/install.sh", self.test_dir], capture_output=True, text=True)
|
||||||
|
self.assertEqual(res.returncode, 0)
|
||||||
|
self.assertTrue(os.path.exists(".env"), ".env should remain untouched")
|
||||||
|
self.assertFalse(os.path.exists(".mam.env"), ".mam.env should NOT hijack unowned .env")
|
||||||
|
|
||||||
|
def test_t13_owned_legacy_env_migration_and_manifest_rewrite(self):
|
||||||
|
"""T-13: Owned .env is migrated to .mam.env and manifest is updated."""
|
||||||
|
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
os.makedirs("deploy", exist_ok=True)
|
||||||
|
shutil.copy(os.path.join(repo_root, "deploy/install.sh"), "deploy/install.sh")
|
||||||
|
|
||||||
|
os.makedirs(".mam", exist_ok=True)
|
||||||
|
with open(".mam/install_manifest.txt", "w") as f:
|
||||||
|
f.write(".env\n")
|
||||||
|
|
||||||
|
with open(".env", "w") as f:
|
||||||
|
f.write("MQTT_BROKER=owned-broker.internal\n")
|
||||||
|
|
||||||
|
res = subprocess.run(["bash", "deploy/install.sh", self.test_dir], capture_output=True, text=True)
|
||||||
|
self.assertEqual(res.returncode, 0)
|
||||||
|
self.assertTrue(os.path.exists(".mam.env"))
|
||||||
|
self.assertFalse(os.path.exists(".env"))
|
||||||
|
|
||||||
|
with open(".mam/install_manifest.txt", "r") as f:
|
||||||
|
manifest_lines = [line.strip() for line in f]
|
||||||
|
self.assertIn(".mam.env", manifest_lines)
|
||||||
|
self.assertNotIn(".env", manifest_lines)
|
||||||
|
|
||||||
|
def test_t16_backup_deduplication_across_reinstall_cycles(self):
|
||||||
|
"""T-16 (Backup Hygiene): Repeated remove.sh -y -> install.sh cycles do not multiply identical backups."""
|
||||||
|
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
os.makedirs("deploy", exist_ok=True)
|
||||||
|
shutil.copy(os.path.join(repo_root, "deploy/install.sh"), "deploy/install.sh")
|
||||||
|
shutil.copy(os.path.join(repo_root, "deploy/remove.sh"), "deploy/remove.sh")
|
||||||
|
if os.path.exists(os.path.join(repo_root, ".mam.env.example")):
|
||||||
|
shutil.copy(os.path.join(repo_root, ".mam.env.example"), ".mam.env.example")
|
||||||
|
|
||||||
|
# Initial setup: MAM-owned .mam.env with user secret
|
||||||
|
os.makedirs(".mam", exist_ok=True)
|
||||||
|
with open(".mam/install_manifest.txt", "w") as f:
|
||||||
|
f.write(".mam.env\nremove.sh\nupdate.sh\n.mam.env.example\n")
|
||||||
|
with open(".mam.env", "w") as f:
|
||||||
|
f.write("MQTT_PASSWORD=REAL_USER_SECRET_12345\n")
|
||||||
|
|
||||||
|
# Cycle 1: remove -y
|
||||||
|
res1 = subprocess.run(["bash", "deploy/remove.sh", "--force"], capture_output=True, text=True)
|
||||||
|
self.assertEqual(res1.returncode, 0)
|
||||||
|
self.assertTrue(os.path.exists(".mam.env.mam-backup"))
|
||||||
|
|
||||||
|
# Cycle 1: reinstall
|
||||||
|
res_inst1 = subprocess.run(["bash", "deploy/install.sh", self.test_dir], capture_output=True, text=True)
|
||||||
|
self.assertEqual(res_inst1.returncode, 0)
|
||||||
|
|
||||||
|
# Cycle 2: remove -y & reinstall
|
||||||
|
subprocess.run(["bash", "deploy/remove.sh", "--force"], check=True)
|
||||||
|
subprocess.run(["bash", "deploy/install.sh", self.test_dir], check=True)
|
||||||
|
|
||||||
|
# Cycle 3: remove -y (identical default content should be deduplicated)
|
||||||
|
subprocess.run(["bash", "deploy/remove.sh", "--force"], check=True)
|
||||||
|
|
||||||
|
# Verify backup count stabilizes at <= 2 (Slot 1 original secret + 1 dedup default slot)
|
||||||
|
backups = [f for f in os.listdir(self.test_dir) if f.startswith(".mam.env.mam-backup")]
|
||||||
|
self.assertLessEqual(len(backups), 2, f"Backup files duplicated indefinitely: {backups}")
|
||||||
|
|
||||||
|
def test_t17_immutable_slot_1_user_secret_retention(self):
|
||||||
|
"""T-17 [MERGE BLOCKER]: Slot 1 (.mam.env.mam-backup) retains original user secret after repeated cycles."""
|
||||||
|
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
os.makedirs("deploy", exist_ok=True)
|
||||||
|
shutil.copy(os.path.join(repo_root, "deploy/install.sh"), "deploy/install.sh")
|
||||||
|
shutil.copy(os.path.join(repo_root, "deploy/remove.sh"), "deploy/remove.sh")
|
||||||
|
if os.path.exists(os.path.join(repo_root, ".mam.env.example")):
|
||||||
|
shutil.copy(os.path.join(repo_root, ".mam.env.example"), ".mam.env.example")
|
||||||
|
|
||||||
|
os.makedirs(".mam", exist_ok=True)
|
||||||
|
with open(".mam/install_manifest.txt", "w") as f:
|
||||||
|
f.write(".mam.env\nremove.sh\n")
|
||||||
|
with open(".mam.env", "w") as f:
|
||||||
|
f.write("MQTT_PASSWORD=REAL_USER_SECRET_12345\n")
|
||||||
|
|
||||||
|
# 3 cycles of remove -> install
|
||||||
|
for _ in range(3):
|
||||||
|
subprocess.run(["bash", "deploy/remove.sh", "--force"], check=True)
|
||||||
|
subprocess.run(["bash", "deploy/install.sh", self.test_dir], check=True)
|
||||||
|
|
||||||
|
with open(".mam.env.mam-backup", "r") as f:
|
||||||
|
slot1_content = f.read()
|
||||||
|
self.assertIn("REAL_USER_SECRET_12345", slot1_content, "Slot 1 MUST retain original user secret permanently")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user