|
| 1 | +""" |
| 2 | +Regression tests for issue #15 — /api/set-workspace path validation. |
| 3 | +
|
| 4 | +Exercises validate_workspace_path() directly. Imports from utils/ to avoid |
| 5 | +pulling Flask into scope (tests/test_cli_args.py convention). |
| 6 | +
|
| 7 | +Run: |
| 8 | + python -m unittest tests.test_workspace_path_validation -v |
| 9 | +""" |
| 10 | + |
| 11 | +from __future__ import annotations |
| 12 | + |
| 13 | +import os |
| 14 | +import shutil |
| 15 | +import sys |
| 16 | +import tempfile |
| 17 | +import unittest |
| 18 | +from pathlib import Path |
| 19 | + |
| 20 | +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) |
| 21 | +sys.path.insert(0, REPO_ROOT) |
| 22 | + |
| 23 | +from utils.path_validation import WorkspacePathError, validate_workspace_path |
| 24 | + |
| 25 | + |
| 26 | +def _make_cursor_workspace_dir(parent: str, name: str = "real-storage") -> str: |
| 27 | + """Create a directory that looks like a Cursor workspaceStorage dir. |
| 28 | +
|
| 29 | + Layout: |
| 30 | + <parent>/<name>/ |
| 31 | + ws-001/state.vscdb ← marker file the validator looks for |
| 32 | + """ |
| 33 | + storage = os.path.join(parent, name) |
| 34 | + ws = os.path.join(storage, "ws-001") |
| 35 | + os.makedirs(ws) |
| 36 | + with open(os.path.join(ws, "state.vscdb"), "wb") as f: |
| 37 | + f.write(b"") |
| 38 | + return storage |
| 39 | + |
| 40 | + |
| 41 | +class TestValidateWorkspacePath(unittest.TestCase): |
| 42 | + |
| 43 | + def setUp(self): |
| 44 | + self.tmp = tempfile.mkdtemp(prefix="cursor-validate-test-") |
| 45 | + self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True) |
| 46 | + |
| 47 | + # ─── Happy path ──────────────────────────────────────────────── |
| 48 | + |
| 49 | + def test_accepts_directory_with_cursor_marker(self): |
| 50 | + storage = _make_cursor_workspace_dir(self.tmp) |
| 51 | + result = validate_workspace_path(storage) |
| 52 | + self.assertEqual(result, os.path.realpath(storage)) |
| 53 | + |
| 54 | + def test_returns_canonical_path_collapsing_dotdot(self): |
| 55 | + # /tmp/<x>/real-storage/../real-storage → /tmp/<x>/real-storage |
| 56 | + storage = _make_cursor_workspace_dir(self.tmp) |
| 57 | + traversal_input = os.path.join(storage, "..", os.path.basename(storage)) |
| 58 | + result = validate_workspace_path(traversal_input) |
| 59 | + self.assertEqual(result, os.path.realpath(storage)) |
| 60 | + # Assert no `..` *segment* in the canonical path (vs. a substring check |
| 61 | + # on the raw string, which would spuriously fail if the OS-supplied |
| 62 | + # tempdir name ever embedded `..` in a folder name). |
| 63 | + self.assertNotIn(os.pardir, Path(result).parts) |
| 64 | + |
| 65 | + # ─── Hard rejects ────────────────────────────────────────────── |
| 66 | + |
| 67 | + def test_rejects_empty_string(self): |
| 68 | + with self.assertRaises(WorkspacePathError) as ctx: |
| 69 | + validate_workspace_path("") |
| 70 | + self.assertIn("required", str(ctx.exception)) |
| 71 | + |
| 72 | + def test_rejects_whitespace_only(self): |
| 73 | + with self.assertRaises(WorkspacePathError): |
| 74 | + validate_workspace_path(" \t ") |
| 75 | + |
| 76 | + def test_rejects_non_string(self): |
| 77 | + with self.assertRaises(WorkspacePathError): |
| 78 | + validate_workspace_path(None) # type: ignore[arg-type] |
| 79 | + |
| 80 | + def test_rejects_non_existent_path(self): |
| 81 | + bogus = os.path.join(self.tmp, "does-not-exist", "anywhere") |
| 82 | + with self.assertRaises(WorkspacePathError) as ctx: |
| 83 | + validate_workspace_path(bogus) |
| 84 | + self.assertIn("does not exist", str(ctx.exception)) |
| 85 | + |
| 86 | + def test_rejects_file_not_directory(self): |
| 87 | + f = os.path.join(self.tmp, "regular-file") |
| 88 | + with open(f, "w") as h: |
| 89 | + h.write("not a directory") |
| 90 | + with self.assertRaises(WorkspacePathError) as ctx: |
| 91 | + validate_workspace_path(f) |
| 92 | + self.assertIn("not a directory", str(ctx.exception)) |
| 93 | + |
| 94 | + def test_rejects_directory_without_cursor_markers(self): |
| 95 | + # Existing directory but no state.vscdb anywhere — common case for |
| 96 | + # a user pointing at /tmp, /etc, /, ~/.ssh, etc. |
| 97 | + plain = os.path.join(self.tmp, "plain-dir") |
| 98 | + os.makedirs(os.path.join(plain, "subdir")) |
| 99 | + with self.assertRaises(WorkspacePathError) as ctx: |
| 100 | + validate_workspace_path(plain) |
| 101 | + self.assertIn("Cursor workspaceStorage", str(ctx.exception)) |
| 102 | + |
| 103 | + # ─── Path-traversal class ────────────────────────────────────── |
| 104 | + |
| 105 | + def test_traversal_into_non_workspace_is_rejected(self): |
| 106 | + # Keep traversal target inside this test's own temp tree — escaping |
| 107 | + # to /tmp itself would be non-deterministic (any other test or |
| 108 | + # process creating a `state.vscdb` under /tmp/<dir>/state.vscdb |
| 109 | + # would flip this test's outcome). |
| 110 | + # |
| 111 | + # <self.tmp>/isolated-root/storage/../.. → <self.tmp>/isolated-root |
| 112 | + # which contains no state.vscdb under any subdir → reject on markers. |
| 113 | + isolated_root = os.path.join(self.tmp, "isolated-root") |
| 114 | + os.makedirs(isolated_root) |
| 115 | + storage = _make_cursor_workspace_dir(isolated_root) |
| 116 | + escape = os.path.join(storage, "..", "..") |
| 117 | + with self.assertRaises(WorkspacePathError): |
| 118 | + validate_workspace_path(escape) |
| 119 | + |
| 120 | + # ─── Symlink-escape class ────────────────────────────────────── |
| 121 | + # POSIX-only; CI runs tests on ubuntu-latest so these still run in CI. |
| 122 | + |
| 123 | + @unittest.skipIf(sys.platform == "win32", "POSIX symlinks only") |
| 124 | + def test_symlink_to_non_workspace_is_rejected(self): |
| 125 | + # A symlink that points to / (no Cursor markers) is rejected because |
| 126 | + # realpath() resolves to the real target before the marker check. |
| 127 | + link = os.path.join(self.tmp, "evil-link") |
| 128 | + os.symlink("/", link) |
| 129 | + with self.assertRaises(WorkspacePathError) as ctx: |
| 130 | + validate_workspace_path(link) |
| 131 | + self.assertIn("Cursor workspaceStorage", str(ctx.exception)) |
| 132 | + |
| 133 | + @unittest.skipIf(sys.platform == "win32", "POSIX symlinks only") |
| 134 | + def test_symlink_to_real_workspace_is_canonicalised_and_accepted(self): |
| 135 | + # Symlink → real Cursor storage. Accepted, but the canonical path |
| 136 | + # returned is the realpath (the storage dir), NOT the symlink path. |
| 137 | + storage = _make_cursor_workspace_dir(self.tmp) |
| 138 | + link = os.path.join(self.tmp, "good-link") |
| 139 | + os.symlink(storage, link) |
| 140 | + result = validate_workspace_path(link) |
| 141 | + self.assertEqual(result, os.path.realpath(storage)) |
| 142 | + self.assertNotEqual(result, link) |
| 143 | + |
| 144 | + |
| 145 | +class TestSetWorkspaceApi(unittest.TestCase): |
| 146 | + """API-layer regressions for POST /api/set-workspace. |
| 147 | +
|
| 148 | + The validator helper has its own coverage above; these cases exist to |
| 149 | + pin behaviour the API handler owns (request body shape handling, |
| 150 | + HTTP status mapping). Notably the non-dict-body case which used to |
| 151 | + surface as a 500 instead of a 400 — see CodeRabbit on PR #16. |
| 152 | + """ |
| 153 | + |
| 154 | + def setUp(self): |
| 155 | + from flask import Flask |
| 156 | + from api.config_api import bp as config_bp |
| 157 | + from utils.workspace_path import set_workspace_path_override |
| 158 | + |
| 159 | + self.tmp = tempfile.mkdtemp(prefix="cursor-validate-api-test-") |
| 160 | + self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True) |
| 161 | + # Reset the module-global workspace override after each test. The |
| 162 | + # 200-path test below mutates it via the API and the tempdir is then |
| 163 | + # rmtree'd by the cleanup above — without this, a future sibling test |
| 164 | + # inspecting the override would see a stale, now-deleted path. |
| 165 | + self.addCleanup(set_workspace_path_override, None) |
| 166 | + |
| 167 | + app = Flask(__name__) |
| 168 | + app.config["TESTING"] = True |
| 169 | + app.register_blueprint(config_bp) |
| 170 | + self.client = app.test_client() |
| 171 | + |
| 172 | + def test_non_dict_json_array_returns_400_not_500(self): |
| 173 | + # Regression: a JSON array body (truthy, non-dict) used to trip |
| 174 | + # AttributeError on body.get(...) and surface as a 500. |
| 175 | + resp = self.client.post( |
| 176 | + "/api/set-workspace", |
| 177 | + data="[]", |
| 178 | + content_type="application/json", |
| 179 | + ) |
| 180 | + self.assertEqual(resp.status_code, 400) |
| 181 | + self.assertIn("error", resp.get_json()) |
| 182 | + |
| 183 | + def test_non_dict_json_string_returns_400(self): |
| 184 | + resp = self.client.post( |
| 185 | + "/api/set-workspace", |
| 186 | + data='"some string"', |
| 187 | + content_type="application/json", |
| 188 | + ) |
| 189 | + self.assertEqual(resp.status_code, 400) |
| 190 | + |
| 191 | + def test_non_dict_json_number_returns_400(self): |
| 192 | + resp = self.client.post( |
| 193 | + "/api/set-workspace", |
| 194 | + data="42", |
| 195 | + content_type="application/json", |
| 196 | + ) |
| 197 | + self.assertEqual(resp.status_code, 400) |
| 198 | + |
| 199 | + def test_dict_with_valid_path_returns_200_with_canonical(self): |
| 200 | + storage = _make_cursor_workspace_dir(self.tmp) |
| 201 | + resp = self.client.post( |
| 202 | + "/api/set-workspace", |
| 203 | + json={"path": storage}, |
| 204 | + ) |
| 205 | + self.assertEqual(resp.status_code, 200) |
| 206 | + body = resp.get_json() |
| 207 | + self.assertTrue(body["success"]) |
| 208 | + self.assertEqual(body["path"], os.path.realpath(storage)) |
| 209 | + |
| 210 | + def test_validate_path_returns_canonical_and_count(self): |
| 211 | + storage = _make_cursor_workspace_dir(self.tmp) |
| 212 | + resp = self.client.post("/api/validate-path", json={"path": storage}) |
| 213 | + self.assertEqual(resp.status_code, 200) |
| 214 | + data = resp.get_json() |
| 215 | + self.assertTrue(data["valid"]) |
| 216 | + self.assertGreaterEqual(data["workspaceCount"], 1) |
| 217 | + self.assertEqual(data["path"], os.path.realpath(storage)) |
| 218 | + |
| 219 | + def test_validate_path_invalid_returns_error(self): |
| 220 | + plain = os.path.join(self.tmp, "no-markers") |
| 221 | + os.makedirs(plain) |
| 222 | + resp = self.client.post("/api/validate-path", json={"path": plain}) |
| 223 | + self.assertEqual(resp.status_code, 200) |
| 224 | + data = resp.get_json() |
| 225 | + self.assertFalse(data["valid"]) |
| 226 | + self.assertIn("error", data) |
| 227 | + |
| 228 | + def test_validate_path_non_dict_json_returns_structured_error(self): |
| 229 | + # Mirror set_workspace: truthy non-dict JSON must not reach body.get. |
| 230 | + resp = self.client.post( |
| 231 | + "/api/validate-path", |
| 232 | + data='"not an object"', |
| 233 | + content_type="application/json", |
| 234 | + ) |
| 235 | + self.assertEqual(resp.status_code, 200) |
| 236 | + data = resp.get_json() |
| 237 | + self.assertFalse(data["valid"]) |
| 238 | + self.assertEqual(data["error"], "invalid JSON body") |
| 239 | + self.assertEqual(data["workspaceCount"], 0) |
| 240 | + |
| 241 | + |
| 242 | +if __name__ == "__main__": |
| 243 | + unittest.main() |
0 commit comments