Skip to content

Commit 59da5e5

Browse files
CyberSecDefclaude
andcommitted
Validate progress snapshot in revision and export endpoints (#135)
Without a snapshot, /revise_chapter would silently strip every planning context (architecture, timeline, fate, arc, antagonist, technology, theme, POV) before overwriting the chapter, and /export + /export_editors_notes would collapse the filename to "Novel.md" / "Novel-Editors_Notes.md" — silently overwriting prior exports. Reachable when a session is restored from on-disk JSON that predates the snapshot field. All three endpoints now reject early with HTTP 400 and a clear message instead of producing degraded or destructive output. Closes #135 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0f1ebf3 commit 59da5e5

5 files changed

Lines changed: 118 additions & 13 deletions

File tree

novelforge/routes/export.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,17 @@ def export_novel() -> Response | tuple[Response, int]:
120120
if not progress_data or progress_data.get("status") != "done":
121121
return jsonify({"error": "Novel generation not complete."}), 400
122122

123-
title = (progress_data.get("snapshot") or {}).get("title", "Novel")
123+
snapshot = progress_data.get("snapshot")
124+
if not isinstance(snapshot, dict) or not snapshot:
125+
# Without a snapshot the filename collapses to "Novel.md" and silently
126+
# overwrites any prior export. Reject early instead of clobbering.
127+
logger.warning("Export rejected: progress entry %s has no snapshot", token)
128+
return jsonify({
129+
"error": "Progress data is incomplete (missing generation snapshot). "
130+
"Reload or regenerate the novel before exporting."
131+
}), 400
132+
133+
title = snapshot.get("title", "Novel")
124134
chapters_done = progress_data.get("chapters_done", [])
125135

126136
markdown_content = _format_manuscript(title, chapters_done)
@@ -148,7 +158,17 @@ def export_editors_notes() -> Response | tuple[Response, int]:
148158
if not progress_data or progress_data.get("status") != "done":
149159
return jsonify({"error": "Novel generation not complete."}), 400
150160

151-
title = (progress_data.get("snapshot") or {}).get("title", "Novel")
161+
snapshot = progress_data.get("snapshot")
162+
if not isinstance(snapshot, dict) or not snapshot:
163+
# Same overwrite hazard as /export — without a snapshot the filename
164+
# collapses to "Novel-Editors_Notes.md" for every novel.
165+
logger.warning("Editor's notes export rejected: progress entry %s has no snapshot", token)
166+
return jsonify({
167+
"error": "Progress data is incomplete (missing generation snapshot). "
168+
"Reload or regenerate the novel before exporting."
169+
}), 400
170+
171+
title = snapshot.get("title", "Novel")
152172
consistency = progress_data.get("consistency") or {}
153173
global_continuity_audit = progress_data.get("global_continuity_audit") or {}
154174
narrative_compression_report = progress_data.get("narrative_compression_report") or {}

novelforge/routes/generation/revision.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,19 @@ def revise_chapter() -> Response | tuple[Response, int]:
7878
if target_idx is None:
7979
return jsonify({"error": "Selected chapter was not found."}), 404
8080

81-
snap = progress_data.get("snapshot", {})
81+
snap = progress_data.get("snapshot")
82+
if not isinstance(snap, dict) or not snap:
83+
# Missing snapshot would silently strip every planning context (architecture,
84+
# timeline, fate, arc, etc.) before overwriting the chapter. Reachable via
85+
# restored sessions whose on-disk JSON predates the snapshot field.
86+
logger.warning(
87+
"Revision rejected: progress entry %s has no snapshot (chapter %d)",
88+
token, chapter_number,
89+
)
90+
return jsonify({
91+
"error": "Progress data is incomplete (missing generation snapshot). "
92+
"Reload or regenerate the novel before revising chapters."
93+
}), 400
8294
title = snap.get("title", "Novel")
8395
genre = snap.get("genre", "")
8496
total_chapters = int(snap.get("chapters", len(chapters_done) or 1))

tests/test_coverage.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ class TestEditorsNotesFormatting:
2424
def _full_progress_data(self):
2525
return {
2626
"status": "done",
27+
"snapshot": {"title": "Full Notes Test", "genre": "Fantasy"},
2728
"chapters_done": [
2829
{"number": 1, "title": "Ch1", "content": "Text", "summary": "S1"},
2930
{"number": 2, "title": "Ch2", "content": "Text", "summary": "S2"},
@@ -161,7 +162,11 @@ def test_full_editors_notes_export(self, client, tmp_path, monkeypatch):
161162

162163
def test_editors_notes_no_content(self, client):
163164
token = "00000000-0000-4000-8000-000000000021"
164-
progress_manager.create(token, {"status": "done", "current": 0, "total": 0, "step": "", "chapters_done": [], "error": None})
165+
progress_manager.create(token, {
166+
"status": "done", "current": 0, "total": 0, "step": "",
167+
"snapshot": {"title": "Empty", "genre": ""},
168+
"chapters_done": [], "error": None,
169+
})
165170
with client.session_transaction() as sess:
166171
sess["title"] = "Empty"
167172

tests/test_export_snapshot.py

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -111,8 +111,8 @@ def test_title_reproducible_across_session_changes(self, client, tmp_path, monke
111111
assert "Session Title A" not in content1
112112
assert "Session Title B" not in content2
113113

114-
def test_no_snapshot_falls_back_to_default(self, client, tmp_path, monkeypatch):
115-
"""When a token has no snapshot the title defaults to 'Novel'."""
114+
def test_missing_snapshot_returns_400(self, client, tmp_path, monkeypatch):
115+
"""Without a snapshot the export would silently overwrite Novel.md — reject early."""
116116
import novelforge.config as config
117117
monkeypatch.setattr(config, "EXPORT_DIR", str(tmp_path))
118118

@@ -127,18 +127,37 @@ def test_no_snapshot_falls_back_to_default(self, client, tmp_path, monkeypatch):
127127
{"number": 1, "title": "Ch1", "content": "Text.", "summary": "S"},
128128
],
129129
})
130-
# Session sets a title but snapshot is absent – route should still work.
131130
with client.session_transaction() as sess:
132131
sess["title"] = "Session Only Title"
133132

134133
r = client.post("/export", data=json.dumps({"token": token}),
135134
content_type="application/json")
136-
assert r.status_code == 200
137-
url = r.get_json()["download_url"]
138-
content = client.get(url).data.decode("utf-8")
139-
# Falls back to "Novel" heading, not the session title.
140-
assert "# Novel" in content
141-
assert "Session Only Title" not in content
135+
assert r.status_code == 400
136+
assert "snapshot" in r.get_json()["error"].lower()
137+
# Nothing should be written to the export dir.
138+
assert not list(tmp_path.glob("*.md"))
139+
140+
def test_empty_snapshot_returns_400(self, client, tmp_path, monkeypatch):
141+
"""A snapshot key whose value is an empty dict is treated the same as missing."""
142+
import novelforge.config as config
143+
monkeypatch.setattr(config, "EXPORT_DIR", str(tmp_path))
144+
145+
token = "00000000-0000-4000-8000-000000000006"
146+
progress_manager.create(token, {
147+
"status": "done",
148+
"current": 1,
149+
"total": 1,
150+
"step": "Complete",
151+
"error": None,
152+
"snapshot": {},
153+
"chapters_done": [
154+
{"number": 1, "title": "Ch1", "content": "Text.", "summary": "S"},
155+
],
156+
})
157+
158+
r = client.post("/export", data=json.dumps({"token": token}),
159+
content_type="application/json")
160+
assert r.status_code == 400
142161

143162

144163
# ---------------------------------------------------------------------------
@@ -193,6 +212,31 @@ def test_heading_reflects_snapshot_title(self, client, tmp_path, monkeypatch):
193212
assert "Correct Notes Title" in content
194213
assert "Wrong Session Title" not in content
195214

215+
def test_missing_snapshot_returns_400(self, client, tmp_path, monkeypatch):
216+
"""Without a snapshot the notes file would collapse to a generic name — reject early."""
217+
import novelforge.config as config
218+
monkeypatch.setattr(config, "EXPORT_DIR", str(tmp_path))
219+
220+
token = "00000000-0000-4000-8000-000000000007"
221+
progress_manager.create(token, {
222+
"status": "done",
223+
"current": 1,
224+
"total": 1,
225+
"step": "Complete",
226+
"error": None,
227+
"chapters_done": [
228+
{"number": 1, "title": "Ch1", "content": "Text.", "summary": "S"},
229+
],
230+
"consistency": {"overall_assessment": "Good.", "issues": []},
231+
})
232+
233+
r = client.post("/export_editors_notes",
234+
data=json.dumps({"token": token}),
235+
content_type="application/json")
236+
assert r.status_code == 400
237+
assert "snapshot" in r.get_json()["error"].lower()
238+
assert not list(tmp_path.glob("*.md"))
239+
196240

197241
# ---------------------------------------------------------------------------
198242
# /generate_illustrations – metadata comes from snapshot

tests/test_integration.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,7 @@ def _make_token(self, suffix: str = "") -> str:
440440
"invalidation": "00000000-0000-4000-8000-000000000072",
441441
"persist": "00000000-0000-4000-8000-000000000073",
442442
"persist-invalidate": "00000000-0000-4000-8000-000000000074",
443+
"no-snap": "00000000-0000-4000-8000-000000000075",
443444
}
444445
return _suffix_map.get(suffix, f"00000000-0000-4000-8000-{suffix[:12].ljust(12, '0')}")
445446

@@ -543,6 +544,28 @@ def test_revise_persists_chapters(self, client, mock_llm, tmp_path, monkeypatch)
543544
assert len(saved.get("completed_chapters", [])) == 1
544545
assert saved["completed_chapters"][0]["content"] != "Original text."
545546

547+
def test_revise_missing_snapshot_returns_400(self, client, mock_llm):
548+
"""Without a snapshot the revision would proceed with empty planning context
549+
and overwrite the chapter — reject early instead."""
550+
token = self._make_token("no-snap")
551+
progress_manager.create(token, {
552+
"status": "done",
553+
"current": 1,
554+
"total": 1,
555+
"step": "Complete",
556+
"error": None,
557+
"chapters_done": [
558+
{"number": 1, "title": "Ch1", "content": "Original text.", "summary": "Old"},
559+
],
560+
"consistency": {"issues": [], "overall_assessment": ""},
561+
})
562+
r = self._post_revise(client, token)
563+
assert r.status_code == 400
564+
assert "snapshot" in r.get_json()["error"].lower()
565+
# Chapter content must remain untouched.
566+
pd = progress_manager.get(token)
567+
assert pd["chapters_done"][0]["content"] == "Original text."
568+
546569
def test_revise_persistence_includes_invalidated_reports(self, client, mock_llm, tmp_path, monkeypatch):
547570
"""Persisted state after revision reflects the invalidated (None) report values."""
548571
from novelforge.routes.generation import _DERIVED_REPORT_FIELDS
@@ -582,6 +605,7 @@ def _seed_done(self, client, token="00000000-0000-4000-8000-000000000080"):
582605
"total": 2,
583606
"step": "Complete",
584607
"error": None,
608+
"snapshot": {"title": "Export Test Novel", "genre": "Fantasy"},
585609
"chapters_done": [
586610
{"number": 1, "title": "Ch1", "content": "Chapter 1 text.", "summary": "S1"},
587611
{"number": 2, "title": "Ch2", "content": "Chapter 2 text.", "summary": "S2"},

0 commit comments

Comments
 (0)