Skip to content

Commit 5ea782c

Browse files
committed
test: add coverage for paths.py validation utilities
validate_identifier and ensure_within_root are the core path-safety primitives used across store, mailbox, and workspace modules but had zero dedicated tests. Adds 30 tests covering valid/invalid identifiers, allow_empty flag, custom error kind, path traversal via .., absolute segments, symlink escapes, and normal join behavior. Made-with: Cursor
1 parent ef0e104 commit 5ea782c

File tree

1 file changed

+103
-0
lines changed

1 file changed

+103
-0
lines changed

tests/test_paths.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"""Tests for clawteam.paths — identifier validation and path containment."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
7+
from clawteam.paths import ensure_within_root, validate_identifier
8+
9+
10+
class TestValidateIdentifier:
11+
"""validate_identifier accepts safe names and rejects dangerous ones."""
12+
13+
@pytest.mark.parametrize(
14+
"value",
15+
[
16+
"alice",
17+
"my-team",
18+
"agent_01",
19+
"v2.0",
20+
"A",
21+
"0",
22+
"a.b-c_d",
23+
"UPPER.lower-123_456",
24+
],
25+
)
26+
def test_valid_identifiers(self, value):
27+
assert validate_identifier(value) == value
28+
29+
def test_dots_only_allowed(self):
30+
assert validate_identifier("..") == ".."
31+
assert validate_identifier(".") == "."
32+
33+
@pytest.mark.parametrize(
34+
"value",
35+
[
36+
"",
37+
" ",
38+
"../etc",
39+
"foo/bar",
40+
"foo\\bar",
41+
"name with space",
42+
"tab\there",
43+
"new\nline",
44+
"\x00null",
45+
"café",
46+
"日本語",
47+
],
48+
)
49+
def test_invalid_identifiers(self, value):
50+
with pytest.raises(ValueError, match="Invalid"):
51+
validate_identifier(value)
52+
53+
def test_allow_empty_true(self):
54+
assert validate_identifier("", allow_empty=True) == ""
55+
56+
def test_allow_empty_false_rejects_empty(self):
57+
with pytest.raises(ValueError, match="must not be empty"):
58+
validate_identifier("", allow_empty=False)
59+
60+
def test_custom_kind_in_error(self):
61+
with pytest.raises(ValueError, match="Invalid team name"):
62+
validate_identifier("bad/name", kind="team name")
63+
64+
65+
class TestEnsureWithinRoot:
66+
"""ensure_within_root prevents path traversal escapes."""
67+
68+
def test_simple_join(self, tmp_path):
69+
result = ensure_within_root(tmp_path, "teams", "alpha")
70+
assert result == tmp_path / "teams" / "alpha"
71+
72+
def test_single_part(self, tmp_path):
73+
result = ensure_within_root(tmp_path, "config.json")
74+
assert result == tmp_path / "config.json"
75+
76+
def test_dotdot_rejected(self, tmp_path):
77+
with pytest.raises(ValueError, match="escapes"):
78+
ensure_within_root(tmp_path, "..", "etc", "passwd")
79+
80+
def test_absolute_segment_rejected(self, tmp_path):
81+
with pytest.raises(ValueError, match="escapes"):
82+
ensure_within_root(tmp_path, "/etc/passwd")
83+
84+
def test_dotdot_in_middle_rejected(self, tmp_path):
85+
child = tmp_path / "sub"
86+
child.mkdir()
87+
with pytest.raises(ValueError, match="escapes"):
88+
ensure_within_root(child, "..", "..", "outside")
89+
90+
def test_symlink_escape_rejected(self, tmp_path):
91+
legit = tmp_path / "data"
92+
legit.mkdir()
93+
outside = tmp_path / "secret"
94+
outside.mkdir()
95+
link = legit / "escape"
96+
link.symlink_to(outside)
97+
with pytest.raises(ValueError, match="escapes"):
98+
ensure_within_root(legit, "escape")
99+
100+
def test_returns_unresolved_path_on_success(self, tmp_path):
101+
result = ensure_within_root(tmp_path, "a", "b")
102+
assert result == tmp_path / "a" / "b"
103+
assert not result.is_absolute() or str(result).startswith(str(tmp_path))

0 commit comments

Comments
 (0)