"""Unit tests for migrate_profiles.py migration helper.""" import os import sys import json import unittest import tempfile import shutil from unittest.mock import patch from io import StringIO # Add project root to sys.path PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) if PROJECT_ROOT not in sys.path: sys.path.insert(0, PROJECT_ROOT) import migrate_profiles class TestMigrateProfiles(unittest.TestCase): def setUp(self): self.temp_dir = tempfile.mkdtemp() self.v1_content = { "version": 1, "profiles": { "prgs": { "base_url": "redacted-prgs-service", "username": "jcwalker3", "auth": { "type": "keychain", "id": "redacted-author-ref" }, "default_owner": "Scaled-Tech-Consulting", "execution_profile": "personal-prgs", "allowed_operations": ["read", "comment"], "forbidden_operations": ["approve", "merge"] }, "mdcps": { "base_url": "redacted-mdcps-service", "username": "913443", "auth": { "type": "keychain", "id": "redacted-mdcps-ref" }, "default_owner": "Contractor", "execution_profile": "mdcps", "allowed_operations": ["read"], "forbidden_operations": ["merge"] }, "prgs-reviewer": { "base_url": "redacted-prgs-service", "username": "sysadmin", "auth": { "type": "keychain", "id": "redacted-reviewer-ref" }, "default_owner": "Scaled-Tech-Consulting", "execution_profile": "prgs-reviewer", "allowed_operations": [ "read", "review", "comment", "approve", "request_changes", "merge" ], "forbidden_operations": ["branch", "commit", "push", "open_pr"] } } } self.input_file = os.path.join(self.temp_dir, "profiles.json") with open(self.input_file, "w") as f: json.dump(self.v1_content, f) def tearDown(self): shutil.rmtree(self.temp_dir) def test_migration_logic(self): """Test the structural transformation and capability mapping.""" v2_data = migrate_profiles.migrate_v1_to_v2(self.v1_content) self.assertEqual(v2_data["version"], 2) # Check environment structure envs = v2_data["environments"] self.assertIn("prgs", envs) self.assertIn("mdcps", envs) # Check service and identity structure prgs_gitea = envs["prgs"]["services"]["gitea"] self.assertEqual(prgs_gitea["base_url"], "redacted-prgs-service") self.assertEqual(prgs_gitea["default_owner"], "Scaled-Tech-Consulting") author = prgs_gitea["identities"]["author"] self.assertEqual(author["username"], "jcwalker3") self.assertEqual(author["auth"]["id"], "redacted-author-ref") self.assertEqual(author["allowed_operations"], ["read", "comment"]) self.assertEqual(author["forbidden_operations"], ["approve", "merge"]) reviewer = prgs_gitea["identities"]["reviewer"] self.assertEqual(reviewer["role"], "reviewer") self.assertEqual(reviewer["username"], "sysadmin") self.assertEqual(reviewer["auth"]["id"], "redacted-reviewer-ref") self.assertIn("merge", reviewer["allowed_operations"]) def test_alias_generation(self): """Test that aliases are correctly generated to support old profile names.""" v2_data = migrate_profiles.migrate_v1_to_v2(self.v1_content) aliases = v2_data["aliases"] self.assertEqual(aliases["prgs"], "prgs.gitea.author") self.assertEqual(aliases["prgs-author"], "prgs.gitea.author") self.assertEqual(aliases["prgs-reviewer"], "prgs.gitea.reviewer") self.assertEqual(aliases["mdcps"], "mdcps.gitea.author") def test_no_secret_behavior(self): """Ensure secrets are never extracted, printed, or processed.""" v2_data = migrate_profiles.migrate_v1_to_v2(self.v1_content) # Check that auth structures only contain keychain references, not credentials for env in v2_data["environments"].values(): for svc in env["services"].values(): for ident in svc["identities"].values(): auth = ident["auth"] self.assertEqual(auth["type"], "keychain") self.assertIn("id", auth) self.assertNotIn("token", auth) self.assertNotIn("password", auth) def test_validation(self): """Test that the generated v2 configuration validates against Gitea-Tools v2 loader.""" v2_data = migrate_profiles.migrate_v1_to_v2(self.v1_content) self.assertTrue(migrate_profiles.validate_v2_data(v2_data)) @patch("sys.stdout", new_callable=StringIO) def test_dry_run_default(self, mock_stdout): """Verify that running without -w prints generated config without modifying files.""" output_file = os.path.join(self.temp_dir, "migrated_dry.json") test_args = [ "migrate_profiles.py", "-i", self.input_file, "-o", output_file ] with patch.object(sys, "argv", test_args): with self.assertRaises(SystemExit) as cm: migrate_profiles.main() self.assertEqual(cm.exception.code, 0) self.assertFalse(os.path.exists(output_file)) self.assertFalse(os.path.exists(f"{self.input_file}.bak")) stdout_output = mock_stdout.getvalue() self.assertIn("DRY-RUN MODE", stdout_output) self.assertIn("version", stdout_output) self.assertIn("identities", stdout_output) self.assertIn("aliases", stdout_output) self.assertNotIn("redacted-prgs-service", stdout_output) self.assertNotIn("redacted-mdcps-service", stdout_output) self.assertNotIn("redacted-author-ref", stdout_output) self.assertNotIn("redacted-mdcps-ref", stdout_output) self.assertNotIn("redacted-reviewer-ref", stdout_output) self.assertNotIn("keychain", stdout_output) self.assertNotIn("auth", stdout_output) def test_dry_run_hides_token_like_values(self): """Verify dry-run summary does not expose token-like auth metadata.""" sensitive = json.loads(json.dumps(self.v1_content)) sensitive["profiles"]["prgs"]["auth"]["id"] = "super-secret-token-value" with open(self.input_file, "w") as f: json.dump(sensitive, f) test_args = ["migrate_profiles.py", "-i", self.input_file] with patch("sys.stdout", new_callable=StringIO) as mock_stdout: with patch.object(sys, "argv", test_args): with self.assertRaises(SystemExit) as cm: migrate_profiles.main() self.assertEqual(cm.exception.code, 0) stdout_output = mock_stdout.getvalue() self.assertNotIn("super-secret-token-value", stdout_output) self.assertNotIn("token", stdout_output.lower()) def test_explicit_operations_are_preserved(self): """Explicit v1 permissions must not be replaced by role defaults.""" v1_data = json.loads(json.dumps(self.v1_content)) v1_data["profiles"]["prgs-reviewer"]["allowed_operations"] = ["read"] v1_data["profiles"]["prgs-reviewer"]["forbidden_operations"] = ["merge"] v2_data = migrate_profiles.migrate_v1_to_v2(v1_data) reviewer = ( v2_data["environments"]["prgs"]["services"]["gitea"] ["identities"]["reviewer"] ) self.assertEqual(reviewer["allowed_operations"], ["read"]) self.assertEqual(reviewer["forbidden_operations"], ["merge"]) def test_inferred_role_defaults_only_when_unambiguous(self): """Role defaults are allowed only for clear author/reviewer profiles.""" v1_data = { "version": 1, "profiles": { "prgs-author": { "base_url": "redacted-prgs-service", "username": "jcwalker3", "auth": {"type": "keychain", "id": "hidden-author-ref"}, "execution_profile": "prgs-author", } } } v2_data = migrate_profiles.migrate_v1_to_v2(v1_data) author = ( v2_data["environments"]["prgs"]["services"]["gitea"] ["identities"]["author"] ) self.assertEqual( author["allowed_operations"], migrate_profiles.AUTHOR_DEFAULT_ALLOWED, ) self.assertEqual( author["forbidden_operations"], migrate_profiles.AUTHOR_DEFAULT_FORBIDDEN, ) def test_ambiguous_permission_source_fails_closed(self): """A profile without explicit permissions or clear role must not widen.""" v1_data = { "version": 1, "profiles": { "prgs": { "base_url": "redacted-prgs-service", "username": "jcwalker3", "auth": {"type": "keychain", "id": "hidden-author-ref"}, "execution_profile": "personal-prgs", } } } with self.assertRaisesRegex(ValueError, "fail closed"): migrate_profiles.migrate_v1_to_v2(v1_data) def test_partial_permission_source_fails_closed(self): """Allowed without forbidden, or vice versa, is ambiguous.""" v1_data = json.loads(json.dumps(self.v1_content)) del v1_data["profiles"]["prgs"]["forbidden_operations"] with self.assertRaisesRegex(ValueError, "fail closed"): migrate_profiles.migrate_v1_to_v2(v1_data) def test_write_mode_and_backup(self): """Verify that write mode creates a backup and correctly saves the validated config.""" output_file = os.path.join(self.temp_dir, "migrated.json") backup_file = os.path.join(self.temp_dir, "profiles_backup.json.bak") test_args = [ "migrate_profiles.py", "-i", self.input_file, "-o", output_file, "--backup", backup_file, "-w" ] with patch.object(sys, "argv", test_args): migrate_profiles.main() # Verify backup exists and matches original v1 config self.assertTrue(os.path.exists(backup_file)) with open(backup_file, "r") as f: backup_data = json.load(f) self.assertEqual(backup_data["version"], 1) self.assertIn("prgs", backup_data["profiles"]) # Verify migrated v2 config exists and validates self.assertTrue(os.path.exists(output_file)) with open(output_file, "r") as f: v2_data = json.load(f) self.assertEqual(v2_data["version"], 2) self.assertIn("environments", v2_data) self.assertEqual(v2_data["aliases"]["prgs"], "prgs.gitea.author") def test_malformed_input_fails_safely(self): """Test that malformed JSON or invalid version numbers cause a clean exit with code 1.""" bad_json_file = os.path.join(self.temp_dir, "bad.json") with open(bad_json_file, "w") as f: f.write("{invalid-json}") test_args = ["migrate_profiles.py", "-i", bad_json_file] with patch.object(sys, "argv", test_args): with self.assertRaises(SystemExit) as cm: migrate_profiles.main() self.assertEqual(cm.exception.code, 1) bad_version_file = os.path.join(self.temp_dir, "bad_version.json") with open(bad_version_file, "w") as f: json.dump({"version": 3, "profiles": {}}, f) test_args = ["migrate_profiles.py", "-i", bad_version_file] with patch.object(sys, "argv", test_args): with self.assertRaises(SystemExit) as cm: migrate_profiles.main() self.assertEqual(cm.exception.code, 1) if __name__ == "__main__": unittest.main()