blob: ce204dc0d609c22f1ed972976815899d81d1d271 [file]
#!/usr/bin/env fuchsia-vendored-python
# Copyright 2026 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Unit tests for config module, atomic writing, and backup creation."""
from __future__ import annotations
import io
import json
import os
import pathlib
import tempfile
import unittest
from unittest import mock
from agents.lib import config, permissions, state
class ConfigTest(unittest.TestCase):
"""Hermetic unit tests for config operations."""
def setUp(self) -> None:
self.stdout_patch = mock.patch("sys.stdout", new_callable=io.StringIO)
self.mock_stdout = self.stdout_patch.start()
self.addCleanup(self.stdout_patch.stop)
self.stderr_patch = mock.patch("sys.stderr", new_callable=io.StringIO)
self.mock_stderr = self.stderr_patch.start()
self.addCleanup(self.stderr_patch.stop)
self.temp_dir = tempfile.TemporaryDirectory()
self.addCleanup(self.temp_dir.cleanup)
self.mock_root = pathlib.Path(self.temp_dir.name)
self.state_dir = self.mock_root / "state"
self.fuchsia_dir = self.mock_root / "fuchsia"
self.fuchsia_dir_patch = mock.patch.object(
permissions, "find_fuchsia_dir", return_value=self.fuchsia_dir
)
self.fuchsia_dir_patch.start()
self.addCleanup(self.fuchsia_dir_patch.stop)
def test_get_default_config_path_fallback(self) -> None:
"""Verify default fallback to ~/.gemini/config/config.json when no env vars are set."""
with mock.patch.dict(os.environ, {}, clear=True):
with mock.patch.object(
pathlib.Path, "home", return_value=pathlib.Path("/mock/home")
):
expected = pathlib.Path("/mock/home/.gemini/config/config.json")
self.assertEqual(config.get_default_config_path(), expected)
def test_get_default_config_path_gemini_config_dir(self) -> None:
"""Verify GEMINI_CONFIG_DIR environment variable override."""
with mock.patch.dict(
os.environ, {"GEMINI_CONFIG_DIR": "/custom/config/dir"}, clear=True
):
expected = pathlib.Path("/custom/config/dir/config.json")
self.assertEqual(config.get_default_config_path(), expected)
def test_get_default_config_path_gemini_home(self) -> None:
"""Verify GEMINI_HOME environment variable override."""
with mock.patch.dict(
os.environ, {"GEMINI_HOME": "/custom/gemini/home"}, clear=True
):
expected = pathlib.Path("/custom/gemini/home/config/config.json")
self.assertEqual(config.get_default_config_path(), expected)
def test_get_default_config_path_precedence(self) -> None:
"""Verify GEMINI_CONFIG_DIR takes precedence over GEMINI_HOME."""
with mock.patch.dict(
os.environ,
{
"GEMINI_CONFIG_DIR": "/custom/config/dir",
"GEMINI_HOME": "/custom/gemini/home",
},
clear=True,
):
expected = pathlib.Path("/custom/config/dir/config.json")
self.assertEqual(config.get_default_config_path(), expected)
def test_load_config_nonexistent(self) -> None:
"""Verify load_config returns empty dict for nonexistent file."""
nonexistent = self.mock_root / "does_not_exist.json"
self.assertEqual(config.load_config(nonexistent), {})
def test_load_config_valid(self) -> None:
"""Verify load_config correctly parses JSON object."""
cfg_file = self.mock_root / "config.json"
cfg_file.write_text('{"foo": "bar", "num": 42}', encoding="utf-8")
self.assertEqual(
config.load_config(cfg_file), {"foo": "bar", "num": 42}
)
def test_load_config_non_dict(self) -> None:
"""Verify load_config returns empty dict for JSON list or primitive."""
cfg_file = self.mock_root / "config.json"
cfg_file.write_text("[1, 2, 3]", encoding="utf-8")
self.assertEqual(config.load_config(cfg_file), {})
def test_load_config_invalid_json(self) -> None:
"""Verify load_config raises JSONDecodeError on syntax error."""
cfg_file = self.mock_root / "config.json"
cfg_file.write_text("{bad json", encoding="utf-8")
with self.assertRaises(json.JSONDecodeError):
config.load_config(cfg_file)
def test_save_config_atomic(self) -> None:
"""Verify save_config_atomic writes valid JSON."""
cfg_file = self.mock_root / "nested" / "config.json"
data = {"userSettings": {"enabled": True}}
config.save_config_atomic(cfg_file, data)
self.assertTrue(cfg_file.is_file())
with cfg_file.open("r", encoding="utf-8") as fh:
loaded = json.load(fh)
self.assertEqual(loaded, data)
def test_merge_grants(self) -> None:
"""Verify grant deduplication and order preservation."""
existing = ["grant1", "grant2"]
to_add = ["grant2", "grant3", "grant1", "grant4"]
updated, added = config.merge_grants(existing, to_add)
self.assertEqual(updated, ["grant1", "grant2", "grant3", "grant4"])
self.assertEqual(added, ["grant3", "grant4"])
def test_apply_grants_new_file(self) -> None:
"""Verify apply_grants creates new config atomically and initializes state."""
cfg_file = self.mock_root / "config.json"
success = config.apply_grants(
config_path=cfg_file,
allow=["command(fx build)"],
deny=["command(fx clean)"],
ask=["command(fx reboot)"],
dry_run=False,
state_dir=self.state_dir,
selected_profile="local-changes",
)
self.assertTrue(success)
self.assertTrue(cfg_file.is_file())
with cfg_file.open("r", encoding="utf-8") as fh:
data = json.load(fh)
grants = data["userSettings"]["globalPermissionGrants"]
self.assertEqual(grants["allow"], ["command(fx build)"])
self.assertEqual(grants["deny"], ["command(fx clean)"])
self.assertEqual(grants["ask"], ["command(fx reboot)"])
journal = state.load_state(self.state_dir / "state.json")
self.assertEqual(journal.active_profile, "local-changes")
self.assertEqual(len(journal.history), 1)
def test_apply_grants_existing_file_and_backup(self) -> None:
"""Verify preexisting config is backed up to backups/ before modification."""
cfg_file = self.mock_root / "config.json"
initial_data = {
"userSettings": {
"customSetting": 123,
"globalPermissionGrants": {
"allow": ["command(custom_cmd)"],
"deny": [],
"ask": [],
},
},
"rootField": "foo",
}
original_json = json.dumps(initial_data, indent=2)
cfg_file.write_text(original_json, encoding="utf-8")
success = config.apply_grants(
config_path=cfg_file,
allow=["command(fx build)"],
deny=["command(fx clean)"],
ask=[],
dry_run=False,
state_dir=self.state_dir,
selected_profile="local-changes",
)
self.assertTrue(success)
backups = list((self.state_dir / "backups").glob("config_*.json"))
self.assertEqual(len(backups), 1)
self.assertEqual(backups[0].read_text(encoding="utf-8"), original_json)
data = json.loads(cfg_file.read_text(encoding="utf-8"))
self.assertEqual(data["rootField"], "foo")
self.assertEqual(data["userSettings"]["customSetting"], 123)
grants = data["userSettings"]["globalPermissionGrants"]
self.assertEqual(
grants["allow"], ["command(custom_cmd)", "command(fx build)"]
)
self.assertEqual(grants["deny"], ["command(fx clean)"])
def test_apply_grants_dry_run(self) -> None:
"""Verify dry_run=True does not touch disk."""
cfg_file = self.mock_root / "config.json"
success = config.apply_grants(
config_path=cfg_file,
allow=["command(fx build)"],
deny=[],
ask=[],
dry_run=True,
state_dir=self.state_dir,
)
self.assertTrue(success)
self.assertFalse(cfg_file.exists())
self.assertFalse((self.state_dir / "state.json").exists())
def test_apply_grants_invalid_json(self) -> None:
"""Verify invalid JSON in existing config.json is gracefully handled."""
cfg_file = self.mock_root / "config.json"
cfg_file.write_text("{invalid_json", encoding="utf-8")
success = config.apply_grants(
config_path=cfg_file,
allow=["command(fx build)"],
deny=[],
ask=[],
dry_run=False,
state_dir=self.state_dir,
)
self.assertFalse(success)
self.assertIn("Error reading JSON from", self.mock_stderr.getvalue())
if __name__ == "__main__":
unittest.main()