Ermöglicht Apps außerhalb des Browsers (z.B. den TUI-Client), sich per Authorization: Bearer <token> zu authentifizieren statt per Session-Cookie. Die neue ApiTokenAuthMiddleware setzt request.user genau wie ein Login, wodurch alle bestehenden books/podcasts/radio-Endpunkte ohne Änderungen token-fähig werden. GET /api/sync/ liefert zusätzlich den kompletten Nutzerzustand (Bücher-Metadaten, Lesefortschritt, Notizen, Podcasts, Sender) in einem Request; Schreiben läuft weiter über die bestehenden Endpunkte, um deren Merge-Semantik (furthest-wins Progress, Notes-Upsert) wiederzuverwenden. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
91 lines
3.9 KiB
Python
91 lines
3.9 KiB
Python
import json
|
|
|
|
from django.contrib.auth.models import User
|
|
from django.test import TestCase
|
|
|
|
from .models import ApiToken
|
|
from books.models import EBook, EBookProgress, EBookHighlights
|
|
|
|
|
|
class ApiTokenAuthMiddlewareTests(TestCase):
|
|
def setUp(self):
|
|
self.user = User.objects.create_user(username='alice', password='pw12345678')
|
|
self.token = ApiToken.objects.create(user=self.user)
|
|
|
|
def test_valid_bearer_token_authenticates(self):
|
|
resp = self.client.get('/books/', HTTP_AUTHORIZATION=f'Bearer {self.token.token}')
|
|
self.assertEqual(resp.status_code, 200)
|
|
|
|
def test_missing_token_is_unauthenticated(self):
|
|
resp = self.client.get('/books/')
|
|
self.assertEqual(resp.status_code, 401)
|
|
|
|
def test_invalid_token_is_unauthenticated(self):
|
|
resp = self.client.get('/books/', HTTP_AUTHORIZATION='Bearer not-a-real-token')
|
|
self.assertEqual(resp.status_code, 401)
|
|
|
|
def test_token_scoped_to_owning_user(self):
|
|
other = User.objects.create_user(username='bob', password='pw12345678')
|
|
EBook.objects.create(user=other, meta_ct='ct', meta_iv='iv', data_ct='ct', data_iv='iv')
|
|
resp = self.client.get('/books/', HTTP_AUTHORIZATION=f'Bearer {self.token.token}')
|
|
self.assertEqual(resp.json(), [])
|
|
|
|
def test_regenerate_invalidates_old_token(self):
|
|
old = self.token.token
|
|
self.token.regenerate()
|
|
resp = self.client.get('/books/', HTTP_AUTHORIZATION=f'Bearer {old}')
|
|
self.assertEqual(resp.status_code, 401)
|
|
resp = self.client.get('/books/', HTTP_AUTHORIZATION=f'Bearer {self.token.token}')
|
|
self.assertEqual(resp.status_code, 200)
|
|
|
|
def test_regenerate_view_requires_login_and_rotates_token(self):
|
|
resp = self.client.post('/accounts/api-token/regenerate/')
|
|
self.assertEqual(resp.status_code, 302)
|
|
self.assertIn('/accounts/login/', resp.url)
|
|
|
|
self.client.login(username='alice', password='pw12345678')
|
|
old = self.token.token
|
|
resp = self.client.post('/accounts/api-token/regenerate/')
|
|
self.assertRedirects(resp, '/accounts/settings/')
|
|
self.token.refresh_from_db()
|
|
self.assertNotEqual(self.token.token, old)
|
|
|
|
|
|
class SyncSnapshotTests(TestCase):
|
|
def setUp(self):
|
|
self.user = User.objects.create_user(username='alice', password='pw12345678')
|
|
self.token = ApiToken.objects.create(user=self.user)
|
|
self.book = EBook.objects.create(
|
|
user=self.user, meta_ct='meta-ct', meta_iv='meta-iv', data_ct='data-ct', data_iv='data-iv',
|
|
)
|
|
EBookProgress.objects.create(
|
|
user=self.user, book=self.book, scroll_fraction=0.5, position_anchor='3:0.5',
|
|
)
|
|
EBookHighlights.objects.create(user=self.user, book=self.book, ct='hl-ct', iv='hl-iv')
|
|
|
|
def test_requires_auth(self):
|
|
resp = self.client.get('/api/sync/')
|
|
self.assertEqual(resp.status_code, 401)
|
|
|
|
def test_snapshot_shape_and_data(self):
|
|
resp = self.client.get('/api/sync/', HTTP_AUTHORIZATION=f'Bearer {self.token.token}')
|
|
self.assertEqual(resp.status_code, 200)
|
|
data = resp.json()
|
|
for key in (
|
|
'server_time', 'books', 'book_progress', 'book_highlights', 'book_bookmarks',
|
|
'podcast_feeds', 'episode_progress', 'podcast_queue', 'saved_stations',
|
|
):
|
|
self.assertIn(key, data)
|
|
|
|
self.assertEqual(len(data['books']), 1)
|
|
self.assertEqual(data['books'][0]['meta_ct'], 'meta-ct')
|
|
|
|
self.assertEqual(len(data['book_progress']), 1)
|
|
self.assertEqual(data['book_progress'][0]['book_id'], self.book.id)
|
|
self.assertEqual(data['book_progress'][0]['position_anchor'], '3:0.5')
|
|
|
|
self.assertEqual(len(data['book_highlights']), 1)
|
|
self.assertEqual(data['book_highlights'][0]['ct'], 'hl-ct')
|
|
|
|
# No data_ct/data_iv leaked into the snapshot (book bytes stay lazy-fetched)
|
|
self.assertNotIn('data_ct', data['books'][0])
|