v1.1: harden recovery and control boundaries

WP-D and WP-E complete the planned v1.1 implementation packages.

WP-D — recovery honesty:
- backups verify the completed copy with PRAGMA integrity_check
- corruption missed by quick_check is detected by the full check
- existing good backups remain protected
- oversized exports are still delivered but declare whether this version can
  import them, while the 20 MB import limit remains unchanged
- backup was exercised through the real browser UI on both the normal campaign
  database and a campaign-shaped database over 100 MB

WP-E — control-boundary contrast:
- interactive control boundaries meet the WCAG 1.4.11 3:1 target
- the contrast audit is now a failing gate rather than an advisory
- rendered browser measurements pass for the composer, controls, tabs and nav
- text contrast and focus visibility remain intact
- owner reviewed and approved the before/after screenshots

Reports:
- planning/reports/v1.1/V1.1-WP-D-REPORT.md
- planning/reports/v1.1/V1.1-WP-E-REPORT.md

All planned v1.1 work packages A-E are now complete. Release validation has not
yet begun.
This commit is contained in:
JesseMarkowitz
2026-09-16 05:37:13 -04:00
parent 59b5ebc2d8
commit 87a40326a2
21 changed files with 2401 additions and 60 deletions
+294
View File
@@ -0,0 +1,294 @@
"""v1.1 WP-D: a backup that was really checked, and an export that says what it is.
Two recovery-path claims, each of which was true only in the small before this
package:
- **A backup is verified.** M9 ran `PRAGMA quick_check` on the finished copy.
That reads every page and every record, and skips the cross-check between a
table and its indexes — so a copy whose index disagrees with its table passed.
`test_the_fixture_is_the_difference_between_the_two_checks` builds exactly that
damage and shows the two pragmas disagreeing about it, before anything here
uses it as evidence.
- **An export is importable.** Nothing compared the bundle with
`limits.MAX_IMPORT_BODY_BYTES`, so a campaign could be exported and then
refused by its own importer, with the reader finding out at the moment they
needed it. The export still succeeds — the file is complete, and a version
that refused to write it would destroy the copy someone was trying to make —
and now it says so.
python -m pytest tests/test_v11_d_recovery.py -v
"""
import json
import sqlite3
from pathlib import Path
import pytest
from fastapi import Depends
from fastapi.testclient import TestClient
from app import auth, backup, limits, models
from app.database import Base, SessionLocal, engine, get_db
from app.main import app
@pytest.fixture()
def client():
Base.metadata.create_all(bind=engine)
setup = SessionLocal()
user = models.User(is_guest=False, email="wp-d@example.com")
setup.add(user)
setup.flush()
setup.add(models.Settings(user_id=user.id, model="test-model"))
adventure = models.Adventure(user_id=user.id, title="Recovery")
setup.add(adventure)
setup.flush()
setup.add(models.Action(adventure_id=adventure.id, type="start", text="The story opens."))
setup.commit()
adv_id, user_id = adventure.id, user.id
setup.close()
app.dependency_overrides[auth.get_current_user] = (
lambda db=Depends(get_db): db.get(models.User, user_id))
test_client = TestClient(app)
test_client.adv_id = adv_id
test_client.user_id = user_id
try:
yield test_client
finally:
app.dependency_overrides.clear()
Base.metadata.drop_all(bind=engine)
# ------------------------------------------------------------ the fixture
def build_corrupt_copy(path: Path) -> None:
"""A database whose index disagrees with its table, and nothing else.
One digit inside one index leaf page is changed, so that entry names a key
no row holds and one row's key is in no index entry. Every page is still
structurally sound and every record still parses, which is the whole point:
this is the damage `quick_check` is not looking for.
"""
path.unlink(missing_ok=True)
connection = sqlite3.connect(path)
connection.execute("PRAGMA page_size=4096")
connection.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, k TEXT NOT NULL, filler TEXT)")
connection.execute("CREATE INDEX i_t_k ON t(k)")
connection.executemany("INSERT INTO t (k, filler) VALUES (?, ?)",
[(f"k{n:06d}", "x" * 40) for n in range(400)])
connection.commit()
page_size = connection.execute("PRAGMA page_size").fetchone()[0]
leaves = [row[0] for row in connection.execute(
"SELECT pageno FROM dbstat WHERE name='i_t_k' AND pagetype='leaf' ORDER BY pageno")]
connection.close()
assert leaves, "the index must have a leaf page to damage"
raw = bytearray(path.read_bytes())
start = (leaves[0] - 1) * page_size
page = raw[start:start + page_size]
at = page.find(b"k000")
assert at != -1, "expected an indexed key on the index's first leaf page"
page[at + 4] = ord("9") # k000144 -> k000944: a key no row has
raw[start:start + page_size] = page
path.write_bytes(bytes(raw))
def check(path: Path, pragma: str) -> str:
connection = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
try:
return ", ".join(str(row[0]) for row in connection.execute(f"PRAGMA {pragma}").fetchall())
finally:
connection.close()
def test_the_fixture_is_the_difference_between_the_two_checks(tmp_path):
"""Before using it as evidence: quick_check calls this database fine."""
damaged = tmp_path / "damaged.db"
build_corrupt_copy(damaged)
assert check(damaged, "quick_check") == "ok"
integrity = check(damaged, "integrity_check")
assert integrity != "ok"
assert "i_t_k" in integrity # it names the index that disagrees
# ---------------------------------------------------------------- backups
def test_a_healthy_backup_passes_the_full_check_and_is_kept(client, tmp_path):
source = tmp_path / "campaign.db"
source.write_bytes(Path(str(engine.url.database)).read_bytes())
result = backup.create(source)
assert result.integrity == "ok"
assert result.path.exists() and result.bytes > 0
assert check(result.path, "integrity_check") == "ok"
assert result.path.parent == backup.directory(source)
def test_the_backup_runs_the_full_check_not_the_quick_one(client, tmp_path, monkeypatch):
"""The pragma itself, named. SQLite traces every statement it executes, so
this reads what the backup actually asked the copy rather than inferring it."""
asked: list[str] = []
real_connect = sqlite3.connect
def tracing(*args, **kwargs):
connection = real_connect(*args, **kwargs)
connection.set_trace_callback(asked.append)
return connection
monkeypatch.setattr(backup.sqlite3, "connect", tracing)
source = tmp_path / "campaign.db"
source.write_bytes(Path(str(engine.url.database)).read_bytes())
backup.create(source).path.unlink()
assert any("integrity_check" in sql for sql in asked), asked
assert not any("quick_check" in sql for sql in asked), asked
def test_a_copy_the_full_check_rejects_is_not_kept(client, tmp_path, monkeypatch):
"""The copy is damaged after it is written and before it is verified, which
is where a real page-level fault would appear: between the copy and the
rename. Nothing wearing a backup's name may be left behind."""
source = tmp_path / "campaign.db"
source.write_bytes(Path(str(engine.url.database)).read_bytes())
real_copy = backup._copy
def damage(source_path, working):
pages = real_copy(source_path, working)
build_corrupt_copy(working)
return pages
monkeypatch.setattr(backup, "_copy", damage)
with pytest.raises(backup.BackupError) as refused:
backup.create(source)
assert "did not verify" in str(refused.value)
assert "i_t_k" in str(refused.value) # it says what was wrong
kept = list(backup.directory(source).glob("*"))
assert kept == [], f"a rejected backup was left behind: {kept}"
def test_a_rejected_backup_leaves_an_earlier_good_one_alone(client, tmp_path, monkeypatch):
source = tmp_path / "campaign.db"
source.write_bytes(Path(str(engine.url.database)).read_bytes())
good = backup.create(source)
before = good.path.read_bytes()
real_copy = backup._copy
def damage(source_path, working):
pages = real_copy(source_path, working)
build_corrupt_copy(working)
return pages
monkeypatch.setattr(backup, "_copy", damage)
with pytest.raises(backup.BackupError):
backup.create(source)
assert good.path.exists()
assert good.path.read_bytes() == before
assert check(good.path, "integrity_check") == "ok"
assert [p.name for p in backup.directory(source).glob("*")] == [good.path.name]
def test_the_backup_file_semantics_are_unchanged(client, tmp_path):
"""Same directory, same stamped name, same reported fields: WP-D changed the
check, not the file."""
source = tmp_path / "campaign.db"
source.write_bytes(Path(str(engine.url.database)).read_bytes())
first = backup.create(source)
second = backup.create(source)
assert first.path.name.startswith(backup.PREFIX) and first.path.suffix == ".db"
assert first.path != second.path, "an existing backup is never overwritten"
assert set(first.as_dict()) == {"filename", "bytes", "pages", "seconds", "integrity"}
listed = [row["filename"] for row in backup.existing(source)]
assert sorted(listed) == sorted([first.path.name, second.path.name])
# ----------------------------------------------------------------- exports
def export(client, adv_id):
response = client.get(f"/api/adventures/{adv_id}/export")
assert response.status_code == 200, response.text[:200]
return response
def test_a_normal_export_carries_no_warning(client):
response = export(client, client.adv_id)
assert "X-Export-Warning" not in response.headers
assert response.headers["X-Importable-By-This-Version"] == "true"
assert int(response.headers["X-Import-Limit-Bytes"]) == limits.MAX_IMPORT_BODY_BYTES
assert int(response.headers["X-Export-Bytes"]) == len(response.content)
assert response.json()["format"] == "ai-dnd-adventure-v3"
def fill_past_the_limit(adv_id: int) -> int:
"""Real rows, until the campaign's bundle is genuinely over the ceiling.
Not a mocked size: the export below serialises all of it.
"""
chunk = "The rain kept on over the harbour road, and nobody came. " * 900 # ~50 kB
written = 0
with SessionLocal() as db:
while written < limits.MAX_IMPORT_BODY_BYTES + 2 * 1024 * 1024:
db.add_all([models.Action(adventure_id=adv_id, type="ai", text=chunk)
for _ in range(40)])
db.commit()
written += 40 * len(chunk)
return written
def test_an_oversized_export_is_still_delivered_and_says_it_cannot_come_back(client):
fill_past_the_limit(client.adv_id)
response = export(client, client.adv_id)
# Delivered, whole, and still the same format.
body = response.content
assert len(body) > limits.MAX_IMPORT_BODY_BYTES
parsed = json.loads(body)
assert parsed["format"] == "ai-dnd-adventure-v3"
assert len(parsed["actions"]) > 40
# And honest about what this version can do with it.
assert response.headers["X-Importable-By-This-Version"] == "false"
warning = response.headers["X-Export-Warning"]
assert limits.import_limit_label() in warning
assert "exported successfully" in warning
assert "cannot import" in warning
assert int(response.headers["X-Export-Bytes"]) == len(body)
def test_the_warning_follows_the_configured_limit(monkeypatch):
"""The text is generated from the constant, so changing the constant changes
the sentence rather than leaving a stale number in it."""
assert "20 MB" in limits.oversized_export_warning(21_000_000)
monkeypatch.setattr(limits, "MAX_IMPORT_BODY_BYTES", 50 * 1024 * 1024)
assert limits.import_limit_label() == "50 MB"
assert "50 MB" in limits.oversized_export_warning(60_000_000)
assert "20 MB" not in limits.oversized_export_warning(60_000_000)
def test_the_bundle_itself_never_carries_the_warning(client):
"""The warning is about the export, not part of the portable story file."""
fill_past_the_limit(client.adv_id)
parsed = json.loads(export(client, client.adv_id).content)
flat = json.dumps(parsed).lower()
assert "import limit" not in flat
assert "cannot import" not in flat
for key in parsed:
assert "warning" not in key.lower()
def test_that_same_bundle_is_refused_by_import_naming_the_limit(client):
fill_past_the_limit(client.adv_id)
body = export(client, client.adv_id).content
response = client.post("/api/adventures/import", content=body,
headers={"Content-Type": "application/json"})
assert response.status_code == 413
detail = response.json()["detail"]
assert "too large" in detail.lower()
assert limits.import_limit_label() in detail
def test_a_bundle_under_the_limit_still_imports(client):
"""The refusal is about size alone: the ordinary path is untouched."""
body = export(client, client.adv_id).content
assert len(body) < limits.MAX_IMPORT_BODY_BYTES
response = client.post("/api/adventures/import", content=body,
headers={"Content-Type": "application/json"})
assert response.status_code == 201, response.text[:200]