feat: scoped host merge for non-admin config saves

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfPpSpccTWBfZg1FTveyaU
This commit is contained in:
2026-07-09 16:43:08 -04:00
co-authored by Claude Fable 5
parent 98b50dbbf4
commit 5c6f462081
2 changed files with 214 additions and 0 deletions
+134
View File
@@ -56,3 +56,137 @@ TCS = {
def test_user_threshold_configs_global_plus_own():
assert set(ca.user_threshold_configs(TCS, "alice")) == {"default", "alice_tc"}
assert set(ca.user_threshold_configs(TCS, "bob")) == {"default"}
# ---------------------------------------------------------------------------
# merge_hosts_scoped
# ---------------------------------------------------------------------------
MERGE_HOSTS = {
"mine": {"owner": "alice", "watch": True, "notification_channels": ["global_ch"]},
"managed": {"owner": "bob", "managers": ["alice"], "watch": True,
"notification_channels": ["bob_ch"]},
"foreign": {"owner": "bob", "watch": False},
}
MERGE_CHANNELS = {
"global_ch": {"type": "pushover"},
"alice_ch": {"type": "email", "owner": "alice"},
"bob_ch": {"type": "signal", "owner": "bob"},
}
MERGE_TCS = {"alice_tc": {"owner": "alice"}, "bob_tc": {"owner": "bob"}}
def _merge_hosts(payload, existing=None):
return ca.merge_hosts_scoped(
existing if existing is not None else dict(MERGE_HOSTS),
payload, "alice", MERGE_CHANNELS, MERGE_TCS,
)
def _full_payload(**overrides):
"""Payload covering alice's full visible subset, with per-host overrides."""
p = {
"mine": {"owner": "alice", "watch": True, "notification_channels": ["global_ch"]},
"managed": {"watch": True, "notification_channels": ["bob_ch"]},
}
p.update(overrides)
return p
def test_merge_hosts_preserves_foreign_hosts():
result = _merge_hosts(_full_payload())
assert result["foreign"] == {"owner": "bob", "watch": False}
def test_merge_hosts_manager_edits_settings():
result = _merge_hosts(_full_payload(managed={"watch": False, "dyndns": True,
"notification_channels": ["bob_ch"]}))
assert result["managed"]["watch"] is False
assert result["managed"]["dyndns"] is True
# access fields carried over untouched
assert result["managed"]["owner"] == "bob"
assert result["managed"]["managers"] == ["alice"]
def test_merge_hosts_manager_cannot_change_owner():
with pytest.raises(ca.ScopedMergeError, match="managed"):
_merge_hosts(_full_payload(managed={"watch": True, "owner": "alice"}))
def test_merge_hosts_manager_cannot_change_managers():
with pytest.raises(ca.ScopedMergeError, match="managers"):
_merge_hosts(_full_payload(managed={"watch": True, "managers": ["alice", "carol"]}))
def test_merge_hosts_manager_same_access_values_ok():
# Submitting unchanged access fields is not a violation.
result = _merge_hosts(_full_payload(managed={"watch": True, "owner": "bob",
"managers": ["alice"],
"notification_channels": ["bob_ch"]}))
assert result["managed"]["owner"] == "bob"
def test_merge_hosts_manager_cannot_delete():
payload = {"mine": {"owner": "alice", "watch": True}} # 'managed' missing
with pytest.raises(ca.ScopedMergeError, match="managed"):
_merge_hosts(payload)
def test_merge_hosts_owner_can_delete():
payload = _full_payload()
del payload["mine"]
result = _merge_hosts(payload)
assert "mine" not in result
assert "managed" in result and "foreign" in result
def test_merge_hosts_owner_can_transfer_ownership():
result = _merge_hosts(_full_payload(mine={"owner": "bob", "watch": True}))
assert result["mine"]["owner"] == "bob"
def test_merge_hosts_new_host_owner_forced():
result = _merge_hosts(_full_payload(newhost={"watch": True, "owner": "bob"}))
assert result["newhost"]["owner"] == "alice"
def test_merge_hosts_cannot_touch_foreign_host():
with pytest.raises(ca.ScopedMergeError, match="foreign"):
_merge_hosts(_full_payload(foreign={"watch": True}))
def test_merge_hosts_cannot_add_foreign_private_channel():
with pytest.raises(ca.ScopedMergeError, match="bob_ch"):
_merge_hosts(_full_payload(mine={"owner": "alice", "watch": True,
"notification_channels": ["bob_ch"]}))
def test_merge_hosts_keeps_preexisting_foreign_assignment():
# 'managed' already has bob_ch; keeping it is fine.
result = _merge_hosts(_full_payload())
assert result["managed"]["notification_channels"] == ["bob_ch"]
def test_merge_hosts_can_add_global_and_own_channel():
result = _merge_hosts(_full_payload(mine={"owner": "alice", "watch": True,
"notification_channels": ["global_ch", "alice_ch"]}))
assert result["mine"]["notification_channels"] == ["global_ch", "alice_ch"]
def test_merge_hosts_threshold_assignment_validated():
with pytest.raises(ca.ScopedMergeError, match="bob_tc"):
_merge_hosts(_full_payload(mine={"owner": "alice", "watch": True,
"threshold_config": ["bob_tc"]}))
result = _merge_hosts(_full_payload(mine={"owner": "alice", "watch": True,
"threshold_config": ["default", "alice_tc"]}))
assert result["mine"]["threshold_config"] == ["default", "alice_tc"]
def test_merge_hosts_rejects_unknown_fields():
with pytest.raises(ca.ScopedMergeError, match="sneaky"):
_merge_hosts(_full_payload(mine={"owner": "alice", "watch": True, "sneaky": 1}))
def test_merge_hosts_owner_clearing_list_removes_key():
result = _merge_hosts(_full_payload(mine={"owner": "alice", "watch": True}))
assert "notification_channels" not in result["mine"]