426 lines
16 KiB
Python
426 lines
16 KiB
Python
import io
|
|
import secrets
|
|
import shutil
|
|
import tempfile
|
|
|
|
from django.contrib.auth import get_user_model
|
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
|
from django.test import TestCase, override_settings
|
|
from django.urls import reverse
|
|
|
|
from library.models import FileIndex
|
|
|
|
from .models import BIO_MAX_LENGTH, ApiToken, UserProfile
|
|
|
|
|
|
def _read_media(stored_path):
|
|
from django.core.files.storage import default_storage
|
|
|
|
with default_storage.open(stored_path, 'rb') as f:
|
|
return f.read()
|
|
|
|
|
|
class MediaTestCase(TestCase):
|
|
"""Base class that redirects uploaded media to a temp directory."""
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
cls._media_root = tempfile.mkdtemp(prefix='packs_test_media_')
|
|
cls.override = override_settings(MEDIA_ROOT=cls._media_root)
|
|
cls.override.enable()
|
|
super().setUpClass()
|
|
|
|
@classmethod
|
|
def tearDownClass(cls):
|
|
super().tearDownClass()
|
|
cls.override.disable()
|
|
shutil.rmtree(cls._media_root, ignore_errors=True)
|
|
|
|
|
|
class GatedTestCase(TestCase):
|
|
"""Base class with a helper to authorize the client past the gate."""
|
|
|
|
def gate(self):
|
|
session = self.client.session
|
|
session['authorized'] = True
|
|
session.save()
|
|
|
|
|
|
class ApiTokenAuthTests(TestCase):
|
|
"""Credential auth: 'Authorization: Bearer <username> <token>'."""
|
|
|
|
def setUp(self):
|
|
User = get_user_model()
|
|
self.alice = User.objects.create_user(username='Alice', password='pw')
|
|
UserProfile.objects.get_or_create(user=self.alice)
|
|
self.bob = User.objects.create_user(username='Bob', password='pw')
|
|
UserProfile.objects.get_or_create(user=self.bob)
|
|
self.token = secrets.token_urlsafe(48)
|
|
ApiToken.objects.create(
|
|
user=self.alice,
|
|
token=self.token,
|
|
key_prefix=self.token[:8],
|
|
label='test',
|
|
)
|
|
|
|
def _auth(self, credential=None, username=None, token=None):
|
|
if credential is None:
|
|
credential = f'{username or ""} {token or ""}'.strip()
|
|
return self.client.get(
|
|
'/home/',
|
|
HTTP_AUTHORIZATION=f'Bearer {credential}',
|
|
HTTP_ACCEPT='application/json',
|
|
)
|
|
|
|
def test_valid_credentials_pass(self):
|
|
resp = self._auth(username='Alice', token=self.token)
|
|
self.assertEqual(resp.status_code, 200)
|
|
|
|
def test_wrong_username_rejected(self):
|
|
# A valid token presented under another user's name must not work.
|
|
resp = self._auth(username='Bob', token=self.token)
|
|
self.assertEqual(resp.status_code, 401)
|
|
|
|
def test_wrong_token_rejected(self):
|
|
resp = self._auth(username='Alice', token='x' * 64)
|
|
self.assertEqual(resp.status_code, 401)
|
|
|
|
def test_revoked_token_rejected(self):
|
|
ApiToken.objects.all().delete()
|
|
resp = self._auth(username='Alice', token=self.token)
|
|
self.assertEqual(resp.status_code, 401)
|
|
|
|
def test_missing_username_rejected(self):
|
|
resp = self._auth(token=self.token)
|
|
self.assertEqual(resp.status_code, 401)
|
|
|
|
def test_missing_credentials_rejected(self):
|
|
resp = self._auth()
|
|
self.assertEqual(resp.status_code, 401)
|
|
|
|
def test_username_case_insensitive(self):
|
|
resp = self._auth(username='aLiCe', token=self.token)
|
|
self.assertEqual(resp.status_code, 200)
|
|
|
|
def test_unknown_user_rejected(self):
|
|
resp = self._auth(username='Nobody', token=self.token)
|
|
self.assertEqual(resp.status_code, 401)
|
|
|
|
def test_authenticates_as_owner(self):
|
|
self.client.get(
|
|
'/home/',
|
|
HTTP_AUTHORIZATION=f'Bearer Alice {self.token}',
|
|
HTTP_ACCEPT='application/json',
|
|
)
|
|
token = ApiToken.objects.get(token=self.token)
|
|
self.assertIsNotNone(token.last_used)
|
|
|
|
|
|
class AccountPageTests(MediaTestCase, GatedTestCase):
|
|
def setUp(self):
|
|
User = get_user_model()
|
|
self.user = User.objects.create_user(username='Alice', password='pw')
|
|
UserProfile.objects.get_or_create(user=self.user)
|
|
|
|
def test_account_page_requires_login(self):
|
|
self.gate()
|
|
resp = self.client.get(reverse('profiles:account'))
|
|
self.assertEqual(resp.status_code, 302)
|
|
|
|
def test_account_page_renders_for_owner(self):
|
|
self.gate()
|
|
self.client.login(username='Alice', password='pw')
|
|
resp = self.client.get(reverse('profiles:account'))
|
|
self.assertEqual(resp.status_code, 200)
|
|
|
|
def test_logout_preserves_gate_access(self):
|
|
self.gate()
|
|
self.client.login(username='Alice', password='pw')
|
|
resp = self.client.post(reverse('profiles:logout'))
|
|
self.assertRedirects(resp, '/home/')
|
|
self.assertFalse(self.client.session.get('_auth_user_id'))
|
|
# The gate flag must survive logout.
|
|
self.assertTrue(self.client.session.get('authorized'))
|
|
resp = self.client.get('/home/')
|
|
self.assertEqual(resp.status_code, 200)
|
|
|
|
def test_username_change(self):
|
|
self.gate()
|
|
self.client.login(username='Alice', password='pw')
|
|
resp = self.client.post(reverse('profiles:account'), {
|
|
'action': 'username',
|
|
'username': 'Alicia',
|
|
})
|
|
self.assertRedirects(resp, reverse('profiles:account'))
|
|
self.assertTrue(get_user_model().objects.filter(username='Alicia').exists())
|
|
|
|
def test_username_taken_rejected(self):
|
|
User = get_user_model()
|
|
User.objects.create_user(username='Bob', password='pw')
|
|
self.gate()
|
|
self.client.login(username='Alice', password='pw')
|
|
resp = self.client.post(reverse('profiles:account'), {
|
|
'action': 'username',
|
|
'username': 'Bob',
|
|
})
|
|
self.assertEqual(resp.status_code, 200)
|
|
self.assertContains(resp, 'already taken')
|
|
|
|
def test_email_blank_voids_field(self):
|
|
self.gate()
|
|
self.client.login(username='Alice', password='pw')
|
|
User = get_user_model()
|
|
User.objects.filter(pk=self.user.pk).update(email='alice@example.com')
|
|
resp = self.client.post(reverse('profiles:account'), {
|
|
'action': 'email',
|
|
'email': '',
|
|
})
|
|
self.assertRedirects(resp, reverse('profiles:account'))
|
|
self.user.refresh_from_db()
|
|
self.assertEqual(self.user.email, '')
|
|
|
|
def test_password_change(self):
|
|
self.gate()
|
|
self.client.login(username='Alice', password='pw')
|
|
resp = self.client.post(reverse('profiles:account'), {
|
|
'action': 'password',
|
|
'old_password': 'pw',
|
|
'new_password1': 'new-secure-pass-123',
|
|
'new_password2': 'new-secure-pass-123',
|
|
})
|
|
self.assertRedirects(resp, reverse('profiles:account'))
|
|
self.assertTrue(self.client.login(username='Alice', password='new-secure-pass-123'))
|
|
|
|
def test_avatar_upload_creates_file_index(self):
|
|
import PIL.Image
|
|
|
|
self.gate()
|
|
self.client.login(username='Alice', password='pw')
|
|
buf = io.BytesIO()
|
|
PIL.Image.new('RGB', (8, 8), 'red').save(buf, format='PNG')
|
|
upload = SimpleUploadedFile('pic.png', buf.getvalue(), content_type='image/png')
|
|
resp = self.client.post(reverse('profiles:account'), {
|
|
'action': 'avatar',
|
|
'avatar': upload,
|
|
})
|
|
self.assertRedirects(resp, reverse('profiles:account'))
|
|
self.user.userprofile.refresh_from_db()
|
|
self.assertIsNotNone(self.user.userprofile.avatar)
|
|
fi = FileIndex.objects.get(pk=self.user.userprofile.avatar_id)
|
|
self.assertEqual(fi.kind, 'avatar')
|
|
self.assertTrue(fi.stored_path.endswith('picture.jpeg'))
|
|
self.assertEqual(fi.content_type, 'image/jpeg')
|
|
self.assertEqual(fi.owner, self.user)
|
|
# The indexed file must be reachable through FileRequest.
|
|
url = reverse('library:file_request', args=[fi.uuid])
|
|
self.client.get(url) # warm auth
|
|
resp = self.client.get(url)
|
|
self.assertEqual(resp.status_code, 200)
|
|
self.assertEqual(b''.join(resp.streaming_content), _read_media(fi.stored_path))
|
|
|
|
|
|
class ProfilePageTests(GatedTestCase):
|
|
def setUp(self):
|
|
User = get_user_model()
|
|
self.alice = User.objects.create_user(username='Alice', password='pw')
|
|
UserProfile.objects.get_or_create(user=self.alice)
|
|
self.bob = User.objects.create_user(username='Bob', password='pw')
|
|
UserProfile.objects.get_or_create(user=self.bob)
|
|
|
|
def test_profile_page_renders(self):
|
|
self.gate()
|
|
resp = self.client.get(reverse('profiles:user_profile', args=['Alice']))
|
|
self.assertEqual(resp.status_code, 200)
|
|
self.assertContains(resp, 'Alice')
|
|
|
|
def test_profile_unknown_user_404(self):
|
|
self.gate()
|
|
resp = self.client.get(reverse('profiles:user_profile', args=['Nobody']))
|
|
self.assertEqual(resp.status_code, 404)
|
|
|
|
def test_bio_owner_can_edit(self):
|
|
self.gate()
|
|
self.client.login(username='Alice', password='pw')
|
|
resp = self.client.post(reverse('profiles:user_profile', args=['Alice']), {
|
|
'action': 'bio',
|
|
'bio': 'Hello there',
|
|
})
|
|
self.assertRedirects(resp, reverse('profiles:user_profile', args=['Alice']))
|
|
self.alice.userprofile.refresh_from_db()
|
|
self.assertEqual(self.alice.userprofile.bio, 'Hello there')
|
|
|
|
def test_bio_non_owner_cannot_edit(self):
|
|
self.gate()
|
|
self.client.login(username='Bob', password='pw')
|
|
resp = self.client.post(reverse('profiles:user_profile', args=['Alice']), {
|
|
'action': 'bio',
|
|
'bio': 'Hacked',
|
|
})
|
|
# Non-owner must not be able to rewrite the bio.
|
|
self.alice.userprofile.refresh_from_db()
|
|
self.assertEqual(self.alice.userprofile.bio, '')
|
|
|
|
def test_bio_over_max_length_rejected(self):
|
|
self.gate()
|
|
self.client.login(username='Alice', password='pw')
|
|
oversized = 'x' * (BIO_MAX_LENGTH + 1)
|
|
resp = self.client.post(reverse('profiles:user_profile', args=['Alice']), {
|
|
'action': 'bio',
|
|
'bio': oversized,
|
|
})
|
|
self.assertEqual(resp.status_code, 200)
|
|
self.assertContains(resp, 'at most')
|
|
self.alice.userprofile.refresh_from_db()
|
|
self.assertEqual(self.alice.userprofile.bio, '')
|
|
|
|
def test_bio_at_max_length_accepted(self):
|
|
self.gate()
|
|
self.client.login(username='Alice', password='pw')
|
|
at_limit = 'y' * BIO_MAX_LENGTH
|
|
resp = self.client.post(reverse('profiles:user_profile', args=['Alice']), {
|
|
'action': 'bio',
|
|
'bio': at_limit,
|
|
})
|
|
self.assertRedirects(resp, reverse('profiles:user_profile', args=['Alice']))
|
|
self.alice.userprofile.refresh_from_db()
|
|
self.assertEqual(len(self.alice.userprofile.bio), BIO_MAX_LENGTH)
|
|
|
|
def test_bio_special_symbols_round_trip(self):
|
|
self.gate()
|
|
self.client.login(username='Alice', password='pw')
|
|
# Emoji, quotes, ampersands, HTML, unicode — must all round-trip.
|
|
fancy = 'Café <b>&"\'</b> 😀 line\nbreak\ttab 100% +plus?'
|
|
resp = self.client.post(reverse('profiles:user_profile', args=['Alice']), {
|
|
'action': 'bio',
|
|
'bio': fancy,
|
|
})
|
|
self.assertRedirects(resp, reverse('profiles:user_profile', args=['Alice']))
|
|
self.alice.userprofile.refresh_from_db()
|
|
self.assertEqual(self.alice.userprofile.bio, fancy)
|
|
|
|
def _set_bio(self, text):
|
|
self.alice.userprofile.bio = text
|
|
self.alice.userprofile.save()
|
|
|
|
def test_bio_renders_markdown(self):
|
|
self._set_bio('# Hi\n\n**bold** and *italic* and `code`')
|
|
self.gate()
|
|
resp = self.client.get(reverse('profiles:user_profile', args=['Alice']))
|
|
self.assertContains(resp, '<h1>Hi</h1>', html=True)
|
|
self.assertContains(resp, '<strong>bold</strong>', html=True)
|
|
self.assertContains(resp, '<em>italic</em>', html=True)
|
|
|
|
def test_bio_raw_html_escaped(self):
|
|
self._set_bio('<script>alert(1)</script>')
|
|
self.gate()
|
|
resp = self.client.get(reverse('profiles:user_profile', args=['Alice']))
|
|
self.assertNotContains(resp, '<script>alert(1)</script>', html=True)
|
|
self.assertContains(resp, '<script>alert(1)</script>')
|
|
|
|
def test_bio_dangerous_link_scheme_rejected(self):
|
|
self._set_bio('[x](javascript:alert(1))')
|
|
self.gate()
|
|
resp = self.client.get(reverse('profiles:user_profile', args=['Alice']))
|
|
self.assertNotContains(resp, 'href="javascript:alert(1)"')
|
|
|
|
def test_bio_gfm_features(self):
|
|
self._set_bio('| a | b |\n|---|---|\n| 1 | 2 |\n\n~~gone~~')
|
|
self.gate()
|
|
resp = self.client.get(reverse('profiles:user_profile', args=['Alice']))
|
|
self.assertContains(resp, '<table>', html=False)
|
|
self.assertContains(resp, '<s>gone</s>', html=False)
|
|
|
|
def test_bio_preview_requires_login(self):
|
|
self.gate()
|
|
resp = self.client.post(reverse('profiles:bio_preview'), {'bio': '# x'})
|
|
self.assertEqual(resp.status_code, 302)
|
|
|
|
def test_bio_preview_renders(self):
|
|
self.gate()
|
|
self.client.login(username='Alice', password='pw')
|
|
resp = self.client.post(reverse('profiles:bio_preview'), {'bio': '**hi** *there*'})
|
|
self.assertEqual(resp.status_code, 200)
|
|
self.assertJSONEqual(resp.content, {'html': '<p><strong>hi</strong> <em>there</em></p>'})
|
|
|
|
def test_bio_preview_get_rejected(self):
|
|
self.gate()
|
|
self.client.login(username='Alice', password='pw')
|
|
resp = self.client.get(reverse('profiles:bio_preview'))
|
|
self.assertEqual(resp.status_code, 405)
|
|
|
|
|
|
class FileRequestTests(MediaTestCase, GatedTestCase):
|
|
def setUp(self):
|
|
User = get_user_model()
|
|
self.alice = User.objects.create_user(username='Alice', password='pw')
|
|
UserProfile.objects.get_or_create(user=self.alice)
|
|
self.file_index = FileIndex.objects.create(
|
|
owner=self.alice,
|
|
kind='avatar',
|
|
stored_path='avatars/user_%d/picture.jpeg' % self.alice.pk,
|
|
original_filename='pic.jpeg',
|
|
content_type='image/jpeg',
|
|
size=4,
|
|
md5='abc',
|
|
)
|
|
self.url = reverse('library:file_request', args=[self.file_index.uuid])
|
|
|
|
def _write_media(self):
|
|
from django.core.files.storage import default_storage
|
|
default_storage.save(self.file_index.stored_path, io.BytesIO(b'ABCD'))
|
|
|
|
def test_full_request_200(self):
|
|
self._write_media()
|
|
self.gate()
|
|
resp = self.client.get(self.url)
|
|
self.assertEqual(resp.status_code, 200)
|
|
self.assertEqual(b''.join(resp.streaming_content), b'ABCD')
|
|
self.assertEqual(resp['Accept-Ranges'], 'bytes')
|
|
|
|
def test_byte_range_206(self):
|
|
self._write_media()
|
|
self.gate()
|
|
resp = self.client.get(self.url, HTTP_RANGE='bytes=0-1')
|
|
self.assertEqual(resp.status_code, 206)
|
|
self.assertEqual(b''.join(resp.streaming_content), b'AB')
|
|
self.assertEqual(resp['Content-Range'], 'bytes 0-1/4')
|
|
|
|
def test_suffix_range_206(self):
|
|
self._write_media()
|
|
self.gate()
|
|
resp = self.client.get(self.url, HTTP_RANGE='bytes=-2')
|
|
self.assertEqual(resp.status_code, 206)
|
|
self.assertEqual(b''.join(resp.streaming_content), b'CD')
|
|
|
|
def test_unsatisfiable_range_416(self):
|
|
self._write_media()
|
|
self.gate()
|
|
resp = self.client.get(self.url, HTTP_RANGE='bytes=99-100')
|
|
self.assertEqual(resp.status_code, 416)
|
|
self.assertEqual(resp['Content-Range'], 'bytes */4')
|
|
|
|
def test_media_requires_gate(self):
|
|
self._write_media()
|
|
resp = self.client.get(self.url)
|
|
self.assertEqual(resp.status_code, 401)
|
|
|
|
def test_media_accessible_with_token(self):
|
|
self._write_media()
|
|
token = secrets.token_urlsafe(48)
|
|
ApiToken.objects.create(
|
|
user=self.alice,
|
|
token=token,
|
|
key_prefix=token[:8],
|
|
label='test',
|
|
)
|
|
resp = self.client.get(
|
|
self.url,
|
|
HTTP_AUTHORIZATION=f'Bearer Alice {token}',
|
|
)
|
|
self.assertEqual(resp.status_code, 200)
|
|
|
|
def test_missing_file_404(self):
|
|
self.gate()
|
|
resp = self.client.get(self.url)
|
|
self.assertEqual(resp.status_code, 404)
|