diff --git a/server/server.py b/server/server.py index 8bdac6c..4a62b28 100644 --- a/server/server.py +++ b/server/server.py @@ -343,6 +343,29 @@ def auto_register_default(): print(f"[init] Registered default project: {path}") +def register_project(raw_path: str, raw_name: str | None = None) -> tuple: + """Register a project for raw_path. Returns (payload, status). + + Paths without an .md suffix get backlog.md appended; a missing file is + created from the blank template; the name defaults to the parent dir name. + """ + path = Path(raw_path).expanduser().resolve() + if not path.name.endswith(".md"): + path = path / "backlog.md" + name = safe_project_name(raw_name or path.parent.name) + if not name: + return {"ok": False, "error": "Invalid project name"}, 400 + registry = CONFIG.load_registry() + if name in registry: + return {"ok": False, "error": f"Project '{name}' already registered"}, 409 + if not path.exists(): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(build_markdown("", BLANK_HISTORY), encoding="utf-8") + registry[name] = {"path": str(path), "name": name} + CONFIG.save_registry(registry) + return {"ok": True, "name": name, "path": str(path)}, 200 + + # --------------------------------------------------------------------------- # HTTP Handler # --------------------------------------------------------------------------- @@ -358,7 +381,7 @@ class Handler(BaseHTTPRequestHandler): self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.send_header("Access-Control-Allow-Origin", "*") - self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + self.send_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS") self.send_header("Access-Control-Allow-Headers", "Content-Type") self.end_headers() self.wfile.write(body) @@ -418,7 +441,7 @@ class Handler(BaseHTTPRequestHandler): def do_OPTIONS(self): self.send_response(204) self.send_header("Access-Control-Allow-Origin", "*") - self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + self.send_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS") self.send_header("Access-Control-Allow-Headers", "Content-Type") self.end_headers() @@ -554,6 +577,22 @@ class Handler(BaseHTTPRequestHandler): parsed = urlparse(self.path) path = parsed.path + if path == "/api/projects": + body = self._read_json_body_or_400() + if body is None: + return + raw_path = body.get("path") + if not isinstance(raw_path, str) or not raw_path: + self._json_response({"ok": False, "error": "Missing 'path'"}, 400) + return + raw_name = body.get("name") + if raw_name is not None and not isinstance(raw_name, str): + self._json_response({"ok": False, "error": "Invalid project name"}, 400) + return + payload, status = register_project(raw_path, raw_name) + self._json_response(payload, status) + return + if path.startswith("/api/projects/"): name, action = self._project_route_parts(path) body = self._read_json_body_or_400() @@ -562,6 +601,21 @@ class Handler(BaseHTTPRequestHandler): paths = self._resolve_project_or_404(name) if paths is None: return + if action == "rename": + new_name = safe_project_name(body.get("newName", "")) + if not new_name: + self._json_response({"ok": False, "error": "Invalid project name"}, 400) + return + registry = CONFIG.load_registry() + if new_name in registry: + self._json_response( + {"ok": False, "error": f"Project '{new_name}' already registered"}, 409) + return + entry = registry.pop(name) + registry[new_name] = {"path": entry["path"], "name": new_name} + CONFIG.save_registry(registry) + self._json_response({"ok": True, "name": new_name}) + return if action == "backlog": result = write_master_at(body.get("content", ""), paths) self._json_response(result) @@ -671,6 +725,38 @@ class Handler(BaseHTTPRequestHandler): self._json_response({"error": "Not found"}, 404) + def do_DELETE(self): + parsed = urlparse(self.path) + path = parsed.path + qs = parse_qs(parsed.query) + + if path.startswith("/api/projects/"): + name, action = self._project_route_parts(path) + if action: + self._json_response({"error": "Not found"}, 404) + return + registry = CONFIG.load_registry() + if name not in registry: + self._json_response({"error": "Project not found"}, 404) + return + if len(registry) == 1: + self._json_response({"error": "At least one project must remain registered"}, 400) + return + master = Path(registry[name]["path"]).resolve() + del registry[name] + CONFIG.save_registry(registry) + result = {"ok": True, "name": name, "deleted": False} + if qs.get("delete_file", [""])[0] == "true" and master.exists(): + try: + master.unlink() + result["deleted"] = True + except OSError as e: + result["warning"] = f"Could not delete file: {e}" + self._json_response(result) + return + + self._json_response({"error": "Not found"}, 404) + # --------------------------------------------------------------------------- # Main diff --git a/tests/test_server.py b/tests/test_server.py index 0e4ef27..8441c72 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -13,6 +13,7 @@ Two layers: import http.client import io import json +import os import tempfile import threading import unittest @@ -420,5 +421,254 @@ class TestProjectNamespace(unittest.TestCase): self.assertEqual(data["content"], make_content("late-registered")) +class TestProjectManagement(unittest.TestCase): + """Todo 3: register / unregister / rename endpoints (failing-first).""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self._orig_config = ss.CONFIG + ss.CONFIG = ss.Config(Path(self.tmp.name), 0) + ss.initialize_storage() + self.httpd = HTTPServer(("127.0.0.1", 0), ss.Handler) + self.port = self.httpd.server_address[1] + self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True) + self.thread.start() + self.addCleanup(setattr, ss, "CONFIG", self._orig_config) + self.addCleanup(self.httpd.server_close) + self.addCleanup(self.httpd.shutdown) + + def request(self, method: str, path: str, json_body=None, raw_body=None): + if raw_body is not None: + payload = raw_body.encode("utf-8") + elif json_body is not None: + payload = json.dumps(json_body).encode("utf-8") + else: + payload = None + headers = {"Content-Type": "application/json"} if payload is not None else {} + conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10) + conn.request(method, path, body=payload, headers=headers) + resp = conn.getresponse() + data = resp.read() + conn.close() + return resp.status, data.decode("utf-8") + + def request_json(self, method: str, path: str, json_body=None, raw_body=None): + status, text = self.request(method, path, json_body=json_body, raw_body=raw_body) + return status, json.loads(text) + + def registry_on_disk(self) -> dict: + with open(ss.CONFIG.registry_file, encoding="utf-8") as f: + return json.load(f) + + def project_names(self) -> list: + _, data = self.request_json("GET", "/api/projects") + return [p["name"] for p in data["projects"]] + + def register_via_api(self, path, name=None): + body = {"path": str(path)} if name is None else {"path": str(path), "name": name} + return self.request_json("POST", "/api/projects", json_body=body) + + # (a) register a directory path → backlog.md appended, blank file created, listed + def test_register_creates_blank_backlog_and_lists(self): + proj_dir = Path(self.tmp.name) / "proj" + expected_file = proj_dir / "backlog.md" + status, resp = self.register_via_api(proj_dir) + self.assertEqual(status, 200) + self.assertEqual(resp, {"ok": True, "name": "proj", "path": str(expected_file)}) + + # File created on disk with the blank template (mirrors startup creation) + self.assertTrue(expected_file.is_file()) + content = expected_file.read_text(encoding="utf-8") + self.assertIn("# Backlog", content) + self.assertIn("", content) + self.assertIn("", content) + self.assertIn(ss.BLANK_HISTORY, content) + self.assertTrue(content.rstrip().endswith("-->")) # integrity marker present + + # Listed by GET /api/projects and present in the on-disk registry + self.assertIn("proj", self.project_names()) + self.assertEqual(self.registry_on_disk()["proj"], + {"path": str(expected_file), "name": "proj"}) + + # (b) registering the same derived name again → 409, registry unchanged + def test_register_duplicate_name_409(self): + proj_dir = Path(self.tmp.name) / "proj" + status, _ = self.register_via_api(proj_dir) + self.assertEqual(status, 200) + before = self.registry_on_disk() + + status, resp = self.register_via_api(proj_dir) + self.assertEqual(status, 409) + self.assertEqual(resp, {"ok": False, + "error": "Project 'proj' already registered"}) + self.assertEqual(self.registry_on_disk(), before) + + # (c) unsafe display name is mangled by safe_project_name + def test_register_mangles_unsafe_name(self): + proj_dir = Path(self.tmp.name) / "p2" + status, resp = self.register_via_api(proj_dir, name="My Proj!") + self.assertEqual(status, 200) + self.assertEqual(resp["name"], "My-Proj-") + + # Empty-string name falls back to the directory name + status, resp = self.register_via_api(Path(self.tmp.name) / "myproj", name="") + self.assertEqual(status, 200) + self.assertEqual(resp["name"], "myproj") + + # (d) rename: old key gone, content reachable under the new key. + # Documented choice: the re-keyed entry is appended at the END of the + # registry (insertion position is not preserved) — pinned by the order assert. + def test_rename_moves_key_and_keeps_content(self): + proj_dir = Path(self.tmp.name) / "proj" + status, _ = self.register_via_api(proj_dir) + self.assertEqual(status, 200) + content = make_content("renamed-project") + status, _ = self.request_json("POST", "/api/projects/proj/backlog", + json_body={"content": content}) + self.assertEqual(status, 200) + + status, resp = self.request_json("POST", "/api/projects/proj/rename", + json_body={"newName": "beta"}) + self.assertEqual(status, 200) + self.assertEqual(resp, {"ok": True, "name": "beta"}) + + self.assertEqual(self.project_names(), ["default", "beta"]) # append-at-end + self.assertNotIn("proj", self.registry_on_disk()) + status, data = self.request_json("GET", "/api/projects/beta/backlog") + self.assertEqual(status, 200) + self.assertEqual(data["content"], content) + # path preserved through the rename + self.assertEqual(self.registry_on_disk()["beta"]["path"], str(proj_dir / "backlog.md")) + status, _ = self.request("GET", "/api/projects/proj/backlog") + self.assertEqual(status, 404) + + # (e1) DELETE without flag → registry entry gone, file (and neighbors) untouched + def test_delete_without_flag_keeps_file(self): + proj_dir = Path(self.tmp.name) / "proj" + status, _ = self.register_via_api(proj_dir) + self.assertEqual(status, 200) + master = proj_dir / "backlog.md" + backups_dir = proj_dir / "backups" + backups_dir.mkdir() + (backups_dir / "backlog_manual.md").write_text("keep", encoding="utf-8") + (proj_dir / "archive.md").write_text("archive-keep", encoding="utf-8") + + status, resp = self.request_json("DELETE", "/api/projects/proj") + self.assertEqual(status, 200) + self.assertTrue(resp["ok"]) + self.assertFalse(resp["deleted"]) + + self.assertNotIn("proj", self.registry_on_disk()) + self.assertEqual(self.project_names(), ["default"]) + self.assertTrue(master.is_file()) # file still on disk + self.assertTrue((backups_dir / "backlog_manual.md").is_file()) + self.assertTrue((proj_dir / "archive.md").is_file()) + + # (e2) DELETE with delete_file=true → the single .md gone; backups/ and archive survive + def test_delete_with_flag_removes_only_the_md_file(self): + proj_dir = Path(self.tmp.name) / "proj" + status, _ = self.register_via_api(proj_dir) + self.assertEqual(status, 200) + master = proj_dir / "backlog.md" + backups_dir = proj_dir / "backups" + backups_dir.mkdir() + (backups_dir / "backlog_manual.md").write_text("keep", encoding="utf-8") + (proj_dir / "archive.md").write_text("archive-keep", encoding="utf-8") + + status, resp = self.request_json("DELETE", "/api/projects/proj?delete_file=true") + self.assertEqual(status, 200) + self.assertTrue(resp["ok"]) + self.assertTrue(resp["deleted"]) + + self.assertFalse(master.exists()) # only the .md removed + self.assertTrue(backups_dir.is_dir()) # backups dir untouched + self.assertTrue((backups_dir / "backlog_manual.md").is_file()) + self.assertTrue((proj_dir / "archive.md").is_file()) + self.assertNotIn("proj", self.registry_on_disk()) + + # (f) DELETE on the last remaining project → 400, registry unchanged + def test_delete_last_project_400(self): + status, resp = self.request_json("DELETE", "/api/projects/default") + self.assertEqual(status, 400) + self.assertEqual(resp, {"error": "At least one project must remain registered"}) + self.assertIn("default", self.registry_on_disk()) + self.assertEqual(self.project_names(), ["default"]) + + # (g) rename conflicts and unknowns + def test_rename_to_existing_name_409(self): + for name in ("alpha", "beta"): + status, _ = self.register_via_api(Path(self.tmp.name) / name) + self.assertEqual(status, 200) + status, resp = self.request_json("POST", "/api/projects/alpha/rename", + json_body={"newName": "beta"}) + self.assertEqual(status, 409) + self.assertEqual(resp, {"ok": False, "error": "Project 'beta' already registered"}) + self.assertIn("alpha", self.registry_on_disk()) # unchanged + + def test_rename_unknown_source_404(self): + status, resp = self.request_json("POST", "/api/projects/nope/rename", + json_body={"newName": "beta"}) + self.assertEqual(status, 404) + self.assertIn("error", resp) + + def test_rename_empty_new_name_400(self): + status, _ = self.register_via_api(Path(self.tmp.name) / "proj") + self.assertEqual(status, 200) + status, resp = self.request_json("POST", "/api/projects/proj/rename", + json_body={"newName": " "}) + self.assertEqual(status, 400) + self.assertEqual(resp, {"ok": False, "error": "Invalid project name"}) + status, resp = self.request_json("POST", "/api/projects/proj/rename", json_body={}) + self.assertEqual(status, 400) + self.assertIn("proj", self.registry_on_disk()) # unchanged + + # (g) a name that safe-mangles to empty → 400 (whitespace is truthy, no fallback) + def test_register_whitespace_name_400(self): + status, resp = self.register_via_api(Path(self.tmp.name) / "proj", name=" ") + self.assertEqual(status, 400) + self.assertEqual(resp, {"ok": False, "error": "Invalid project name"}) + self.assertEqual(self.project_names(), ["default"]) + + # (h) RELATIVE path resolves against the server process CWD — pinned as intended + def test_register_relative_path_resolves_against_cwd(self): + cwd_tmp = tempfile.TemporaryDirectory() + self.addCleanup(cwd_tmp.cleanup) + self.addCleanup(os.chdir, os.getcwd()) + os.chdir(cwd_tmp.name) + status, resp = self.register_via_api("relproj") + self.assertEqual(status, 200) + expected = (Path(cwd_tmp.name) / "relproj" / "backlog.md").resolve() + self.assertEqual(resp["path"], str(expected)) + self.assertTrue(expected.is_file()) + + # Adversarial: malformed / empty / path-less register bodies → 400, server stays up + def test_register_malformed_bodies_400(self): + status, resp = self.request_json("POST", "/api/projects", + raw_body='{"path": not-json') + self.assertEqual(status, 400) + self.assertIn("error", resp) + + status, resp = self.request_json("POST", "/api/projects", json_body={}) + self.assertEqual(status, 400) + self.assertIn("error", resp) + + status, _ = self.request("POST", "/api/projects") # empty body + self.assertEqual(status, 400) + + status, _ = self.request("GET", "/api/backlog") # server still alive + self.assertEqual(status, 200) + + # Adversarial: DELETE on unknown project / unknown API path / action path → 404 + def test_delete_unknown_targets_404(self): + status, resp = self.request_json("DELETE", "/api/projects/nope") + self.assertEqual(status, 404) + self.assertIn("error", resp) + status, _ = self.request("DELETE", "/api/nope") + self.assertEqual(status, 404) + status, _ = self.request("DELETE", "/api/projects/default/backlog") + self.assertEqual(status, 404) + + if __name__ == "__main__": unittest.main()