diff --git a/novelforge/routes/export.py b/novelforge/routes/export.py index 710fba6..e3f679b 100644 --- a/novelforge/routes/export.py +++ b/novelforge/routes/export.py @@ -19,6 +19,7 @@ from novelforge.llm.client import call_llm, parse_llm_json, friendly_llm_error from novelforge.llm.image import call_image_api from novelforge.agents.chapter import build_illustration_prompt_generator_prompt +from novelforge.routes.generation._shared import _is_valid_token logger = logging.getLogger(__name__) @@ -105,6 +106,9 @@ def export_novel() -> Response | tuple[Response, int]: data = request.get_json(silent=True) or {} token = data.get("token", "") + if not _is_valid_token(token): + return jsonify({"error": "Invalid progress token."}), 400 + progress_data = progress_manager.get(token) if not progress_data or progress_data.get("status") != "done": @@ -130,6 +134,9 @@ def export_editors_notes() -> Response | tuple[Response, int]: data = request.get_json(silent=True) or {} token = data.get("token", "") + if not _is_valid_token(token): + return jsonify({"error": "Invalid progress token."}), 400 + progress_data = progress_manager.get(token) if not progress_data or progress_data.get("status") != "done": @@ -544,6 +551,9 @@ def generate_illustrations() -> Response | tuple[Response, int]: data = request.get_json(silent=True) or {} token = data.get("token", "") + if not _is_valid_token(token): + return jsonify({"error": "Invalid progress token."}), 400 + progress_data = progress_manager.get(token) if not progress_data or progress_data.get("status") != "done": diff --git a/novelforge/routes/generation/_shared.py b/novelforge/routes/generation/_shared.py index 706fd2a..aea7248 100644 --- a/novelforge/routes/generation/_shared.py +++ b/novelforge/routes/generation/_shared.py @@ -1,7 +1,22 @@ """Shared state for the generation route package.""" +import re + from flask import Blueprint +# --------------------------------------------------------------------------- +# Progress-token validation +# --------------------------------------------------------------------------- + +_UUID_RE = re.compile( + r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' +) + + +def _is_valid_token(token: str) -> bool: + """Return True iff *token* matches the UUID v4 format used by this app.""" + return bool(token and _UUID_RE.match(token)) + generation_bp = Blueprint("generation", __name__) # Minimum seconds between time-based progress snapshot persists. diff --git a/novelforge/routes/generation/chapters.py b/novelforge/routes/generation/chapters.py index 01d5bbb..0d8dda2 100644 --- a/novelforge/routes/generation/chapters.py +++ b/novelforge/routes/generation/chapters.py @@ -47,7 +47,7 @@ get_session_id, save_session_state, persist_completed_chapters, ) from novelforge.routes.generation._shared import ( - generation_bp, _PROGRESS_PERSIST_INTERVAL, + generation_bp, _PROGRESS_PERSIST_INTERVAL, _is_valid_token, ) from novelforge.routes.generation.audits import run_post_manuscript_audits @@ -536,6 +536,8 @@ def _set_step(step_label: str) -> None: @generation_bp.route("/progress/") def progress(token: str) -> Response | tuple[Response, int]: """Lightweight poll endpoint – returns only status/step fields, not chapter content.""" + if not _is_valid_token(token): + return jsonify({"error": "Invalid progress token."}), 400 data = progress_manager.get(token) if data is None: return jsonify({"error": "Unknown token"}), 404 @@ -547,6 +549,8 @@ def progress(token: str) -> Response | tuple[Response, int]: @generation_bp.route("/progress//full") def progress_full(token: str) -> Response | tuple[Response, int]: """Full progress endpoint – returns the complete payload including chapter content and reports.""" + if not _is_valid_token(token): + return jsonify({"error": "Invalid progress token."}), 400 data = progress_manager.get(token) if data is None: return jsonify({"error": "Unknown token"}), 404 diff --git a/novelforge/routes/generation/revision.py b/novelforge/routes/generation/revision.py index 1e52630..9707f3f 100644 --- a/novelforge/routes/generation/revision.py +++ b/novelforge/routes/generation/revision.py @@ -29,7 +29,7 @@ ) from novelforge.session.persistence import persist_completed_chapters from novelforge.routes.generation._shared import ( - generation_bp, _DERIVED_REPORT_FIELDS, + generation_bp, _DERIVED_REPORT_FIELDS, _is_valid_token, ) logger = logging.getLogger(__name__) @@ -53,6 +53,8 @@ def revise_chapter() -> Response | tuple[Response, int]: if not token: return jsonify({"error": "Missing progress token."}), 400 + if not _is_valid_token(token): + return jsonify({"error": "Invalid progress token."}), 400 if chapter_number < 1: return jsonify({"error": "Chapter number must be at least 1."}), 400 if not instructions: diff --git a/tests/test_app.py b/tests/test_app.py index a9f687a..278e4ca 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -220,7 +220,7 @@ def test_generate_chapters_no_session(self, client): assert r.status_code == 400 def test_progress_unknown_token(self, client): - r = client.get("/progress/does-not-exist") + r = client.get("/progress/00000000-0000-4000-8000-000000000000") assert r.status_code == 404 def test_download_nonexistent(self, client): @@ -230,7 +230,7 @@ def test_download_nonexistent(self, client): def test_export_no_token(self, client): r = client.post( "/export", - data=json.dumps({"token": "fake-token"}), + data=json.dumps({"token": "00000000-0000-4000-8000-000000000010"}), content_type="application/json", ) assert r.status_code == 400 @@ -238,7 +238,7 @@ def test_export_no_token(self, client): def test_export_editors_notes_no_token(self, client): r = client.post( "/export_editors_notes", - data=json.dumps({"token": "fake-token"}), + data=json.dumps({"token": "00000000-0000-4000-8000-000000000010"}), content_type="application/json", ) assert r.status_code == 400 @@ -246,7 +246,7 @@ def test_export_editors_notes_no_token(self, client): def test_export_editors_notes_success_filename(self, client): from novelforge.progress import progress_manager - token = "test-token-editors-notes" + token = "00000000-0000-4000-8000-000000000011" progress_manager.create(token, { "status": "done", "current": 0, @@ -275,7 +275,7 @@ def test_export_editors_notes_success_filename(self, client): def test_revise_chapter_requires_instructions(self, client): from novelforge.progress import progress_manager - token = "test-token-revise-empty" + token = "00000000-0000-4000-8000-000000000012" progress_manager.create(token, { "status": "done", "current": 1, @@ -296,7 +296,7 @@ def test_revise_chapter_requires_instructions(self, client): def test_revise_chapter_success_reruns_agents(self, client, monkeypatch): from novelforge.progress import progress_manager - token = "test-token-revise-success" + token = "00000000-0000-4000-8000-000000000013" progress_manager.create(token, { "status": "done", "current": 2, @@ -368,7 +368,7 @@ def test_revise_chapter_uses_snapshot_not_live_session(self, client, monkeypatch """Editing the session after generation must not affect revision semantics.""" from novelforge.progress import progress_manager - token = "test-token-snapshot-isolation" + token = "00000000-0000-4000-8000-000000000014" original_title = "Original Title" changed_title = "Completely Different Title" diff --git a/tests/test_coverage.py b/tests/test_coverage.py index 9d2a9e8..cd0ae7e 100644 --- a/tests/test_coverage.py +++ b/tests/test_coverage.py @@ -127,7 +127,7 @@ def test_full_editors_notes_export(self, client, tmp_path, monkeypatch): import novelforge.config as config monkeypatch.setattr(config, "EXPORT_DIR", str(tmp_path)) - token = "editors-full" + token = "00000000-0000-4000-8000-000000000020" progress_manager.create(token, self._full_progress_data()) with client.session_transaction() as sess: sess["title"] = "Full Notes Test" @@ -160,7 +160,7 @@ def test_full_editors_notes_export(self, client, tmp_path, monkeypatch): assert "Repeated chase scene" in content def test_editors_notes_no_content(self, client): - token = "editors-empty" + token = "00000000-0000-4000-8000-000000000021" progress_manager.create(token, {"status": "done", "current": 0, "total": 0, "step": "", "chapters_done": [], "error": None}) with client.session_transaction() as sess: sess["title"] = "Empty" @@ -663,7 +663,7 @@ def test_export_creates_file(self, client, tmp_path, monkeypatch): import novelforge.config as config monkeypatch.setattr(config, "EXPORT_DIR", str(tmp_path)) - token = "export-file-test" + token = "00000000-0000-4000-8000-000000000022" progress_manager.create(token, { "status": "done", "current": 2, diff --git a/tests/test_export_escaping.py b/tests/test_export_escaping.py index 6aa48ed..e657874 100644 --- a/tests/test_export_escaping.py +++ b/tests/test_export_escaping.py @@ -192,7 +192,7 @@ class TestRelationshipTableEscaping: _EXPECTED_ROW_PIPES = 5 def test_pipe_in_character_name_escaped(self, client, tmp_path, monkeypatch): - token = "esc-rel-pipe-name" + token = "00000000-0000-4000-8000-000000000050" _seed_token(token, character_relationship_map={ "characters": ["Alice | Wonderland", "Bob"], "relationships": [ @@ -213,7 +213,7 @@ def test_pipe_in_character_name_escaped(self, client, tmp_path, monkeypatch): ) def test_pipe_in_relationship_type_escaped(self, client, tmp_path, monkeypatch): - token = "esc-rel-pipe-type" + token = "00000000-0000-4000-8000-000000000051" _seed_token(token, character_relationship_map={ "characters": ["Alice", "Bob"], "relationships": [ @@ -225,7 +225,7 @@ def test_pipe_in_relationship_type_escaped(self, client, tmp_path, monkeypatch): assert r"\|" in content # pipe was escaped somewhere in the table def test_pipe_in_description_escaped(self, client, tmp_path, monkeypatch): - token = "esc-rel-pipe-desc" + token = "00000000-0000-4000-8000-000000000052" _seed_token(token, character_relationship_map={ "characters": ["Alice", "Bob"], "relationships": [ @@ -243,7 +243,7 @@ def test_pipe_in_description_escaped(self, client, tmp_path, monkeypatch): assert r"\|" in content def test_newline_in_description_removed(self, client, tmp_path, monkeypatch): - token = "esc-rel-nl-desc" + token = "00000000-0000-4000-8000-000000000053" _seed_token(token, character_relationship_map={ "characters": ["Alice", "Bob"], "relationships": [ @@ -277,7 +277,7 @@ def _get_mermaid_block(self, content: str) -> str: def test_double_quote_in_character_name_escaped( self, client, tmp_path, monkeypatch ): - token = "esc-mermaid-quote-name" + token = "00000000-0000-4000-8000-000000000054" _seed_token(token, character_relationship_map={ "characters": ['Al"ice', "Bob"], "relationships": [ @@ -295,7 +295,7 @@ def test_double_quote_in_character_name_escaped( def test_double_quote_in_edge_label_escaped( self, client, tmp_path, monkeypatch ): - token = "esc-mermaid-quote-edge" + token = "00000000-0000-4000-8000-000000000055" _seed_token(token, character_relationship_map={ "characters": ["Alice", "Bob"], "relationships": [ @@ -321,7 +321,7 @@ def test_pipe_in_edge_label_does_not_use_pipe_syntax( self, client, tmp_path, monkeypatch ): """Edge labels with pipes must use -- "…" --> style, not -->|…| style.""" - token = "esc-mermaid-pipe-edge" + token = "00000000-0000-4000-8000-000000000056" _seed_token(token, character_relationship_map={ "characters": ["Alice", "Bob"], "relationships": [ @@ -339,7 +339,7 @@ def test_pipe_in_edge_label_does_not_use_pipe_syntax( def test_newline_in_character_name_removed( self, client, tmp_path, monkeypatch ): - token = "esc-mermaid-nl-name" + token = "00000000-0000-4000-8000-000000000057" _seed_token(token, character_relationship_map={ "characters": ["Al\nice", "Bob"], "relationships": [ @@ -365,7 +365,7 @@ class TestListItemEscaping: def test_newline_in_consistency_issue_collapsed( self, client, tmp_path, monkeypatch ): - token = "esc-list-nl-consistency" + token = "00000000-0000-4000-8000-000000000058" _seed_token(token, consistency={ "overall_assessment": "Some issues.", "issues": ["Chapter 1 is\ngood\nbut chapter 2 is not"], @@ -381,7 +381,7 @@ def test_pipe_in_list_item_passes_through( self, client, tmp_path, monkeypatch ): """Pipes in list items are left as-is (not a table context).""" - token = "esc-list-pipe-ok" + token = "00000000-0000-4000-8000-000000000059" _seed_token(token, consistency={ "overall_assessment": "Fine.", "issues": ["Chapters 1|2|3 need work"], @@ -393,7 +393,7 @@ def test_markdown_fence_in_description_stays_on_one_line( self, client, tmp_path, monkeypatch ): """A value containing backtick fences doesn't open a new code block.""" - token = "esc-list-fence" + token = "00000000-0000-4000-8000-000000000060" hostile_thread = "```python\nprint('hello')\n```" _seed_token(token, loose_thread_report={ "thread_integrity": "weak", @@ -418,7 +418,7 @@ def test_markdown_fence_in_description_stays_on_one_line( def test_newline_in_recommendation_collapsed( self, client, tmp_path, monkeypatch ): - token = "esc-list-nl-rec" + token = "00000000-0000-4000-8000-000000000061" _seed_token(token, narrative_compression_report={ "compression_priority": "medium", "overall_assessment": "OK", diff --git a/tests/test_export_snapshot.py b/tests/test_export_snapshot.py index 630697b..4b24fc2 100644 --- a/tests/test_export_snapshot.py +++ b/tests/test_export_snapshot.py @@ -57,7 +57,7 @@ def test_title_from_snapshot_not_session(self, client, tmp_path, monkeypatch): import novelforge.config as config monkeypatch.setattr(config, "EXPORT_DIR", str(tmp_path)) - token = "snap-export-title" + token = "00000000-0000-4000-8000-000000000001" _done_token(token, title="Snapshot Novel Title") # Deliberately set a *different* title in the live session. @@ -84,7 +84,7 @@ def test_title_reproducible_across_session_changes(self, client, tmp_path, monke import novelforge.config as config monkeypatch.setattr(config, "EXPORT_DIR", str(tmp_path)) - token = "snap-export-repro" + token = "00000000-0000-4000-8000-000000000002" _done_token(token, title="Reproducible Title") # First export with one session title. @@ -116,7 +116,7 @@ def test_no_snapshot_falls_back_to_default(self, client, tmp_path, monkeypatch): import novelforge.config as config monkeypatch.setattr(config, "EXPORT_DIR", str(tmp_path)) - token = "snap-export-no-snap" + token = "00000000-0000-4000-8000-000000000003" progress_manager.create(token, { "status": "done", "current": 1, @@ -153,7 +153,7 @@ def test_filename_reflects_snapshot_title(self, client, tmp_path, monkeypatch): import novelforge.config as config monkeypatch.setattr(config, "EXPORT_DIR", str(tmp_path)) - token = "snap-notes-title" + token = "00000000-0000-4000-8000-000000000004" _done_token(token, title="Snapshot Notes Title") with client.session_transaction() as sess: @@ -176,7 +176,7 @@ def test_heading_reflects_snapshot_title(self, client, tmp_path, monkeypatch): import novelforge.config as config monkeypatch.setattr(config, "EXPORT_DIR", str(tmp_path)) - token = "snap-notes-heading" + token = "00000000-0000-4000-8000-000000000005" _done_token(token, title="Correct Notes Title") with client.session_transaction() as sess: @@ -250,7 +250,7 @@ def start(self): monkeypatch.setattr(export_module.threading, "Thread", SyncThread) - token = "snap-illust-meta" + token = "00000000-0000-4000-8000-000000000006" _done_token( token, title="Snapshot Illustration Novel", diff --git a/tests/test_illustration_job.py b/tests/test_illustration_job.py index b6b1114..bb1046b 100644 --- a/tests/test_illustration_job.py +++ b/tests/test_illustration_job.py @@ -74,7 +74,7 @@ def test_returns_illustration_token(self, client, mock_llm, monkeypatch): lambda p, filename_prefix="": "img.png") monkeypatch.setattr(export_module.threading, "Thread", _SyncThread) - token = "route-token-test" + token = "00000000-0000-4000-8000-000000000030" _done_novel(token) r = client.post("/generate_illustrations", @@ -92,7 +92,7 @@ def test_rejects_missing_token(self, client): assert r.status_code == 400 def test_rejects_incomplete_novel(self, client): - progress_manager.create("incomplete-novel", { + progress_manager.create("00000000-0000-4000-8000-000000000031", { "status": "running", "current": 1, "total": 3, @@ -101,7 +101,7 @@ def test_rejects_incomplete_novel(self, client): "error": None, }) r = client.post("/generate_illustrations", - data=json.dumps({"token": "incomplete-novel"}), + data=json.dumps({"token": "00000000-0000-4000-8000-000000000031"}), content_type="application/json") assert r.status_code == 400 @@ -109,7 +109,7 @@ def test_rejects_missing_image_api_key(self, client, monkeypatch): import novelforge.config as config monkeypatch.setattr(config, "IMAGE_API_KEY", "") - token = "no-key-token" + token = "00000000-0000-4000-8000-000000000032" _done_novel(token) r = client.post("/generate_illustrations", data=json.dumps({"token": token}), @@ -127,7 +127,7 @@ def test_novel_token_records_illustration_token(self, client, mock_llm, monkeypa lambda p, filename_prefix="": "img.png") monkeypatch.setattr(export_module.threading, "Thread", _SyncThread) - token = "novel-with-illust-link" + token = "00000000-0000-4000-8000-000000000033" _done_novel(token) r = client.post("/generate_illustrations", @@ -172,7 +172,7 @@ def _raise_on_novel_token(self, tok, data): monkeypatch.setattr(type(pm), "update", _raise_on_novel_token) - token = "deleted-novel-link" + token = "00000000-0000-4000-8000-000000000034" _done_novel(token) with caplog.at_level(logging.WARNING, logger="novelforge.routes.export"): @@ -203,7 +203,7 @@ def test_job_reaches_done_status(self, client, mock_llm, monkeypatch): lambda p, filename_prefix="": f"{filename_prefix}_ok.png") _patch_sync_thread(monkeypatch) - token = "success-novel" + token = "00000000-0000-4000-8000-000000000035" _done_novel(token) r = client.post("/generate_illustrations", @@ -225,7 +225,7 @@ def test_all_images_have_success_status(self, client, mock_llm, monkeypatch): lambda p, filename_prefix="": f"{filename_prefix}_ok.png") _patch_sync_thread(monkeypatch) - token = "all-success" + token = "00000000-0000-4000-8000-000000000036" _done_novel(token) r = client.post("/generate_illustrations", @@ -253,7 +253,7 @@ def test_successful_images_mirrored_to_novel_token( lambda p, filename_prefix="": f"{filename_prefix}_ok.png") _patch_sync_thread(monkeypatch) - token = "mirror-test" + token = "00000000-0000-4000-8000-000000000037" _done_novel(token) client.post("/generate_illustrations", @@ -277,7 +277,7 @@ def test_progress_pollable_after_completion(self, client, mock_llm, monkeypatch) lambda p, filename_prefix="": "file.png") _patch_sync_thread(monkeypatch) - token = "poll-test" + token = "00000000-0000-4000-8000-000000000038" _done_novel(token) r = client.post("/generate_illustrations", @@ -339,7 +339,7 @@ def flaky_llm(messages, *, action="", json_mode=False): monkeypatch.setattr(export_module, "call_llm", flaky_llm) - token = "retry-novel" + token = "00000000-0000-4000-8000-000000000039" _done_novel(token) r = client.post("/generate_illustrations", @@ -364,7 +364,7 @@ def test_all_retries_exhausted_sets_error_status(self, client, monkeypatch): lambda *a, **kw: (_ for _ in ()).throw( RuntimeError("LLM always fails"))) - token = "retry-exhausted" + token = "00000000-0000-4000-8000-000000000040" _done_novel(token) r = client.post("/generate_illustrations", @@ -396,7 +396,7 @@ def tracking_sleep(s): monkeypatch.setattr(export_module.time, "sleep", tracking_sleep) - token = "no-sleep-route" + token = "00000000-0000-4000-8000-000000000041" _done_novel(token) client.post("/generate_illustrations", @@ -454,7 +454,7 @@ def partial_image_api(prompt, filename_prefix=""): monkeypatch.setattr(export_module, "call_image_api", partial_image_api) - token = "partial-fail" + token = "00000000-0000-4000-8000-000000000042" _done_novel(token) r = client.post("/generate_illustrations", @@ -482,7 +482,7 @@ def test_all_images_fail_sets_error_status(self, client, mock_llm, monkeypatch): monkeypatch.setattr(export_module, "call_image_api", lambda p, filename_prefix="": None) - token = "all-fail" + token = "00000000-0000-4000-8000-000000000043" _done_novel(token) r = client.post("/generate_illustrations", @@ -511,7 +511,7 @@ def test_per_image_error_field_populated_on_failure( monkeypatch.setattr(export_module, "call_image_api", lambda p, filename_prefix="": None) - token = "per-img-err" + token = "00000000-0000-4000-8000-000000000044" _done_novel(token) r = client.post("/generate_illustrations", diff --git a/tests/test_integration.py b/tests/test_integration.py index bb6d112..4932783 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -312,12 +312,12 @@ def test_progress_full_endpoint_includes_heavy_fields(self, client, monkeypatch) def test_progress_full_endpoint_unknown_token(self, client): """/progress//full returns 404 for unknown token.""" - r = client.get("/progress/nonexistent-token/full") + r = client.get("/progress/00000000-0000-4000-8000-000000000000/full") assert r.status_code == 404 def test_progress_endpoint_unknown_token(self, client): """/progress/ returns 404 for unknown token.""" - r = client.get("/progress/nonexistent-token") + r = client.get("/progress/00000000-0000-4000-8000-000000000000") assert r.status_code == 404 @@ -432,7 +432,16 @@ class TestReviseChapter: """Chapter revision with mocked LLM.""" def _make_token(self, suffix: str = "") -> str: - return f"test-revise-{suffix}" if suffix else "test-revise-integration" + # Use deterministic UUID-format tokens keyed by suffix so each test has + # a unique valid token that passes format validation. + _suffix_map = { + "": "00000000-0000-4000-8000-000000000070", + "content": "00000000-0000-4000-8000-000000000071", + "invalidation": "00000000-0000-4000-8000-000000000072", + "persist": "00000000-0000-4000-8000-000000000073", + "persist-invalidate": "00000000-0000-4000-8000-000000000074", + } + return _suffix_map.get(suffix, f"00000000-0000-4000-8000-{suffix[:12].ljust(12, '0')}") def _create_progress(self, token: str, *, with_reports: bool = False, session_id: str = "") -> None: state: dict = { @@ -566,7 +575,7 @@ def test_revise_persistence_includes_invalidated_reports(self, client, mock_llm, class TestExport: """Export routes with mocked progress data.""" - def _seed_done(self, client, token="export-test"): + def _seed_done(self, client, token="00000000-0000-4000-8000-000000000080"): progress_manager.create(token, { "status": "done", "current": 2, @@ -588,7 +597,7 @@ def _seed_done(self, client, token="export-test"): sess["title"] = "Export Test Novel" def test_export_manuscript(self, client): - token = "export-manuscript" + token = "00000000-0000-4000-8000-000000000081" self._seed_done(client, token) r = client.post( "/export", @@ -599,7 +608,7 @@ def test_export_manuscript(self, client): assert "download_url" in r.get_json() def test_export_not_complete(self, client): - token = "export-incomplete" + token = "00000000-0000-4000-8000-000000000082" progress_manager.create(token, {"status": "running", "current": 1, "total": 5, "step": "", "chapters_done": [], "error": None}) r = client.post( "/export", @@ -609,7 +618,7 @@ def test_export_not_complete(self, client): assert r.status_code == 400 def test_export_editors_notes(self, client): - token = "export-notes" + token = "00000000-0000-4000-8000-000000000083" self._seed_done(client, token) r = client.post( "/export_editors_notes", @@ -623,7 +632,7 @@ def test_download_exported_file(self, client, tmp_path, monkeypatch): import novelforge.config as config monkeypatch.setattr(config, "EXPORT_DIR", str(tmp_path)) - token = "export-download" + token = "00000000-0000-4000-8000-000000000084" self._seed_done(client, token) r = client.post( "/export", @@ -689,7 +698,7 @@ def test_generate_illustrations_no_image_key(self, client, monkeypatch): import novelforge.config as config monkeypatch.setattr(config, "IMAGE_API_KEY", "") - token = "illust-no-key" + token = "00000000-0000-4000-8000-000000000085" progress_manager.create(token, { "status": "done", "current": 1, diff --git a/tests/test_token_validation.py b/tests/test_token_validation.py new file mode 100644 index 0000000..c5e72e4 --- /dev/null +++ b/tests/test_token_validation.py @@ -0,0 +1,215 @@ +""" +Tests for progress-token format validation. + +Covers the acceptance criteria from the issue: + - Malformed tokens receive an "Invalid progress token." 400 response + instead of a misleading "Novel generation not complete." message. + - Valid UUID-format tokens that are unknown receive "not complete" (400) + or "Unknown token" (404) from the respective endpoints. + - _is_valid_token correctly accepts / rejects token strings. +""" + +import json +import uuid + +import pytest + +from novelforge.routes.generation._shared import _is_valid_token, _UUID_RE +from novelforge.progress import progress_manager + + +# --------------------------------------------------------------------------- +# Unit tests for _is_valid_token +# --------------------------------------------------------------------------- + +class TestIsValidToken: + """_is_valid_token must accept well-formed UUIDs and reject everything else.""" + + def test_valid_uuid_accepted(self): + assert _is_valid_token(str(uuid.uuid4())) + + def test_all_zeros_uuid_accepted(self): + # UUID(int=0) is technically valid format (though not v4) + assert _is_valid_token("00000000-0000-0000-0000-000000000000") + + def test_uppercase_rejected(self): + # Our regex requires lowercase hex + assert not _is_valid_token("AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA") + + def test_missing_dashes_rejected(self): + assert not _is_valid_token("00000000000040008000000000000001") + + def test_too_short_rejected(self): + assert not _is_valid_token("00000000-0000-4000-8000-00000000001") + + def test_too_long_rejected(self): + assert not _is_valid_token("00000000-0000-4000-8000-0000000000011") + + def test_human_readable_string_rejected(self): + assert not _is_valid_token("fake-token") + + def test_empty_string_rejected(self): + assert not _is_valid_token("") + + def test_none_like_string_rejected(self): + assert not _is_valid_token("None") + + def test_export_module_uses_shared_helper(self): + """export.py must re-use the shared _is_valid_token from _shared.py.""" + from novelforge.routes.export import _is_valid_token as export_helper + from novelforge.routes.generation._shared import _is_valid_token as shared_helper + assert export_helper is shared_helper, ( + "export._is_valid_token must be the same object as generation._shared._is_valid_token" + ) + + +# --------------------------------------------------------------------------- +# HTTP-route tests: malformed token → 400 "Invalid progress token." +# --------------------------------------------------------------------------- + +_MALFORMED_TOKENS = [ + "fake-token", + "incomplete-novel", + "not-a-uuid", + "", + "00000000-0000-0000-0000", # too short + "AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA", # uppercase +] + +# Subset of malformed tokens that are valid URL path segments (non-empty). +_MALFORMED_URL_TOKENS = [t for t in _MALFORMED_TOKENS if t] + + +class TestExportNovelInvalidToken: + @pytest.mark.parametrize("bad_token", _MALFORMED_TOKENS) + def test_rejects_malformed_token(self, client, bad_token): + r = client.post( + "/export", + data=json.dumps({"token": bad_token}), + content_type="application/json", + ) + assert r.status_code == 400 + assert r.get_json()["error"] == "Invalid progress token." + + def test_valid_but_unknown_uuid_gives_not_complete(self, client): + """A well-formed UUID that has no progress entry returns 'not complete'.""" + unknown = str(uuid.uuid4()) + r = client.post( + "/export", + data=json.dumps({"token": unknown}), + content_type="application/json", + ) + assert r.status_code == 400 + assert "not complete" in r.get_json()["error"].lower() + + +class TestExportEditorsNotesInvalidToken: + @pytest.mark.parametrize("bad_token", _MALFORMED_TOKENS) + def test_rejects_malformed_token(self, client, bad_token): + r = client.post( + "/export_editors_notes", + data=json.dumps({"token": bad_token}), + content_type="application/json", + ) + assert r.status_code == 400 + assert r.get_json()["error"] == "Invalid progress token." + + def test_valid_but_unknown_uuid_gives_not_complete(self, client): + unknown = str(uuid.uuid4()) + r = client.post( + "/export_editors_notes", + data=json.dumps({"token": unknown}), + content_type="application/json", + ) + assert r.status_code == 400 + assert "not complete" in r.get_json()["error"].lower() + + +class TestGenerateIllustrationsInvalidToken: + @pytest.mark.parametrize("bad_token", _MALFORMED_TOKENS) + def test_rejects_malformed_token(self, client, bad_token): + r = client.post( + "/generate_illustrations", + data=json.dumps({"token": bad_token}), + content_type="application/json", + ) + assert r.status_code == 400 + assert r.get_json()["error"] == "Invalid progress token." + + def test_valid_but_unknown_uuid_gives_not_complete(self, client): + unknown = str(uuid.uuid4()) + r = client.post( + "/generate_illustrations", + data=json.dumps({"token": unknown}), + content_type="application/json", + ) + assert r.status_code == 400 + assert "not complete" in r.get_json()["error"].lower() + + +class TestReviseChapterInvalidToken: + @pytest.mark.parametrize("bad_token", [t for t in _MALFORMED_TOKENS if t]) + def test_rejects_malformed_token(self, client, bad_token): + r = client.post( + "/revise_chapter", + data=json.dumps({ + "token": bad_token, + "chapter_number": 1, + "instructions": "Add drama.", + }), + content_type="application/json", + ) + assert r.status_code == 400 + assert r.get_json()["error"] == "Invalid progress token." + + def test_rejects_empty_token(self, client): + """Empty token is caught by the earlier 'missing token' guard.""" + r = client.post( + "/revise_chapter", + data=json.dumps({ + "token": "", + "chapter_number": 1, + "instructions": "Add drama.", + }), + content_type="application/json", + ) + assert r.status_code == 400 + assert "missing" in r.get_json()["error"].lower() + + def test_valid_but_unknown_uuid_gives_not_complete(self, client): + unknown = str(uuid.uuid4()) + r = client.post( + "/revise_chapter", + data=json.dumps({ + "token": unknown, + "chapter_number": 1, + "instructions": "Add drama.", + }), + content_type="application/json", + ) + assert r.status_code == 400 + assert "not complete" in r.get_json()["error"].lower() + + +class TestProgressEndpointInvalidToken: + @pytest.mark.parametrize("bad_token", _MALFORMED_URL_TOKENS) + def test_lightweight_rejects_malformed_token(self, client, bad_token): + r = client.get(f"/progress/{bad_token}") + assert r.status_code == 400 + assert r.get_json()["error"] == "Invalid progress token." + + @pytest.mark.parametrize("bad_token", _MALFORMED_URL_TOKENS) + def test_full_rejects_malformed_token(self, client, bad_token): + r = client.get(f"/progress/{bad_token}/full") + assert r.status_code == 400 + assert r.get_json()["error"] == "Invalid progress token." + + def test_valid_but_unknown_uuid_gives_404(self, client): + unknown = str(uuid.uuid4()) + r = client.get(f"/progress/{unknown}") + assert r.status_code == 404 + + def test_valid_but_unknown_uuid_full_gives_404(self, client): + unknown = str(uuid.uuid4()) + r = client.get(f"/progress/{unknown}/full") + assert r.status_code == 404