140 lines
9.7 KiB
Python
140 lines
9.7 KiB
Python
import unittest
|
|
from unittest.mock import patch
|
|
from backend.app.config import settings
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
from backend.app import db
|
|
from backend.app.feature_access import FEATURES, permissions, update_permissions
|
|
from backend.app.routers import admin, auth, events, insights, portal, recaps, requests
|
|
from backend.app.security import create_access_token
|
|
from backend.tests.test_backend_quality import TempDatabaseMixin
|
|
|
|
|
|
class FeatureAccessTests(TempDatabaseMixin, unittest.TestCase):
|
|
def setUp(self):
|
|
super().setUp()
|
|
secret = patch.object(settings, "jwt_secret", "feature-access-tests-only-secret-123456789")
|
|
secret.start()
|
|
self.addCleanup(secret.stop)
|
|
db.create_user('feature-viewer', 'Example-password123!', role='user')
|
|
db.create_user('feature-admin', 'Example-password123!', role='admin')
|
|
self.user = db.get_user_by_username('feature-viewer')
|
|
app = FastAPI()
|
|
for module in (admin, auth, events, insights, portal, recaps, requests):
|
|
app.include_router(module.router)
|
|
self.client = TestClient(app)
|
|
self.client.headers['Authorization'] = 'Bearer ' + create_access_token(self.user['username'], 'user')
|
|
|
|
def test_defaults_persist_and_invites_share_existing_setting(self):
|
|
self.assertEqual(permissions(self.user), dict(stats=True, requests=True, new_requests=True, issues=True, invites=False, ignore_profile_limits=False))
|
|
update_permissions({'stats': False, 'invites': True}, self.user['username'])
|
|
db.init_db()
|
|
fresh = db.get_user_by_username(self.user['username'])
|
|
self.assertTrue(fresh['invite_management_enabled'])
|
|
self.assertFalse(permissions(fresh)['stats'])
|
|
db.set_user_invite_management_enabled(self.user['username'], False)
|
|
self.assertFalse(permissions(db.get_user_by_username(self.user['username']))['invites'])
|
|
|
|
def test_all_feature_apis_reject_disabled_access_with_existing_token(self):
|
|
update_permissions(dict.fromkeys(FEATURES, False), self.user['username'])
|
|
endpoints = [
|
|
('GET', '/insights', None), ('GET', '/insights/reports/monthly', None),
|
|
('GET', '/insights/reports/monthly.csv', None), ('GET', '/insights/artwork/item?token=x', None),
|
|
('GET', '/profile/email-recaps', None), ('POST', '/profile/email-recaps/send', {}),
|
|
('GET', '/requests/recent', None), ('GET', '/requests/search?query=Movie', None),
|
|
('GET', '/requests/request-options?mediaType=movie&tmdbId=1', None),
|
|
('POST', '/requests/create', {'mediaType': 'movie', 'tmdbId': 1}),
|
|
('GET', '/requests/1/snapshot', None), ('POST', '/requests/1/actions/search', {}),
|
|
('GET', '/requests/1/issue-options', None), ('POST', '/requests/1/actions/replace', {}),
|
|
('GET', '/portal/items?kind=issue', None), ('GET', '/portal/requests', None),
|
|
('POST', '/portal/items', {'kind': 'issue'}), ('POST', '/portal/items', {'kind': 'request'}),
|
|
('GET', '/portal/issues/media-status', None), ('POST', '/portal/requests/1/issues', {}),
|
|
('GET', '/auth/profile/invites', None), ('POST', '/auth/profile/invites', {}),
|
|
('PUT', '/auth/profile/invites/1', {}), ('DELETE', '/auth/profile/invites/1', None),
|
|
('GET', '/events/stream', None), ('GET', '/events/requests/1/stream', None),
|
|
]
|
|
for method, path, payload in endpoints:
|
|
with self.subTest(path=path, method=method):
|
|
self.assertEqual(self.client.request(method, path, json=payload).status_code, 403)
|
|
self.assertEqual(self.client.get('/auth/me').json()['features'], dict.fromkeys(FEATURES, False))
|
|
self.assertEqual(self.client.get('/auth/profile').status_code, 200)
|
|
|
|
def test_bulk_is_admin_only_strict_and_leaves_other_features_untouched(self):
|
|
self.assertEqual(self.client.put('/admin/users/features/bulk', json={'issues': False}).status_code, 403)
|
|
self.client.headers['Authorization'] = 'Bearer ' + create_access_token('feature-admin', 'admin')
|
|
for invalid in ({'issues': 'false'}, {'unknown': True}, {}):
|
|
self.assertEqual(self.client.put('/admin/users/features/bulk', json=invalid).status_code, 400)
|
|
response = self.client.put('/admin/users/features/bulk', json={'issues': False})
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertEqual(response.json()['updated'], 1)
|
|
self.assertFalse(permissions(self.user)['issues'])
|
|
self.assertTrue(permissions(self.user)['requests'])
|
|
self.assertTrue(all(permissions(db.get_user_by_username('feature-admin')).values()))
|
|
self.assertEqual(self.client.put('/admin/users/feature-admin/features', json={'stats': False}).status_code, 400)
|
|
self.assertEqual(self.client.put('/admin/users/missing/features', json={'stats': False}).status_code, 404)
|
|
|
|
def test_issue_and_request_item_routes_cannot_bypass_disabled_feature(self):
|
|
issue = db.create_portal_item(kind='issue', title='Problem', description='Problem', created_by_username=self.user['username'], created_by_id=self.user['id'])
|
|
update_permissions({'issues': False}, self.user['username'])
|
|
for path in (f'/portal/items/{issue["id"]}', f'/portal/items/{issue["id"]}/comments', '/portal/items', '/portal/overview'):
|
|
self.assertEqual(self.client.get(path).status_code, 403)
|
|
self.assertEqual(self.client.get('/portal/requests').status_code, 200)
|
|
self.assertEqual(self.client.get('/portal/items?kind=request').status_code, 200)
|
|
update_permissions({'issues': True, 'requests': False, 'new_requests': False}, self.user['username'])
|
|
self.assertEqual(self.client.get(f'/portal/items/{issue["id"]}').status_code, 200)
|
|
self.assertEqual(self.client.get('/portal/items?kind=issue').status_code, 200)
|
|
overview = self.client.get('/portal/overview?kind=issue')
|
|
self.assertEqual(overview.status_code, 200)
|
|
self.assertEqual(overview.json()['overview']['by_kind'], {'issue': 1})
|
|
self.assertEqual(self.client.post('/requests/create', json={'mediaType': 'movie', 'tmdbId': 1}).status_code, 403)
|
|
|
|
def test_deleted_account_does_not_leave_permissions_for_reused_id(self):
|
|
update_permissions({'stats': False}, self.user['username'])
|
|
db.delete_user_by_username(self.user['username'])
|
|
with db._connect() as conn:
|
|
self.assertEqual(conn.execute('SELECT COUNT(*) FROM user_feature_permissions').fetchone()[0], 0)
|
|
|
|
|
|
def test_open_request_stream_closes_after_permission_revocation(self):
|
|
import asyncio
|
|
from unittest.mock import AsyncMock
|
|
from types import SimpleNamespace
|
|
async def scenario():
|
|
request = SimpleNamespace(is_disconnected=AsyncMock(return_value=False))
|
|
response = await events.events_stream(request, user={**self.user, "features": permissions(self.user)})
|
|
iterator = response.body_iterator
|
|
self.assertIn('retry', await anext(iterator))
|
|
update_permissions({'requests': False}, self.user['username'])
|
|
with self.assertRaises(StopAsyncIteration):
|
|
await anext(iterator)
|
|
asyncio.run(scenario())
|
|
|
|
def test_legacy_portal_kind_normalization_cannot_bypass_permissions(self):
|
|
update_permissions({'requests': False, 'new_requests': False, 'issues': True}, self.user['username'])
|
|
for kind in ['request', 'REQUEST', ' Request ', ' ', '']:
|
|
with self.subTest(kind=kind):
|
|
self.assertEqual(self.client.get('/portal/items', params={'kind': kind}).status_code, 403)
|
|
self.assertEqual(self.client.get('/portal/overview', params={'kind': kind}).status_code, 403)
|
|
self.assertEqual(self.client.post('/portal/items', json={'kind': kind}).status_code, 403)
|
|
self.assertEqual(self.client.post('/portal/items', json={'kind': None}).status_code, 403)
|
|
self.assertEqual(self.client.post('/portal/items', json={}).status_code, 403)
|
|
|
|
def test_manual_override_permission_is_checked_again_at_download(self):
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock
|
|
from backend.app.models import Snapshot, RequestType
|
|
from backend.app.services import manual_releases
|
|
runtime=SimpleNamespace(jellyseerr_base_url=None,jellyseerr_api_key=None,sonarr_base_url='http://sonarr',sonarr_api_key='test')
|
|
snapshot=Snapshot(request_id='42',title='Example',request_type=RequestType.tv,raw={'arr':{'item':{'id':55}}})
|
|
release={'guid':'out','indexerId':1,'title':'Example','requiresOverride':True,'rejections':['Quality is not wanted in profile']}
|
|
payload={**release,'ignoreProfileLimits':True,'selectionToken':manual_releases.issue_selection(release,'42',self.user,'http://sonarr',55)}
|
|
collector=SimpleNamespace(configured=lambda:True,grab_release=AsyncMock(return_value={}))
|
|
with patch.object(requests,'get_runtime_settings',return_value=runtime),patch.object(requests,'build_snapshot',new=AsyncMock(return_value=snapshot)),patch.object(requests,'SonarrClient',return_value=collector),patch.object(requests,'save_action'):
|
|
self.assertEqual(self.client.post('/requests/42/actions/grab',json=payload).status_code,403)
|
|
collector.grab_release.assert_not_awaited()
|
|
update_permissions({'ignore_profile_limits':True},self.user['username'])
|
|
self.assertEqual(self.client.post('/requests/42/actions/grab',json=payload).status_code,200)
|
|
update_permissions({'ignore_profile_limits':False},self.user['username'])
|
|
self.assertEqual(self.client.post('/requests/42/actions/grab',json={**payload,'requiresOverride':False,'approved':True}).status_code,403)
|
|
collector.grab_release.assert_awaited_once()
|