llm-ingestion-okf/tests/test_manifest.py
Kjell Tore Guttormsen 5aa020f4b2 feat(manifest): reject [/] in extraction title (ingest-spec §4)
Ingest-spec §4 (ratified D1, commons bfa5a9b) requires a title to reject
`[` and `]` fail-fast at manifest load. The title is rendered verbatim into
the index link label and frontmatter (§5/§6), where a bracket would break
index-link and navigation parsing (method-spec §3 Step 1). The rule was
specced but unenforced: validation only checked single-line.

Satisfies the commons "Title link-safety" load-bearing §11 seam. Same
`manifest_schema` error code as the sibling single-line/max_rows rules;
parametrized cases added to test_manifest and test_error_codes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016oMpAhQcJZVGBW182stSPn
2026-07-23 21:33:47 +02:00

349 lines
11 KiB
Python

"""Manifest parsing and fail-fast validation (ingest-spec §3, §4).
The manifest MUST be schema-validated fail-fast BEFORE any source call.
The verdict layer is reserved: no `okf_type: verdict` (case-insensitive),
and generated filenames stay disjoint from `index.md` / `promoted-verdict-*`.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import pytest
from llm_ingestion_okf.errors import IngestError, ManifestError
from llm_ingestion_okf.manifest import (
Extraction,
FileSource,
HttpSource,
Manifest,
SqlSource,
generated_filename,
load_manifest,
)
def valid_file_manifest() -> dict[str, Any]:
return {
"manifest_version": 1,
"source": {"type": "file", "id": "catalogue-1", "root": "data"},
"bundle_summary": "A test bundle.",
"extractions": [
{
"id": "orders",
"title": "Orders",
"query": "orders.csv",
"okf_type": "dataset",
"max_rows": 100,
}
],
}
def valid_sql_manifest() -> dict[str, Any]:
manifest = valid_file_manifest()
manifest["source"] = {"type": "sql", "id": "db-1", "connection_ref": "TEST_DB"}
manifest["extractions"][0]["query"] = "SELECT a, b FROM t ORDER BY a"
return manifest
def valid_http_manifest() -> dict[str, Any]:
manifest = valid_file_manifest()
manifest["source"] = {"type": "http", "id": "api-1", "base_url": "https://example.test"}
manifest["extractions"][0]["query"] = "/orders"
return manifest
def write_manifest(tmp_path: Path, data: dict[str, Any]) -> Path:
path = tmp_path / "manifest.json"
path.write_text(json.dumps(data), encoding="utf-8")
return path
def load(tmp_path: Path, data: dict[str, Any]) -> Manifest:
return load_manifest(write_manifest(tmp_path, data))
# --- error hierarchy ---
def test_manifest_error_is_ingest_error() -> None:
assert issubclass(ManifestError, IngestError)
assert issubclass(IngestError, Exception)
# --- valid manifests parse into typed objects ---
def test_valid_file_manifest_parses(tmp_path: Path) -> None:
manifest = load(tmp_path, valid_file_manifest())
assert manifest.manifest_version == 1
assert isinstance(manifest.source, FileSource)
assert manifest.source.id == "catalogue-1"
assert manifest.source.root == "data"
assert manifest.bundle_summary == "A test bundle."
assert manifest.extractions == (
Extraction(
id="orders", title="Orders", query="orders.csv", okf_type="dataset", max_rows=100
),
)
def test_valid_sql_manifest_parses(tmp_path: Path) -> None:
manifest = load(tmp_path, valid_sql_manifest())
assert isinstance(manifest.source, SqlSource)
assert manifest.source.connection_ref == "TEST_DB"
def test_valid_http_manifest_parses(tmp_path: Path) -> None:
manifest = load(tmp_path, valid_http_manifest())
assert isinstance(manifest.source, HttpSource)
assert manifest.source.base_url == "https://example.test"
assert manifest.source.credential_ref is None
def test_http_manifest_with_credential_ref(tmp_path: Path) -> None:
data = valid_http_manifest()
data["source"]["credential_ref"] = "API_TOKEN"
manifest = load(tmp_path, data)
assert isinstance(manifest.source, HttpSource)
assert manifest.source.credential_ref == "API_TOKEN"
# --- top-level schema ---
@pytest.mark.parametrize("missing", ["manifest_version", "source", "bundle_summary", "extractions"])
def test_missing_top_level_field_rejected(tmp_path: Path, missing: str) -> None:
data = valid_file_manifest()
del data[missing]
with pytest.raises(ManifestError):
load(tmp_path, data)
@pytest.mark.parametrize("version", [0, 2, "1", 1.0, True, None])
def test_unsupported_manifest_version_rejected(tmp_path: Path, version: Any) -> None:
data = valid_file_manifest()
data["manifest_version"] = version
with pytest.raises(ManifestError):
load(tmp_path, data)
def test_unknown_top_level_field_rejected(tmp_path: Path) -> None:
data = valid_file_manifest()
data["extra"] = "x"
with pytest.raises(ManifestError):
load(tmp_path, data)
def test_non_object_manifest_rejected(tmp_path: Path) -> None:
path = tmp_path / "manifest.json"
path.write_text(json.dumps([1, 2]), encoding="utf-8")
with pytest.raises(ManifestError):
load_manifest(path)
def test_invalid_json_rejected(tmp_path: Path) -> None:
path = tmp_path / "manifest.json"
path.write_text("{not json", encoding="utf-8")
with pytest.raises(ManifestError):
load_manifest(path)
@pytest.mark.parametrize("summary", [None, 7, ["x"]])
def test_non_string_bundle_summary_rejected(tmp_path: Path, summary: Any) -> None:
data = valid_file_manifest()
data["bundle_summary"] = summary
with pytest.raises(ManifestError):
load(tmp_path, data)
# --- source: polymorphic on type ---
@pytest.mark.parametrize("bad_type", ["ftp", "", None, 3])
def test_unknown_source_type_rejected(tmp_path: Path, bad_type: Any) -> None:
data = valid_file_manifest()
data["source"]["type"] = bad_type
with pytest.raises(ManifestError):
load(tmp_path, data)
@pytest.mark.parametrize(
("factory", "required"),
[
(valid_file_manifest, "root"),
(valid_sql_manifest, "connection_ref"),
(valid_http_manifest, "base_url"),
],
)
def test_missing_source_field_rejected(tmp_path: Path, factory: Any, required: str) -> None:
data = factory()
del data["source"][required]
with pytest.raises(ManifestError):
load(tmp_path, data)
def test_unknown_source_field_rejected(tmp_path: Path) -> None:
data = valid_file_manifest()
data["source"]["connection_ref"] = "X" # sql field on a file source
with pytest.raises(ManifestError):
load(tmp_path, data)
@pytest.mark.parametrize("bad_id", ["", "-x", "Xy", "a_b", "a b", "æ", 7, None])
def test_bad_source_id_rejected(tmp_path: Path, bad_id: Any) -> None:
data = valid_file_manifest()
data["source"]["id"] = bad_id
with pytest.raises(ManifestError):
load(tmp_path, data)
@pytest.mark.parametrize(
"url",
[
"https://user:pass@example.test", # embedded credentials (spec §4: MUST NOT)
"https://token@example.test",
],
)
def test_base_url_with_embedded_credentials_rejected(tmp_path: Path, url: str) -> None:
data = valid_http_manifest()
data["source"]["base_url"] = url
with pytest.raises(ManifestError):
load(tmp_path, data)
# --- extractions ---
def test_empty_extractions_rejected(tmp_path: Path) -> None:
data = valid_file_manifest()
data["extractions"] = []
with pytest.raises(ManifestError):
load(tmp_path, data)
@pytest.mark.parametrize("missing", ["id", "title", "query", "okf_type", "max_rows"])
def test_missing_extraction_field_rejected(tmp_path: Path, missing: str) -> None:
data = valid_file_manifest()
del data["extractions"][0][missing]
with pytest.raises(ManifestError):
load(tmp_path, data)
def test_unknown_extraction_field_rejected(tmp_path: Path) -> None:
data = valid_file_manifest()
data["extractions"][0]["max_row"] = 5 # typo for max_rows
with pytest.raises(ManifestError):
load(tmp_path, data)
@pytest.mark.parametrize("bad_id", ["", "-x", "Orders", "a_b", "a/b", "a.b", 7, None])
def test_bad_extraction_id_rejected(tmp_path: Path, bad_id: Any) -> None:
data = valid_file_manifest()
data["extractions"][0]["id"] = bad_id
with pytest.raises(ManifestError):
load(tmp_path, data)
def test_duplicate_extraction_ids_rejected(tmp_path: Path) -> None:
data = valid_file_manifest()
data["extractions"].append(dict(data["extractions"][0]))
with pytest.raises(ManifestError):
load(tmp_path, data)
@pytest.mark.parametrize(
"bad_title",
[
"two\nlines",
"carriage\rreturn",
"",
7,
None,
# `[`/`]` break index-link and navigation parsing (ingest spec §4/§6):
# rejected fail-fast at manifest load so downstream verbatim rendering is safe.
"opening [bracket",
"closing ]bracket",
"[wrapped]",
],
)
def test_bad_title_rejected(tmp_path: Path, bad_title: Any) -> None:
data = valid_file_manifest()
data["extractions"][0]["title"] = bad_title
with pytest.raises(ManifestError):
load(tmp_path, data)
@pytest.mark.parametrize("bad_rows", [0, -1, "100", 1.5, True, False, None])
def test_bad_max_rows_rejected(tmp_path: Path, bad_rows: Any) -> None:
data = valid_file_manifest()
data["extractions"][0]["max_rows"] = bad_rows
with pytest.raises(ManifestError):
load(tmp_path, data)
@pytest.mark.parametrize("bad_query", ["", 7, None])
def test_bad_query_rejected(tmp_path: Path, bad_query: Any) -> None:
data = valid_file_manifest()
data["extractions"][0]["query"] = bad_query
with pytest.raises(ManifestError):
load(tmp_path, data)
# --- verdict reservation (spec §3) — load-bearing seam ---
@pytest.mark.parametrize("verdict", ["verdict", "Verdict", "VERDICT", "vErDiCt"])
def test_verdict_okf_type_rejected(tmp_path: Path, verdict: str) -> None:
"""Load-bearing (spec §11): MUST fail if a verdict-typed mapping stops being rejected."""
data = valid_file_manifest()
data["extractions"][0]["okf_type"] = verdict
with pytest.raises(ManifestError):
load(tmp_path, data)
@pytest.mark.parametrize("bad_type", ["", 7, None])
def test_bad_okf_type_rejected(tmp_path: Path, bad_type: Any) -> None:
data = valid_file_manifest()
data["extractions"][0]["okf_type"] = bad_type
with pytest.raises(ManifestError):
load(tmp_path, data)
@pytest.mark.parametrize("extraction_id", ["index", "promoted-verdict-1", "orders"])
def test_generated_filenames_stay_outside_reserved_namespaces(extraction_id: str) -> None:
"""The ingest- prefix keeps generated names disjoint from index.md and
promoted-verdict-* (spec §5) — even for adversarial extraction ids."""
name = generated_filename(extraction_id)
assert name == f"ingest-{extraction_id}.md"
assert name != "index.md"
assert not name.startswith("promoted-verdict-")
# --- fail-fast: validation error BEFORE any source call (spec §4) ---
def test_validation_fails_before_any_source_access(tmp_path: Path) -> None:
"""Load-bearing (spec §11): a malformed manifest never starts a run.
The manifest points at a nonexistent source root AND carries a reserved
okf_type. Validation must raise ManifestError — not any I/O error — and
must not resolve or touch the source at all.
"""
data = valid_file_manifest()
data["source"]["root"] = str(tmp_path / "does-not-exist")
data["extractions"][0]["okf_type"] = "verdict"
with pytest.raises(ManifestError):
load(tmp_path, data)
def test_validation_does_not_require_source_to_exist(tmp_path: Path) -> None:
"""Manifest validation is schema-only: source existence is a connector
concern, checked at extraction time — never at validation time."""
data = valid_file_manifest()
data["source"]["root"] = str(tmp_path / "does-not-exist")
manifest = load(tmp_path, data)
assert isinstance(manifest.source, FileSource)