Each handler that now calls a service and commits is driven through HTTP once. A second account's watch state, Watch later list and private download profile are never read or changed. The autotag job's orphan prune is seen committed.
249 lines
9.4 KiB
Python
249 lines
9.4 KiB
Python
"""The core's owner filters, each held by a second account whose rows must not count, show or change.
|
|
|
|
Every one of these reads or writes a user-owned table through `userscope.owned_by`; the fence holds
|
|
the form (no inline `Model.user_id == …`), these hold that the filter is there at all. Each test gives
|
|
a second account the same kind of rows, so a filter that went missing answers with theirs. DB lane.
|
|
"""
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from sqlalchemy import func, select
|
|
|
|
from app import debugsession, notifications, quota
|
|
from app.catalog.models import Channel, Video
|
|
from app.downloads.models import DownloadJob, DownloadProfile
|
|
from app.models import DebugScreenshot, DebugSession, Notification, QuotaEvent
|
|
from app.reader.models import Playlist, PlaylistItem, VideoState
|
|
from app.routes import tokens as tokens_routes
|
|
from app.routes.auth import RESET_TTL, _issue_token
|
|
|
|
|
|
def _notify(db, user, n=1, *, read=False):
|
|
for i in range(n):
|
|
db.add(Notification(user_id=user.id, type="t", title=f"n{i}", read=read))
|
|
db.commit()
|
|
|
|
|
|
def _visible(db, user) -> int:
|
|
return db.scalar(
|
|
select(func.count())
|
|
.select_from(Notification)
|
|
.where(Notification.user_id == user.id, Notification.dismissed.is_(False))
|
|
)
|
|
|
|
|
|
def test_the_notification_list_shows_only_mine(db, make_user, as_user):
|
|
me, other = make_user(), make_user()
|
|
_notify(db, me)
|
|
_notify(db, other, 2)
|
|
body = as_user(me).get("/api/me/notifications").json()
|
|
assert (body["total"], len(body["items"])) == (1, 1)
|
|
|
|
|
|
def test_the_unread_count_counts_only_mine(db, make_user, as_user):
|
|
me, other = make_user(), make_user()
|
|
_notify(db, me)
|
|
_notify(db, other, 2)
|
|
assert as_user(me).get("/api/me/notifications/unread_count").json() == {"count": 1}
|
|
|
|
|
|
def test_reading_all_reads_only_mine(db, make_user, as_user):
|
|
me, other = make_user(), make_user()
|
|
_notify(db, me)
|
|
_notify(db, other, 2)
|
|
assert as_user(me).post("/api/me/notifications/read_all").status_code == 200
|
|
assert as_user(other).get("/api/me/notifications/unread_count").json() == {"count": 2}
|
|
|
|
|
|
def test_clearing_all_dismisses_only_mine(db, make_user, as_user):
|
|
me, other = make_user(), make_user()
|
|
_notify(db, me)
|
|
_notify(db, other, 2)
|
|
assert as_user(me).post("/api/me/notifications/clear").status_code == 200
|
|
db.expire_all()
|
|
assert (_visible(db, me), _visible(db, other)) == (0, 2)
|
|
|
|
|
|
def test_trimming_read_notifications_trims_only_mine(db, make_user):
|
|
me, other = make_user(), make_user()
|
|
_notify(db, me, 3, read=True)
|
|
_notify(db, other, 3, read=True)
|
|
assert notifications.trim_read(db, me.id, cap=1) == 2
|
|
db.commit()
|
|
assert _visible(db, other) == 3
|
|
|
|
|
|
def test_todays_actions_count_only_mine(db, make_user):
|
|
me, other = make_user(), make_user()
|
|
quota.log_action(me.id, quota.QuotaAction.VIDEOS_SEARCH)
|
|
quota.log_action(other.id, quota.QuotaAction.VIDEOS_SEARCH)
|
|
quota.log_action(other.id, quota.QuotaAction.VIDEOS_SEARCH)
|
|
assert quota.actions_today(db, me.id, quota.QuotaAction.VIDEOS_SEARCH) == 1
|
|
|
|
|
|
def test_my_usage_sums_only_mine(db, make_user, as_user):
|
|
me, other = make_user(), make_user()
|
|
db.add_all(
|
|
[
|
|
QuotaEvent(user_id=me.id, action="videos.list", units=5),
|
|
QuotaEvent(user_id=other.id, action="videos.list", units=7),
|
|
]
|
|
)
|
|
db.commit()
|
|
body = as_user(me).get("/api/quota/my-usage").json()
|
|
assert (body["today"], body["all_time"], body["by_action"]) == (5, 5, {"videos.list": 5})
|
|
|
|
|
|
def test_the_token_ceiling_counts_only_mine(db, make_user, as_user, monkeypatch):
|
|
monkeypatch.setattr(tokens_routes, "MAX_TOKENS", 1)
|
|
me, other = make_user(), make_user()
|
|
first = as_user(other).post("/api/tokens", json={"name": "theirs", "preset": "hub"})
|
|
assert first.status_code == 200, first.text
|
|
r = as_user(me).post("/api/tokens", json={"name": "mine", "preset": "hub"})
|
|
assert r.status_code == 200, r.text
|
|
|
|
|
|
def test_a_reset_burns_only_my_other_links(client, db, make_user):
|
|
me, other = make_user(), make_user()
|
|
mine = _issue_token(db, me, "reset", RESET_TTL)
|
|
theirs = _issue_token(db, other, "reset", RESET_TTL)
|
|
db.commit()
|
|
r = client.post(
|
|
"/auth/password-reset/confirm", json={"token": mine, "password": "a brand new passphrase"}
|
|
)
|
|
assert r.json() == {"ok": True}
|
|
assert client.post("/auth/password-reset/check", json={"token": theirs}).json() == {"ok": True}
|
|
|
|
|
|
def test_screenshot_storage_counts_only_mine(db, make_user):
|
|
me, other = make_user(), make_user()
|
|
now = datetime.now(timezone.utc)
|
|
for i, (user, size) in enumerate([(me, 10), (other, 1000)]):
|
|
db.add(
|
|
DebugSession(
|
|
id=f"s{i}",
|
|
user_id=user.id,
|
|
level="DEBUG",
|
|
started_at=now,
|
|
until=now + timedelta(hours=1),
|
|
)
|
|
)
|
|
db.flush()
|
|
db.add(
|
|
DebugScreenshot(
|
|
session_id=f"s{i}", created_at=now, mime="image/png", size=size, data=b"x" * size
|
|
)
|
|
)
|
|
db.commit()
|
|
assert debugsession.stored_screenshot_bytes(db, me.id) == 10
|
|
|
|
|
|
# --- the units' owner filters, through their routes --------------------------------------------
|
|
# The same rule one layer up: a second account acting on the SAME video, list or profile id must
|
|
# reach only its own row. Each test holds the first account's row unchanged afterwards.
|
|
|
|
|
|
def _videos(db, *ids):
|
|
db.add(Channel(id="UCo", title="O"))
|
|
db.flush()
|
|
for vid in ids:
|
|
db.add(
|
|
Video(
|
|
id=vid,
|
|
channel_id="UCo",
|
|
published_at=datetime.now(timezone.utc),
|
|
duration_seconds=3600,
|
|
)
|
|
)
|
|
db.commit()
|
|
|
|
|
|
def _video_state(db, user, vid="v1"):
|
|
db.expire_all()
|
|
row = db.execute(
|
|
select(VideoState).where(VideoState.user_id == user.id, VideoState.video_id == vid)
|
|
).scalar_one_or_none()
|
|
return None if row is None else (row.status, row.position_seconds)
|
|
|
|
|
|
def test_unmarking_a_video_leaves_another_users_state(db, make_user, as_user):
|
|
me, other = make_user(), make_user()
|
|
_videos(db, "v1")
|
|
db.add(VideoState(user_id=other.id, video_id="v1", status="watched"))
|
|
db.commit()
|
|
r = as_user(me).post("/api/videos/v1/state", json={"status": "new"})
|
|
assert r.status_code == 200, r.text
|
|
assert _video_state(db, other) == ("watched", 0)
|
|
|
|
|
|
def test_clearing_a_video_leaves_another_users_state(db, make_user, as_user):
|
|
me, other = make_user(), make_user()
|
|
_videos(db, "v1")
|
|
db.add(VideoState(user_id=other.id, video_id="v1", status="hidden", position_seconds=90))
|
|
db.commit()
|
|
assert as_user(me).delete("/api/videos/v1/state").status_code == 200
|
|
assert _video_state(db, other) == ("hidden", 90)
|
|
|
|
|
|
def test_a_resume_point_is_set_and_cleared_on_my_own_row_only(db, make_user, as_user):
|
|
me, other = make_user(), make_user()
|
|
_videos(db, "v1")
|
|
db.add(VideoState(user_id=other.id, video_id="v1", status="hidden", position_seconds=90))
|
|
db.commit()
|
|
client = as_user(me)
|
|
body = {"position_seconds": 600, "duration_seconds": 3600}
|
|
assert client.post("/api/videos/v1/progress", json=body).status_code == 200
|
|
assert (_video_state(db, me), _video_state(db, other)) == (("new", 600), ("hidden", 90))
|
|
body = {"position_seconds": 0, "duration_seconds": 3600}
|
|
assert client.post("/api/videos/v1/progress", json=body).status_code == 200
|
|
assert (_video_state(db, me), _video_state(db, other)) == (None, ("hidden", 90))
|
|
|
|
|
|
def _watch_later_ids(db, user):
|
|
db.expire_all()
|
|
return sorted(
|
|
db.execute(
|
|
select(PlaylistItem.video_id)
|
|
.join(Playlist, Playlist.id == PlaylistItem.playlist_id)
|
|
.where(Playlist.user_id == user.id, Playlist.kind == "watch_later")
|
|
).scalars()
|
|
)
|
|
|
|
|
|
def test_watch_later_adds_and_removes_use_my_own_list(db, make_user, as_user):
|
|
me, other = make_user(), make_user()
|
|
_videos(db, "v1", "v2")
|
|
theirs = as_user(other).post("/api/playlists/watch-later", json={"video_id": "v1"}).json()
|
|
client = as_user(me)
|
|
mine = client.post("/api/playlists/watch-later", json={"video_id": "v2"}).json()
|
|
assert mine["playlist_id"] != theirs["playlist_id"]
|
|
# Their saved video is not on my list, so removing it from mine leaves theirs alone.
|
|
assert client.delete("/api/playlists/watch-later/v1").status_code == 200
|
|
assert (_watch_later_ids(db, me), _watch_later_ids(db, other)) == (["v2"], ["v1"])
|
|
|
|
|
|
def test_another_users_private_profile_is_refused_like_a_missing_one(db, make_user, as_user):
|
|
me, other = make_user(), make_user()
|
|
theirs = DownloadProfile(user_id=other.id, name="Theirs", spec={"format": "best"})
|
|
builtin = DownloadProfile(user_id=None, name="Best", spec={"format": "best"}, is_builtin=True)
|
|
db.add_all([theirs, builtin])
|
|
db.commit()
|
|
client = as_user(me)
|
|
|
|
def enqueue(profile_id):
|
|
return client.post(
|
|
"/api/downloads", json={"source": "dQw4w9WgXcQ", "profile_id": profile_id}
|
|
)
|
|
|
|
missing = enqueue(theirs.id + builtin.id + 1)
|
|
refused = enqueue(theirs.id)
|
|
assert (refused.status_code, refused.json()) == (404, missing.json())
|
|
assert refused.json()["detail"] == {"code": "not_found", "reason": "profile"}
|
|
db.expire_all()
|
|
assert db.execute(select(DownloadJob)).first() is None
|
|
# A built-in is everyone's to use.
|
|
r = enqueue(builtin.id)
|
|
assert r.status_code == 200, r.text
|
|
db.expire_all()
|
|
assert db.execute(select(DownloadJob.profile_id)).scalars().all() == [builtin.id]
|