Files
mempalace/tests/test_palace_graph_tunnels.py
T
Arnold Wender 5fd09d3693 fix(security): restrict tunnels.json file permissions
~/.mempalace/tunnels.json (introduced in #790) was created via plain
open(..., "w") with no chmod, and its parent dir via os.makedirs()
without mode=0o700. On Linux with default umask 022 both end up
world-readable (0o644 / 0o755).

Tunnels reveal cross-wing connections — which projects, people, and
rooms the user has explicitly linked — so they are sensitive metadata
that should not be readable by other local users on shared systems.

Apply the same 0o700 / 0o600 pattern that #814 established for the
other sensitive palace files. Chmod calls are wrapped in try/except
(OSError, NotImplementedError) for Windows / unsupported-filesystem
compatibility.

Closes #1165
2026-04-24 22:57:34 +02:00

168 lines
6.0 KiB
Python

"""Tests for explicit tunnel helpers in mempalace.palace_graph."""
import os
import stat
import sys
from unittest.mock import MagicMock, patch
import pytest
with patch.dict("sys.modules", {"chromadb": MagicMock()}):
import mempalace.palace_graph as palace_graph
def _use_tmp_tunnel_file(monkeypatch, tmp_path):
tunnel_file = tmp_path / "tunnels.json"
monkeypatch.setattr(palace_graph, "_TUNNEL_FILE", str(tunnel_file))
return tunnel_file
class TestTunnelStorage:
def test_load_tunnels_missing_file_returns_empty_list(self, tmp_path, monkeypatch):
_use_tmp_tunnel_file(monkeypatch, tmp_path)
assert palace_graph._load_tunnels() == []
def test_load_tunnels_corrupt_file_returns_empty_list(self, tmp_path, monkeypatch):
tunnel_file = _use_tmp_tunnel_file(monkeypatch, tmp_path)
tunnel_file.write_text("{not valid json", encoding="utf-8")
assert palace_graph._load_tunnels() == []
def test_save_and_load_round_trip(self, tmp_path, monkeypatch):
_use_tmp_tunnel_file(monkeypatch, tmp_path)
tunnels = [
{
"id": "abc123",
"source": {"wing": "wing_code", "room": "auth"},
"target": {"wing": "wing_people", "room": "users"},
"label": "same concept",
}
]
palace_graph._save_tunnels(tunnels)
assert palace_graph._load_tunnels() == tunnels
@pytest.mark.skipif(
sys.platform == "win32",
reason="POSIX file-permission bits only apply on Unix-like systems",
)
def test_save_tunnels_restricts_permissions(self, tmp_path, monkeypatch):
"""Regression for #1165 — tunnels.json reveals cross-wing links and
must not be world-readable on shared Linux/multi-user systems."""
tunnel_file = _use_tmp_tunnel_file(monkeypatch, tmp_path)
palace_graph._save_tunnels(
[
{
"id": "x",
"source": {"wing": "a", "room": "r1"},
"target": {"wing": "b", "room": "r2"},
"label": "",
}
]
)
file_mode = stat.S_IMODE(os.stat(tunnel_file).st_mode)
assert file_mode == 0o600, f"tunnels.json mode is {oct(file_mode)}, expected 0o600"
parent_mode = stat.S_IMODE(os.stat(tunnel_file.parent).st_mode)
assert (
parent_mode == 0o700
), f"tunnels.json parent dir mode is {oct(parent_mode)}, expected 0o700"
class TestExplicitTunnels:
def test_create_tunnel_deduplicates_reverse_order_and_updates_label(
self, tmp_path, monkeypatch
):
_use_tmp_tunnel_file(monkeypatch, tmp_path)
first = palace_graph.create_tunnel(
"wing_code", "auth", "wing_people", "users", label="same concept"
)
second = palace_graph.create_tunnel(
"wing_people", "users", "wing_code", "auth", label="updated label"
)
assert first["id"] == second["id"]
assert len(palace_graph.list_tunnels()) == 1
assert second["label"] == "updated label"
assert second["created_at"] == first["created_at"]
assert "updated_at" in second
def test_create_tunnel_rejects_empty_names(self, tmp_path, monkeypatch):
_use_tmp_tunnel_file(monkeypatch, tmp_path)
with pytest.raises(ValueError):
palace_graph.create_tunnel("", "auth", "wing_people", "users")
def test_list_tunnels_filters_by_either_side(self, tmp_path, monkeypatch):
_use_tmp_tunnel_file(monkeypatch, tmp_path)
palace_graph.create_tunnel("wing_code", "auth", "wing_people", "users", label="A")
palace_graph.create_tunnel("wing_ops", "deploy", "wing_people", "users", label="B")
assert len(palace_graph.list_tunnels()) == 2
assert len(palace_graph.list_tunnels("wing_people")) == 2
assert len(palace_graph.list_tunnels("wing_code")) == 1
def test_delete_tunnel_removes_saved_tunnel(self, tmp_path, monkeypatch):
_use_tmp_tunnel_file(monkeypatch, tmp_path)
tunnel = palace_graph.create_tunnel(
"wing_code", "auth", "wing_people", "users", label="same concept"
)
assert palace_graph.delete_tunnel(tunnel["id"]) == {"deleted": tunnel["id"]}
assert palace_graph.list_tunnels() == []
def test_follow_tunnels_returns_direction_and_preview(self, tmp_path, monkeypatch):
_use_tmp_tunnel_file(monkeypatch, tmp_path)
palace_graph.create_tunnel(
"wing_code",
"auth",
"wing_people",
"users",
label="same concept",
target_drawer_id="drawer_users_1",
)
col = MagicMock()
col.get.return_value = {
"ids": ["drawer_users_1"],
"documents": ["A" * 400],
"metadatas": [{}],
}
outgoing = palace_graph.follow_tunnels("wing_code", "auth", col=col)
assert len(outgoing) == 1
assert outgoing[0]["direction"] == "outgoing"
assert outgoing[0]["connected_wing"] == "wing_people"
assert outgoing[0]["connected_room"] == "users"
assert outgoing[0]["drawer_id"] == "drawer_users_1"
assert len(outgoing[0]["drawer_preview"]) == 300
incoming = palace_graph.follow_tunnels("wing_people", "users", col=col)
assert len(incoming) == 1
assert incoming[0]["direction"] == "incoming"
assert incoming[0]["connected_wing"] == "wing_code"
def test_follow_tunnels_returns_connections_even_if_collection_lookup_fails(
self, tmp_path, monkeypatch
):
_use_tmp_tunnel_file(monkeypatch, tmp_path)
palace_graph.create_tunnel(
"wing_code",
"auth",
"wing_people",
"users",
label="same concept",
target_drawer_id="drawer_users_1",
)
col = MagicMock()
col.get.side_effect = RuntimeError("boom")
connections = palace_graph.follow_tunnels("wing_code", "auth", col=col)
assert len(connections) == 1
assert "drawer_preview" not in connections[0]