实现本地 Markdown 清洗评审器

This commit is contained in:
2026-08-24 01:02:16 +08:00
parent 80001a8ab9
commit ac798a2610
39 changed files with 9357 additions and 119 deletions
+1
View File
@@ -0,0 +1 @@
"""Test support package for repository-local integration fixtures."""
+212
View File
@@ -0,0 +1,212 @@
from __future__ import annotations
import json
from hashlib import sha256
from pathlib import Path
from typing import Any, TypedDict, cast
class ReviewFixture(TypedDict):
run_directory: Path
source_path: Path
manifest_path: Path
locator_path: Path
result_path: Path
original: str
cleaned: str
def digest(text: str) -> str:
return sha256(text.encode()).hexdigest()
def write_json(path: Path, payload: object) -> None:
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def read_json(path: Path) -> dict[str, Any]:
return cast(dict[str, Any], json.loads(path.read_text(encoding="utf-8")))
def create_review_run(
root: Path,
*,
status: str = "success",
with_locator: bool = True,
) -> ReviewFixture:
run_directory = root / "artifacts/2026-08-23/runs/review-run"
document_directory = run_directory / "documents/paper"
document_directory.mkdir(parents=True)
source_path = root / "paper.md"
original = "\ufeff😀 old\r\nCafe\u0301\n"
cleaned = "\ufeff😀 new\r\nCafe\u0301\n"
source_path.write_text(original, encoding="utf-8")
input_hash = digest(original)
current_hash = digest(cleaned) if status == "success" else input_hash
changes: list[dict[str, object]] = []
if status == "success":
changes.append(
{
"component_id": "paper.rule",
"component_version": "1.0.0",
"component_position": 0,
"proposal_ref": {
"component_position": 0,
"snapshot_sha256": input_hash,
"proposal_index": 0,
},
"edit_index": 0,
"reason": "替换测试单词",
"span": {"start": 3, "end": 6},
"location": {"line": 1, "column": 4},
"before": "old",
"after": "new",
"before_sha256": input_hash,
"after_sha256": current_hash,
}
)
errors: list[dict[str, object]] = []
if status == "failed":
errors.append(
{
"component_id": "paper.rule",
"component_version": "1.0.0",
"component_position": 0,
"stage": "transform",
"error_type": "SyntheticError",
"message": "测试组件失败。",
}
)
residuals: list[dict[str, object]] = []
if status == "unstable":
residuals.append(
{
"component_id": "paper.rule",
"component_version": "1.0.0",
"component_position": 0,
"proposal_ref": {
"component_position": 0,
"snapshot_sha256": input_hash,
"proposal_index": 0,
},
"proposal": {
"snapshot_sha256": input_hash,
"reason": "仍可替换测试单词",
"edits": [
{
"snapshot_sha256": input_hash,
"span": {"start": 3, "end": 6},
"expected_text": "old",
"replacement": "new",
}
],
},
}
)
result = {
"schema_version": 1,
"document": {"document_id": "paper", "source_label": "inputs/paper.md"},
"status": status,
"input_sha256": input_hash,
"current_sha256": current_hash,
"changes": changes,
"errors": errors,
"residual_proposals": residuals,
"output": {
"cleaned_path": "cleaned.md" if status == "success" else None,
"diff_path": "changes.diff" if status == "success" else None,
},
}
result_path = document_directory / "result.json"
write_json(result_path, result)
if status == "success":
(document_directory / "cleaned.md").write_text(cleaned, encoding="utf-8")
(document_directory / "changes.diff").write_text("synthetic diff\n", encoding="utf-8")
manifest = {
"schema_version": 1,
"run": {
"run_id": "review-run",
"run_date": "2026-08-23",
"utc_offset": "+08:00",
"status": status,
"started_at_utc": "2026-08-23T01:00:00Z",
"completed_at_utc": "2026-08-23T01:01:00Z",
"retention_until": "2026-09-22T01:01:00Z",
},
"tool": {
"name": "mdpolish",
"package_version": "0.1.0",
"python_version": "3.13.11",
"platform": "linux-x86_64",
"git_commit": None,
"git_dirty": None,
},
"pipeline": {
"components": [
{
"component_id": "paper.rule",
"version": "1.0.0",
"parameters": [],
"applicability": "替换测试单词。",
},
{
"component_id": "paper.zero",
"version": "1.0.0",
"parameters": [],
"applicability": "不修改当前测试文档。",
},
]
},
"documents": [
{
"document_id": "paper",
"source_label": "inputs/paper.md",
"status": status,
"input_sha256": input_hash,
"current_sha256": current_hash,
"change_count": len(changes),
"result_path": "documents/paper/result.json",
"cleaned_path": "documents/paper/cleaned.md" if status == "success" else None,
"diff_path": "documents/paper/changes.diff" if status == "success" else None,
}
],
"summary": {
"document_count": 1,
"success_count": int(status == "success"),
"failed_count": int(status == "failed"),
"unstable_count": int(status == "unstable"),
"change_count": len(changes),
},
}
manifest_path = run_directory / "manifest.json"
write_json(manifest_path, manifest)
locator_path = run_directory / "review-locator.json"
if with_locator:
write_json(
locator_path,
{
"schema_version": 1,
"run": {
"run_id": "review-run",
"run_directory": str(run_directory.resolve()),
"manifest_path": "manifest.json",
},
"documents": [
{
"document_id": "paper",
"source_path": str(source_path.resolve()),
"input_sha256": input_hash,
}
],
},
)
return {
"run_directory": run_directory,
"source_path": source_path,
"manifest_path": manifest_path,
"locator_path": locator_path,
"result_path": result_path,
"original": original,
"cleaned": cleaned,
}
+189
View File
@@ -0,0 +1,189 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import replace
import pytest
from mdpolish._artifact_replay import (
ReplayChange,
ReplayComponent,
ReplayError,
replay_change_chain,
)
from mdpolish.models import markdown_sha256
def change(
*,
component_position: int,
before_text: str,
after_text: str,
start: int,
end: int,
before_sha256: str,
after_sha256: str,
proposal_index: int = 0,
edit_index: int = 0,
) -> ReplayChange:
return ReplayChange(
component_id=f"test.{component_position}",
component_version="1.0.0",
component_position=component_position,
proposal_component_position=component_position,
proposal_snapshot_sha256=before_sha256,
proposal_index=proposal_index,
edit_index=edit_index,
start=start,
end=end,
before=before_text,
after=after_text,
before_sha256=before_sha256,
after_sha256=after_sha256,
)
def test_replay_builds_zero_change_stages_and_utf16_editor_ranges() -> None:
original = "\ufeff😀 old\r\nCafe\u0301"
final = "\ufeff😀 new\r\nCafe\u0301"
input_hash = markdown_sha256(original)
final_hash = markdown_sha256(final)
recorded = change(
component_position=0,
before_text="old",
after_text="new",
start=3,
end=6,
before_sha256=input_hash,
after_sha256=final_hash,
)
replayed = replay_change_chain(
input_markdown=original,
input_sha256=input_hash,
components=(ReplayComponent("test.0", "1.0.0"), ReplayComponent("test.1", "1.0.0")),
changes=(recorded,),
current_sha256=final_hash,
current_markdown=final,
include_zero_change_stages=True,
)
assert replayed.current_markdown == final
assert len(replayed.stages) == 2
assert replayed.stages[1].before_markdown == final
assert replayed.stages[1].after_markdown == final
assert replayed.stages[1].changes == ()
assert (replayed.changes[0].line, replayed.changes[0].column) == (1, 4)
assert (replayed.changes[0].editor_start, replayed.changes[0].editor_end) == (4, 7)
def test_replay_uses_full_descending_application_key_not_record_order() -> None:
original = "abcd"
final = "aXXcYY"
input_hash = markdown_sha256(original)
final_hash = markdown_sha256(final)
right = change(
component_position=0,
before_text="d",
after_text="YY",
start=3,
end=4,
before_sha256=input_hash,
after_sha256=final_hash,
proposal_index=1,
)
left = change(
component_position=0,
before_text="b",
after_text="XX",
start=1,
end=2,
before_sha256=input_hash,
after_sha256=final_hash,
)
replayed = replay_change_chain(
input_markdown=original,
input_sha256=input_hash,
components=(ReplayComponent("test.0", "1.0.0"),),
changes=(right, left),
current_sha256=final_hash,
current_markdown=final,
include_zero_change_stages=True,
)
assert replayed.current_markdown == final
@pytest.mark.parametrize(
("mutate", "message"),
[
(lambda item: replace(item, component_position=2), "component position"),
(lambda item: replace(item, proposal_component_position=1), "proposal reference"),
(lambda item: replace(item, before="bad"), "recorded snapshot"),
(lambda item: replace(item, after_sha256="0" * 64), "batch hash"),
],
)
def test_replay_rejects_untrusted_change_chains(
mutate: Callable[[ReplayChange], ReplayChange], message: str
) -> None:
original = "old"
final = "new"
input_hash = markdown_sha256(original)
final_hash = markdown_sha256(final)
valid = change(
component_position=0,
before_text="old",
after_text="new",
start=0,
end=3,
before_sha256=input_hash,
after_sha256=final_hash,
)
tampered = mutate(valid)
with pytest.raises(ReplayError, match=message):
replay_change_chain(
input_markdown=original,
input_sha256=input_hash,
components=(ReplayComponent("test.0", "1.0.0"),),
changes=(tampered,),
current_sha256=final_hash,
current_markdown=final,
include_zero_change_stages=True,
)
def test_replay_rejects_conflicting_ranges() -> None:
original = "abc"
input_hash = markdown_sha256(original)
first = change(
component_position=0,
before_text="ab",
after_text="x",
start=0,
end=2,
before_sha256=input_hash,
after_sha256="0" * 64,
)
second = change(
component_position=0,
before_text="bc",
after_text="y",
start=1,
end=3,
before_sha256=input_hash,
after_sha256="0" * 64,
proposal_index=1,
)
with pytest.raises(ReplayError, match="conflicting"):
replay_change_chain(
input_markdown=original,
input_sha256=input_hash,
components=(ReplayComponent("test.0", "1.0.0"),),
changes=(first, second),
current_sha256=input_hash,
current_markdown=None,
include_zero_change_stages=False,
)
+138 -8
View File
@@ -10,6 +10,8 @@ import pytest
import mdpolish.artifact_store as artifact_store
from mdpolish.artifact_store import ArtifactStoreError, StoredDocument, publish_run
_SOURCE_BYTES = b"source\n"
def stored_document(document_id: str = "paper", output: bytes | None = b"cleaned\n") -> StoredDocument:
status = "success" if output is not None else "failed"
@@ -18,7 +20,7 @@ def stored_document(document_id: str = "paper", output: bytes | None = b"cleaned
"schema_version": 1,
"document": {"document_id": document_id, "source_label": f"inputs/{document_id}.md"},
"status": status,
"input_sha256": "1" * 64,
"input_sha256": sha256(_SOURCE_BYTES).hexdigest(),
"current_sha256": current_sha256,
"changes": [],
"errors": [] if status == "success" else [{"error_type": "SyntheticError"}],
@@ -82,15 +84,49 @@ def manifest_json(
return (json.dumps(payload, indent=2) + "\n").encode()
def review_locator_json(
artifacts_root: Path,
run_date: str,
run_id: str,
documents: tuple[StoredDocument, ...],
) -> bytes:
source_root = artifacts_root.parent / f"{artifacts_root.name}-sources"
source_root.mkdir(exist_ok=True)
locator_documents: list[dict[str, object]] = []
for position, document in enumerate(documents):
source_path = source_root / f"{position}-{document.document_id}.md"
source_path.write_bytes(_SOURCE_BYTES)
report = json.loads(document.result_json)
locator_documents.append(
{
"document_id": document.document_id,
"source_path": str(source_path.resolve()),
"input_sha256": report["input_sha256"],
}
)
payload = {
"schema_version": 1,
"run": {
"run_id": run_id,
"run_directory": str((artifacts_root / run_date / "runs" / run_id).resolve()),
"manifest_path": "manifest.json",
},
"documents": locator_documents,
}
return (json.dumps(payload, indent=2) + "\n").encode()
def test_publish_run_creates_private_date_layout_and_status_specific_files(tmp_path: Path) -> None:
artifacts_root = tmp_path / "artifacts"
documents = (stored_document("success"), stored_document("failed", None))
locator_json = review_locator_json(artifacts_root, "2026-08-22", "example-run", documents)
run_directory = publish_run(
artifacts_root=artifacts_root,
run_date="2026-08-22",
run_id="example-run",
manifest_json=manifest_json("2026-08-22", "example-run", documents),
review_locator_json=locator_json,
documents=documents,
)
@@ -100,6 +136,7 @@ def test_publish_run_creates_private_date_layout_and_status_specific_files(tmp_p
assert (run_directory / "documents/success/cleaned.md").read_bytes() == b"cleaned\n"
assert (run_directory / "documents/success/changes.diff").read_bytes() == b""
assert (run_directory / "documents/failed/result.json").is_file()
assert (run_directory / "review-locator.json").read_bytes() == locator_json
assert not (run_directory / "documents/failed/cleaned.md").exists()
assert not (run_directory / "documents/failed/changes.diff").exists()
@@ -121,11 +158,13 @@ def test_publish_run_rejects_existing_target_without_overwriting(tmp_path: Path)
artifacts_root = tmp_path / "artifacts"
documents = (stored_document(),)
first_manifest = manifest_json("2026-08-22", "same-run", documents)
locator_json = review_locator_json(artifacts_root, "2026-08-22", "same-run", documents)
run_directory = publish_run(
artifacts_root=artifacts_root,
run_date="2026-08-22",
run_id="same-run",
manifest_json=first_manifest,
review_locator_json=locator_json,
documents=documents,
)
@@ -135,6 +174,7 @@ def test_publish_run_rejects_existing_target_without_overwriting(tmp_path: Path)
run_date="2026-08-22",
run_id="same-run",
manifest_json=first_manifest,
review_locator_json=locator_json,
documents=documents,
)
@@ -152,13 +192,15 @@ def test_publish_run_rejects_existing_target_without_overwriting(tmp_path: Path)
],
)
def test_publish_run_rejects_unsafe_date_and_run_id(tmp_path: Path, run_date: str, run_id: str) -> None:
artifacts_root = tmp_path / "artifacts"
documents = (stored_document(),)
with pytest.raises(ArtifactStoreError):
publish_run(
artifacts_root=tmp_path / "artifacts",
artifacts_root=artifacts_root,
run_date=run_date,
run_id=run_id,
manifest_json=manifest_json(run_date, run_id, documents),
review_locator_json=review_locator_json(artifacts_root, run_date, run_id, documents),
documents=documents,
)
@@ -166,22 +208,30 @@ def test_publish_run_rejects_unsafe_date_and_run_id(tmp_path: Path, run_date: st
def test_publish_run_rejects_inconsistent_or_duplicate_document_artifacts(tmp_path: Path) -> None:
valid = stored_document()
bad_hash = StoredDocument("paper", valid.result_json, b"output", b"", "0" * 64)
first_artifacts_root = tmp_path / "artifacts-a"
with pytest.raises(ArtifactStoreError, match="output hash"):
publish_run(
artifacts_root=tmp_path / "artifacts-a",
artifacts_root=first_artifacts_root,
run_date="2026-08-22",
run_id="bad-hash",
manifest_json=manifest_json("2026-08-22", "bad-hash", (bad_hash,)),
review_locator_json=review_locator_json(
first_artifacts_root, "2026-08-22", "bad-hash", (bad_hash,)
),
documents=(bad_hash,),
)
duplicates = (stored_document(), stored_document())
second_artifacts_root = tmp_path / "artifacts-b"
with pytest.raises(ArtifactStoreError, match="unique"):
publish_run(
artifacts_root=tmp_path / "artifacts-b",
artifacts_root=second_artifacts_root,
run_date="2026-08-22",
run_id="duplicate",
manifest_json=manifest_json("2026-08-22", "duplicate", duplicates),
review_locator_json=review_locator_json(
second_artifacts_root, "2026-08-22", "duplicate", duplicates
),
documents=duplicates,
)
@@ -189,32 +239,81 @@ def test_publish_run_rejects_inconsistent_or_duplicate_document_artifacts(tmp_pa
def test_publish_run_rejects_manifest_path_or_document_mismatch(tmp_path: Path) -> None:
documents = (stored_document(),)
wrong_date = manifest_json("2026-08-21", "review", documents)
first_artifacts_root = tmp_path / "artifacts-a"
with pytest.raises(ArtifactStoreError, match="identity"):
publish_run(
artifacts_root=tmp_path / "artifacts-a",
artifacts_root=first_artifacts_root,
run_date="2026-08-22",
run_id="review",
manifest_json=wrong_date,
review_locator_json=review_locator_json(
first_artifacts_root, "2026-08-22", "review", documents
),
documents=documents,
)
payload = json.loads(manifest_json("2026-08-22", "review", documents))
payload["documents"][0]["change_count"] = 99
mismatched_index = (json.dumps(payload, indent=2) + "\n").encode()
second_artifacts_root = tmp_path / "artifacts-b"
with pytest.raises(ArtifactStoreError, match="index"):
publish_run(
artifacts_root=tmp_path / "artifacts-b",
artifacts_root=second_artifacts_root,
run_date="2026-08-22",
run_id="review",
manifest_json=mismatched_index,
review_locator_json=review_locator_json(
second_artifacts_root, "2026-08-22", "review", documents
),
documents=documents,
)
def test_publish_run_rejects_inconsistent_review_locator(tmp_path: Path) -> None:
artifacts_root = tmp_path / "artifacts"
documents = (stored_document(),)
locator = json.loads(review_locator_json(artifacts_root, "2026-08-22", "review", documents))
locator["documents"][0]["input_sha256"] = "0" * 64
inconsistent_locator = (json.dumps(locator, indent=2) + "\n").encode()
with pytest.raises(ArtifactStoreError, match="document identity"):
publish_run(
artifacts_root=artifacts_root,
run_date="2026-08-22",
run_id="review",
manifest_json=manifest_json("2026-08-22", "review", documents),
review_locator_json=inconsistent_locator,
documents=documents,
)
assert not artifacts_root.exists()
def test_publish_run_rejects_source_changed_after_locator_creation(tmp_path: Path) -> None:
artifacts_root = tmp_path / "artifacts"
documents = (stored_document(),)
locator_json = review_locator_json(artifacts_root, "2026-08-22", "review", documents)
locator = json.loads(locator_json)
Path(locator["documents"][0]["source_path"]).write_bytes(b"changed\n")
with pytest.raises(ArtifactStoreError, match="input hash"):
publish_run(
artifacts_root=artifacts_root,
run_date="2026-08-22",
run_id="review",
manifest_json=manifest_json("2026-08-22", "review", documents),
review_locator_json=locator_json,
documents=documents,
)
assert not artifacts_root.exists()
def test_publish_race_does_not_replace_a_new_target(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
artifacts_root = tmp_path / "artifacts"
documents = (stored_document(),)
original_rename = artifact_store._rename_no_replace
@@ -226,10 +325,11 @@ def test_publish_race_does_not_replace_a_new_target(
monkeypatch.setattr(artifact_store, "_rename_no_replace", create_competing_target)
with pytest.raises(ArtifactStoreError, match="already exists"):
publish_run(
artifacts_root=tmp_path / "artifacts",
artifacts_root=artifacts_root,
run_date="2026-08-22",
run_id="raced",
manifest_json=manifest_json("2026-08-22", "raced", documents),
review_locator_json=review_locator_json(artifacts_root, "2026-08-22", "raced", documents),
documents=documents,
)
@@ -253,14 +353,44 @@ def test_write_failure_cleans_temporary_directory_and_does_not_publish(
original_write(path, content)
monkeypatch.setattr(artifact_store, "_write_private_file", fail_second_write)
artifacts_root = tmp_path / "artifacts"
documents = (stored_document(),)
with pytest.raises(OSError, match="synthetic"):
publish_run(
artifacts_root=tmp_path / "artifacts",
artifacts_root=artifacts_root,
run_date="2026-08-22",
run_id="broken",
manifest_json=manifest_json("2026-08-22", "broken", documents),
review_locator_json=review_locator_json(artifacts_root, "2026-08-22", "broken", documents),
documents=documents,
)
runs_directory = tmp_path / "artifacts/2026-08-22/runs"
assert list(runs_directory.iterdir()) == []
def test_locator_write_failure_does_not_publish(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
original_write = artifact_store._write_private_file
def fail_locator_write(path: Path, content: bytes) -> None:
if path.name == "review-locator.json":
raise OSError("synthetic locator write failure")
original_write(path, content)
monkeypatch.setattr(artifact_store, "_write_private_file", fail_locator_write)
artifacts_root = tmp_path / "artifacts"
documents = (stored_document(),)
with pytest.raises(OSError, match="locator"):
publish_run(
artifacts_root=artifacts_root,
run_date="2026-08-22",
run_id="locator-failure",
manifest_json=manifest_json("2026-08-22", "locator-failure", documents),
review_locator_json=review_locator_json(
artifacts_root, "2026-08-22", "locator-failure", documents
),
documents=documents,
)
+21
View File
@@ -109,6 +109,27 @@ def test_run_experiment_publishes_manifest_reports_diff_and_preserves_inputs(tmp
assert (result.run_directory / "documents/second/changes.diff").read_bytes() == b""
manifest = json.loads((result.run_directory / "manifest.json").read_bytes())
locator = json.loads((result.run_directory / "review-locator.json").read_bytes())
assert locator == {
"schema_version": 1,
"run": {
"run_id": "local-review",
"run_directory": str(result.run_directory.resolve()),
"manifest_path": "manifest.json",
},
"documents": [
{
"document_id": "first",
"source_path": str(first_path.resolve()),
"input_sha256": manifest["documents"][0]["input_sha256"],
},
{
"document_id": "second",
"source_path": str(second_path.resolve()),
"input_sha256": manifest["documents"][1]["input_sha256"],
},
],
}
assert manifest["run"]["run_date"] == "2026-08-22"
assert manifest["run"]["utc_offset"] == "+08:00"
assert manifest["run"]["started_at_utc"] == "2026-08-22T02:30:00Z"
+132
View File
@@ -0,0 +1,132 @@
from __future__ import annotations
from pathlib import Path
from typing import Any, cast
import pytest
from reviewer.server import ReviewArtifactError, ReviewArtifacts
from tests.reviewer_fixture import create_review_run, read_json, write_json
def test_reviewer_returns_full_comparison_and_all_component_stages(tmp_path: Path) -> None:
fixture = create_review_run(tmp_path)
repository = ReviewArtifacts(fixture["run_directory"])
run = cast(dict[str, Any], repository.run_summary())
comparison = cast(dict[str, Any], repository.document_comparison("paper"))
changed = cast(dict[str, Any], repository.component_stage("paper", 0))
unchanged = cast(dict[str, Any], repository.component_stage("paper", 1))
assert run["summary"] == {
"document_count": 1,
"success_count": 1,
"failed_count": 0,
"unstable_count": 0,
"change_count": 1,
}
assert run["documents"][0]["source_available"] is True
assert comparison["original_markdown"] == fixture["original"]
assert comparison["cleaned_markdown"] == fixture["cleaned"]
assert comparison["changes"][0]["editor_range"] == {"start": 4, "end": 7}
assert changed["before_markdown"] == fixture["original"]
assert changed["after_markdown"] == fixture["cleaned"]
assert unchanged["before_markdown"] == fixture["cleaned"]
assert unchanged["after_markdown"] == fixture["cleaned"]
assert unchanged["changes"] == []
@pytest.mark.parametrize("status", ["failed", "unstable"])
def test_non_success_documents_expose_audit_but_no_formal_output(tmp_path: Path, status: str) -> None:
fixture = create_review_run(tmp_path, status=status)
repository = ReviewArtifacts(fixture["run_directory"])
comparison = cast(dict[str, Any], repository.document_comparison("paper"))
assert comparison["original_markdown"] == fixture["original"]
assert comparison["cleaned_markdown"] is None
if status == "failed":
assert comparison["errors"]
assert comparison["residual_proposals"] == []
else:
assert comparison["errors"] == []
assert comparison["residual_proposals"]
with pytest.raises(ReviewArtifactError, match="success"):
repository.component_stage("paper", 0)
def test_historical_run_without_locator_reports_unavailable_source(tmp_path: Path) -> None:
fixture = create_review_run(tmp_path, with_locator=False)
repository = ReviewArtifacts(fixture["run_directory"])
summary = cast(dict[str, Any], repository.run_summary())
assert summary["documents"][0]["source_available"] is False
assert "review-locator.json" in summary["documents"][0]["availability_error"]
comparison = cast(dict[str, Any], repository.document_comparison("paper"))
assert comparison["original_markdown"] is None
assert comparison["cleaned_markdown"] == fixture["cleaned"]
assert comparison["changes"][0]["editor_range"] is None
with pytest.raises(ReviewArtifactError, match=r"review-locator\.json"):
repository.component_stage("paper", 0)
def test_source_hash_change_is_not_silently_displayed(tmp_path: Path) -> None:
fixture = create_review_run(tmp_path)
repository = ReviewArtifacts(fixture["run_directory"])
source_path = fixture["source_path"]
assert isinstance(source_path, Path)
source_path.write_text("changed\n", encoding="utf-8")
summary = cast(dict[str, Any], repository.run_summary())
assert summary["documents"][0]["source_available"] is False
assert "哈希" in summary["documents"][0]["availability_error"]
comparison = cast(dict[str, Any], repository.document_comparison("paper"))
assert comparison["original_markdown"] is None
assert comparison["changes"][0]["editor_range"] is None
with pytest.raises(ReviewArtifactError, match="哈希"):
repository.component_stage("paper", 0)
def test_reviewer_rejects_unknown_schema_and_artifact_path_escape(tmp_path: Path) -> None:
fixture = create_review_run(tmp_path)
manifest_path = fixture["manifest_path"]
assert isinstance(manifest_path, Path)
manifest = read_json(manifest_path)
manifest["schema_version"] = 2
write_json(manifest_path, manifest)
with pytest.raises(ReviewArtifactError) as unknown:
ReviewArtifacts(fixture["run_directory"])
assert unknown.value.code == "unsupported_schema"
second = create_review_run(tmp_path / "second")
second_manifest_path = second["manifest_path"]
assert isinstance(second_manifest_path, Path)
second_manifest = read_json(second_manifest_path)
second_manifest["documents"][0]["result_path"] = "../outside.json"
write_json(second_manifest_path, second_manifest)
with pytest.raises(ReviewArtifactError, match="路径"):
ReviewArtifacts(second["run_directory"])
def test_reviewer_rejects_tampered_snapshot_chain_and_unknown_identity(tmp_path: Path) -> None:
fixture = create_review_run(tmp_path)
result_path = fixture["result_path"]
assert isinstance(result_path, Path)
result = read_json(result_path)
result["changes"][0]["after_sha256"] = "0" * 64
write_json(result_path, result)
repository = ReviewArtifacts(fixture["run_directory"])
with pytest.raises(ReviewArtifactError) as replay_error:
repository.document_comparison("paper")
assert replay_error.value.code == "untrusted_replay"
with pytest.raises(ReviewArtifactError) as document_error:
repository.document_comparison("missing")
assert document_error.value.http_status == 404
with pytest.raises(ReviewArtifactError) as component_error:
repository.component_stage("paper", 99)
assert component_error.value.http_status == 404
+105
View File
@@ -0,0 +1,105 @@
from __future__ import annotations
import json
import threading
from collections.abc import Generator
from http.client import HTTPConnection, HTTPResponse
from pathlib import Path
from typing import Any, cast
import pytest
from reviewer.server import ReviewArtifacts
from reviewer.server.__main__ import create_server
from tests.reviewer_fixture import ReviewFixture, create_review_run
def request(
port: int,
method: str,
path: str,
*,
headers: dict[str, str] | None = None,
) -> tuple[HTTPResponse, bytes]:
connection = HTTPConnection("127.0.0.1", port, timeout=3)
connection.request(method, path, headers=headers or {})
response = connection.getresponse()
content = response.read()
connection.close()
return response, content
@pytest.fixture
def running_server(tmp_path: Path) -> Generator[tuple[int, ReviewFixture], None, None]:
fixture = create_review_run(tmp_path)
static_root = tmp_path / "static"
(static_root / "assets").mkdir(parents=True)
(static_root / "index.html").write_text("<main>reviewer</main>\n", encoding="utf-8")
(static_root / "assets/app.js").write_text("export {};\n", encoding="utf-8")
repository = ReviewArtifacts(fixture["run_directory"])
server = create_server(repository, static_root)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield server.server_address[1], fixture
finally:
server.shutdown()
server.server_close()
thread.join(timeout=3)
def test_server_exposes_same_origin_api_and_static_build(
running_server: tuple[int, ReviewFixture],
) -> None:
port, fixture = running_server
api_response, api_content = request(port, "GET", "/api/v1/run")
page_response, page_content = request(port, "GET", "/")
asset_response, _ = request(port, "HEAD", "/assets/app.js")
payload = cast(dict[str, Any], json.loads(api_content))
assert api_response.status == 200
assert payload["run"]["run_id"] == "review-run"
assert str(fixture["source_path"]) not in api_content.decode()
assert api_response.getheader("Access-Control-Allow-Origin") is None
assert api_response.getheader("Cache-Control") == "no-store"
assert "default-src 'self'" in cast(str, api_response.getheader("Content-Security-Policy"))
assert page_response.status == 200
assert page_content == b"<main>reviewer</main>\n"
assert asset_response.status == 200
assert asset_response.getheader("Content-Type") == "text/javascript; charset=utf-8"
@pytest.mark.parametrize(
("method", "path", "headers", "status", "code"),
[
("POST", "/api/v1/run", None, 405, "method_not_allowed"),
("GET", "/api/v1/run", {"Host": "example.com"}, 403, "invalid_origin"),
(
"GET",
"/api/v1/run",
{"Origin": "http://example.com"},
403,
"invalid_origin",
),
("GET", "/api/v1/documents/missing", None, 404, "unknown_document"),
("GET", "/api/v1/documents/paper/components/99", None, 404, "unknown_component"),
("GET", "/assets/missing.js", None, 404, "not_found"),
("GET", "/..%2Fsecret.txt", None, 404, "not_found"),
],
)
def test_server_rejects_unsafe_or_unknown_requests(
running_server: tuple[int, ReviewFixture],
method: str,
path: str,
headers: dict[str, str] | None,
status: int,
code: str,
) -> None:
port, _ = running_server
response, content = request(port, method, path, headers=headers)
payload = cast(dict[str, Any], json.loads(content))
assert response.status == status
assert payload["error"]["code"] == code