b2f0c2a1e2
Ранее разделы с perm=None (Новости, Задачи, Дислокация, Документы, Чат, Календарь, HR-портал, Справка) были доступны любому вошедшему — сотрудник без назначенной группы видел почти весь портал. Теперь такие разделы требуют хотя бы одной группы; без групп доступны только базовые: Главная, Профиль, Поддержка IT. - user_can_access: BASELINE_OPEN_KEYS + gate «есть ли группа» для perm=None - PortalSectionAccessMiddleware: барьер по префиксу URL (в т.ч. вложенные страницы вроде /tasks/5/), /settings/ не гейтим (редирект на свой профиль) - index.html: пункты меню под can_section (сайдбар, дропдаун, bottom-nav), секция «Управление» скрывается целиком, если пусто - тесты: политика без-группы + прямой заход по URL; тест-фабрики выдают группу (реальный сотрудник её имеет) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
457 lines
20 KiB
Python
457 lines
20 KiB
Python
"""
|
||
Тесты ленты: создание постов (права), фильтры, закрепление,
|
||
детальная страница, комментарии, посты на дашборде.
|
||
"""
|
||
import io
|
||
import shutil
|
||
import tempfile
|
||
from datetime import date
|
||
|
||
from django.contrib.auth import get_user_model
|
||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||
from django.test import TestCase, override_settings
|
||
|
||
from apps.feed.models import Post, PostComment
|
||
from apps.employees.tests import login_2fa
|
||
|
||
Employee = get_user_model()
|
||
|
||
_employee_seq = 0
|
||
|
||
|
||
def make_employee(**overrides):
|
||
"""Сотрудник с выключенными синхронизациями (сигналы не зовут Celery)"""
|
||
global _employee_seq
|
||
_employee_seq += 1
|
||
defaults = {
|
||
'username': f'feed.user{_employee_seq}',
|
||
'email': f'feed.user{_employee_seq}@company.local',
|
||
'first_name': 'Іван',
|
||
'last_name': 'Тестовий',
|
||
'hire_date': date(2026, 1, 1),
|
||
'user_location_type': 'office',
|
||
'mattermost_sync_enabled': False,
|
||
'ad_sync_enabled': False,
|
||
}
|
||
defaults.update(overrides)
|
||
emp = Employee.objects.create(**defaults)
|
||
# Любая группа = «провизионированный» сотрудник (видит открытые разделы).
|
||
from django.contrib.auth.models import Group
|
||
emp.groups.add(Group.objects.get_or_create(name='Сотрудники (тест)')[0])
|
||
return emp
|
||
|
||
|
||
class FeedPageTests(TestCase):
|
||
@classmethod
|
||
def setUpTestData(cls):
|
||
cls.staff = make_employee(is_staff=True)
|
||
cls.user = make_employee()
|
||
cls.author = make_employee(first_name='Марія', last_name='Петренко')
|
||
|
||
def test_feed_lists_published_posts(self):
|
||
Post.objects.create(title='Опублікований пост', body='текст', author=self.author)
|
||
Post.objects.create(title='Чернетка', body='текст', author=self.author,
|
||
is_published=False)
|
||
login_2fa(self.client, self.user)
|
||
response = self.client.get('/news/', HTTP_HX_REQUEST='true')
|
||
self.assertEqual(response.status_code, 200)
|
||
self.assertContains(response, 'Опублікований пост')
|
||
self.assertNotContains(response, 'Чернетка')
|
||
|
||
def test_pinned_posts_are_separated(self):
|
||
pinned = Post.objects.create(title='Закріплений', body='т', author=self.author,
|
||
is_pinned=True)
|
||
regular = Post.objects.create(title='Звичайний', body='т', author=self.author)
|
||
login_2fa(self.client, self.user)
|
||
response = self.client.get('/news/', HTTP_HX_REQUEST='true')
|
||
self.assertIn(pinned, response.context['pinned_posts'])
|
||
self.assertIn(regular, response.context['posts'])
|
||
self.assertContains(response, 'Закріплено')
|
||
|
||
def test_category_filter(self):
|
||
Post.objects.create(title='IT новина', body='т', category='it', author=self.author)
|
||
Post.objects.create(title='HR новина', body='т', category='hr', author=self.author)
|
||
login_2fa(self.client, self.user)
|
||
response = self.client.get('/news/?category=it', HTTP_HX_REQUEST='true')
|
||
self.assertContains(response, 'IT новина')
|
||
self.assertNotContains(response, 'HR новина')
|
||
|
||
def test_important_filter(self):
|
||
Post.objects.create(title='Термінова', body='т', is_important=True,
|
||
author=self.author)
|
||
Post.objects.create(title='Планова', body='т', author=self.author)
|
||
login_2fa(self.client, self.user)
|
||
response = self.client.get('/news/?category=important', HTTP_HX_REQUEST='true')
|
||
self.assertContains(response, 'Термінова')
|
||
self.assertNotContains(response, 'Планова')
|
||
|
||
def test_invalid_filter_falls_back_to_all(self):
|
||
Post.objects.create(title='Будь-яка', body='т', author=self.author)
|
||
login_2fa(self.client, self.user)
|
||
response = self.client.get('/news/?category=hacked', HTTP_HX_REQUEST='true')
|
||
self.assertEqual(response.status_code, 200)
|
||
self.assertEqual(response.context['active_filter'], '')
|
||
self.assertContains(response, 'Будь-яка')
|
||
|
||
def test_staff_can_create_post(self):
|
||
login_2fa(self.client, self.staff)
|
||
response = self.client.post('/news/', {
|
||
'title': 'Нова публікація',
|
||
'body': 'Текст публікації',
|
||
'category': 'it',
|
||
'is_important': 'on',
|
||
}, HTTP_HX_REQUEST='true')
|
||
self.assertEqual(response.status_code, 200)
|
||
post = Post.objects.get(title='Нова публікація')
|
||
self.assertEqual(post.author, self.staff)
|
||
self.assertEqual(post.category, 'it')
|
||
self.assertTrue(post.is_important)
|
||
self.assertFalse(post.is_pinned)
|
||
|
||
def test_empty_body_shows_error_and_creates_nothing(self):
|
||
login_2fa(self.client, self.staff)
|
||
response = self.client.post('/news/', {
|
||
'title': 'Тільки заголовок',
|
||
'body': '<p> </p>', # пустая разметка из TinyMCE
|
||
'category': 'general',
|
||
}, HTTP_HX_REQUEST='true')
|
||
self.assertFalse(Post.objects.filter(title='Тільки заголовок').exists())
|
||
self.assertContains(response, 'Публікацію не збережено')
|
||
|
||
def test_image_only_body_is_accepted(self):
|
||
login_2fa(self.client, self.staff)
|
||
self.client.post('/news/', {
|
||
'title': 'Фотозвіт',
|
||
'body': '<p><img src="/media/feed/x.png"></p>',
|
||
'category': 'general',
|
||
}, HTTP_HX_REQUEST='true')
|
||
post = Post.objects.get(title='Фотозвіт')
|
||
self.assertIn('/media/feed/x.png', post.body)
|
||
|
||
def test_regular_user_cannot_create_post(self):
|
||
login_2fa(self.client, self.user)
|
||
self.client.post('/news/', {
|
||
'title': 'Спроба', 'body': 'т', 'category': 'general',
|
||
}, HTTP_HX_REQUEST='true')
|
||
self.assertFalse(Post.objects.filter(title='Спроба').exists())
|
||
|
||
def test_create_form_visible_only_to_staff(self):
|
||
login_2fa(self.client, self.user)
|
||
response = self.client.get('/news/', HTTP_HX_REQUEST='true')
|
||
self.assertNotContains(response, 'post-create-form')
|
||
|
||
login_2fa(self.client, self.staff)
|
||
response = self.client.get('/news/', HTTP_HX_REQUEST='true')
|
||
self.assertContains(response, 'post-create-form')
|
||
|
||
def test_invalid_category_falls_back_to_general(self):
|
||
login_2fa(self.client, self.staff)
|
||
self.client.post('/news/', {
|
||
'title': 'Пост', 'body': 'т', 'category': 'hacked',
|
||
}, HTTP_HX_REQUEST='true')
|
||
self.assertEqual(Post.objects.get(title='Пост').category, 'general')
|
||
|
||
def test_anonymous_redirected(self):
|
||
response = self.client.get('/news/')
|
||
self.assertEqual(response.status_code, 302)
|
||
|
||
|
||
class BodySanitizationTests(TestCase):
|
||
"""Тело поста — HTML из TinyMCE; XSS вычищается при сохранении"""
|
||
|
||
@classmethod
|
||
def setUpTestData(cls):
|
||
cls.author = make_employee()
|
||
|
||
def test_script_tags_are_stripped(self):
|
||
post = Post.objects.create(
|
||
title='XSS', author=self.author,
|
||
body='<p>Текст</p><script>alert("xss")</script>',
|
||
)
|
||
self.assertNotIn('<script>', post.body)
|
||
self.assertNotIn('alert', post.body)
|
||
self.assertIn('<p>Текст</p>', post.body)
|
||
|
||
def test_event_handlers_are_stripped(self):
|
||
post = Post.objects.create(
|
||
title='XSS', author=self.author,
|
||
body='<p onclick="alert(1)">Клік</p><a href="javascript:alert(1)">лінк</a>',
|
||
)
|
||
self.assertNotIn('onclick', post.body)
|
||
self.assertNotIn('javascript:', post.body)
|
||
|
||
def test_alignment_styles_are_preserved(self):
|
||
post = Post.objects.create(
|
||
title='Вирівнювання', author=self.author,
|
||
body='<p style="text-align: center;">центр</p>'
|
||
'<h2 style="text-align: right;">заголовок</h2>'
|
||
'<li style="text-align: center;">пункт</li>'
|
||
'<img src="/media/feed/x.png" style="float: left;">',
|
||
)
|
||
self.assertIn('<p style="text-align: center;">', post.body)
|
||
self.assertIn('<h2 style="text-align: right;">', post.body)
|
||
self.assertIn('<li style="text-align: center;">', post.body)
|
||
self.assertIn('style="float: left;"', post.body)
|
||
|
||
def test_formatting_is_preserved(self):
|
||
post = Post.objects.create(
|
||
title='Формат', author=self.author,
|
||
body='<p><strong>Жирний</strong> та <em>курсив</em></p>'
|
||
'<ul><li>пункт</li></ul>'
|
||
'<span style="color: rgb(224, 62, 45);">кольоровий</span>',
|
||
)
|
||
self.assertIn('<strong>Жирний</strong>', post.body)
|
||
self.assertIn('<em>курсив</em>', post.body)
|
||
self.assertIn('<li>пункт</li>', post.body)
|
||
self.assertIn('style=', post.body)
|
||
|
||
def test_detail_and_feed_render_html_dashboard_strips_it(self):
|
||
user = make_employee()
|
||
post = Post.objects.create(
|
||
title='HTML пост', author=self.author,
|
||
body='<p><strong>Важливий</strong> текст поста</p>',
|
||
)
|
||
login_2fa(self.client, user)
|
||
|
||
detail = self.client.get(f'/news/{post.pk}/', HTTP_HX_REQUEST='true')
|
||
self.assertContains(detail, '<strong>Важливий</strong>')
|
||
|
||
feed = self.client.get('/news/', HTTP_HX_REQUEST='true')
|
||
self.assertContains(feed, '<strong>Важливий</strong>')
|
||
|
||
dashboard = self.client.get('/dashboard/', HTTP_HX_REQUEST='true')
|
||
self.assertNotContains(dashboard, '<strong>Важливий</strong>')
|
||
self.assertContains(dashboard, 'Важливий текст поста')
|
||
|
||
|
||
def make_png_bytes():
|
||
from PIL import Image
|
||
buffer = io.BytesIO()
|
||
Image.new('RGB', (4, 4), 'red').save(buffer, format='PNG')
|
||
return buffer.getvalue()
|
||
|
||
|
||
_test_media = tempfile.mkdtemp(prefix='feed-test-media-')
|
||
|
||
|
||
@override_settings(MEDIA_ROOT=_test_media)
|
||
class ImageUploadTests(TestCase):
|
||
@classmethod
|
||
def setUpTestData(cls):
|
||
cls.staff = make_employee(is_staff=True)
|
||
cls.user = make_employee()
|
||
|
||
@classmethod
|
||
def tearDownClass(cls):
|
||
super().tearDownClass()
|
||
shutil.rmtree(_test_media, ignore_errors=True)
|
||
|
||
def test_staff_uploads_image(self):
|
||
login_2fa(self.client, self.staff)
|
||
file = SimpleUploadedFile('pic.png', make_png_bytes(), content_type='image/png')
|
||
response = self.client.post('/news/upload-image/', {'file': file})
|
||
self.assertEqual(response.status_code, 200)
|
||
location = response.json()['location']
|
||
self.assertTrue(location.startswith('/media/feed/'))
|
||
self.assertTrue(location.endswith('.png'))
|
||
|
||
def test_uploaded_image_survives_sanitizer(self):
|
||
login_2fa(self.client, self.staff)
|
||
file = SimpleUploadedFile('pic.png', make_png_bytes(), content_type='image/png')
|
||
location = self.client.post('/news/upload-image/', {'file': file}).json()['location']
|
||
post = Post.objects.create(
|
||
title='З картинкою', author=self.staff,
|
||
body=f'<p><img src="{location}"></p>',
|
||
)
|
||
self.assertIn(f'src="{location}"', post.body)
|
||
|
||
def test_data_uri_image_is_stripped(self):
|
||
post = Post.objects.create(
|
||
title='База64', author=self.staff,
|
||
body='<p><img src="data:image/png;base64,AAAA"></p>',
|
||
)
|
||
self.assertNotIn('data:', post.body)
|
||
|
||
def test_non_staff_cannot_upload(self):
|
||
login_2fa(self.client, self.user)
|
||
file = SimpleUploadedFile('pic.png', make_png_bytes(), content_type='image/png')
|
||
response = self.client.post('/news/upload-image/', {'file': file})
|
||
self.assertEqual(response.status_code, 403)
|
||
|
||
def test_non_image_content_is_rejected(self):
|
||
login_2fa(self.client, self.staff)
|
||
file = SimpleUploadedFile('fake.png', b'not an image at all', content_type='image/png')
|
||
response = self.client.post('/news/upload-image/', {'file': file})
|
||
self.assertEqual(response.status_code, 400)
|
||
|
||
def test_disallowed_extension_is_rejected(self):
|
||
login_2fa(self.client, self.staff)
|
||
file = SimpleUploadedFile('script.svg', make_png_bytes(), content_type='image/svg+xml')
|
||
response = self.client.post('/news/upload-image/', {'file': file})
|
||
self.assertEqual(response.status_code, 400)
|
||
|
||
def test_missing_file_is_rejected(self):
|
||
login_2fa(self.client, self.staff)
|
||
response = self.client.post('/news/upload-image/', {})
|
||
self.assertEqual(response.status_code, 400)
|
||
|
||
|
||
class PostDetailTests(TestCase):
|
||
@classmethod
|
||
def setUpTestData(cls):
|
||
cls.user = make_employee()
|
||
cls.author = make_employee()
|
||
cls.post = Post.objects.create(
|
||
title='Детальний пост', body='Повний текст поста', author=cls.author,
|
||
)
|
||
|
||
def test_detail_renders_full_post(self):
|
||
login_2fa(self.client, self.user)
|
||
response = self.client.get(f'/news/{self.post.pk}/', HTTP_HX_REQUEST='true')
|
||
self.assertEqual(response.status_code, 200)
|
||
self.assertContains(response, 'Детальний пост')
|
||
self.assertContains(response, 'Повний текст поста')
|
||
|
||
def test_unpublished_post_returns_404(self):
|
||
hidden = Post.objects.create(title='Прихований', body='т',
|
||
author=self.author, is_published=False)
|
||
login_2fa(self.client, self.user)
|
||
response = self.client.get(f'/news/{hidden.pk}/', HTTP_HX_REQUEST='true')
|
||
self.assertEqual(response.status_code, 404)
|
||
|
||
def test_any_user_can_comment(self):
|
||
login_2fa(self.client, self.user)
|
||
response = self.client.post(f'/news/{self.post.pk}/', {
|
||
'text': 'Чудова новина!',
|
||
}, HTTP_HX_REQUEST='true')
|
||
self.assertEqual(response.status_code, 200)
|
||
comment = PostComment.objects.get(post=self.post)
|
||
self.assertEqual(comment.author, self.user)
|
||
self.assertEqual(comment.text, 'Чудова новина!')
|
||
self.assertContains(response, 'Чудова новина!')
|
||
|
||
def test_empty_comment_is_ignored(self):
|
||
login_2fa(self.client, self.user)
|
||
self.client.post(f'/news/{self.post.pk}/', {'text': ' '},
|
||
HTTP_HX_REQUEST='true')
|
||
self.assertFalse(PostComment.objects.exists())
|
||
|
||
|
||
class FullBodyInFeedTests(TestCase):
|
||
def test_feed_shows_full_post_html(self):
|
||
user = make_employee()
|
||
author = make_employee()
|
||
Post.objects.create(
|
||
title='Повний пост', author=author,
|
||
body='<p><strong>Жирний</strong> повний текст прямо в стрічці</p>',
|
||
)
|
||
login_2fa(self.client, user)
|
||
response = self.client.get('/news/', HTTP_HX_REQUEST='true')
|
||
# тело выводится как HTML целиком, без обрезки и striptags
|
||
self.assertContains(response, '<strong>Жирний</strong>')
|
||
self.assertContains(response, 'повний текст прямо в стрічці')
|
||
|
||
def test_feed_has_collapse_markup(self):
|
||
user = make_employee()
|
||
author = make_employee()
|
||
Post.objects.create(title='Пост', body='<p>т</p>', author=author)
|
||
login_2fa(self.client, user)
|
||
html = self.client.get('/news/', HTTP_HX_REQUEST='true').content.decode()
|
||
# контейнер сворачивания и кнопка-переключатель присутствуют
|
||
self.assertIn('post-collapse', html)
|
||
self.assertIn('feedTogglePost', html)
|
||
self.assertIn('Показати повністю', html)
|
||
|
||
|
||
class EditDeletePermissionTests(TestCase):
|
||
@classmethod
|
||
def setUpTestData(cls):
|
||
cls.author = make_employee()
|
||
cls.staff = make_employee(is_staff=True)
|
||
cls.stranger = make_employee()
|
||
|
||
def setUp(self):
|
||
self.post = Post.objects.create(
|
||
title='Мій пост', body='<p>текст</p>', author=self.author,
|
||
)
|
||
|
||
def test_author_can_edit(self):
|
||
login_2fa(self.client, self.author)
|
||
response = self.client.post(f'/news/{self.post.pk}/edit/', {
|
||
'title': 'Виправлений', 'body': '<p>новий текст</p>', 'category': 'hr',
|
||
}, HTTP_HX_REQUEST='true')
|
||
self.assertEqual(response.status_code, 302)
|
||
self.post.refresh_from_db()
|
||
self.assertEqual(self.post.title, 'Виправлений')
|
||
self.assertEqual(self.post.category, 'hr')
|
||
|
||
def test_staff_can_edit_foreign_post(self):
|
||
login_2fa(self.client, self.staff)
|
||
response = self.client.post(f'/news/{self.post.pk}/edit/', {
|
||
'title': 'Модеровано', 'body': '<p>текст</p>', 'category': 'general',
|
||
}, HTTP_HX_REQUEST='true')
|
||
self.assertEqual(response.status_code, 302)
|
||
self.post.refresh_from_db()
|
||
self.assertEqual(self.post.title, 'Модеровано')
|
||
# автор не подменяется при модерации
|
||
self.assertEqual(self.post.author, self.author)
|
||
|
||
def test_stranger_cannot_edit(self):
|
||
login_2fa(self.client, self.stranger)
|
||
response = self.client.get(f'/news/{self.post.pk}/edit/', HTTP_HX_REQUEST='true')
|
||
self.assertEqual(response.status_code, 404)
|
||
|
||
def test_edit_page_prefills_form(self):
|
||
login_2fa(self.client, self.author)
|
||
response = self.client.get(f'/news/{self.post.pk}/edit/', HTTP_HX_REQUEST='true')
|
||
self.assertEqual(response.status_code, 200)
|
||
self.assertContains(response, 'Мій пост')
|
||
|
||
def test_author_can_delete(self):
|
||
login_2fa(self.client, self.author)
|
||
response = self.client.post(f'/news/{self.post.pk}/delete/', HTTP_HX_REQUEST='true')
|
||
self.assertEqual(response.status_code, 302)
|
||
self.assertFalse(Post.objects.filter(pk=self.post.pk).exists())
|
||
|
||
def test_staff_cannot_delete_foreign_post_via_portal(self):
|
||
login_2fa(self.client, self.staff)
|
||
response = self.client.post(f'/news/{self.post.pk}/delete/', HTTP_HX_REQUEST='true')
|
||
self.assertEqual(response.status_code, 404)
|
||
self.assertTrue(Post.objects.filter(pk=self.post.pk).exists())
|
||
|
||
def test_delete_requires_post_method(self):
|
||
login_2fa(self.client, self.author)
|
||
response = self.client.get(f'/news/{self.post.pk}/delete/')
|
||
self.assertEqual(response.status_code, 405)
|
||
self.assertTrue(Post.objects.filter(pk=self.post.pk).exists())
|
||
|
||
def test_action_buttons_visibility_in_feed(self):
|
||
login_2fa(self.client, self.author)
|
||
html = self.client.get('/news/', HTTP_HX_REQUEST='true').content.decode()
|
||
self.assertIn('Редагувати', html)
|
||
self.assertIn('Видалити', html)
|
||
|
||
login_2fa(self.client, self.stranger)
|
||
html = self.client.get('/news/', HTTP_HX_REQUEST='true').content.decode()
|
||
self.assertNotIn('Редагувати', html)
|
||
self.assertNotIn('Видалити', html)
|
||
|
||
|
||
class DashboardFeedTests(TestCase):
|
||
def test_dashboard_shows_latest_posts(self):
|
||
user = make_employee()
|
||
author = make_employee()
|
||
Post.objects.create(title='Новина на дашборді', body='т', author=author)
|
||
login_2fa(self.client, user)
|
||
response = self.client.get('/dashboard/', HTTP_HX_REQUEST='true')
|
||
self.assertContains(response, 'Новина на дашборді')
|
||
|
||
def test_dashboard_hides_unpublished(self):
|
||
user = make_employee()
|
||
author = make_employee()
|
||
Post.objects.create(title='Прихована новина', body='т', author=author,
|
||
is_published=False)
|
||
login_2fa(self.client, user)
|
||
response = self.client.get('/dashboard/', HTTP_HX_REQUEST='true')
|
||
self.assertNotContains(response, 'Прихована новина')
|