diora-web/accounts/tests.py
marwin 30a6d29ca8
All checks were successful
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Successful in 23s
Sync-API für lokale Clients: Personal-Access-Token + GET /api/sync/
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>
2026-08-15 16:20:54 +02:00

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])