blob: e5f43ffa49189f5ab00152d91ff43b9fa3bea4cb [file]
# 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 agent state journal, status formatting, and configuration rollback."""
from __future__ import annotations
import io
import json
import os
import pathlib
import tempfile
import unittest
from collections.abc import Sequence
from unittest import mock
from agents.lib import state
class StateTest(unittest.TestCase):
"""Hermetic unit tests for state journal and rollback 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.state_path = self.state_dir / "state.json"
self.backups_dir = self.state_dir / "backups"
self.config_path = self.mock_root / "config.json"
def test_get_default_state_dir_fallback(self) -> None:
"""Verify default fallback to ~/.local/share/Fuchsia/agents/setup 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/.local/share/Fuchsia/agents/setup"
)
self.assertEqual(state.get_default_state_dir(), expected)
def test_get_default_state_dir_xdg_state_home(self) -> None:
"""Verify XDG_STATE_HOME environment variable override."""
with mock.patch.dict(
os.environ, {"XDG_STATE_HOME": "/custom/xdg_state"}, clear=True
):
expected = pathlib.Path("/custom/xdg_state/Fuchsia/agents/setup")
self.assertEqual(state.get_default_state_dir(), expected)
def test_get_default_state_dir_xdg_data_home(self) -> None:
"""Verify XDG_DATA_HOME environment variable fallback when XDG_STATE_HOME is not set."""
with mock.patch.dict(
os.environ, {"XDG_DATA_HOME": "/custom/xdg_data"}, clear=True
):
expected = pathlib.Path("/custom/xdg_data/Fuchsia/agents/setup")
self.assertEqual(state.get_default_state_dir(), expected)
def test_get_default_state_dir_precedence(self) -> None:
"""Verify XDG_STATE_HOME takes precedence over XDG_DATA_HOME."""
with mock.patch.dict(
os.environ,
{
"XDG_STATE_HOME": "/custom/xdg_state",
"XDG_DATA_HOME": "/custom/xdg_data",
},
clear=True,
):
expected = pathlib.Path("/custom/xdg_state/Fuchsia/agents/setup")
self.assertEqual(state.get_default_state_dir(), expected)
def test_load_and_save_state(self) -> None:
"""Verify saving and loading StateJournal round-trips correctly."""
journal = state.StateJournal(
schema_version=1,
last_updated="2026-08-26 12:00:00",
fuchsia_root="/path/to/fuchsia",
active_profile="local-changes",
managed_grants={
"allow": ["command(fx status)"],
"deny": ["command(git clean)"],
"ask": ["command(fx reboot)"],
},
history=[
{
"timestamp": "2026-08-26 12:00:00",
"profile": "local-changes",
"backup_file": "/path/to/backup.json",
"managed_grants": {
"allow": ["command(fx status)"],
"deny": ["command(git clean)"],
"ask": ["command(fx reboot)"],
},
}
],
)
state.save_state(journal, self.state_path)
self.assertTrue(self.state_path.is_file())
loaded = state.load_state(self.state_path)
self.assertEqual(loaded.schema_version, 1)
self.assertEqual(loaded.last_updated, "2026-08-26 12:00:00")
self.assertEqual(loaded.fuchsia_root, "/path/to/fuchsia")
self.assertEqual(loaded.active_profile, "local-changes")
self.assertEqual(loaded.managed_grants["allow"], ["command(fx status)"])
self.assertEqual(loaded.managed_grants["deny"], ["command(git clean)"])
self.assertEqual(loaded.managed_grants["ask"], ["command(fx reboot)"])
self.assertEqual(len(loaded.history), 1)
self.assertEqual(loaded.history[0]["profile"], "local-changes")
def test_load_state_missing_and_corrupt(self) -> None:
"""Verify load_state returns a clean default journal when missing or corrupted."""
# Missing file
missing_path = self.state_dir / "non_existent.json"
clean_state = state.load_state(missing_path)
self.assertIsInstance(clean_state, state.StateJournal)
self.assertEqual(clean_state.active_profile, "")
self.assertEqual(clean_state.history, [])
# Corrupt JSON
self.state_dir.mkdir(parents=True, exist_ok=True)
self.state_path.write_text("{corrupt json", encoding="utf-8")
clean_state = state.load_state(self.state_path)
self.assertIsInstance(clean_state, state.StateJournal)
self.assertEqual(clean_state.active_profile, "")
# Non-dict JSON
self.state_path.write_text("[1, 2, 3]", encoding="utf-8")
clean_state = state.load_state(self.state_path)
self.assertIsInstance(clean_state, state.StateJournal)
self.assertEqual(clean_state.active_profile, "")
def test_create_backup_and_pruning(self) -> None:
"""Verify backup creation and automatic pruning beyond max_backups."""
# Non-existent config
self.assertIsNone(
state.create_backup(self.config_path, self.backups_dir)
)
# Create config file
self.config_path.write_text('{"test": 1}', encoding="utf-8")
# Create backups with small max_backups limit
max_backups = 3
created: list[pathlib.Path] = []
for i in range(5):
self.config_path.write_text(f'{{"test": {i}}}', encoding="utf-8")
b_path = state.create_backup(
self.config_path, self.backups_dir, max_backups=max_backups
)
self.assertIsNotNone(b_path)
assert b_path is not None
created.append(b_path)
existing_backups = list(self.backups_dir.glob("config_*.json"))
self.assertEqual(len(existing_backups), max_backups)
# Oldest backup should have been pruned
self.assertFalse(created[0].exists())
self.assertFalse(created[1].exists())
self.assertTrue(created[-1].exists())
def test_reconcile_grants_profile_switching(self) -> None:
"""Verify switching profiles moves rules across categories (deny -> allow)."""
existing_grants: dict[str, Sequence[str]] = {
"allow": ["command(fx status)"],
"deny": ["command(fx format-code)", "command(git clean)"],
"ask": ["command(fx ota)"],
}
prev_managed: dict[str, Sequence[str]] = {
"allow": ["command(fx status)"],
"deny": ["command(fx format-code)", "command(git clean)"],
"ask": ["command(fx ota)"],
}
target_managed: dict[str, Sequence[str]] = {
"allow": [
"command(fx status)",
"command(fx format-code)",
"command(fx ota)",
],
"deny": ["command(git clean)"],
"ask": [],
}
final_grants, target = state.reconcile_grants(
existing_grants=existing_grants,
prev_managed=prev_managed,
target_managed=target_managed,
)
self.assertEqual(
final_grants["allow"],
[
"command(fx status)",
"command(fx format-code)",
"command(fx ota)",
],
)
self.assertEqual(final_grants["deny"], ["command(git clean)"])
self.assertEqual(final_grants["ask"], [])
self.assertNotIn("command(fx format-code)", final_grants["deny"])
self.assertNotIn("command(fx ota)", final_grants["ask"])
self.assertEqual(target, target_managed)
def test_reconcile_grants_preserves_user_custom(self) -> None:
"""Verify user-added custom rules are preserved during reconciliation."""
existing_grants: dict[str, Sequence[str]] = {
"allow": ["command(fx status)", "command(my_custom_tool)"],
"deny": ["command(git clean)", "command(custom_block)"],
"ask": ["command(custom_prompt)"],
}
prev_managed: dict[str, Sequence[str]] = {
"allow": ["command(fx status)"],
"deny": ["command(git clean)"],
"ask": [],
}
target_managed: dict[str, Sequence[str]] = {
"allow": ["command(fx status)", "command(fx test)"],
"deny": ["command(git clean)"],
"ask": [],
}
final_grants, _ = state.reconcile_grants(
existing_grants=existing_grants,
prev_managed=prev_managed,
target_managed=target_managed,
)
self.assertIn("command(my_custom_tool)", final_grants["allow"])
self.assertIn("command(fx test)", final_grants["allow"])
self.assertIn("command(custom_block)", final_grants["deny"])
self.assertIn("command(custom_prompt)", final_grants["ask"])
def test_reconcile_grants_list_evolution(self) -> None:
"""Verify obsolete managed rules removed in upstream lists are purged."""
existing_grants: dict[str, Sequence[str]] = {
"allow": ["command(fx status)", "command(fx obsolete_cmd)"],
"deny": ["command(git clean)"],
"ask": [],
}
prev_managed: dict[str, Sequence[str]] = {
"allow": ["command(fx status)", "command(fx obsolete_cmd)"],
"deny": ["command(git clean)"],
"ask": [],
}
target_managed: dict[str, Sequence[str]] = {
"allow": ["command(fx status)"],
"deny": ["command(git clean)"],
"ask": [],
}
final_grants, _ = state.reconcile_grants(
existing_grants=existing_grants,
prev_managed=prev_managed,
target_managed=target_managed,
)
self.assertNotIn("command(fx obsolete_cmd)", final_grants["allow"])
self.assertEqual(final_grants["allow"], ["command(fx status)"])
def test_rollback_success_and_failures(self) -> None:
"""Verify rollback error handling, dry run, and successful restoration."""
# Failure: steps < 1
self.assertFalse(
state.rollback(
self.config_path, self.state_path, self.backups_dir, steps=0
)
)
self.assertIn("must be positive", self.mock_stderr.getvalue())
# Failure: no history
self.assertFalse(
state.rollback(
self.config_path, self.state_path, self.backups_dir, steps=1
)
)
self.assertIn("No transaction history", self.mock_stderr.getvalue())
# Create 2 transactions in journal and backup files
self.backups_dir.mkdir(parents=True, exist_ok=True)
backup1 = self.backups_dir / "config_20260826_010000.json"
backup1.write_text('{"version": 1}', encoding="utf-8")
backup2 = self.backups_dir / "config_20260826_020000.json"
backup2.write_text('{"version": 2}', encoding="utf-8")
self.config_path.write_text('{"version": 3}', encoding="utf-8")
journal = state.StateJournal(
schema_version=1,
last_updated="2026-08-26 03:00:00",
active_profile="external-changes",
managed_grants={"allow": ["cmd3"], "deny": [], "ask": []},
history=[
{
"timestamp": "2026-08-26 01:00:00",
"profile": "read-only",
"backup_file": str(backup1),
"managed_grants": {
"allow": ["cmd1"],
"deny": [],
"ask": [],
},
},
{
"timestamp": "2026-08-26 02:00:00",
"profile": "local-changes",
"backup_file": str(backup2),
"managed_grants": {
"allow": ["cmd2"],
"deny": [],
"ask": [],
},
},
],
)
state.save_state(journal, self.state_path)
# Failure: steps > history length
self.assertFalse(
state.rollback(
self.config_path, self.state_path, self.backups_dir, steps=3
)
)
self.assertIn("Cannot rollback 3 steps", self.mock_stderr.getvalue())
# Dry run rollback 1 step
self.assertTrue(
state.rollback(
self.config_path,
self.state_path,
self.backups_dir,
steps=1,
dry_run=True,
)
)
# Content unchanged after dry run
self.assertEqual(
self.config_path.read_text(encoding="utf-8"), '{"version": 3}'
)
# Execute rollback 1 step
self.assertTrue(
state.rollback(
self.config_path,
self.state_path,
self.backups_dir,
steps=1,
dry_run=False,
)
)
self.assertEqual(
self.config_path.read_text(encoding="utf-8"), '{"version": 2}'
)
reloaded = state.load_state(self.state_path)
self.assertEqual(reloaded.active_profile, "read-only")
self.assertEqual(len(reloaded.history), 1)
# Execute rollback remaining step (back to before initial setup)
self.assertTrue(
state.rollback(
self.config_path,
self.state_path,
self.backups_dir,
steps=1,
dry_run=False,
)
)
self.assertEqual(
self.config_path.read_text(encoding="utf-8"), '{"version": 1}'
)
reloaded_final = state.load_state(self.state_path)
self.assertEqual(reloaded_final.active_profile, "")
self.assertEqual(len(reloaded_final.history), 0)
def test_reset_success(self) -> None:
"""Verify reset removes managed grants while preserving custom rules and userSettings."""
initial_config = {
"rootField": "preserve",
"userSettings": {
"customConfig": 42,
"globalPermissionGrants": {
"allow": ["command(fx status)", "command(my_custom_allow)"],
"deny": ["command(git clean)", "command(my_custom_deny)"],
"ask": ["command(my_custom_ask)"],
},
},
}
self.config_path.write_text(
json.dumps(initial_config, indent=2), encoding="utf-8"
)
journal = state.StateJournal(
schema_version=1,
last_updated="2026-08-26 12:00:00",
fuchsia_root="/path/to/fuchsia",
active_profile="local-changes",
managed_grants={
"allow": ["command(fx status)"],
"deny": ["command(git clean)"],
"ask": [],
},
history=[{"timestamp": "2026-08-26 12:00:00"}],
)
state.save_state(journal, self.state_path)
# Dry run reset
self.assertTrue(
state.reset(
self.config_path,
self.state_path,
self.backups_dir,
dry_run=True,
)
)
# Verify nothing modified
loaded_journal = state.load_state(self.state_path)
self.assertEqual(loaded_journal.active_profile, "local-changes")
# Execute reset
self.assertTrue(
state.reset(
self.config_path,
self.state_path,
self.backups_dir,
dry_run=False,
)
)
# Check config.json
with self.config_path.open("r", encoding="utf-8") as fh:
data = json.load(fh)
self.assertEqual(data["rootField"], "preserve")
self.assertEqual(data["userSettings"]["customConfig"], 42)
grants = data["userSettings"]["globalPermissionGrants"]
self.assertEqual(grants["allow"], ["command(my_custom_allow)"])
self.assertEqual(grants["deny"], ["command(my_custom_deny)"])
self.assertEqual(grants["ask"], ["command(my_custom_ask)"])
# Check state.json
clean_state = state.load_state(self.state_path)
self.assertEqual(clean_state.active_profile, "")
self.assertEqual(clean_state.managed_grants["allow"], [])
self.assertEqual(clean_state.history, [])
def test_format_status(self) -> None:
"""Verify format_status displays active profile, rule breakdown, and history."""
# Non-existent files
non_existent_status = state.format_status(
self.mock_root / "missing_config.json",
self.mock_root / "missing_state.json",
)
self.assertIn("Active Profile : (none)", non_existent_status)
self.assertIn("[not found]", non_existent_status)
self.assertIn("(no history recorded)", non_existent_status)
# Existing files with grants
config_data = {
"userSettings": {
"globalPermissionGrants": {
"allow": ["command(fx status)", "command(my_tool)"],
"deny": ["command(git clean)"],
"ask": [],
}
}
}
self.config_path.write_text(
json.dumps(config_data, indent=2), encoding="utf-8"
)
journal = state.StateJournal(
schema_version=1,
last_updated="2026-08-26 12:00:00",
fuchsia_root="/path/to/fuchsia",
active_profile="local-changes",
managed_grants={
"allow": ["command(fx status)"],
"deny": ["command(git clean)"],
"ask": [],
},
history=[
{
"timestamp": "2026-08-26 12:00:00",
"profile": "local-changes",
"backup_file": "config_20260826_120000.json",
}
],
)
state.save_state(journal, self.state_path)
status_text = state.format_status(self.config_path, self.state_path)
self.assertIn("Active Profile : local-changes", status_text)
self.assertIn("Last Updated : 2026-08-26 12:00:00", status_text)
self.assertIn(
"[ALLOW] : 2 total ( 1 managed, 1 custom)", status_text
)
self.assertIn(
"[DENY ] : 1 total ( 1 managed, 0 custom)", status_text
)
self.assertIn("Recent History (1 transactions):", status_text)
self.assertIn("Profile: local-changes", status_text)
if __name__ == "__main__":
unittest.main()