From 9db5fc6accefa3f99b28fce4d7e3f4a83eabeee0 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Wed, 7 Oct 2026 19:51:39 -0700 Subject: [PATCH] fix: decode tag names from the URL only once The tag lookup, rename and delete routes ran urllib.parse.unquote on match_info values that aiohttp had already percent-decoded, so a tag literally named '%41' (requested as %2541) was handled as 'A'. The second decode is dropped. --- py/api/prompts.py | 18 +++++++++--------- tests/test_api_prompts.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/py/api/prompts.py b/py/api/prompts.py index 0545651..c632476 100644 --- a/py/api/prompts.py +++ b/py/api/prompts.py @@ -387,9 +387,9 @@ class PromptRoutesMixin: async def get_tag_prompts(self, request): """Get prompts for a single tag.""" try: - from urllib.parse import unquote - - tag_name = unquote(request.match_info.get("tag_name", "")) + # aiohttp has already percent-decoded match_info; decoding again + # would turn a tag literally named "%41" into "A". + tag_name = request.match_info.get("tag_name", "") if not tag_name: return web.json_response( {"success": False, "error": "Tag name required"}, status=400 @@ -497,9 +497,9 @@ class PromptRoutesMixin: async def rename_tag_endpoint(self, request): """Rename a tag across all prompts.""" try: - from urllib.parse import unquote - - tag_name = unquote(request.match_info.get("tag_name", "")) + # aiohttp has already percent-decoded match_info; decoding again + # would turn a tag literally named "%41" into "A". + tag_name = request.match_info.get("tag_name", "") if not tag_name: return web.json_response( {"success": False, "error": "Tag name required"}, status=400 @@ -542,9 +542,9 @@ class PromptRoutesMixin: async def delete_tag_endpoint(self, request): """Delete a tag from all prompts.""" try: - from urllib.parse import unquote - - tag_name = unquote(request.match_info.get("tag_name", "")) + # aiohttp has already percent-decoded match_info; decoding again + # would turn a tag literally named "%41" into "A". + tag_name = request.match_info.get("tag_name", "") if not tag_name: return web.json_response( {"success": False, "error": "Tag name required"}, status=400 diff --git a/tests/test_api_prompts.py b/tests/test_api_prompts.py index c45716a..8f82b96 100644 --- a/tests/test_api_prompts.py +++ b/tests/test_api_prompts.py @@ -431,6 +431,39 @@ class TestPublicPathsInPromptResponses(RouteCoverageCase): self.assertIn("prompts.db", data["error"], route) +class TestTagNamesAreDecodedOnce(RouteCoverageCase): + """aiohttp already decodes match_info; a tag literally named '%41' stays '%41'.""" + + async def setUpAsync(self): + await super().setUpAsync() + self.pid = self._save_prompt("percent tag", tags=["%41"]) + + async def test_tag_prompts_lookup(self): + status, data = await self._json("GET", "/prompt_manager/tags/%2541/prompts") + + self.assertEqual(status, 200) + self.assertEqual(data["tag"], "%41") + self.assertEqual(len(data["prompts"]), 1) + + async def test_rename_tag(self): + status, data = await self._json( + "PUT", "/prompt_manager/tags/%2541", json={"new_name": "renamed"} + ) + + self.assertEqual(status, 200) + self.assertEqual(data["old_name"], "%41") + self.assertEqual(data["affected_count"], 1) + self.assertEqual(self.api.db.get_prompt_by_id(self.pid)["tags"], ["renamed"]) + + async def test_delete_tag(self): + status, data = await self._json("DELETE", "/prompt_manager/tags/%2541") + + self.assertEqual(status, 200) + self.assertEqual(data["tag_name"], "%41") + self.assertEqual(data["affected_count"], 1) + self.assertEqual(self.api.db.get_prompt_by_id(self.pid)["tags"], []) + + class TestTagMutationRoutes(RouteCoverageCase): async def test_rename_rejects_bad_json_and_empty_name(self):