Admin routes for the subscription queue, and record the build in plan.md

Phase 4's acceptance criterion is that my brother approves the first import
himself, which needs a UI, so /pending now carries source management, sync-now, and
multi-select approve/reject. Driven over real HTTP rather than only through the
templates: unauthenticated requests redirect to login, all four new routes reject a
missing or forged CSRF token, and the live account rendered 117 checkboxes.

Approving three at once added three channels, which is the point of the fix
underneath. _form() collapses repeated fields to the last value, which is correct
for every single-value field but silently wrong for a form of checkboxes all named
`id` — it would have approved only the last box ticked. Added _form_list(), with the
parsed body cached because rfile can only be read once and the approval path needs
both views of it.

The approval page is deliberately its own page rather than a section on the index:
the first sync of the real account queued 119 channels, and that does not belong
inline under the channel table. Source errors are shown in full rather than
truncated, because the useful ones say exactly what to do — "subscriptions are
private, uncheck Keep all my subscriptions private" — and hiding that behind a log
file defeats the purpose of surfacing it.

plan.md §13 now reflects what is actually built rather than what was intended, and a
new §17 records the three bugs the build turned up, including which of them a test
caught and which two needed real data. 337 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Tom Flux
2026-08-12 16:41:47 +01:00
co-authored by Claude Opus 5
parent 155f05773d
commit 61cc1672ec
4 changed files with 360 additions and 33 deletions
+72
View File
@@ -144,3 +144,75 @@ def test_csrf_token_rejects_tampering():
def test_csrf_rejects_an_empty_token():
secret = auth.new_secret()
assert not auth.verify_csrf(secret, "s", "")
# --------------------------------------------------- sources / approval queue
def _source(**kw):
base = {"key": "youtube:UCbrother", "label": "C Flux",
"channel_id": "UCPcTWaLV8zwx4WP4QExHj4Q", "enabled": 1, "imported": 1,
"last_sync_at": "2026-08-12T16:00:00+00:00", "last_sync_ok": 1,
"consecutive_failures": 0, "last_error": None}
base.update(kw)
return base
def _pending(n=3):
return [{"id": i, "source": "youtube:UCbrother", "title": f"Channel {i}",
"channel_id": f"UC{i:022d}", "seen_at": "2026-08-12T16:00:00+00:00"}
for i in range(1, n + 1)]
def test_pending_page_renders_the_queue():
html = templates.pending_page(pending=_pending(3), sources=[_source()],
csrf="tok").decode()
assert "3 channel(s) waiting" in html
assert "Channel 1" in html and "Channel 3" in html
assert "Approve selected" in html
def test_every_queue_row_shares_the_id_field_name():
"""The approve handler reads a repeated `id` field; if the template numbered
them uniquely the multi-select would silently approve nothing."""
html = templates.pending_page(pending=_pending(3), sources=[_source()],
csrf="tok").decode()
assert html.count('name="id"') == 3
def test_pending_page_with_an_empty_queue():
html = templates.pending_page(pending=[], sources=[_source()], csrf="tok").decode()
assert "Nothing awaiting approval" in html
def test_pending_page_with_no_sources():
html = templates.pending_page(pending=[], sources=[], csrf="tok").decode()
assert "No sources yet" in html
def test_never_synced_source_is_labelled():
html = templates.pending_page(pending=[], sources=[_source(last_sync_ok=None)],
csrf="tok").decode()
assert "never synced" in html
def test_failing_source_shows_the_actionable_error_in_full():
"""The useful errors say exactly what to do; truncating them defeats the point."""
message = ('subscriptions are private (subscriptionForbidden) — nothing '
'changed. Fix: YouTube → Settings → Privacy → uncheck "Keep all '
'my subscriptions private".')
html = templates.pending_page(
pending=[], sources=[_source(last_sync_ok=0, consecutive_failures=3,
last_error=message)],
csrf="tok").decode()
assert "failing (3)" in html
assert "Keep all" in html
def test_hostile_pending_title_is_escaped():
items = _pending(1)
items[0]["title"] = '<img src=x onerror=alert(1)>'
html = templates.pending_page(pending=items, sources=[_source()],
csrf="tok").decode()
assert "<img src=x" not in html
assert "&lt;img" in html