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:
@@ -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]
|
||||
Reference in New Issue
Block a user