fix(feed): a malformed saved view cannot break the autotag cleanup, and a foreign private tag reads as missing

E32 review round 1 (B5, B6).

- The autotag orphan cleanup reads every user's feed views. A view whose tag pick was not a list
  ("tags": 5) raised for everyone's job, True passed as tag id 1, and ~66k ids pushed the NOT IN
  past psycopg's 65535 bind parameters. The views are now read defensively and the kept ids
  filtered in Python; the saved-views route holds filters.tags to a list of at most 500 ids, on
  create and on update.
- tags_by_category resolved any tag id, so another user's private tag landed in its real category
  and the feed summary answered differently than for an id that does not exist, which told the
  caller which foreign ids exist. It is owner-scoped now (pre-existing; S2's missing-tag branch
  made the difference visible).
This commit is contained in:
2026-10-04 02:14:04 +02:00
parent df4a79a1f1
commit 887b4e546c
6 changed files with 118 additions and 21 deletions
+28
View File
@@ -34,3 +34,31 @@ def test_an_orphan_system_tag_goes_but_one_a_saved_view_filters_on_stays(db, mak
db.expire_all()
names = set(db.execute(select(Tag.name)).scalars())
assert names == {"Gaming", "Music", "Mine"} # only the unreferenced system orphan went
def test_a_malformed_saved_view_cannot_break_the_cleanup(db, make_user):
"""A view stored around the route's shape check (or before it) must not raise for everyone's job
— `"tags": 5` raised TypeError — and `True` is not tag id 1 (E32 review)."""
user = make_user()
orphan = Tag(user_id=None, name="Cooking", category="topic")
db.add(orphan)
db.flush()
for i, filters in enumerate(
({"tags": 5}, {"tags": [True]}, ["not", "a", "dict"], {"tags": None})
):
db.add(SavedView(user_id=user.id, source="feed", name=f"bad{i}", filters=filters))
db.commit()
assert autotag._cleanup_orphan_system_tags(db) == 1
db.expire_all()
assert db.execute(select(Tag).where(Tag.name == "Cooking")).first() is None
def test_a_view_kept_tag_list_larger_than_a_bind_parameter_limit_still_cleans_up(db, make_user):
user = make_user()
orphan = Tag(user_id=None, name="Cooking", category="topic")
db.add(orphan)
db.flush()
huge = list(range(10_000_000, 10_070_000)) # past psycopg's 65535 bind parameters
db.add(SavedView(user_id=user.id, source="feed", name="huge", filters={"tags": huge}))
db.commit()
assert autotag._cleanup_orphan_system_tags(db) == 1
+22
View File
@@ -12,6 +12,8 @@ from datetime import datetime, timezone
import pytest
from app import feedquery
from app.models import Channel, ChannelTag, Subscription, Tag, Video, VideoState
@@ -192,6 +194,26 @@ def test_a_deleted_tags_id_filters_to_nothing_not_to_everything(db, as_user, mak
assert r.json()["items"] == [], qs
def test_another_users_private_tag_answers_like_one_that_does_not_exist(db, as_user, make_user):
"""The category split resolved any tag id, so a foreign PRIVATE tag landed in its real category
and the summary's facets differed from a non-existent id's: an oracle for which foreign ids exist
(E32 review). Both must now read as "missing"."""
user, tag_ids = _seed_tagged(db, make_user)
stranger = make_user(email="stranger@example.com")
private = Tag(user_id=stranger.id, name="Secret", category="language")
db.add(private)
db.commit()
assert feedquery.tags_by_category(db, user, [tag_ids["L1"], private.id]) == {
"language": [tag_ids["L1"]],
feedquery.MISSING_TAG_CATEGORY: [private.id],
}
client = as_user(user)
foreign = client.get(f"/api/feed/summary?show=all&tags={tag_ids['T1']}&tags={private.id}")
absent = client.get(f"/api/feed/summary?show=all&tags={tag_ids['T1']}&tags=987654")
assert foreign.status_code == absent.status_code == 200
assert foreign.json() == absent.json()
def test_untagged_filter_keeps_only_channels_the_user_has_not_tagged(db, as_user, make_user):
"""The "No tag" filter (item B): `untagged=true` keeps only channels with NO tag the USER personally
attached (no user-owned ChannelTag). System/auto tags (user_id NULL) don't count, and another user's
+15
View File
@@ -67,3 +67,18 @@ def test_duplicate_name_within_a_source_conflicts(db, monkeypatch, override_dep)
b = c.post("/api/saved-views", json={"name": "Dup", "filters": {}, "source": "plex"})
assert a.status_code == 200
assert b.status_code == 409 # same (user, source, name)
def test_a_feed_views_tag_pick_must_be_a_short_list_of_ids(db, monkeypatch, override_dep):
"""The autotag job reads every view's tag pick (E32 review): the route holds it to the shape the
feed sends, on create and on update."""
user = _user(db)
c = _client(db, user, monkeypatch, override_dep)
for bad in (5, "1", [1, "2"], [True], list(range(501))):
r = c.post("/api/saved-views", json={"name": "Bad", "filters": {"tags": bad}})
assert r.status_code == 422, (bad, r.text)
ok = c.post("/api/saved-views", json={"name": "Ok", "filters": {"tags": [1, 2]}})
assert ok.status_code == 200, ok.text
vid = ok.json()["id"]
assert c.patch(f"/api/saved-views/{vid}", json={"filters": {"tags": 5}}).status_code == 422
assert c.patch(f"/api/saved-views/{vid}", json={"filters": {"tags": []}}).status_code == 200