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, ImpersonationLog, 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 '.""" 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é &"\' 😀 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, '

Hi

', html=True) self.assertContains(resp, 'bold', html=True) self.assertContains(resp, 'italic', html=True) def test_bio_raw_html_escaped(self): self._set_bio('') self.gate() resp = self.client.get(reverse('profiles:user_profile', args=['Alice'])) self.assertNotContains(resp, '', 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, '', html=False) self.assertContains(resp, 'gone', 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': '

hi there

'}) 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) class ImpersonationTests(GatedTestCase): """Staff impersonation: switch users without logging out, PK-based staff-to-staff rule, superusers off-limits, gate state preserved.""" def setUp(self): User = get_user_model() # Created in this order → increasing PKs: boss(1) < junior(2) < normal(3) < top(4). self.boss = User.objects.create_user(username='Boss', password='pw', is_staff=True) UserProfile.objects.get_or_create(user=self.boss) self.junior = User.objects.create_user(username='JuniorStaff', password='pw', is_staff=True) UserProfile.objects.get_or_create(user=self.junior) self.normal = User.objects.create_user(username='NormalUser', password='pw') UserProfile.objects.get_or_create(user=self.normal) self.top = User.objects.create_superuser(username='TopSuper', password='pw') UserProfile.objects.get_or_create(user=self.top) def _login(self, user): self.gate() self.client.login(username=user.username, password='pw') def _start(self, user, **extra): return self.client.post( reverse('profiles:impersonate_start', args=[user.pk]), data=extra, ) @property def active_user_id(self): value = self.client.session.get('_auth_user_id') return int(value) if value else None def test_users_page_gated(self): resp = self.client.get(reverse('profiles:user_list')) self.assertEqual(resp.status_code, 302) def test_users_page_lists_all(self): self.gate() resp = self.client.get(reverse('profiles:user_list')) self.assertEqual(resp.status_code, 200) for name in ('Boss', 'JuniorStaff', 'NormalUser', 'TopSuper'): self.assertContains(resp, name) def test_users_page_search(self): self.gate() resp = self.client.get(reverse('profiles:user_list'), {'q': 'Normal'}) self.assertContains(resp, 'NormalUser') self.assertNotContains(resp, 'Boss') def test_impersonate_requires_post(self): self._login(self.boss) resp = self.client.get(reverse('profiles:impersonate_start', args=[self.normal.pk])) self.assertEqual(resp.status_code, 405) def test_non_staff_cannot_impersonate(self): self._login(self.normal) resp = self._start(self.normal) self.assertEqual(resp.status_code, 403) self.assertEqual(ImpersonationLog.objects.count(), 0) def test_staff_impersonates_normal_user(self): self._login(self.boss) resp = self._start(self.normal, reason='moderation review') self.assertRedirects(resp, reverse('profiles:user_profile', args=['NormalUser'])) self.assertEqual(self.active_user_id, self.normal.pk) self.assertEqual(self.client.session.get('impersonator_id'), self.boss.pk) self.assertTrue(self.client.session.get('authorized')) log = ImpersonationLog.objects.get() self.assertEqual(log.impersonator, self.boss) self.assertEqual(log.target, self.normal) self.assertEqual(log.reason, 'moderation review') self.assertIsNone(log.ended_at) def test_gate_preserved_while_impersonating(self): self._login(self.boss) self._start(self.normal) # Follow-up page loads render (200) instead of bouncing to the gate. resp = self.client.get(reverse('profiles:user_profile', args=['NormalUser'])) self.assertEqual(resp.status_code, 200) def test_cannot_impersonate_self(self): self._login(self.boss) resp = self._start(self.boss) self.assertEqual(resp.status_code, 302) self.assertEqual(self.active_user_id, self.boss.pk) self.assertNotIn('impersonator_id', self.client.session) self.assertEqual(ImpersonationLog.objects.count(), 0) def test_older_staff_can_impersonate_newer_staff(self): self._login(self.boss) self._start(self.junior) self.assertEqual(self.active_user_id, self.junior.pk) def test_newer_staff_cannot_impersonate_older_staff(self): self._login(self.junior) self._start(self.boss) self.assertEqual(self.active_user_id, self.junior.pk) self.assertNotIn('impersonator_id', self.client.session) self.assertEqual(ImpersonationLog.objects.count(), 0) def test_cannot_impersonate_superuser(self): self._login(self.boss) self._start(self.top) self.assertEqual(self.active_user_id, self.boss.pk) self.assertEqual(ImpersonationLog.objects.count(), 0) def test_stop_impersonation_restores_original(self): self._login(self.boss) self._start(self.normal) resp = self.client.post(reverse('profiles:impersonate_stop')) self.assertRedirects(resp, reverse('landing:home')) self.assertEqual(self.active_user_id, self.boss.pk) self.assertNotIn('impersonator_id', self.client.session) self.assertTrue(self.client.session.get('authorized')) log = ImpersonationLog.objects.get() self.assertIsNotNone(log.ended_at) def test_logout_while_impersonating_stops_instead(self): self._login(self.boss) self._start(self.normal) resp = self.client.post(reverse('profiles:logout')) self.assertRedirects(resp, reverse('landing:home')) # Back on the real account, still logged in, gate still open. self.assertEqual(self.active_user_id, self.boss.pk) self.assertNotIn('impersonator_id', self.client.session) self.assertTrue(self.client.session.get('authorized')) self.assertTrue(ImpersonationLog.objects.get().ended_at) # A second logout is a real logout. self.client.post(reverse('profiles:logout')) self.assertIsNone(self.active_user_id) def test_impersonate_button_visibility(self): self._login(self.boss) resp = self.client.get(reverse('profiles:user_profile', args=['NormalUser'])) self.assertContains(resp, 'Impersonate') # Self → no button. resp = self.client.get(reverse('profiles:user_profile', args=['Boss'])) self.assertNotContains(resp, 'Impersonate') def test_impersonate_button_hidden_for_non_staff(self): self._login(self.normal) resp = self.client.get(reverse('profiles:user_profile', args=['NormalUser'])) self.assertNotContains(resp, 'Impersonate') def test_impersonate_button_hidden_while_impersonating(self): self._login(self.boss) self._start(self.junior) # acting as a staff account now # Even though the impersonated account is staff, no impersonate button. resp = self.client.get(reverse('profiles:user_profile', args=['Boss'])) self.assertNotContains(resp, 'Impersonate')