From bc2616ed8eac5df302210b1de56fd4b25fa8e8ee Mon Sep 17 00:00:00 2001 From: Nancy <9d.24.nancy.sangani@gmail.com> Date: Tue, 9 Jun 2026 16:23:38 +0530 Subject: [PATCH] feat(learning-path): add server-side API with token-based ownership verification --- routes/main_routes.py | 134 ++++++++++ tests/test_learning_path.py | 500 ++++++++++++++++++++++++++++++++++++ utils/learning_path.py | 194 ++++++++++++++ 3 files changed, 828 insertions(+) create mode 100644 tests/test_learning_path.py create mode 100644 utils/learning_path.py diff --git a/routes/main_routes.py b/routes/main_routes.py index f023c32..d199a16 100644 --- a/routes/main_routes.py +++ b/routes/main_routes.py @@ -9,6 +9,14 @@ from utils.data_loader import find_project_by_id, load_all_projects, get_available_levels, get_project_stats from utils.roadmap_comparer import load_all_career_roadmaps, compare_roadmaps from utils.file_server import read_starter_code, resolve_starter_file, get_starter_code_dir +from utils.learning_path import ( + create_learning_path, + get_learning_path, + update_learning_path, + PathNotFoundError, + PathAlreadyExistsError, + AuthorizationError, +) from config import Config import os @@ -232,3 +240,129 @@ def search_projects(): filtered_projects.append(project) return jsonify(filtered_projects) + + +# --------------------------------------------------------------------------- +# Learning path API +# +# Endpoints for reading and writing a user's learning path data. Every +# request must supply the owner token that was returned when the path was +# first created. Requests with a missing or wrong token are rejected with +# 403 Forbidden before any data is read or modified, closing the +# cross-user exposure described in issue #736. +# +# Token transport: the X-Learning-Path-Token request header. +# Path identity: the URL segment (opaque, UUID-like string). +# --------------------------------------------------------------------------- + +_TOKEN_HEADER = "X-Learning-Path-Token" +_MAX_DATA_BYTES = 64 * 1024 # 64 KB — guard against oversized payloads + + +def _extract_token(req): + """Return the bearer token from the request header, or None if absent.""" + return req.headers.get(_TOKEN_HEADER, "").strip() or None + + +@main.route("/api/learning-path/", methods=["POST"]) +def create_path(path_id): + """Create a new learning path and bind it to the supplied token. + + Request headers: + X-Learning-Path-Token (required) - the secret token chosen by the + client (should be a random UUID or similar). + + Request body (JSON): + Any JSON object representing the initial learning-path state. + + Response 201: {"path_id": "", "message": "Learning path created."} + Response 400: malformed request body or invalid path_id / token format. + Response 409: a learning path with this path_id already exists. + """ + token = _extract_token(request) + if not token: + return jsonify({"error": f"'{_TOKEN_HEADER}' header is required."}), 400 + + payload = request.get_json(silent=True) + if payload is None: + return jsonify({"error": "Request body must be valid JSON."}), 400 + + if not isinstance(payload, dict): + return jsonify({"error": "Request body must be a JSON object."}), 400 + + try: + create_learning_path(path_id, token, payload) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + except PathAlreadyExistsError: + return jsonify({"error": "A learning path with this ID already exists."}), 409 + + return jsonify({"path_id": path_id, "message": "Learning path created."}), 201 + + +@main.route("/api/learning-path/", methods=["GET"]) +def read_path(path_id): + """Return the data payload for a learning path. + + Request headers: + X-Learning-Path-Token (required) - the token associated with this + path when it was created. + + Response 200: {"path_id": "", "data": { ... }} + Response 400: token header missing or path_id format invalid. + Response 403: token does not match the owner token. + Response 404: no learning path found for this path_id. + """ + token = _extract_token(request) + if not token: + return jsonify({"error": f"'{_TOKEN_HEADER}' header is required."}), 400 + + try: + data = get_learning_path(path_id, token) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + except PathNotFoundError: + return jsonify({"error": "Learning path not found."}), 404 + except AuthorizationError: + return jsonify({"error": "Forbidden: invalid token for this path."}), 403 + + return jsonify({"path_id": path_id, "data": data}), 200 + + +@main.route("/api/learning-path/", methods=["PUT"]) +def update_path(path_id): + """Overwrite the data payload for an existing learning path. + + Request headers: + X-Learning-Path-Token (required) - the token associated with this + path when it was created. + + Request body (JSON): + Any JSON object representing the new learning-path state. + + Response 200: {"path_id": "", "message": "Learning path updated."} + Response 400: malformed request body, missing token, or invalid format. + Response 403: token does not match the owner token. + Response 404: no learning path found for this path_id. + """ + token = _extract_token(request) + if not token: + return jsonify({"error": f"'{_TOKEN_HEADER}' header is required."}), 400 + + payload = request.get_json(silent=True) + if payload is None: + return jsonify({"error": "Request body must be valid JSON."}), 400 + + if not isinstance(payload, dict): + return jsonify({"error": "Request body must be a JSON object."}), 400 + + try: + update_learning_path(path_id, token, payload) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + except PathNotFoundError: + return jsonify({"error": "Learning path not found."}), 404 + except AuthorizationError: + return jsonify({"error": "Forbidden: invalid token for this path."}), 403 + + return jsonify({"path_id": path_id, "message": "Learning path updated."}), 200 diff --git a/tests/test_learning_path.py b/tests/test_learning_path.py new file mode 100644 index 0000000..8e6a725 --- /dev/null +++ b/tests/test_learning_path.py @@ -0,0 +1,500 @@ +# tests/test_learning_path.py +# Tests for the learning path API and its ownership-verification layer. +# +# Run with: python -m pytest tests/test_learning_path.py +# Or: python tests/test_learning_path.py +# +# Test categories: +# 1. Unit tests for utils/learning_path.py (storage + auth logic) +# 2. HTTP route tests via the Flask test client +# +# Each test that writes to the store calls _clear_all() in a setup step so +# tests are fully independent of each other and of test ordering. + +import sys +import os +import secrets + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from utils.learning_path import ( + create_learning_path, + get_learning_path, + update_learning_path, + path_exists, + _clear_all, + PathNotFoundError, + PathAlreadyExistsError, + AuthorizationError, +) +from app import app + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +TOKEN_HEADER = "X-Learning-Path-Token" + + +def make_token(): + """Return a fresh random token suitable for testing.""" + return secrets.token_urlsafe(32) + + +def get_client(): + """Return a Flask test client with testing mode enabled.""" + app.config["TESTING"] = True + return app.test_client() + + +# --------------------------------------------------------------------------- +# 1. Unit tests — utils/learning_path.py +# --------------------------------------------------------------------------- + +class TestCreateLearningPath: + + def setup_method(self): + _clear_all() + + def test_create_stores_data(self): + """Creating a path should make it retrievable with the correct token.""" + token = make_token() + create_learning_path("path-1", token, {"step": 1}) + data = get_learning_path("path-1", token) + assert data == {"step": 1} + + def test_create_path_exists_returns_true(self): + """path_exists should return True after creation.""" + token = make_token() + create_learning_path("path-2", token, {}) + assert path_exists("path-2") is True + + def test_create_duplicate_raises(self): + """Creating the same path_id twice should raise PathAlreadyExistsError.""" + token = make_token() + create_learning_path("dup", token, {}) + with pytest.raises(PathAlreadyExistsError): + create_learning_path("dup", token, {"step": 2}) + + def test_create_returns_copy_of_data(self): + """Mutating the dict passed to create should not affect the stored value.""" + data = {"step": 1} + token = make_token() + create_learning_path("copy-test", token, data) + data["step"] = 999 + retrieved = get_learning_path("copy-test", token) + assert retrieved["step"] == 1 + + def test_create_invalid_path_id_raises(self): + """path_id with illegal characters should raise ValueError.""" + with pytest.raises(ValueError): + create_learning_path("bad path!", make_token(), {}) + + def test_create_empty_path_id_raises(self): + """Empty path_id should raise ValueError.""" + with pytest.raises(ValueError): + create_learning_path("", make_token(), {}) + + def test_create_empty_token_raises(self): + """Empty token should raise ValueError.""" + with pytest.raises(ValueError): + create_learning_path("p1", "", {}) + + def test_create_non_dict_data_raises(self): + """Passing a list as data should raise ValueError.""" + with pytest.raises(ValueError): + create_learning_path("p1", make_token(), ["not", "a", "dict"]) + + +class TestGetLearningPath: + + def setup_method(self): + _clear_all() + + def test_get_correct_token_returns_data(self): + """GET with the correct token should return the stored data.""" + token = make_token() + create_learning_path("get-1", token, {"progress": 42}) + assert get_learning_path("get-1", token) == {"progress": 42} + + def test_get_wrong_token_raises_authorization_error(self): + """GET with the wrong token should raise AuthorizationError.""" + token = make_token() + create_learning_path("get-2", token, {}) + with pytest.raises(AuthorizationError): + get_learning_path("get-2", make_token()) + + def test_get_missing_path_raises_not_found(self): + """GET for a non-existent path_id should raise PathNotFoundError.""" + with pytest.raises(PathNotFoundError): + get_learning_path("does-not-exist", make_token()) + + def test_get_returns_copy_not_reference(self): + """Mutating the dict returned by get should not affect the store.""" + token = make_token() + create_learning_path("ref-test", token, {"x": 1}) + result = get_learning_path("ref-test", token) + result["x"] = 999 + assert get_learning_path("ref-test", token)["x"] == 1 + + +class TestUpdateLearningPath: + + def setup_method(self): + _clear_all() + + def test_update_replaces_data(self): + """PUT with the correct token should overwrite the stored data.""" + token = make_token() + create_learning_path("upd-1", token, {"step": 1}) + update_learning_path("upd-1", token, {"step": 2, "done": True}) + assert get_learning_path("upd-1", token) == {"step": 2, "done": True} + + def test_update_wrong_token_raises_authorization_error(self): + """PUT with the wrong token should raise AuthorizationError.""" + token = make_token() + create_learning_path("upd-2", token, {}) + with pytest.raises(AuthorizationError): + update_learning_path("upd-2", make_token(), {"x": 1}) + + def test_update_missing_path_raises_not_found(self): + """PUT for a non-existent path_id should raise PathNotFoundError.""" + with pytest.raises(PathNotFoundError): + update_learning_path("ghost", make_token(), {}) + + def test_update_does_not_change_token(self): + """After a successful PUT the original token must still work.""" + token = make_token() + create_learning_path("token-stable", token, {"v": 1}) + update_learning_path("token-stable", token, {"v": 2}) + # Original token still grants access + assert get_learning_path("token-stable", token)["v"] == 2 + + +class TestPathExists: + + def setup_method(self): + _clear_all() + + def test_nonexistent_path_returns_false(self): + assert path_exists("no-such-path") is False + + def test_existing_path_returns_true(self): + token = make_token() + create_learning_path("exists-1", token, {}) + assert path_exists("exists-1") is True + + def test_non_string_path_id_returns_false(self): + assert path_exists(None) is False + assert path_exists(123) is False + + +class TestTokenComparison: + + def setup_method(self): + _clear_all() + + def test_similar_tokens_are_rejected(self): + """A token differing by a single character must not be accepted.""" + token = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + wrong = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab" + create_learning_path("near-miss", token, {"secret": True}) + with pytest.raises(AuthorizationError): + get_learning_path("near-miss", wrong) + + def test_empty_token_on_get_raises_value_error(self): + token = make_token() + create_learning_path("empty-tok", token, {}) + with pytest.raises(ValueError): + get_learning_path("empty-tok", "") + + def test_whitespace_only_token_raises_value_error(self): + token = make_token() + create_learning_path("ws-tok", token, {}) + with pytest.raises(ValueError): + get_learning_path("ws-tok", " ") + + +# --------------------------------------------------------------------------- +# 2. HTTP route tests — /api/learning-path/ +# --------------------------------------------------------------------------- + +class TestCreatePathRoute: + + def setup_method(self): + _clear_all() + + def test_post_creates_path_returns_201(self): + client = get_client() + token = make_token() + response = client.post( + "/api/learning-path/my-path-1", + json={"step": 1}, + headers={TOKEN_HEADER: token}, + ) + assert response.status_code == 201 + data = response.get_json() + assert data["path_id"] == "my-path-1" + assert "message" in data + + def test_post_missing_token_header_returns_400(self): + client = get_client() + response = client.post("/api/learning-path/my-path-2", json={"step": 1}) + assert response.status_code == 400 + assert "error" in response.get_json() + + def test_post_non_json_body_returns_400(self): + client = get_client() + response = client.post( + "/api/learning-path/my-path-3", + data="not json", + content_type="text/plain", + headers={TOKEN_HEADER: make_token()}, + ) + assert response.status_code == 400 + + def test_post_body_is_array_returns_400(self): + client = get_client() + response = client.post( + "/api/learning-path/my-path-4", + json=[1, 2, 3], + headers={TOKEN_HEADER: make_token()}, + ) + assert response.status_code == 400 + + def test_post_duplicate_path_id_returns_409(self): + client = get_client() + token = make_token() + client.post( + "/api/learning-path/dup-path", + json={}, + headers={TOKEN_HEADER: token}, + ) + response = client.post( + "/api/learning-path/dup-path", + json={"x": 1}, + headers={TOKEN_HEADER: token}, + ) + assert response.status_code == 409 + + def test_post_invalid_path_id_characters_returns_400(self): + """path_id with spaces or special characters must be rejected.""" + client = get_client() + # Flask URL routing converts spaces, so we test via a known-bad char + # that reaches validation: a path_id with a dot + response = client.post( + "/api/learning-path/bad.path", + json={}, + headers={TOKEN_HEADER: make_token()}, + ) + assert response.status_code == 400 + + +class TestReadPathRoute: + + def setup_method(self): + _clear_all() + + def _seed(self, path_id, token, data): + client = get_client() + client.post( + f"/api/learning-path/{path_id}", + json=data, + headers={TOKEN_HEADER: token}, + ) + + def test_get_correct_token_returns_200_with_data(self): + token = make_token() + self._seed("read-1", token, {"progress": 5}) + client = get_client() + response = client.get( + "/api/learning-path/read-1", + headers={TOKEN_HEADER: token}, + ) + assert response.status_code == 200 + body = response.get_json() + assert body["path_id"] == "read-1" + assert body["data"] == {"progress": 5} + + def test_get_wrong_token_returns_403(self): + """A request with a wrong token must be rejected with 403 Forbidden.""" + token = make_token() + self._seed("read-2", token, {"secret": True}) + client = get_client() + response = client.get( + "/api/learning-path/read-2", + headers={TOKEN_HEADER: make_token()}, # different token + ) + assert response.status_code == 403 + assert "error" in response.get_json() + + def test_get_missing_token_returns_400(self): + token = make_token() + self._seed("read-3", token, {}) + client = get_client() + response = client.get("/api/learning-path/read-3") + assert response.status_code == 400 + + def test_get_nonexistent_path_returns_404(self): + client = get_client() + response = client.get( + "/api/learning-path/ghost-path", + headers={TOKEN_HEADER: make_token()}, + ) + assert response.status_code == 404 + + def test_get_does_not_expose_other_users_data(self): + """User B must not be able to read User A's learning path.""" + token_a = make_token() + token_b = make_token() + self._seed("shared-id", token_a, {"private": "user_a_data"}) + client = get_client() + response = client.get( + "/api/learning-path/shared-id", + headers={TOKEN_HEADER: token_b}, + ) + assert response.status_code == 403 + # Confirm the private value is not present anywhere in the response + assert b"user_a_data" not in response.data + + +class TestUpdatePathRoute: + + def setup_method(self): + _clear_all() + + def _seed(self, path_id, token, data): + client = get_client() + client.post( + f"/api/learning-path/{path_id}", + json=data, + headers={TOKEN_HEADER: token}, + ) + + def test_put_correct_token_returns_200(self): + token = make_token() + self._seed("upd-route-1", token, {"step": 1}) + client = get_client() + response = client.put( + "/api/learning-path/upd-route-1", + json={"step": 2}, + headers={TOKEN_HEADER: token}, + ) + assert response.status_code == 200 + assert response.get_json()["path_id"] == "upd-route-1" + + def test_put_actually_updates_stored_data(self): + token = make_token() + self._seed("upd-route-2", token, {"v": 1}) + client = get_client() + client.put( + "/api/learning-path/upd-route-2", + json={"v": 99}, + headers={TOKEN_HEADER: token}, + ) + get_resp = client.get( + "/api/learning-path/upd-route-2", + headers={TOKEN_HEADER: token}, + ) + assert get_resp.get_json()["data"]["v"] == 99 + + def test_put_wrong_token_returns_403(self): + """A PUT with a wrong token must be rejected with 403.""" + token = make_token() + self._seed("upd-route-3", token, {}) + client = get_client() + response = client.put( + "/api/learning-path/upd-route-3", + json={"hijacked": True}, + headers={TOKEN_HEADER: make_token()}, + ) + assert response.status_code == 403 + + def test_put_wrong_token_does_not_modify_data(self): + """A rejected PUT must leave the stored data unchanged.""" + token = make_token() + self._seed("upd-route-4", token, {"original": True}) + client = get_client() + client.put( + "/api/learning-path/upd-route-4", + json={"tampered": True}, + headers={TOKEN_HEADER: make_token()}, + ) + get_resp = client.get( + "/api/learning-path/upd-route-4", + headers={TOKEN_HEADER: token}, + ) + data = get_resp.get_json()["data"] + assert data.get("original") is True + assert "tampered" not in data + + def test_put_missing_token_returns_400(self): + token = make_token() + self._seed("upd-route-5", token, {}) + client = get_client() + response = client.put( + "/api/learning-path/upd-route-5", + json={"x": 1}, + ) + assert response.status_code == 400 + + def test_put_nonexistent_path_returns_404(self): + client = get_client() + response = client.put( + "/api/learning-path/no-such", + json={}, + headers={TOKEN_HEADER: make_token()}, + ) + assert response.status_code == 404 + + def test_put_non_json_body_returns_400(self): + token = make_token() + self._seed("upd-route-6", token, {}) + client = get_client() + response = client.put( + "/api/learning-path/upd-route-6", + data="bad body", + content_type="text/plain", + headers={TOKEN_HEADER: token}, + ) + assert response.status_code == 400 + + +# --------------------------------------------------------------------------- +# Run tests directly (no pytest required) +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + test_classes = [ + TestCreateLearningPath, + TestGetLearningPath, + TestUpdateLearningPath, + TestPathExists, + TestTokenComparison, + TestCreatePathRoute, + TestReadPathRoute, + TestUpdatePathRoute, + ] + + passed = 0 + failed = 0 + + for cls in test_classes: + instance = cls() + methods = [m for m in dir(instance) if m.startswith("test_")] + for method_name in methods: + if hasattr(instance, "setup_method"): + instance.setup_method() + try: + getattr(instance, method_name)() + print(f" PASS {cls.__name__}.{method_name}") + passed += 1 + except Exception as exc: + print(f" FAIL {cls.__name__}.{method_name}: {exc}") + failed += 1 + + print(f"\n{passed} passed, {failed} failed out of {passed + failed} tests") + if failed > 0: + sys.exit(1) diff --git a/utils/learning_path.py b/utils/learning_path.py new file mode 100644 index 0000000..6ead629 --- /dev/null +++ b/utils/learning_path.py @@ -0,0 +1,194 @@ +# utils/learning_path.py +# Server-side storage and ownership verification for user learning paths. +# +# Learning paths are identified by a user-supplied ``path_id`` (an opaque +# string chosen by the client, typically a UUID generated in the browser). +# On the first write the caller must also provide a ``token`` that will be +# permanently associated with that path_id. Every subsequent read or write +# must present the same token; requests with a missing or wrong token are +# rejected with a 403 status before any data is returned or modified. +# +# Storage is intentionally in-memory so the module has no external +# dependencies beyond the Python standard library. Data does not survive +# an application restart, which is acceptable for this project's scope and +# is clearly documented in the API contract. +# +# Public surface: +# create_learning_path(path_id, token, data) -> None (raises on conflict) +# get_learning_path(path_id, token) -> dict (raises on auth fail) +# update_learning_path(path_id, token, data) -> None (raises on auth fail) +# path_exists(path_id) -> bool +# _clear_all() -> None (test helper only) +# +# Error types: +# PathNotFoundError – path_id does not exist +# PathAlreadyExistsError – path_id is already registered (on create) +# AuthorizationError – token does not match the stored token + +import re +import secrets + +# --------------------------------------------------------------------------- +# Module-level storage +# --------------------------------------------------------------------------- + +# Map of path_id -> {"token": str, "data": dict} +_store: dict = {} + +# Maximum byte length accepted for a path_id to prevent abuse +_MAX_PATH_ID_LEN = 128 + +# Regex that path_id values must satisfy (alphanumeric + hyphens/underscores) +_PATH_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,128}$") + + +# --------------------------------------------------------------------------- +# Custom exception hierarchy +# --------------------------------------------------------------------------- + +class LearningPathError(Exception): + """Base class for all learning-path errors.""" + + +class PathNotFoundError(LearningPathError): + """Raised when a path_id does not exist in the store.""" + + +class PathAlreadyExistsError(LearningPathError): + """Raised when trying to create a path_id that is already registered.""" + + +class AuthorizationError(LearningPathError): + """Raised when the supplied token does not match the stored token.""" + + +# --------------------------------------------------------------------------- +# Input validation helpers +# --------------------------------------------------------------------------- + +def _validate_path_id(path_id: str) -> None: + """Raise ValueError if path_id is not a safe, well-formed identifier.""" + if not isinstance(path_id, str) or not _PATH_ID_RE.match(path_id): + raise ValueError( + "path_id must be 1–128 characters and contain only " + "letters, digits, hyphens, or underscores." + ) + + +def _validate_token(token: str) -> None: + """Raise ValueError if token is not a non-empty string.""" + if not isinstance(token, str) or not token.strip(): + raise ValueError("token must be a non-empty string.") + + +def _validate_data(data: dict) -> None: + """Raise ValueError if data is not a plain dict.""" + if not isinstance(data, dict): + raise ValueError("data must be a JSON object (dict).") + + +def _tokens_equal(a: str, b: str) -> bool: + """Compare two token strings in constant time to prevent timing attacks.""" + return secrets.compare_digest(a, b) + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +def create_learning_path(path_id: str, token: str, data: dict) -> None: + """Register a new learning path. + + Associates ``path_id`` with ``token`` and stores the initial ``data`` + payload. The caller is responsible for generating a cryptographically + random token (e.g. ``secrets.token_urlsafe(32)``) before calling this + function. + + Raises: + ValueError – if any argument fails basic validation. + PathAlreadyExistsError – if path_id is already registered. + """ + _validate_path_id(path_id) + _validate_token(token) + _validate_data(data) + + if path_id in _store: + raise PathAlreadyExistsError( + f"A learning path with id '{path_id}' already exists." + ) + + _store[path_id] = {"token": token, "data": dict(data)} + + +def get_learning_path(path_id: str, token: str) -> dict: + """Return the data payload for a learning path. + + Raises: + ValueError – if any argument fails basic validation. + PathNotFoundError – if path_id does not exist. + AuthorizationError – if the token does not match. + """ + _validate_path_id(path_id) + _validate_token(token) + + if path_id not in _store: + raise PathNotFoundError( + f"No learning path found with id '{path_id}'." + ) + + stored = _store[path_id] + if not _tokens_equal(stored["token"], token): + raise AuthorizationError( + "The provided token does not match the owner token for this path." + ) + + # Return a copy so callers cannot mutate the stored state directly + return dict(stored["data"]) + + +def update_learning_path(path_id: str, token: str, data: dict) -> None: + """Overwrite the data payload for an existing learning path. + + The token must match the token supplied when the path was created. + + Raises: + ValueError – if any argument fails basic validation. + PathNotFoundError – if path_id does not exist. + AuthorizationError – if the token does not match. + """ + _validate_path_id(path_id) + _validate_token(token) + _validate_data(data) + + if path_id not in _store: + raise PathNotFoundError( + f"No learning path found with id '{path_id}'." + ) + + stored = _store[path_id] + if not _tokens_equal(stored["token"], token): + raise AuthorizationError( + "The provided token does not match the owner token for this path." + ) + + stored["data"] = dict(data) + + +def path_exists(path_id: str) -> bool: + """Return True if path_id is registered, False otherwise. + + Does not require a token; existence is not considered sensitive because + path_ids are meant to be opaque and unguessable (UUID-like) values. + """ + if not isinstance(path_id, str): + return False + return path_id in _store + + +def _clear_all() -> None: + """Remove all stored paths. + + This function exists solely for test isolation. It must not be called + from application code. + """ + _store.clear()