Files
ACORE/apps/feed/tests.py
T
lokses b2f0c2a1e2 core/portal: сотрудник без группы видит только базовые разделы
Ранее разделы с 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>
2026-09-21 18:26:15 +03:00

457 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Тесты ленты: создание постов (права), фильтры, закрепление,
детальная страница, комментарии, посты на дашборде.
"""
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>&nbsp;</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, 'Прихована новина')