2026-08-15 16:20:54 +02:00
|
|
|
import json
|
Bücher: Cloud-Import aus WebDAV/Nextcloud (SW v41)
Verbindungen werden pro Nutzer in den Einstellungen angelegt (Nextcloud,
ownCloud, Synology oder generisches WebDAV) — der Server ist bewusst nicht
auf eine feste Instanz verdrahtet. Im Bücher-Tab lässt sich der entfernte
Ordner durchblättern und eine .epub/.pdf direkt in die Bibliothek ziehen.
Der Download läuft über den Server, weil der WebDAV-Host cross-origin ist
und keine CORS-Header schickt. Verschlüsselt wird trotzdem erst im Browser:
uploadEbook ist in _importEbookBuffer aufgeteilt, das sich lokaler Upload
und Cloud-Import teilen. Gespeichert wird wie bisher nur Geheimtext.
Weil jeder registrierte Nutzer die Ziel-URL bestimmt und diora im Docker-Netz
neben anderen Diensten läuft, ist der Import eine SSRF-Fläche. Dagegen:
- assert_safe_url weist Hosts ab, die auf nicht-öffentliche Adressen
auflösen (inkl. NAT64 und IPv4-kompatibler v6-Adressen, die is_global
durchlässt)
- _assert_peer_is_safe prüft die tatsächliche Peer-Adresse nach dem
Verbinden — requests löst den Namen ein zweites Mal auf, sonst wäre der
Guard per DNS-Rebinding umgehbar
- Redirects werden abgelehnt statt verfolgt
- identische Fehlermeldung für "nicht auflösbar" und "privat", ohne die
IP zu nennen, damit der Endpunkt kein Scanner für interne Dienste wird
Antwort-Bodies laufen durch _read_capped, und DTDs werden vor dem Parsen
abgewiesen: ElementTree expandiert interne Entities, und seit Python 3.12
gibt es XMLParser.parser nicht mehr, um einen Handler zu setzen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:26:22 +00:00
|
|
|
import socket
|
|
|
|
|
from types import SimpleNamespace
|
|
|
|
|
from unittest.mock import patch
|
2026-08-15 16:20:54 +02:00
|
|
|
|
|
|
|
|
from django.contrib.auth.models import User
|
Bücher: Cloud-Import aus WebDAV/Nextcloud (SW v41)
Verbindungen werden pro Nutzer in den Einstellungen angelegt (Nextcloud,
ownCloud, Synology oder generisches WebDAV) — der Server ist bewusst nicht
auf eine feste Instanz verdrahtet. Im Bücher-Tab lässt sich der entfernte
Ordner durchblättern und eine .epub/.pdf direkt in die Bibliothek ziehen.
Der Download läuft über den Server, weil der WebDAV-Host cross-origin ist
und keine CORS-Header schickt. Verschlüsselt wird trotzdem erst im Browser:
uploadEbook ist in _importEbookBuffer aufgeteilt, das sich lokaler Upload
und Cloud-Import teilen. Gespeichert wird wie bisher nur Geheimtext.
Weil jeder registrierte Nutzer die Ziel-URL bestimmt und diora im Docker-Netz
neben anderen Diensten läuft, ist der Import eine SSRF-Fläche. Dagegen:
- assert_safe_url weist Hosts ab, die auf nicht-öffentliche Adressen
auflösen (inkl. NAT64 und IPv4-kompatibler v6-Adressen, die is_global
durchlässt)
- _assert_peer_is_safe prüft die tatsächliche Peer-Adresse nach dem
Verbinden — requests löst den Namen ein zweites Mal auf, sonst wäre der
Guard per DNS-Rebinding umgehbar
- Redirects werden abgelehnt statt verfolgt
- identische Fehlermeldung für "nicht auflösbar" und "privat", ohne die
IP zu nennen, damit der Endpunkt kein Scanner für interne Dienste wird
Antwort-Bodies laufen durch _read_capped, und DTDs werden vor dem Parsen
abgewiesen: ElementTree expandiert interne Entities, und seit Python 3.12
gibt es XMLParser.parser nicht mehr, um einen Handler zu setzen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:26:22 +00:00
|
|
|
from django.test import TestCase, override_settings
|
2026-08-15 16:20:54 +02:00
|
|
|
|
Bücher: Cloud-Import aus WebDAV/Nextcloud (SW v41)
Verbindungen werden pro Nutzer in den Einstellungen angelegt (Nextcloud,
ownCloud, Synology oder generisches WebDAV) — der Server ist bewusst nicht
auf eine feste Instanz verdrahtet. Im Bücher-Tab lässt sich der entfernte
Ordner durchblättern und eine .epub/.pdf direkt in die Bibliothek ziehen.
Der Download läuft über den Server, weil der WebDAV-Host cross-origin ist
und keine CORS-Header schickt. Verschlüsselt wird trotzdem erst im Browser:
uploadEbook ist in _importEbookBuffer aufgeteilt, das sich lokaler Upload
und Cloud-Import teilen. Gespeichert wird wie bisher nur Geheimtext.
Weil jeder registrierte Nutzer die Ziel-URL bestimmt und diora im Docker-Netz
neben anderen Diensten läuft, ist der Import eine SSRF-Fläche. Dagegen:
- assert_safe_url weist Hosts ab, die auf nicht-öffentliche Adressen
auflösen (inkl. NAT64 und IPv4-kompatibler v6-Adressen, die is_global
durchlässt)
- _assert_peer_is_safe prüft die tatsächliche Peer-Adresse nach dem
Verbinden — requests löst den Namen ein zweites Mal auf, sonst wäre der
Guard per DNS-Rebinding umgehbar
- Redirects werden abgelehnt statt verfolgt
- identische Fehlermeldung für "nicht auflösbar" und "privat", ohne die
IP zu nennen, damit der Endpunkt kein Scanner für interne Dienste wird
Antwort-Bodies laufen durch _read_capped, und DTDs werden vor dem Parsen
abgewiesen: ElementTree expandiert interne Entities, und seit Python 3.12
gibt es XMLParser.parser nicht mehr, um einen Handler zu setzen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:26:22 +00:00
|
|
|
from .models import ApiToken, WebDAVSource
|
|
|
|
|
from .webdav import WebDAVError, assert_safe_url, fetch_file, list_directory, safe_rel_path
|
2026-08-15 16:20:54 +02:00
|
|
|
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)
|
|
|
|
|
|
2026-09-29 19:33:18 +02:00
|
|
|
def test_bearer_token_needs_no_csrf_token(self):
|
|
|
|
|
# import_m3u is not csrf_exempt: without a token it must still be protected.
|
|
|
|
|
from django.test import Client
|
|
|
|
|
client = Client(enforce_csrf_checks=True)
|
|
|
|
|
m3u = '#EXTM3U\n#EXTINF:-1,Test FM\nhttps://example.com/stream\n'
|
|
|
|
|
resp = client.post('/radio/import/', data=m3u, content_type='text/plain')
|
|
|
|
|
self.assertIn(resp.status_code, (302, 401, 403))
|
|
|
|
|
resp = client.post('/radio/import/', data=m3u, content_type='text/plain',
|
|
|
|
|
HTTP_AUTHORIZATION=f'Bearer {self.token.token}')
|
|
|
|
|
self.assertNotEqual(resp.status_code, 403)
|
|
|
|
|
resp = client.post('/radio/import/', data=m3u, content_type='text/plain',
|
|
|
|
|
HTTP_AUTHORIZATION='Bearer not-a-real-token')
|
|
|
|
|
self.assertIn(resp.status_code, (302, 401, 403))
|
|
|
|
|
|
2026-08-15 16:20:54 +02:00
|
|
|
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)
|
|
|
|
|
|
2026-10-05 17:50:44 +02:00
|
|
|
def test_only_books(self):
|
|
|
|
|
resp = self.client.get('/api/sync/?only=books', HTTP_AUTHORIZATION=f'Bearer {self.token.token}')
|
|
|
|
|
data = resp.json()
|
Lesestand: die zuletzt gelesene Stelle gewinnt, nicht mehr die weiteste; sw v100
docs/progress-sync.md ist der Vertrag für Server, Web, Android und Desktop.
- Server: read_at (Zeit, zu der dort gelesen wurde, Uhrfehler des Geräts per
sent_at herausgerechnet, nie nach "jetzt"), Gerät, weiteste Stelle. Ältere
Schreibvorgänge (offline nachgereicht, liegengelassener Tab) gewinnen nicht.
Apps ohne read_at: nur strikt vorwärts. Antwort = Zustand nach dem Schreiben.
/books/ mit X-Server-Now, /api/sync/ mit server_now.
- Web-Reader: Zeit einer Stelle ist der Moment, in dem man durch eigenes Tun
hinkam (Scrollen, Blättern, Tasten, Vorlesen); unverändert Liegendes
schickt nichts bzw. nur seine alte Zeit. Vor dem Wiederherstellen und
während eines Blicks (Fußnote, "Weit gescrollt") wird nicht gespeichert.
Eigene Stellen in einem Ausgang (localStorage) bis der Server sie hat.
Angebote "Auf <Gerät> weitergelesen" und "Weiteste Stelle".
- Tests: books/tests.py (Reihenfolge, Uhrfehler, Zukunft, alte Apps, weiteste
Stelle, Liste und Snapshot).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:31:36 +02:00
|
|
|
self.assertEqual(set(data), {'server_time', 'server_now', 'books', 'book_progress', 'book_highlights', 'book_bookmarks'})
|
2026-10-05 17:50:44 +02:00
|
|
|
self.assertEqual(data['book_highlights'][0]['ct'], 'hl-ct')
|
|
|
|
|
|
2026-08-15 16:20:54 +02:00
|
|
|
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])
|
Bücher: Cloud-Import aus WebDAV/Nextcloud (SW v41)
Verbindungen werden pro Nutzer in den Einstellungen angelegt (Nextcloud,
ownCloud, Synology oder generisches WebDAV) — der Server ist bewusst nicht
auf eine feste Instanz verdrahtet. Im Bücher-Tab lässt sich der entfernte
Ordner durchblättern und eine .epub/.pdf direkt in die Bibliothek ziehen.
Der Download läuft über den Server, weil der WebDAV-Host cross-origin ist
und keine CORS-Header schickt. Verschlüsselt wird trotzdem erst im Browser:
uploadEbook ist in _importEbookBuffer aufgeteilt, das sich lokaler Upload
und Cloud-Import teilen. Gespeichert wird wie bisher nur Geheimtext.
Weil jeder registrierte Nutzer die Ziel-URL bestimmt und diora im Docker-Netz
neben anderen Diensten läuft, ist der Import eine SSRF-Fläche. Dagegen:
- assert_safe_url weist Hosts ab, die auf nicht-öffentliche Adressen
auflösen (inkl. NAT64 und IPv4-kompatibler v6-Adressen, die is_global
durchlässt)
- _assert_peer_is_safe prüft die tatsächliche Peer-Adresse nach dem
Verbinden — requests löst den Namen ein zweites Mal auf, sonst wäre der
Guard per DNS-Rebinding umgehbar
- Redirects werden abgelehnt statt verfolgt
- identische Fehlermeldung für "nicht auflösbar" und "privat", ohne die
IP zu nennen, damit der Endpunkt kein Scanner für interne Dienste wird
Antwort-Bodies laufen durch _read_capped, und DTDs werden vor dem Parsen
abgewiesen: ElementTree expandiert interne Entities, und seit Python 3.12
gibt es XMLParser.parser nicht mehr, um einen Handler zu setzen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:26:22 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# WebDAV cloud import
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
PROPFIND_RESPONSE = b'''<?xml version="1.0"?>
|
|
|
|
|
<d:multistatus xmlns:d="DAV:">
|
|
|
|
|
<d:response>
|
|
|
|
|
<d:href>/remote.php/dav/files/alice/Books/</d:href>
|
|
|
|
|
<d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat>
|
|
|
|
|
</d:response>
|
|
|
|
|
<d:response>
|
|
|
|
|
<d:href>/remote.php/dav/files/alice/Books/Sci-Fi/</d:href>
|
|
|
|
|
<d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat>
|
|
|
|
|
</d:response>
|
|
|
|
|
<d:response>
|
|
|
|
|
<d:href>/remote.php/dav/files/alice/Books/Der%20Steppenwolf.epub</d:href>
|
|
|
|
|
<d:propstat><d:prop><d:resourcetype/><d:getcontentlength>4096</d:getcontentlength></d:prop></d:propstat>
|
|
|
|
|
</d:response>
|
|
|
|
|
<d:response>
|
|
|
|
|
<d:href>/remote.php/dav/files/alice/Books/notes.txt</d:href>
|
|
|
|
|
<d:propstat><d:prop><d:resourcetype/><d:getcontentlength>12</d:getcontentlength></d:prop></d:propstat>
|
|
|
|
|
</d:response>
|
|
|
|
|
</d:multistatus>'''
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _FakeSocket:
|
|
|
|
|
def __init__(self, peer):
|
|
|
|
|
self._peer = peer
|
|
|
|
|
|
|
|
|
|
def getpeername(self):
|
|
|
|
|
return (self._peer, 443)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _FakeResponse:
|
|
|
|
|
def __init__(self, status_code=207, content=b'', headers=None, peer=None):
|
|
|
|
|
self.status_code = status_code
|
|
|
|
|
self.content = content
|
|
|
|
|
self.headers = headers or {}
|
|
|
|
|
self.closed = False
|
|
|
|
|
# Mirrors requests' response.raw._connection.sock, which is what
|
|
|
|
|
# _assert_peer_is_safe introspects. None means "nothing to check".
|
|
|
|
|
if peer is None:
|
|
|
|
|
self.raw = None
|
|
|
|
|
else:
|
|
|
|
|
self.raw = SimpleNamespace(_connection=SimpleNamespace(sock=_FakeSocket(peer)))
|
|
|
|
|
|
|
|
|
|
def iter_content(self, chunk_size=None):
|
|
|
|
|
yield self.content
|
|
|
|
|
|
|
|
|
|
def close(self):
|
|
|
|
|
self.closed = True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class NormalizedBaseUrlTests(TestCase):
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.user = User.objects.create_user(username='alice', password='pw12345678')
|
|
|
|
|
|
|
|
|
|
def _source(self, **kwargs):
|
|
|
|
|
kwargs.setdefault('label', 'cloud')
|
|
|
|
|
kwargs.setdefault('username', 'alice')
|
|
|
|
|
return WebDAVSource(user=self.user, **kwargs)
|
|
|
|
|
|
|
|
|
|
def test_bare_host_expands_to_nextcloud_files_endpoint(self):
|
|
|
|
|
source = self._source(base_url='https://cloud.example.com')
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
source.normalized_base_url(),
|
|
|
|
|
'https://cloud.example.com/remote.php/dav/files/alice/',
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_root_path_is_appended(self):
|
|
|
|
|
source = self._source(base_url='https://cloud.example.com', root_path='/Buecher/')
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
source.normalized_base_url(),
|
|
|
|
|
'https://cloud.example.com/remote.php/dav/files/alice/Buecher/',
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_explicit_dav_url_is_left_alone(self):
|
|
|
|
|
source = self._source(base_url='https://dav.example.com/webdav')
|
|
|
|
|
self.assertEqual(source.normalized_base_url(), 'https://dav.example.com/webdav/')
|
|
|
|
|
|
|
|
|
|
def test_generic_server_without_username_is_not_rewritten(self):
|
|
|
|
|
source = self._source(base_url='https://files.example.com/share', username='')
|
|
|
|
|
self.assertEqual(source.normalized_base_url(), 'https://files.example.com/share/')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SafeRelPathTests(TestCase):
|
|
|
|
|
def test_traversal_is_rejected(self):
|
|
|
|
|
with self.assertRaises(WebDAVError):
|
|
|
|
|
safe_rel_path('Books/../../etc/passwd')
|
|
|
|
|
|
|
|
|
|
def test_leading_slashes_and_dots_are_stripped(self):
|
|
|
|
|
self.assertEqual(safe_rel_path('/Books/./Sci-Fi/'), 'Books/Sci-Fi')
|
|
|
|
|
|
|
|
|
|
def test_empty_path_is_root(self):
|
|
|
|
|
self.assertEqual(safe_rel_path(''), '')
|
|
|
|
|
self.assertEqual(safe_rel_path('/'), '')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AssertSafeUrlTests(TestCase):
|
|
|
|
|
def _resolve_to(self, ip):
|
|
|
|
|
return [(2, 1, 6, '', (ip, 443))]
|
|
|
|
|
|
|
|
|
|
def test_non_http_scheme_rejected(self):
|
|
|
|
|
with self.assertRaises(WebDAVError):
|
|
|
|
|
assert_safe_url('file:///etc/passwd')
|
|
|
|
|
|
|
|
|
|
def test_private_address_rejected(self):
|
|
|
|
|
with patch('accounts.webdav.socket.getaddrinfo', return_value=self._resolve_to('172.18.0.4')):
|
|
|
|
|
with self.assertRaises(WebDAVError):
|
|
|
|
|
assert_safe_url('https://internal.example.com/dav/')
|
|
|
|
|
|
|
|
|
|
def test_loopback_rejected(self):
|
|
|
|
|
with patch('accounts.webdav.socket.getaddrinfo', return_value=self._resolve_to('127.0.0.1')):
|
|
|
|
|
with self.assertRaises(WebDAVError):
|
|
|
|
|
assert_safe_url('http://localhost:11000/remote.php/dav/')
|
|
|
|
|
|
|
|
|
|
def test_link_local_metadata_endpoint_rejected(self):
|
|
|
|
|
with patch('accounts.webdav.socket.getaddrinfo', return_value=self._resolve_to('169.254.169.254')):
|
|
|
|
|
with self.assertRaises(WebDAVError):
|
|
|
|
|
assert_safe_url('http://metadata.example.com/')
|
|
|
|
|
|
|
|
|
|
def test_public_address_allowed(self):
|
|
|
|
|
with patch('accounts.webdav.socket.getaddrinfo', return_value=self._resolve_to('85.214.6.118')):
|
|
|
|
|
assert_safe_url('https://nc.example.com/remote.php/dav/')
|
|
|
|
|
|
|
|
|
|
def test_unresolvable_host_rejected(self):
|
|
|
|
|
with patch('accounts.webdav.socket.getaddrinfo', side_effect=socket.gaierror):
|
|
|
|
|
with self.assertRaises(WebDAVError):
|
|
|
|
|
assert_safe_url('https://nope.example.com/')
|
|
|
|
|
|
|
|
|
|
@override_settings(WEBDAV_ALLOW_PRIVATE_HOSTS=True)
|
|
|
|
|
def test_private_allowed_when_opted_in(self):
|
|
|
|
|
assert_safe_url('http://192.168.1.10/dav/')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ListDirectoryTests(TestCase):
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.user = User.objects.create_user(username='alice', password='pw12345678')
|
|
|
|
|
self.source = WebDAVSource.objects.create(
|
|
|
|
|
user=self.user, label='cloud', base_url='https://cloud.example.com',
|
|
|
|
|
username='alice', password='app-pw', root_path='Books',
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def _list(self, response=None):
|
|
|
|
|
with patch('accounts.webdav.assert_safe_url'), \
|
|
|
|
|
patch('accounts.webdav.requests.request',
|
|
|
|
|
return_value=response or _FakeResponse(content=PROPFIND_RESPONSE)):
|
|
|
|
|
return list_directory(self.source)
|
|
|
|
|
|
|
|
|
|
def test_entries_are_parsed_and_sorted_dirs_first(self):
|
|
|
|
|
entries = self._list()
|
|
|
|
|
self.assertEqual([e['name'] for e in entries],
|
|
|
|
|
['Sci-Fi', 'Der Steppenwolf.epub', 'notes.txt'])
|
|
|
|
|
|
|
|
|
|
def test_collection_itself_is_excluded(self):
|
|
|
|
|
self.assertNotIn('Books', [e['name'] for e in self._list()])
|
|
|
|
|
|
|
|
|
|
def test_book_flag_and_size(self):
|
|
|
|
|
by_name = {e['name']: e for e in self._list()}
|
|
|
|
|
self.assertTrue(by_name['Der Steppenwolf.epub']['is_book'])
|
|
|
|
|
self.assertEqual(by_name['Der Steppenwolf.epub']['size'], 4096)
|
|
|
|
|
self.assertFalse(by_name['notes.txt']['is_book'])
|
|
|
|
|
self.assertTrue(by_name['Sci-Fi']['is_dir'])
|
|
|
|
|
|
|
|
|
|
def test_redirect_is_refused_rather_than_followed(self):
|
|
|
|
|
redirect_response = _FakeResponse(status_code=302, headers={'Location': 'http://127.0.0.1/'})
|
|
|
|
|
with self.assertRaises(WebDAVError):
|
|
|
|
|
self._list(redirect_response)
|
|
|
|
|
|
|
|
|
|
def test_bad_credentials_surface_clearly(self):
|
|
|
|
|
with self.assertRaises(WebDAVError) as ctx:
|
|
|
|
|
self._list(_FakeResponse(status_code=401))
|
|
|
|
|
self.assertIn('App-Passwort', str(ctx.exception))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FetchFileTests(TestCase):
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.user = User.objects.create_user(username='alice', password='pw12345678')
|
|
|
|
|
self.source = WebDAVSource.objects.create(
|
|
|
|
|
user=self.user, label='cloud', base_url='https://cloud.example.com', username='alice',
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_non_book_extension_refused_before_any_request(self):
|
|
|
|
|
with patch('accounts.webdav.requests.request') as mock_request:
|
|
|
|
|
with self.assertRaises(WebDAVError):
|
|
|
|
|
fetch_file(self.source, 'secrets.env', 1024)
|
|
|
|
|
mock_request.assert_not_called()
|
|
|
|
|
|
|
|
|
|
def test_declared_oversize_refused(self):
|
|
|
|
|
response = _FakeResponse(status_code=200, headers={'Content-Length': '99999'})
|
|
|
|
|
with patch('accounts.webdav.assert_safe_url'), \
|
|
|
|
|
patch('accounts.webdav.requests.request', return_value=response):
|
|
|
|
|
with self.assertRaises(WebDAVError):
|
|
|
|
|
fetch_file(self.source, 'big.epub', 1024)
|
|
|
|
|
|
|
|
|
|
def test_streamed_oversize_refused_even_without_content_length(self):
|
|
|
|
|
response = _FakeResponse(status_code=200, content=b'x' * 5000)
|
|
|
|
|
with patch('accounts.webdav.assert_safe_url'), \
|
|
|
|
|
patch('accounts.webdav.requests.request', return_value=response):
|
|
|
|
|
with self.assertRaises(WebDAVError):
|
|
|
|
|
fetch_file(self.source, 'sneaky.epub', 1024)
|
|
|
|
|
|
|
|
|
|
def test_successful_fetch_returns_bytes(self):
|
|
|
|
|
response = _FakeResponse(status_code=200, content=b'EPUB-BYTES')
|
|
|
|
|
with patch('accounts.webdav.assert_safe_url'), \
|
|
|
|
|
patch('accounts.webdav.requests.request', return_value=response):
|
|
|
|
|
self.assertEqual(fetch_file(self.source, 'ok.epub', 1024), b'EPUB-BYTES')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class CloudImportViewTests(TestCase):
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.user = User.objects.create_user(username='alice', password='pw12345678')
|
|
|
|
|
self.other = User.objects.create_user(username='bob', password='pw12345678')
|
|
|
|
|
self.source = WebDAVSource.objects.create(
|
|
|
|
|
user=self.user, label='cloud', base_url='https://cloud.example.com', username='alice',
|
|
|
|
|
)
|
|
|
|
|
self.foreign = WebDAVSource.objects.create(
|
|
|
|
|
user=self.other, label='bobs', base_url='https://other.example.com', username='bob',
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_endpoints_require_authentication(self):
|
|
|
|
|
self.assertEqual(self.client.get('/books/cloud/sources/').status_code, 401)
|
|
|
|
|
self.assertEqual(self.client.get(f'/books/cloud/{self.source.pk}/browse/').status_code, 401)
|
|
|
|
|
self.assertEqual(self.client.get(f'/books/cloud/{self.source.pk}/fetch/?path=a.epub').status_code, 401)
|
|
|
|
|
|
|
|
|
|
def test_sources_are_scoped_to_the_owner(self):
|
|
|
|
|
self.client.force_login(self.user)
|
|
|
|
|
labels = [s['label'] for s in self.client.get('/books/cloud/sources/').json()['sources']]
|
|
|
|
|
self.assertEqual(labels, ['cloud'])
|
|
|
|
|
|
|
|
|
|
def test_foreign_source_is_not_browsable(self):
|
|
|
|
|
self.client.force_login(self.user)
|
|
|
|
|
resp = self.client.get(f'/books/cloud/{self.foreign.pk}/browse/')
|
|
|
|
|
self.assertEqual(resp.status_code, 404)
|
|
|
|
|
|
|
|
|
|
def test_browse_returns_entries(self):
|
|
|
|
|
self.client.force_login(self.user)
|
|
|
|
|
entries = [{'name': 'Dune.epub', 'path': 'Dune.epub', 'is_dir': False,
|
|
|
|
|
'size': 10, 'modified': '', 'is_book': True}]
|
|
|
|
|
with patch('books.webdav.list_directory', return_value=entries):
|
|
|
|
|
resp = self.client.get(f'/books/cloud/{self.source.pk}/browse/')
|
|
|
|
|
self.assertEqual(resp.status_code, 200)
|
|
|
|
|
self.assertEqual(resp.json()['entries'], entries)
|
|
|
|
|
|
|
|
|
|
def test_browse_reports_upstream_failure_as_502(self):
|
|
|
|
|
self.client.force_login(self.user)
|
|
|
|
|
with patch('books.webdav.list_directory', side_effect=WebDAVError('kaputt')):
|
|
|
|
|
resp = self.client.get(f'/books/cloud/{self.source.pk}/browse/')
|
|
|
|
|
self.assertEqual(resp.status_code, 502)
|
|
|
|
|
self.assertEqual(resp.json()['error'], 'kaputt')
|
|
|
|
|
|
|
|
|
|
def test_bad_user_input_is_400_not_502(self):
|
|
|
|
|
self.client.force_login(self.user)
|
|
|
|
|
resp = self.client.get(f'/books/cloud/{self.source.pk}/fetch/?path=../../etc/passwd')
|
|
|
|
|
self.assertEqual(resp.status_code, 400)
|
|
|
|
|
resp = self.client.get(f'/books/cloud/{self.source.pk}/fetch/?path=notes.txt')
|
|
|
|
|
self.assertEqual(resp.status_code, 400)
|
|
|
|
|
|
|
|
|
|
def test_fetch_streams_bytes_without_storing_them(self):
|
|
|
|
|
self.client.force_login(self.user)
|
|
|
|
|
with patch('books.webdav.fetch_file', return_value=b'EPUB-BYTES'):
|
|
|
|
|
resp = self.client.get(f'/books/cloud/{self.source.pk}/fetch/?path=Dune.epub')
|
|
|
|
|
self.assertEqual(resp.status_code, 200)
|
|
|
|
|
self.assertEqual(resp.content, b'EPUB-BYTES')
|
|
|
|
|
# The proxy is a pass-through: nothing is persisted server-side.
|
|
|
|
|
self.assertEqual(EBook.objects.count(), 0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
BILLION_LAUGHS = b'''<?xml version="1.0"?>
|
|
|
|
|
<!DOCTYPE lolz [
|
|
|
|
|
<!ENTITY lol "lol">
|
|
|
|
|
<!ENTITY lol1 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;">
|
|
|
|
|
<!ENTITY lol2 "&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;">
|
|
|
|
|
<!ENTITY lol3 "&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;">
|
|
|
|
|
<!ENTITY lol4 "&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;">
|
|
|
|
|
]>
|
|
|
|
|
<d:multistatus xmlns:d="DAV:"><d:response><d:href>&lol4;</d:href></d:response></d:multistatus>'''
|
|
|
|
|
|
|
|
|
|
# A server that answers with the 404 propstat first — legal per RFC 4918, and
|
|
|
|
|
# what made directories disappear from the listing before _select_prop existed.
|
|
|
|
|
PROPSTAT_404_FIRST = b'''<?xml version="1.0"?>
|
|
|
|
|
<d:multistatus xmlns:d="DAV:">
|
|
|
|
|
<d:response>
|
|
|
|
|
<d:href>/remote.php/dav/files/alice/Books/</d:href>
|
|
|
|
|
<d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop>
|
|
|
|
|
<d:status>HTTP/1.1 200 OK</d:status></d:propstat>
|
|
|
|
|
</d:response>
|
|
|
|
|
<d:response>
|
|
|
|
|
<d:href>/remote.php/dav/files/alice/Books/Sci-Fi/</d:href>
|
|
|
|
|
<d:propstat><d:prop><d:getcontentlength/></d:prop>
|
|
|
|
|
<d:status>HTTP/1.1 404 Not Found</d:status></d:propstat>
|
|
|
|
|
<d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop>
|
|
|
|
|
<d:status>HTTP/1.1 200 OK</d:status></d:propstat>
|
|
|
|
|
</d:response>
|
|
|
|
|
</d:multistatus>'''
|
|
|
|
|
|
|
|
|
|
RELATIVE_HREF_RESPONSE = b'''<?xml version="1.0"?>
|
|
|
|
|
<d:multistatus xmlns:d="DAV:">
|
|
|
|
|
<d:response><d:href>Dune.epub</d:href>
|
|
|
|
|
<d:propstat><d:prop><d:resourcetype/><d:getcontentlength>7</d:getcontentlength></d:prop>
|
|
|
|
|
<d:status>HTTP/1.1 200 OK</d:status></d:propstat>
|
|
|
|
|
</d:response>
|
|
|
|
|
</d:multistatus>'''
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class WebDAVHardeningTests(TestCase):
|
|
|
|
|
"""Regressions for the SSRF / resource-exhaustion review findings."""
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.user = User.objects.create_user(username='alice', password='pw12345678')
|
|
|
|
|
self.source = WebDAVSource.objects.create(
|
|
|
|
|
user=self.user, label='cloud', base_url='https://cloud.example.com',
|
|
|
|
|
username='alice', password='app-pw', root_path='Books',
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def _list(self, response):
|
|
|
|
|
with patch('accounts.webdav.assert_safe_url'), \
|
|
|
|
|
patch('accounts.webdav.requests.request', return_value=response):
|
|
|
|
|
return list_directory(self.source)
|
|
|
|
|
|
|
|
|
|
# --- DNS rebinding -----------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def test_peer_address_is_rechecked_after_connecting(self):
|
|
|
|
|
"""A resolver that answers public-then-private must not leak a body.
|
|
|
|
|
|
|
|
|
|
assert_safe_url passes (it is given a public answer), but the socket
|
|
|
|
|
actually landed on a Docker-internal address.
|
|
|
|
|
"""
|
|
|
|
|
response = _FakeResponse(content=PROPFIND_RESPONSE, peer='172.18.0.5')
|
|
|
|
|
with self.assertRaises(WebDAVError):
|
|
|
|
|
self._list(response)
|
|
|
|
|
self.assertTrue(response.closed)
|
|
|
|
|
|
|
|
|
|
def test_public_peer_is_accepted(self):
|
|
|
|
|
response = _FakeResponse(content=PROPFIND_RESPONSE, peer='85.214.6.118')
|
|
|
|
|
self.assertTrue(self._list(response))
|
|
|
|
|
|
|
|
|
|
@override_settings(WEBDAV_ALLOW_PRIVATE_HOSTS=True)
|
|
|
|
|
def test_peer_check_respects_the_opt_out(self):
|
|
|
|
|
response = _FakeResponse(content=PROPFIND_RESPONSE, peer='192.168.1.10')
|
|
|
|
|
self.assertTrue(self._list(response))
|
|
|
|
|
|
|
|
|
|
# --- Resource exhaustion ----------------------------------------------
|
|
|
|
|
|
|
|
|
|
def test_entity_expansion_is_refused(self):
|
|
|
|
|
with self.assertRaises(WebDAVError):
|
|
|
|
|
self._list(_FakeResponse(content=BILLION_LAUGHS))
|
|
|
|
|
|
|
|
|
|
def test_oversized_listing_is_refused(self):
|
|
|
|
|
oversized = _FakeResponse(headers={'Content-Length': str(9 * 1024 * 1024)})
|
|
|
|
|
with self.assertRaises(WebDAVError):
|
|
|
|
|
self._list(oversized)
|
|
|
|
|
self.assertTrue(oversized.closed)
|
|
|
|
|
|
|
|
|
|
# --- Information disclosure -------------------------------------------
|
|
|
|
|
|
|
|
|
|
def test_rejection_message_leaks_neither_ip_nor_resolvability(self):
|
|
|
|
|
private = [(2, 1, 6, '', ('172.18.0.5', 443))]
|
|
|
|
|
with patch('accounts.webdav.socket.getaddrinfo', return_value=private):
|
|
|
|
|
with self.assertRaises(WebDAVError) as private_ctx:
|
|
|
|
|
assert_safe_url('http://forgejo-db/')
|
|
|
|
|
with patch('accounts.webdav.socket.getaddrinfo', side_effect=socket.gaierror):
|
|
|
|
|
with self.assertRaises(WebDAVError) as missing_ctx:
|
|
|
|
|
assert_safe_url('http://forgejo-db/')
|
|
|
|
|
|
|
|
|
|
self.assertNotIn('172.18.0.5', str(private_ctx.exception))
|
|
|
|
|
# Same wording either way, so the endpoint cannot be used to tell an
|
|
|
|
|
# existing internal host from a nonexistent one.
|
|
|
|
|
self.assertEqual(str(private_ctx.exception), str(missing_ctx.exception))
|
|
|
|
|
|
|
|
|
|
# --- Malformed input ---------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def test_invalid_port_is_reported_not_crashed(self):
|
|
|
|
|
with self.assertRaises(WebDAVError):
|
|
|
|
|
assert_safe_url('https://example.com:99999/dav/')
|
|
|
|
|
|
|
|
|
|
def test_ipv6_transition_ranges_are_rejected(self):
|
|
|
|
|
for address in ('64:ff9b::7f00:1', '::127.0.0.1'):
|
|
|
|
|
with patch('accounts.webdav.socket.getaddrinfo',
|
|
|
|
|
return_value=[(10, 1, 6, '', (address, 443, 0, 0))]):
|
|
|
|
|
with self.assertRaises(WebDAVError, msg=address):
|
|
|
|
|
assert_safe_url('https://nat64.example.com/')
|
|
|
|
|
|
|
|
|
|
# --- PROPFIND parsing --------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def test_directory_survives_a_404_propstat_listed_first(self):
|
|
|
|
|
entries = self._list(_FakeResponse(content=PROPSTAT_404_FIRST))
|
|
|
|
|
by_name = {e['name']: e for e in entries}
|
|
|
|
|
self.assertTrue(by_name['Sci-Fi']['is_dir'])
|
|
|
|
|
|
|
|
|
|
def test_relative_hrefs_are_resolved(self):
|
|
|
|
|
entries = self._list(_FakeResponse(content=RELATIVE_HREF_RESPONSE))
|
|
|
|
|
self.assertEqual([e['name'] for e in entries], ['Dune.epub'])
|
|
|
|
|
|
|
|
|
|
# --- URL normalisation -------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def test_host_named_webdav_still_gets_the_nextcloud_path(self):
|
|
|
|
|
source = WebDAVSource(user=self.user, label='x',
|
|
|
|
|
base_url='https://webdav.example.com', username='alice')
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
source.normalized_base_url(),
|
|
|
|
|
'https://webdav.example.com/remote.php/dav/files/alice/',
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class WebDAVSourceLimitTests(TestCase):
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.user = User.objects.create_user(username='alice', password='pw12345678')
|
|
|
|
|
self.client.force_login(self.user)
|
|
|
|
|
|
|
|
|
|
@override_settings(WEBDAV_MAX_SOURCES_PER_USER=2)
|
|
|
|
|
def test_sources_are_capped_per_user(self):
|
|
|
|
|
with patch('accounts.views._probe_source', return_value=(20, 'ok')):
|
|
|
|
|
for i in range(3):
|
|
|
|
|
self.client.post('/accounts/webdav/add/',
|
|
|
|
|
{'label': f'c{i}', 'base_url': 'https://example.com'})
|
|
|
|
|
self.assertEqual(self.user.webdav_sources.count(), 2)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class CloudFetchCachingTests(TestCase):
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.user = User.objects.create_user(username='alice', password='pw12345678')
|
|
|
|
|
self.source = WebDAVSource.objects.create(
|
|
|
|
|
user=self.user, label='cloud', base_url='https://cloud.example.com', username='alice',
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_plaintext_bytes_are_not_cacheable(self):
|
|
|
|
|
self.client.force_login(self.user)
|
|
|
|
|
with patch('books.webdav.fetch_file', return_value=b'EPUB-BYTES'):
|
|
|
|
|
resp = self.client.get(f'/books/cloud/{self.source.pk}/fetch/?path=Dune.epub')
|
|
|
|
|
self.assertEqual(resp['Cache-Control'], 'no-store')
|
2026-10-08 19:22:04 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class WebDAVEditTests(TestCase):
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.user = User.objects.create_user(username='alice', password='pw12345678')
|
|
|
|
|
self.other = User.objects.create_user(username='bob', password='pw12345678')
|
|
|
|
|
self.source = WebDAVSource.objects.create(
|
|
|
|
|
user=self.user, label='cloud', base_url='https://cloud.example.com',
|
|
|
|
|
username='alice', password='app-pw',
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def _edit(self, pk, **data):
|
|
|
|
|
form = {'label': 'cloud', 'base_url': 'https://cloud.example.com', 'username': 'alice', 'root_path': ''}
|
|
|
|
|
form.update(data)
|
|
|
|
|
with patch('accounts.views.list_directory', return_value=[]):
|
|
|
|
|
return self.client.post(f'/accounts/webdav/{pk}/edit/', form)
|
|
|
|
|
|
|
|
|
|
def test_sets_start_folder_and_keeps_password(self):
|
|
|
|
|
self.client.force_login(self.user)
|
|
|
|
|
resp = self._edit(self.source.pk, root_path='Books', password='')
|
|
|
|
|
self.assertEqual(resp.status_code, 302)
|
|
|
|
|
self.source.refresh_from_db()
|
|
|
|
|
self.assertEqual(self.source.root_path, 'Books')
|
|
|
|
|
self.assertEqual(self.source.password, 'app-pw')
|
|
|
|
|
|
|
|
|
|
def test_new_password_replaces_the_old_one(self):
|
|
|
|
|
self.client.force_login(self.user)
|
|
|
|
|
self._edit(self.source.pk, password='new-pw')
|
|
|
|
|
self.source.refresh_from_db()
|
|
|
|
|
self.assertEqual(self.source.password, 'new-pw')
|
|
|
|
|
|
|
|
|
|
def test_name_and_url_are_required(self):
|
|
|
|
|
self.client.force_login(self.user)
|
|
|
|
|
self._edit(self.source.pk, label='', root_path='Books')
|
|
|
|
|
self.source.refresh_from_db()
|
|
|
|
|
self.assertEqual(self.source.root_path, '')
|
|
|
|
|
|
|
|
|
|
def test_foreign_source_is_not_editable(self):
|
|
|
|
|
self.client.force_login(self.other)
|
|
|
|
|
self._edit(self.source.pk, root_path='x')
|
|
|
|
|
self.source.refresh_from_db()
|
|
|
|
|
self.assertEqual(self.source.root_path, '')
|
|
|
|
|
|
|
|
|
|
def test_settings_page_does_not_show_the_password(self):
|
|
|
|
|
self.client.force_login(self.user)
|
|
|
|
|
html = self.client.get('/accounts/settings/').content.decode()
|
|
|
|
|
self.assertIn(f'/accounts/webdav/{self.source.pk}/edit/', html)
|
|
|
|
|
self.assertNotIn('app-pw', html)
|
2026-10-24 18:12:39 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class CloudInboxTests(TestCase):
|
|
|
|
|
"""An "Eingang" source (docs/library.md): its new books are handed out once, claimed for
|
|
|
|
|
one device for ten minutes, and not again once a device took them."""
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.user = User.objects.create_user('inbox', password='pw12345678')
|
|
|
|
|
self.client.force_login(self.user)
|
|
|
|
|
self.src = WebDAVSource.objects.create(user=self.user, label='Bücher', base_url='https://b.example.org/dav/',
|
|
|
|
|
username='d', password='x', inbox=True)
|
|
|
|
|
WebDAVSource.objects.create(user=self.user, label='andere', base_url='https://c.example.org/dav/')
|
|
|
|
|
|
|
|
|
|
def listing(self, path=''):
|
|
|
|
|
if path == '':
|
|
|
|
|
return [{'name': 'Sub', 'path': 'Sub', 'is_dir': True, 'size': 0, 'modified': '', 'is_book': False},
|
|
|
|
|
{'name': 'A - Eins (2020).epub', 'path': 'A - Eins (2020).epub', 'is_dir': False,
|
|
|
|
|
'size': 100, 'modified': 'Mon', 'is_book': True},
|
|
|
|
|
{'name': 'notiz.txt', 'path': 'notiz.txt', 'is_dir': False, 'size': 1, 'modified': '', 'is_book': False}]
|
|
|
|
|
return [{'name': 'B.pdf', 'path': 'Sub/B.pdf', 'is_dir': False, 'size': 200, 'modified': '', 'is_book': True}]
|
|
|
|
|
|
|
|
|
|
def inbox(self):
|
|
|
|
|
with patch('books.webdav.list_directory', side_effect=lambda source, path='': self.listing(path)):
|
|
|
|
|
return self.client.get('/books/cloud/inbox/').json()
|
|
|
|
|
|
|
|
|
|
def done(self, path, result):
|
|
|
|
|
return self.client.post(f'/books/cloud/{self.src.pk}/done/', json.dumps({'path': path, 'result': result}),
|
|
|
|
|
content_type='application/json')
|
|
|
|
|
|
|
|
|
|
def test_new_books_once_then_claimed_then_done(self):
|
|
|
|
|
files = self.inbox()['files']
|
|
|
|
|
self.assertEqual(sorted(f['path'] for f in files), ['A - Eins (2020).epub', 'Sub/B.pdf'])
|
|
|
|
|
self.assertEqual(self.inbox()['files'], []) # claimed for this device: no one else gets them now
|
|
|
|
|
self.assertEqual(self.done('A - Eins (2020).epub', 'imported').status_code, 200)
|
|
|
|
|
self.assertEqual(self.done('Sub/B.pdf', 'failed').status_code, 200)
|
|
|
|
|
from accounts.models import WebDAVSeen
|
|
|
|
|
WebDAVSeen.objects.filter(path='Sub/B.pdf').update(claimed_at=None) # the claim ran out
|
|
|
|
|
self.assertEqual([f['path'] for f in self.inbox()['files']], ['Sub/B.pdf'])
|
|
|
|
|
|
|
|
|
|
def test_a_changed_file_counts_as_new(self):
|
|
|
|
|
self.inbox()
|
|
|
|
|
self.done('A - Eins (2020).epub', 'imported')
|
|
|
|
|
self.done('Sub/B.pdf', 'known')
|
|
|
|
|
self.assertEqual(self.inbox()['files'], [])
|
|
|
|
|
old = self.listing
|
|
|
|
|
self.listing = lambda path='': [dict(e, size=999) if e['path'].endswith('.epub') else e for e in old(path)]
|
|
|
|
|
self.assertEqual([f['path'] for f in self.inbox()['files']], ['A - Eins (2020).epub'])
|
|
|
|
|
|
|
|
|
|
def test_only_own_sources_and_valid_results(self):
|
|
|
|
|
other = User.objects.create_user('x', password='pw12345678')
|
|
|
|
|
foreign = WebDAVSource.objects.create(user=other, label='f', base_url='https://d.example.org/dav/', inbox=True)
|
|
|
|
|
self.assertEqual(self.client.post(f'/books/cloud/{foreign.pk}/done/', '{}', content_type='application/json').status_code, 404)
|
|
|
|
|
self.inbox()
|
|
|
|
|
self.assertEqual(self.done('A - Eins (2020).epub', 'whatever').status_code, 400)
|
|
|
|
|
self.client.logout()
|
|
|
|
|
self.assertEqual(self.client.get('/books/cloud/inbox/').status_code, 401)
|
|
|
|
|
|
|
|
|
|
def test_settings_switch_the_inbox(self):
|
|
|
|
|
r = self.client.post(f'/accounts/webdav/{self.src.pk}/edit/', {'label': 'Bücher', 'base_url': self.src.base_url})
|
|
|
|
|
self.assertEqual(r.status_code, 302)
|
|
|
|
|
self.src.refresh_from_db()
|
|
|
|
|
self.assertFalse(self.src.inbox)
|
2026-10-24 18:34:49 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class VaultTests(TestCase):
|
|
|
|
|
"""The vault keeps what the devices encrypted, per name; only for its owner."""
|
|
|
|
|
|
|
|
|
|
def test_keep_replace_remove(self):
|
|
|
|
|
u = User.objects.create_user('v', password='pw12345678')
|
|
|
|
|
self.client.force_login(u)
|
|
|
|
|
post = lambda body: self.client.post('/accounts/vault/ai/', json.dumps(body), content_type='application/json')
|
|
|
|
|
self.assertEqual(self.client.get('/accounts/vault/ai/').json(), {'ct': '', 'iv': ''})
|
|
|
|
|
self.assertEqual(post({'ct': 'abc', 'iv': 'i1'}).status_code, 200)
|
|
|
|
|
self.assertEqual(post({'ct': 'def', 'iv': 'i2'}).status_code, 200)
|
|
|
|
|
self.assertEqual(self.client.get('/accounts/vault/ai/').json(), {'ct': 'def', 'iv': 'i2'})
|
|
|
|
|
self.assertEqual(post({'iv': 'x'}).status_code, 400)
|
|
|
|
|
other = User.objects.create_user('w', password='pw12345678')
|
|
|
|
|
self.client.force_login(other)
|
|
|
|
|
self.assertEqual(self.client.get('/accounts/vault/ai/').json()['ct'], '')
|
|
|
|
|
self.client.force_login(u)
|
|
|
|
|
self.assertEqual(post({'ct': ''}).status_code, 200)
|
|
|
|
|
self.assertEqual(self.client.get('/accounts/vault/ai/').json()['ct'], '')
|
|
|
|
|
self.client.logout()
|
|
|
|
|
self.assertEqual(self.client.get('/accounts/vault/ai/').status_code, 401)
|