Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions novelforge/routes/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -105,6 +106,9 @@ def export_novel() -> Response | tuple[Response, int]:
data = request.get_json(silent=True) or {}
token = data.get("token", "")

Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These routes now treat a missing/empty token (default "") as “Invalid progress token.” while other endpoints (e.g., /revise_chapter) return “Missing progress token.” for the same condition. Consider adding an explicit if not token: ... Missing progress token ... guard before format validation to keep API error semantics consistent.

Suggested change
if not token:
return jsonify({"error": "Missing progress token."}), 400

Copilot uses AI. Check for mistakes.
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":
Expand All @@ -130,6 +134,9 @@ def export_editors_notes() -> Response | tuple[Response, int]:
data = request.get_json(silent=True) or {}
token = data.get("token", "")

Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as /export: this endpoint currently returns “Invalid progress token.” for an empty/missing token (because token defaults to ""). If the API intends to distinguish missing vs malformed, add a if not token guard before _is_valid_token() for consistency with /revise_chapter.

Suggested change
if not token:
return jsonify({"error": "Missing progress token."}), 400

Copilot uses AI. Check for mistakes.
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":
Expand Down Expand Up @@ -544,6 +551,9 @@ def generate_illustrations() -> Response | tuple[Response, int]:
data = request.get_json(silent=True) or {}
token = data.get("token", "")

Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the other export endpoints: missing token (no "token" field) becomes "" and is reported as “Invalid progress token.”. If you want to preserve the existing “Missing progress token.” semantics used elsewhere, add a separate empty check before _is_valid_token().

Suggested change
if not token:
return jsonify({"error": "Missing progress token."}), 400

Copilot uses AI. Check for mistakes.
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":
Expand Down
15 changes: 15 additions & 0 deletions novelforge/routes/generation/_shared.py
Original file line number Diff line number Diff line change
@@ -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))
Comment on lines +11 to +18
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Docstring says the helper validates “UUID v4 format”, but the regex only checks canonical UUID text form (and even accepts non-v4 values like all-zeros). Either tighten the regex to enforce v4/variant bits, or adjust the docstring to describe what is actually validated (e.g., lowercase canonical UUID string).

Copilot uses AI. Check for mistakes.

generation_bp = Blueprint("generation", __name__)

# Minimum seconds between time-based progress snapshot persists.
Expand Down
6 changes: 5 additions & 1 deletion novelforge/routes/generation/chapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -536,6 +536,8 @@ def _set_step(step_label: str) -> None:
@generation_bp.route("/progress/<token>")
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
Expand All @@ -547,6 +549,8 @@ def progress(token: str) -> Response | tuple[Response, int]:
@generation_bp.route("/progress/<token>/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
Expand Down
4 changes: 3 additions & 1 deletion novelforge/routes/generation/revision.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand All @@ -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:
Expand Down
14 changes: 7 additions & 7 deletions tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -230,23 +230,23 @@ 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

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

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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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"

Expand Down
6 changes: 3 additions & 3 deletions tests/test_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down
24 changes: 12 additions & 12 deletions tests/test_export_escaping.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand All @@ -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": [
Expand All @@ -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": [
Expand All @@ -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": [
Expand Down Expand Up @@ -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": [
Expand All @@ -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": [
Expand All @@ -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": [
Expand All @@ -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": [
Expand All @@ -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"],
Expand All @@ -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"],
Expand All @@ -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",
Expand All @@ -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",
Expand Down
12 changes: 6 additions & 6 deletions tests/test_export_snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading