fix(api,ui): a refused write says why, wherever the refusal happens
Round 5 taught `notifyActionError` to discard a plain-string `detail` in favour
of the caller's translated fallback, because `req` leaves 403/404/429 to the
caller and those routes hand-write English. Three places did not get the
message.
**The demo account lost its explanation.** `require_human` refused with a plain
string, so from that commit on the shared demo account got its caller's generic
"couldn't do that" and no hint that the demo account was the reason — and the
gate is not backed by a UI gate everywhere: the comfort shelf has no `is_demo`
check, so the pin button is live and simply fails. A structured `{code: "demo"}`
and an `errors.demo` string in both locales. The two auth routes that hand-roll
the same check were swept with it.
**`ChatThread` carried a second copy of the contract.** It filtered 404/429
itself and then did `e.detail || t(…)`, the pre-fix order, so the central fix
could not reach it and "That member hasn't set up messaging yet." stayed English
under a Hungarian UI. It goes through the shared helper now, and a contract test
pins the remaining raw-`detail` consumers as an explicit SET — never a count —
so a new copy fails the gate instead of shipping. The five on that list are
inline surfaces (a form field's error line, a setup banner) and are deferred
with the ~60 backend routes whose details they print.
**The Plex "Add to playlist" dialog was still silent.** Same shape as the
collection editor round 5 fixed and the YouTube twin that already had the fix:
no catch, so a playlist deleted on another device answered a structured 404 that
nothing reported — the tick never flipped, the gone row stayed, and the floating
promise became an unhandled rejection.
Also: the `ABORTED` sentinel's VALUE was asserted nowhere. The consumer test
hand-built the error it expected, so replacing `xhr.onabort`'s rejection with a
friendlier message left the lane green — and every upload the user cancels would
then raise a red "the upload failed" toast, which is what the sentinel prevents.
Both producer tests assert through `isAborted` now.
And two backend tests were pinning fewer sites than their own docstrings claimed:
the channel-404 loop said "all five" and issued three requests (explore and
subscribe unexercised, the excuse for subscribe naming a different call site
with a different reason slug), and the recipient/job test named three refusals
and requested two — `share_download`'s `not_found("recipient")` had no test in
the suite at all, nor did the locale keys added beside it. Every site named now
has a request behind it.
6 tests, mutation-checked against 7 mutations: the demo detail at both the gate
and an auth route, a re-hand-rolled ChatThread, the silent Plex dialog, the
abort sentinel, and each unpinned 404 — all red on the test that names them.
This commit is contained in:
+5
-1
@@ -341,7 +341,11 @@ def require_human(user: User = Depends(current_user)) -> User:
|
||||
no token, so has_read_scope/has_write_scope are False); this makes the boundary explicit
|
||||
where there's no scope check to lean on."""
|
||||
if user.is_demo:
|
||||
raise HTTPException(status_code=403, detail="Not available in the demo account.")
|
||||
# Structured, because `req` leaves 403 to the caller and `notifyActionError` only shows a
|
||||
# STRUCTURED detail — a plain string here reached the demo user as their caller's generic
|
||||
# "couldn't do that", with no hint that the demo account is the reason (round 6). Affordances
|
||||
# this gate sits behind are not all UI-gated on `is_demo` (the comfort shelf is not).
|
||||
raise HTTPException(status_code=403, detail={"code": "demo"})
|
||||
return user
|
||||
|
||||
|
||||
|
||||
@@ -1246,7 +1246,7 @@ def logout_others(
|
||||
are rejected, then re-stamp THIS cookie so the caller stays signed in. For "I left myself logged
|
||||
in somewhere" / a suspected session compromise. The demo account (shared) is excluded."""
|
||||
if user.is_demo:
|
||||
raise HTTPException(status_code=403, detail="Not available in the demo account.")
|
||||
raise HTTPException(status_code=403, detail={"code": "demo"})
|
||||
# Commit the bump BEFORE re-stamping this cookie, so a failed commit can't strand the current
|
||||
# session at a newer epoch than the DB (see set_password).
|
||||
bump_session_epoch(user)
|
||||
@@ -1293,7 +1293,7 @@ def set_password(
|
||||
account) needs no current password — the session already proves identity. Changing an
|
||||
existing one requires the current password, so a hijacked session can't silently rotate it."""
|
||||
if user.is_demo:
|
||||
raise HTTPException(status_code=403, detail="Not available in the demo account.")
|
||||
raise HTTPException(status_code=403, detail={"code": "demo"})
|
||||
new_password = body.password or ""
|
||||
if len(new_password) < PASSWORD_MIN_LEN:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""The shared demo account's refusal says WHY, in the reader's language (deep-review round 6).
|
||||
|
||||
`require_human` answered `403 "Not available in the demo account."` — a plain string. `req` leaves
|
||||
403 to the caller, and round 5 taught `notifyActionError` to discard a plain-string detail in favour
|
||||
of the caller's already-translated fallback, so from that commit on the demo user got a generic
|
||||
"couldn't do that" and no hint that the demo account was the reason. Not every affordance behind
|
||||
this gate is UI-gated on `is_demo` either — the comfort shelf is not, so the pin button is live.
|
||||
|
||||
A structured `{code: "demo"}` is what `localizeDetail` can turn into `errors.demo` in both locales.
|
||||
Pinned at all three sites: the shared dependency and the two auth routes that hand-roll the same
|
||||
check, because an asymmetric fix is the smell this repo bans. Needs the DB.
|
||||
"""
|
||||
|
||||
from app.models import User
|
||||
|
||||
|
||||
def test_every_demo_refusal_carries_the_same_structured_reason(db, make_user, as_user):
|
||||
demo = make_user(email="demo@example.com", is_demo=True)
|
||||
client = as_user(demo)
|
||||
for method, url, body in [
|
||||
# `require_human` itself, through an affordance the UI does NOT gate on is_demo.
|
||||
("post", "/api/discover/pins", {"kind": "video", "ref": "v1"}),
|
||||
# The two routes that repeat the check inline rather than depending on the gate.
|
||||
("post", "/auth/logout-others", None),
|
||||
("post", "/auth/set-password", {"password": "a long enough password"}),
|
||||
]:
|
||||
r = getattr(client, method)(url, **({"json": body} if body is not None else {}))
|
||||
assert r.status_code == 403, (url, r.text)
|
||||
assert r.json()["detail"] == {"code": "demo"}, url
|
||||
|
||||
|
||||
def test_a_real_account_is_not_refused_by_the_gate(db, make_user, as_user):
|
||||
"""The other half: the structured detail must not come from having broken the gate open the
|
||||
other way. A non-demo account gets past `require_human` and fails (or succeeds) on its own
|
||||
merits — anything but a 403 demo refusal."""
|
||||
client = as_user(make_user(email="real@example.com"))
|
||||
r = client.post("/auth/logout-others")
|
||||
assert r.status_code == 200, r.text
|
||||
assert not isinstance(r.json().get("detail"), dict)
|
||||
|
||||
|
||||
def test_the_gate_reads_the_flag_and_not_the_email(db):
|
||||
"""`is_demo` is a column, not a convention about the address — a real account that happens to
|
||||
be called demo@ must not be refused."""
|
||||
from app.auth import require_human
|
||||
|
||||
assert require_human(User(email="demo@example.com", is_demo=False)) is not None
|
||||
@@ -6,6 +6,9 @@ Hungarian UI toasted it verbatim. The structured-404 fix had gone into one ad-ho
|
||||
the shared one, so every other owner check kept the English. Needs the DB.
|
||||
"""
|
||||
|
||||
from app.auth import require_write_scope
|
||||
from app.models import DownloadJob, MediaAsset
|
||||
|
||||
|
||||
def test_a_gone_view_tag_or_pin_says_what_is_gone(db, make_user, as_user):
|
||||
client = as_user(make_user())
|
||||
@@ -41,18 +44,27 @@ def test_a_gone_channel_video_or_user_says_what_is_gone(db, make_user, as_user):
|
||||
assert r.json()["detail"] == {"code": "not_found", "reason": what}, url
|
||||
|
||||
|
||||
def test_every_gone_channel_route_says_it_the_same_way(db, make_user, as_user):
|
||||
def test_every_gone_channel_route_says_it_the_same_way(db, make_user, as_user, override_dep):
|
||||
"""The round-4 sweep converted ONE of the five `db.get(Channel, …) is None -> 404 "Unknown
|
||||
channel"` sites (block), and shipped the HU/EN string with it — so the Hungarian UI said it in
|
||||
Hungarian on the Block button and in English on the four beside it (deep-review round 5). The
|
||||
asymmetric fix is the smell this repo bans, so all five are pinned together."""
|
||||
asymmetric fix is the smell this repo bans, so all five are pinned together.
|
||||
|
||||
Literally all five: round 6 found this loop issuing three requests under a docstring claiming
|
||||
five, with explore silently absent and subscribe excused as "covered by the tags route" — which
|
||||
reaches `_user_subscription`, a different site answering `reason: subscription`. Reverting
|
||||
either of those two to its bare English string left the whole lane green."""
|
||||
admin = make_user(role="admin")
|
||||
client = as_user(admin)
|
||||
# Subscribe's own gate (the optional YouTube write scope) sits in FRONT of the lookup, so a
|
||||
# plain account 403s before reaching it. The gate is not what is under test here.
|
||||
override_dep(require_write_scope, lambda: admin)
|
||||
gone = "UCgone000000000000000000"
|
||||
# Subscribe is behind the write-scope gate, so it is covered by the tags route below instead.
|
||||
for method, url in [
|
||||
("get", f"/api/channels/{gone}"),
|
||||
("post", f"/api/channels/{gone}/explore"),
|
||||
("post", f"/api/channels/{gone}/reset-backfill"),
|
||||
("post", f"/api/channels/{gone}/subscribe"),
|
||||
("post", f"/api/channels/{gone}/block"),
|
||||
]:
|
||||
r = getattr(client, method)(url)
|
||||
@@ -63,15 +75,44 @@ def test_every_gone_channel_route_says_it_the_same_way(db, make_user, as_user):
|
||||
def test_a_gone_subscription_recipient_or_job_says_what_is_gone(db, make_user, as_user):
|
||||
"""The three bare-English 404s the same sweep left behind, which `notifyActionError` printed
|
||||
verbatim into an otherwise Hungarian UI: "Not subscribed to this channel", "No user with that
|
||||
email.", "Unknown job"."""
|
||||
email.", "Unknown job".
|
||||
|
||||
All three, with a request each. Round 6 found this test naming three and issuing two: the
|
||||
recipient site had no test in the whole suite (nor did the `errors.notFound.recipient` keys
|
||||
added beside it), and `run_job_now` rode on its PATCH sibling's coverage."""
|
||||
admin = make_user(role="admin")
|
||||
client = as_user(admin)
|
||||
r = client.post("/api/channels/UCgone000000000000000000/tags", json={"tag_id": 1})
|
||||
assert r.status_code == 404, r.text
|
||||
assert r.json()["detail"] == {"code": "not_found", "reason": "subscription"}
|
||||
r = client.patch("/api/admin/scheduler/jobs/no_such_job", json={"interval_minutes": 30})
|
||||
for method, url in [
|
||||
("patch", "/api/admin/scheduler/jobs/no_such_job"),
|
||||
("post", "/api/admin/scheduler/jobs/no_such_job/run"),
|
||||
]:
|
||||
kw = {"json": {"interval_minutes": 30}} if method == "patch" else {}
|
||||
r = getattr(client, method)(url, **kw)
|
||||
assert r.status_code == 404, (url, r.text)
|
||||
assert r.json()["detail"] == {"code": "not_found", "reason": "job"}, url
|
||||
# Sharing needs a finished download of one's own to share; the recipient lookup is the next check.
|
||||
asset = MediaAsset(
|
||||
source_kind="url", source_ref="share-404", format_sig="sig", status="ready", ref_count=1
|
||||
)
|
||||
db.add(asset)
|
||||
db.flush()
|
||||
job = DownloadJob(
|
||||
user_id=admin.id,
|
||||
asset_id=asset.id,
|
||||
source_kind="url",
|
||||
source_ref="share-404",
|
||||
status="done",
|
||||
queue_pos=1,
|
||||
profile_snapshot={},
|
||||
)
|
||||
db.add(job)
|
||||
db.commit()
|
||||
r = client.post(f"/api/downloads/{job.id}/share", json={"email": "nobody@example.com"})
|
||||
assert r.status_code == 404, r.text
|
||||
assert r.json()["detail"] == {"code": "not_found", "reason": "job"}
|
||||
assert r.json()["detail"] == {"code": "not_found", "reason": "recipient"}
|
||||
|
||||
|
||||
def test_a_nul_in_a_request_string_is_a_422_not_a_500(db, make_user, as_user):
|
||||
|
||||
Reference in New Issue
Block a user