183 lines
8.3 KiB
Python
183 lines
8.3 KiB
Python
"""Unit and integration tests for Phase 2 Web Console recovery controls (#644)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
from starlette.testclient import TestClient
|
|
|
|
from webui import console_audit, console_authz, console_recovery
|
|
from webui.app import create_app
|
|
|
|
|
|
class TestConsoleRecovery(unittest.TestCase):
|
|
|
|
def test_diagnose_recovery_healthy(self) -> None:
|
|
diag = console_recovery.diagnose_recovery()
|
|
self.assertIn(diag.status, {console_recovery.STATUS_HEALTHY, console_recovery.STATUS_ACTION_REQUIRED})
|
|
self.assertIsInstance(diag.playbooks, tuple)
|
|
self.assertGreaterEqual(len(diag.playbooks), 4)
|
|
|
|
playbook_ids = {pb.playbook_id for pb in diag.playbooks}
|
|
self.assertIn(console_recovery.PLAYBOOK_CLEAR_STALE_BINDING, playbook_ids)
|
|
self.assertIn(console_recovery.PLAYBOOK_REBIND_SESSION, playbook_ids)
|
|
self.assertIn(console_recovery.PLAYBOOK_RECONCILE_CLEANUPS, playbook_ids)
|
|
self.assertIn(console_recovery.PLAYBOOK_SANCTIONED_RESTART, playbook_ids)
|
|
|
|
def test_confirmation_phrase_generation_and_matching(self) -> None:
|
|
phrase = console_recovery.confirmation_phrase("clear_stale_binding")
|
|
self.assertEqual(phrase, "confirm clear_stale_binding")
|
|
self.assertTrue(console_recovery.confirmation_matches("clear_stale_binding", "confirm clear_stale_binding"))
|
|
self.assertFalse(console_recovery.confirmation_matches("clear_stale_binding", "wrong phrase"))
|
|
|
|
phrase_target = console_recovery.confirmation_phrase("sanctioned_restart", "gitea-author")
|
|
self.assertEqual(phrase_target, "confirm sanctioned_restart gitea-author")
|
|
self.assertTrue(console_recovery.confirmation_matches("sanctioned_restart", "confirm sanctioned_restart gitea-author", "gitea-author"))
|
|
|
|
def test_build_recovery_preview(self) -> None:
|
|
principal = console_authz.Principal("[email protected]", console_authz.OPERATOR, console_authz.IDENTITY_LOCAL_DEV, True)
|
|
preview = console_recovery.build_recovery_preview(
|
|
playbook_id=console_recovery.PLAYBOOK_CLEAR_STALE_BINDING,
|
|
target="test-worktree",
|
|
principal=principal,
|
|
)
|
|
self.assertEqual(preview["playbook_id"], console_recovery.PLAYBOOK_CLEAR_STALE_BINDING)
|
|
self.assertEqual(preview["action_id"], console_recovery.ACTION_CLEAR_STALE_BINDING)
|
|
self.assertEqual(preview["confirmation_phrase"], "confirm clear_stale_binding test-worktree")
|
|
self.assertTrue(len(preview["mutation_ledger"]) >= 3)
|
|
self.assertTrue(preview["authorization"]["allowed"])
|
|
|
|
def test_build_recovery_preview_unknown_playbook(self) -> None:
|
|
preview = console_recovery.build_recovery_preview("unknown_playbook")
|
|
self.assertFalse(preview.get("allowed"))
|
|
self.assertEqual(preview.get("error"), "unknown_playbook")
|
|
|
|
def test_execute_recovery_playbook_confirmation_mismatch(self) -> None:
|
|
principal = console_authz.Principal("[email protected]", console_authz.OPERATOR, console_authz.IDENTITY_LOCAL_DEV, True)
|
|
result = console_recovery.execute_recovery_playbook(
|
|
playbook_id=console_recovery.PLAYBOOK_CLEAR_STALE_BINDING,
|
|
confirmation="invalid confirmation",
|
|
principal=principal,
|
|
)
|
|
self.assertFalse(result["success"])
|
|
self.assertFalse(result["allowed"])
|
|
self.assertEqual(result["error"], "confirmation_mismatch")
|
|
|
|
def test_execute_recovery_playbook_unauthorized(self) -> None:
|
|
# Anonymous principal has viewer role -> should be denied
|
|
result = console_recovery.execute_recovery_playbook(
|
|
playbook_id=console_recovery.PLAYBOOK_CLEAR_STALE_BINDING,
|
|
confirmation="confirm clear_stale_binding",
|
|
principal=console_authz.ANONYMOUS,
|
|
)
|
|
self.assertFalse(result["success"])
|
|
self.assertFalse(result["allowed"])
|
|
self.assertEqual(result["error"], console_authz.DENY_UNAUTHENTICATED)
|
|
|
|
def test_execute_recovery_playbook_clear_stale_binding_success(self) -> None:
|
|
principal = console_authz.Principal("[email protected]", console_authz.OPERATOR, console_authz.IDENTITY_LOCAL_DEV, True)
|
|
phrase = console_recovery.confirmation_phrase(console_recovery.PLAYBOOK_CLEAR_STALE_BINDING)
|
|
|
|
result = console_recovery.execute_recovery_playbook(
|
|
playbook_id=console_recovery.PLAYBOOK_CLEAR_STALE_BINDING,
|
|
confirmation=phrase,
|
|
principal=principal,
|
|
)
|
|
self.assertTrue(result["allowed"])
|
|
self.assertIn("applied_result", result)
|
|
self.assertIn("post_recovery_verification", result)
|
|
|
|
self.assertIn("audit", result)
|
|
self.assertEqual(result["audit"]["event"]["action"], console_recovery.ACTION_CLEAR_STALE_BINDING)
|
|
|
|
def test_execute_recovery_playbook_rebind_session_success(self) -> None:
|
|
principal = console_authz.Principal("[email protected]", console_authz.OPERATOR, console_authz.IDENTITY_LOCAL_DEV, True)
|
|
phrase = console_recovery.confirmation_phrase(console_recovery.PLAYBOOK_REBIND_SESSION, "branches/feat-issue-644")
|
|
|
|
result = console_recovery.execute_recovery_playbook(
|
|
playbook_id=console_recovery.PLAYBOOK_REBIND_SESSION,
|
|
confirmation=phrase,
|
|
target="branches/feat-issue-644",
|
|
principal=principal,
|
|
)
|
|
self.assertTrue(result["allowed"])
|
|
self.assertTrue(result["success"])
|
|
self.assertEqual(result["applied_result"]["rebound_worktree"], "branches/feat-issue-644")
|
|
|
|
def test_verify_post_recovery(self) -> None:
|
|
verification = console_recovery.verify_post_recovery()
|
|
self.assertIn("clean", verification)
|
|
self.assertIn("status", verification)
|
|
self.assertIn("reasons", verification)
|
|
|
|
|
|
class TestConsoleRecoveryApi(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.app = create_app()
|
|
self.client = TestClient(self.app)
|
|
|
|
def test_api_recovery_diagnose(self) -> None:
|
|
res = self.client.get("/api/v1/system/recovery/diagnose")
|
|
self.assertEqual(res.status_code, 200)
|
|
data = res.json()
|
|
self.assertIn("status", data)
|
|
self.assertIn("clean", data)
|
|
self.assertIn("playbooks", data)
|
|
self.assertTrue(len(data["playbooks"]) >= 4)
|
|
|
|
def test_api_recovery_preview(self) -> None:
|
|
res = self.client.post(
|
|
"/api/v1/system/recovery/preview",
|
|
json={"playbook_id": "clear_stale_binding", "target": "active"},
|
|
)
|
|
self.assertEqual(res.status_code, 200)
|
|
data = res.json()
|
|
self.assertEqual(data["playbook_id"], "clear_stale_binding")
|
|
self.assertEqual(data["confirmation_phrase"], "confirm clear_stale_binding active")
|
|
self.assertIn("mutation_ledger", data)
|
|
|
|
def test_api_recovery_apply_denied_without_auth(self) -> None:
|
|
res = self.client.post(
|
|
"/api/v1/system/recovery/apply",
|
|
json={"playbook_id": "clear_stale_binding", "confirmation": "confirm clear_stale_binding"},
|
|
)
|
|
self.assertEqual(res.status_code, 400)
|
|
data = res.json()
|
|
self.assertFalse(data["success"])
|
|
self.assertFalse(data["allowed"])
|
|
|
|
def test_api_recovery_apply_with_dev_auth(self) -> None:
|
|
env = {
|
|
"WEBUI_AUTH_MODE": "local_dev",
|
|
"WEBUI_DEV_SUBJECT": "[email protected]",
|
|
"WEBUI_DEV_ROLE": "operator",
|
|
}
|
|
with patch.dict(os.environ, env):
|
|
res = self.client.post(
|
|
"/api/v1/system/recovery/apply",
|
|
json={
|
|
"playbook_id": "rebind_session_worktree",
|
|
"target": "branches/feat-issue-644",
|
|
"confirmation": "confirm rebind_session_worktree branches/feat-issue-644",
|
|
},
|
|
)
|
|
self.assertEqual(res.status_code, 200)
|
|
data = res.json()
|
|
self.assertTrue(data["success"])
|
|
self.assertTrue(data["allowed"])
|
|
self.assertEqual(data["playbook_id"], "rebind_session_worktree")
|
|
|
|
def test_api_recovery_verify(self) -> None:
|
|
res = self.client.get("/api/v1/system/recovery/verify")
|
|
self.assertEqual(res.status_code, 200)
|
|
data = res.json()
|
|
self.assertIn("clean", data)
|
|
self.assertIn("status", data)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|