1
0
Fork 0
mirror of https://github.com/EDeev/deev.space.git synced 2026-10-07 20:49:59 +03:00

Тесты: голосование без регистрации, антинакрутка, формы, защита от XSS/SSRF, страницы

33 теста на pytest-django в боевом режиме (DEBUG=False, настройки dspace/settings_test.py):
лайки и дизлайки анонимов, лимит оценок с одного IP, очистка HTML в
комментариях и форме обратной связи, капча, экранирование бейджей и постов в Telegram,
проверка адресов превью, уникальные просмотры и фильтр ботов, cookie посетителя.
This commit is contained in:
Деев Егор Викторович 2026-10-02 12:03:30 +00:00
parent 1320562734
commit 794fceece5
8 changed files with 226 additions and 3 deletions

13
dspace/settings_test.py Normal file
View file

@ -0,0 +1,13 @@
"""Настройки для тестов: боевой режим (DEBUG=False), но без SSL-редиректа и внешних сервисов."""
import os
os.environ.setdefault('DJANGO_SECRET_KEY', 'test-secret-key-not-for-production')
os.environ.setdefault('DJANGO_DEBUG', 'False')
os.environ.setdefault('DJANGO_SECURE_SSL_REDIRECT', 'False')
os.environ.setdefault('DJANGO_ALLOWED_HOSTS', 'testserver,localhost')
from .settings import * # noqa: E402,F403
STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.StaticFilesStorage'
PASSWORD_HASHERS = ['django.contrib.auth.hashers.MD5PasswordHasher']
EMAIL_BACKEND = 'django.core.mail.backends.locmem.EmailBackend'

View file

@ -1,2 +0,0 @@
# Create your tests here.

0
main/tests/__init__.py Normal file
View file

22
main/tests/conftest.py Normal file
View file

@ -0,0 +1,22 @@
import pytest
from main.models import Article, SiteSettings
@pytest.fixture(autouse=True)
def no_network(monkeypatch):
"""Никаких реальных запросов наружу: ping поисковиков и т. п."""
calls = []
monkeypatch.setattr('main.signals.requests.get', lambda *a, **kw: calls.append((a, kw)))
monkeypatch.setattr('main.signals.requests.post', lambda *a, **kw: calls.append((a, kw)))
return calls
@pytest.fixture
def article(db):
return Article.objects.create(title='Первая статья', post='Текст статьи', excerpt='Кратко')
@pytest.fixture
def site_settings(db):
return SiteSettings.load()

46
main/tests/test_pages.py Normal file
View file

@ -0,0 +1,46 @@
import pytest
from django.urls import reverse
from main.models import ArticleView
UA = 'Mozilla/5.0 (X11; Linux x86_64) Firefox/131.0'
@pytest.mark.django_db
@pytest.mark.parametrize('name', ['index', 'about', 'projects', 'achievements', 'contacts', 'blog'])
def test_page_opens(client, name):
assert client.get(reverse(name), HTTP_USER_AGENT=UA).status_code == 200
@pytest.mark.django_db
def test_sitemap_and_robots(client, article):
assert client.get('/sitemap.xml').status_code == 200
@pytest.mark.django_db
def test_article_counts_unique_views(client, article):
url = article.get_absolute_url()
client.get(url, HTTP_USER_AGENT=UA)
client.get(url, HTTP_USER_AGENT=UA)
article.refresh_from_db()
assert article.views == 2
assert ArticleView.objects.filter(article=article).count() == 1
@pytest.mark.django_db
def test_bots_do_not_create_unique_views(client, article):
client.get(article.get_absolute_url(), HTTP_USER_AGENT='Googlebot/2.1')
assert not ArticleView.objects.exists()
@pytest.mark.django_db
def test_visitor_cookie_is_signed_and_httponly(client):
response = client.get(reverse('index'), HTTP_USER_AGENT=UA)
cookie = response.cookies['dspace_vid']
assert cookie['httponly']
assert ':' in cookie.value
@pytest.mark.django_db
def test_unknown_page_returns_404(client):
assert client.get('/no-such-page/', HTTP_USER_AGENT=UA).status_code == 404

View file

@ -0,0 +1,73 @@
from unittest import mock
import pytest
from main.forms import CommentForm, ContactForm
from main.middleware import client_ip_hash, is_bot
from main.models import is_public_http_url
from main.signals import build_telegram_text
from main.templatetags.custom_filters import render_tech_badge
def test_comment_form_strips_scripts_and_keeps_allowed_tags():
form = CommentForm(data={'content': '<script>alert(1)</script><b>жирный</b> https://example.com'})
assert form.is_valid()
content = form.cleaned_data['content']
assert '<script>' not in content
assert '<b>жирный</b>' in content
assert '<a href="https://example.com"' in content
def test_contact_form_removes_all_html():
data = {'name': 'Иван', 'email': 'ivan@example.com', 'subject': 'Вопрос',
'message': '<img src=x onerror=alert(1)>Привет', 'captcha': 'token'}
with mock.patch('main.forms.SmartCaptchaField._verify_captcha', return_value=True):
form = ContactForm(data=data)
assert form.is_valid(), form.errors
assert form.cleaned_data['message'] == 'Привет'
def test_contact_form_rejects_failed_captcha():
data = {'name': 'Бот', 'email': 'bot@example.com', 'subject': 'Спам', 'message': 'Спам', 'captcha': 'bad'}
with mock.patch('main.forms.SmartCaptchaField._verify_captcha', return_value=False):
assert not ContactForm(data=data).is_valid()
def test_tech_badge_escapes_name():
html = render_tech_badge('<script>x</script>')
assert '<script>' not in html
assert '&lt;script&gt;' in html
def test_telegram_text_is_html_escaped(db):
from main.models import Article
article = Article(title='C++ & <Rust>', slug='cpp', post='...', excerpt='a < b')
text = build_telegram_text(article)
assert '<b>C++ &amp; &lt;Rust&gt;</b>' in text
assert 'a &lt; b' in text
assert '\\' not in text
@pytest.mark.parametrize('url', ['file:///etc/passwd', 'ftp://example.com', 'http://localhost/', 'http://10.0.0.1/'])
def test_preview_rejects_non_public_urls(url):
assert not is_public_http_url(url)
def test_preview_accepts_public_url():
public = [(2, 1, 6, '', ('93.184.215.14', 443))]
with mock.patch('main.models.socket.getaddrinfo', return_value=public):
assert is_public_http_url('https://example.com/page')
def test_ip_hash_is_empty_for_private_addresses(rf):
assert client_ip_hash(rf.get('/', REMOTE_ADDR='192.168.1.10')) == ''
assert client_ip_hash(rf.get('/', REMOTE_ADDR='93.184.215.14'))
@pytest.mark.parametrize(('ua', 'expected'), [
('', True), ('Googlebot/2.1', True), ('python-requests/2.32', True),
('Mozilla/5.0 (Windows NT 10.0) Chrome/129.0', False),
])
def test_bot_detection(rf, ua, expected):
assert is_bot(rf.get('/', HTTP_USER_AGENT=ua)) is expected

71
main/tests/test_votes.py Normal file
View file

@ -0,0 +1,71 @@
import json
import pytest
from django.urls import reverse
from main.models import ArticleLike
PUBLIC_IP = '93.184.215.14'
UA = 'Mozilla/5.0 (X11; Linux x86_64) Firefox/131.0'
def vote(client, article, is_like=True, ip=PUBLIC_IP):
return client.post(
reverse('toggle_article_like', args=[article.id]),
data=json.dumps({'is_like': is_like}),
content_type='application/json',
REMOTE_ADDR=ip,
HTTP_USER_AGENT=UA,
)
@pytest.mark.django_db
def test_anonymous_like_is_counted(client, article):
response = vote(client, article)
assert response.status_code == 200
assert response.json() == {'success': True, 'likes': 1, 'dislikes': 0, 'user_vote': True}
assert ArticleLike.objects.get().visitor_id
@pytest.mark.django_db
def test_second_click_removes_vote(client, article):
vote(client, article)
response = vote(client, article)
assert response.json()['user_vote'] is None
assert ArticleLike.objects.count() == 0
@pytest.mark.django_db
def test_like_switches_to_dislike(client, article):
vote(client, article, is_like=True)
response = vote(client, article, is_like=False)
assert response.json()['likes'] == 0
assert response.json()['dislikes'] == 1
assert ArticleLike.objects.count() == 1
@pytest.mark.django_db
def test_votes_per_ip_are_limited(client_factory, article):
statuses = [vote(client_factory(), article).status_code for _ in range(3)]
assert statuses == [200, 200, 429]
@pytest.mark.django_db
def test_private_ip_is_not_limited(client_factory, article):
statuses = [vote(client_factory(), article, ip='10.0.0.5').status_code for _ in range(3)]
assert statuses == [200, 200, 200]
@pytest.mark.django_db
def test_invalid_json_returns_400(client, article):
response = client.post(
reverse('toggle_article_like', args=[article.id]), data='not json', content_type='application/json'
)
assert response.status_code == 400
@pytest.fixture
def client_factory():
from django.test import Client
return Client

View file

@ -16,5 +16,5 @@ ignore = [
"main/management/commands/*" = ["S311"]
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "dspace.settings"
DJANGO_SETTINGS_MODULE = "dspace.settings_test"
python_files = ["test_*.py"]