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:
parent
1320562734
commit
794fceece5
8 changed files with 226 additions and 3 deletions
13
dspace/settings_test.py
Normal file
13
dspace/settings_test.py
Normal 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'
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
|
|
||||||
# Create your tests here.
|
|
||||||
0
main/tests/__init__.py
Normal file
0
main/tests/__init__.py
Normal file
22
main/tests/conftest.py
Normal file
22
main/tests/conftest.py
Normal 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
46
main/tests/test_pages.py
Normal 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
|
||||||
73
main/tests/test_security.py
Normal file
73
main/tests/test_security.py
Normal 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 '<script>' 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++ & <Rust></b>' in text
|
||||||
|
assert 'a < 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
71
main/tests/test_votes.py
Normal 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
|
||||||
|
|
@ -16,5 +16,5 @@ ignore = [
|
||||||
"main/management/commands/*" = ["S311"]
|
"main/management/commands/*" = ["S311"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
DJANGO_SETTINGS_MODULE = "dspace.settings"
|
DJANGO_SETTINGS_MODULE = "dspace.settings_test"
|
||||||
python_files = ["test_*.py"]
|
python_files = ["test_*.py"]
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue