diff --git a/backend/apps/articles/admin.py b/backend/apps/articles/admin.py index ea2c7327..3c3220f9 100644 --- a/backend/apps/articles/admin.py +++ b/backend/apps/articles/admin.py @@ -1,4 +1,5 @@ from django.contrib import admin, messages +from django.utils import timezone from apps.newsletter.tasks import send_article_notification @@ -27,6 +28,20 @@ class ArticleAdmin(admin.ModelAdmin): # processa em background; admin recebe acknowledge imediato. actions = ['resend_notification'] + def save_model(self, request, obj, form, change): + # OPS-1 (CONCERNS / F-10): ao publicar via admin, popular + # published_at automaticamente. Editor frequentemente esquecia + # do campo (null=True, blank=True) — artigo virava 'published' + # com published_at=None, quebrando: + # - ordering '-published_at' (joga para o fim) + # - template `|date:"d \d\e F"` (renderiza vazio) + # - ranking de busca cai no fallback `-created_at` + # NÃO sobrescreve valor existente: respeita agendamento manual + # e edição de artigo já publicado. + if obj.status == Article.Status.PUBLISHED and obj.published_at is None: + obj.published_at = timezone.now() + super().save_model(request, obj, form, change) + @admin.action(description='Reenviar notificação aos assinantes (manual)') def resend_notification(self, request, queryset): enqueued = skipped = 0 diff --git a/backend/apps/articles/tests/test_admin.py b/backend/apps/articles/tests/test_admin.py new file mode 100644 index 00000000..6bbb69ef --- /dev/null +++ b/backend/apps/articles/tests/test_admin.py @@ -0,0 +1,124 @@ +"""Tests do ArticleAdmin (apps/articles/admin.py). + +Fix OPS-1 (CONCERNS / F-10 CA): ao publicar um Article via Django admin, +`published_at` ficava `None` quando o editor não preenchia manualmente o +campo (ele é `null=True, blank=True`). Consequência: +- ordering `-published_at` jogava artigos para o fim da lista +- `published_at|date` no template renderizava string vazia +- ranking de busca (SearchView) caía no fallback `-created_at` + +A correção é setar `published_at = timezone.now()` em `save_model` quando +o status é `PUBLISHED` E o campo ainda está vazio. NÃO sobrescreve valor +existente (ex.: editor agendou ou está re-editando artigo já publicado). +""" +from __future__ import annotations + +import pytest +from django.contrib.admin.sites import AdminSite +from django.contrib.messages.storage.fallback import FallbackStorage +from django.test import RequestFactory +from django.utils import timezone + +from apps.articles.admin import ArticleAdmin +from apps.articles.models import Article, Category + + +@pytest.fixture +def category(db): + obj, _ = Category.objects.get_or_create( + slug='test-admin', defaults={'name': 'Test Admin'}, + ) + return obj + + +@pytest.fixture +def editor(db): + from django.contrib.auth import get_user_model + User = get_user_model() + return User.objects.create_user( + username='editor.admin', + email='editor@admin.test', + password='S3nh@Forte!2026', + role='editor', + ) + + +def _admin_request(user): + factory = RequestFactory() + request = factory.post('/admin/articles/article/add/') + request.user = user + request.session = {} + request._messages = FallbackStorage(request) + return request + + +def _make_article_admin(category, editor, **fields): + """Cria Article sem salvar (admin.save_model fará isso).""" + defaults = dict( + title='Test article', + slug='test-article-ops1', + excerpt='Excerpt', + body='Body', + author=editor, + category=category, + status=Article.Status.DRAFT, + ) + defaults.update(fields) + return Article(**defaults) + + +def test_save_model_sets_published_at_when_transitioning_to_published( + category, editor, +): + """Status=PUBLISHED + published_at=None → admin seta now() automaticamente.""" + admin = ArticleAdmin(Article, AdminSite()) + article = _make_article_admin( + category, editor, status=Article.Status.PUBLISHED, published_at=None, + ) + + before = timezone.now() + admin.save_model(_admin_request(editor), article, form=None, change=False) + after = timezone.now() + + article.refresh_from_db() + assert article.published_at is not None, 'published_at deveria estar setado' + assert before <= article.published_at <= after, ( + f'published_at fora da janela do save_model: {article.published_at}' + ) + + +def test_save_model_keeps_draft_published_at_null(category, editor): + """Status=DRAFT NÃO seta published_at — só publica timestamp ao publicar.""" + admin = ArticleAdmin(Article, AdminSite()) + article = _make_article_admin( + category, editor, status=Article.Status.DRAFT, published_at=None, + ) + + admin.save_model(_admin_request(editor), article, form=None, change=False) + + article.refresh_from_db() + assert article.published_at is None, ( + f'DRAFT não deve ter published_at: {article.published_at}' + ) + + +def test_save_model_preserves_existing_published_at(category, editor): + """Se editor já definiu published_at (ex.: agendou ou está editando + artigo antigo), NÃO sobrescrever — respeita decisão do editor.""" + admin = ArticleAdmin(Article, AdminSite()) + scheduled = timezone.now() - timezone.timedelta(days=7) + article = _make_article_admin( + category, editor, + status=Article.Status.PUBLISHED, + published_at=scheduled, + ) + + admin.save_model(_admin_request(editor), article, form=None, change=False) + + article.refresh_from_db() + # Mesmo timestamp (microssegundos podem perder precisão no DB, mas data + # idêntica é o que importa). + assert article.published_at is not None + assert abs((article.published_at - scheduled).total_seconds()) < 1, ( + f'published_at deveria permanecer {scheduled}, virou {article.published_at}' + ) diff --git a/backend/apps/comments/tests/conftest.py b/backend/apps/comments/tests/conftest.py index c7eae89c..5e00fd95 100644 --- a/backend/apps/comments/tests/conftest.py +++ b/backend/apps/comments/tests/conftest.py @@ -13,6 +13,20 @@ from apps.articles.models import Article, Category +@pytest.fixture(autouse=True) +def _clear_throttle_cache(): + """Limpa cache do DRF throttle entre testes. + + Sem isso, o histórico de hits do ScopedRateThrottle (S-07, scope + 'comments_create') vaza entre testes — um POST em test_A consome + cota e test_B vê 429 antes do esperado. + """ + from django.core.cache import cache + cache.clear() + yield + cache.clear() + + @pytest.fixture def category(db): obj, _ = Category.objects.get_or_create( diff --git a/backend/apps/comments/tests/test_views.py b/backend/apps/comments/tests/test_views.py index d095dcfd..7f30bc8c 100644 --- a/backend/apps/comments/tests/test_views.py +++ b/backend/apps/comments/tests/test_views.py @@ -1,4 +1,12 @@ -""" +"""Test do throttle `comments_create` (S-07 do CONCERNS). + +Sem ScopedRateThrottle dedicado em POST de comments, leitor autenticado +podia floodar artigo viral até saturar moderação reativa (DEFAULT_THROTTLE_RATES +'user'=1000/hour — alto demais para anti-flood imediato). Pattern já vivo +em apps/users/views.py:32. Fix aqui adiciona scope 'comments_create' com +limite anti-flood (default 10/min — generoso para legítimo, agressivo +contra abuso). + Testes E2E do app comments — CRUD via API + permissões + edge cases. Cobertura prioritária (D1 do reorganization-proposal): @@ -330,3 +338,76 @@ def test_like_on_soft_deleted_comment_returns_404( api = authed_client_factory(reader_user) resp = api.post(_like_url(c.pk)) assert resp.status_code == 404 + + +# ── S-07: ScopedRateThrottle comments_create (anti-flood em artigo viral) ──── + + +@pytest.mark.django_db +def test_throttle_comments_create_blocks_flood( + article, reader_user, authed_client_factory, monkeypatch, +): + """Fix S-07: 10/min anti-flood. 11º POST mesma sessão → 429. + + Pattern de pais (users/views.py:32): ScopedRateThrottle + scope nomeado. + Limite 10/min é generoso para leitor legítimo (1 a cada 6s) e agressivo + contra abuso (10× é >suficiente para sinalizar bot). + + Monkeypatch direto em `ScopedRateThrottle.THROTTLE_RATES`: o atributo + é de classe, capturado em import-time de `api_settings.DEFAULT_THROTTLE_RATES`. + Override via `settings.REST_FRAMEWORK` NÃO propaga após import — só + funciona patching no objeto da classe (revertido auto no fim do teste). + """ + from rest_framework.throttling import ScopedRateThrottle + monkeypatch.setattr( + ScopedRateThrottle, + 'THROTTLE_RATES', + {**ScopedRateThrottle.THROTTLE_RATES, 'comments_create': '10/min'}, + ) + + api = authed_client_factory(reader_user) + url = _comments_url(article.slug) + + # Primeiros 10 POSTs devem passar (201). + for i in range(10): + resp = api.post(url, {'content': f'msg {i}'}, format='json') + assert resp.status_code == 201, ( + f'POST {i+1} esperava 201, veio {resp.status_code}: {resp.content}' + ) + + # 11º POST disparta 429 Too Many Requests + resp_429 = api.post(url, {'content': 'flood msg 11'}, format='json') + assert resp_429.status_code == 429, ( + f'POST 11 esperava 429 (throttle), veio {resp_429.status_code}: {resp_429.content}' + ) + # DRF inclui Retry-After header em 429 + assert 'Retry-After' in resp_429, 'Retry-After header ausente no 429' + + +@pytest.mark.django_db +def test_throttle_comments_create_does_not_affect_get_list( + article, reader_user, authed_client_factory, monkeypatch, +): + """Throttle aplica APENAS em POST. GET list permanece ilimitado. + + Garante que get_throttles() retorna lista vazia em GET — leitor que + abre 10 artigos rápido NÃO deve ser bloqueado por throttle de criação. + """ + from rest_framework.throttling import ScopedRateThrottle + monkeypatch.setattr( + ScopedRateThrottle, + 'THROTTLE_RATES', + # Propositalmente BAIXO (2/min) — se GET acidentalmente invocasse + # ScopedRateThrottle, 3º GET já daria 429. + {**ScopedRateThrottle.THROTTLE_RATES, 'comments_create': '2/min'}, + ) + + api = authed_client_factory(reader_user) + url = _comments_url(article.slug) + + # 20 GETs — todos devem passar (throttle só afeta POST) + for i in range(20): + resp = api.get(url) + assert resp.status_code == 200, ( + f'GET {i+1} esperava 200, veio {resp.status_code}' + ) diff --git a/backend/apps/comments/views.py b/backend/apps/comments/views.py index a1df03f0..ed59d5ae 100644 --- a/backend/apps/comments/views.py +++ b/backend/apps/comments/views.py @@ -3,6 +3,7 @@ from rest_framework import generics, status from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response +from rest_framework.throttling import ScopedRateThrottle from rest_framework.views import APIView from apps.articles.models import Article @@ -25,11 +26,21 @@ def _reply_qs(user): class CommentListCreateView(generics.ListCreateAPIView): serializer_class = CommentSerializer + # S-07 (CONCERNS / F-20 CA12): anti-flood em POST. Scope 'comments_create' + # configurado em base.py com 10/min. `get_throttles()` retorna lista vazia + # em GET — listagem permanece sob throttle global 'user'/'anon' default. + throttle_scope = 'comments_create' + def get_permissions(self): if self.request.method == 'GET': return [AllowAny()] return [IsAuthenticated(), IsNotBanned()] + def get_throttles(self): + if self.request.method == 'POST': + return [ScopedRateThrottle()] + return super().get_throttles() + def get_article(self): return generics.get_object_or_404(Article, slug=self.kwargs['slug'], status='published') diff --git a/backend/apps/newsletter/services.py b/backend/apps/newsletter/services.py index 7a2edee7..1858db5e 100644 --- a/backend/apps/newsletter/services.py +++ b/backend/apps/newsletter/services.py @@ -36,27 +36,35 @@ def _unsubscribe_url(subscriber: NewsletterSubscriber) -> str: def send_welcome(subscriber: NewsletterSubscriber) -> bool: """Send the welcome / confirmation email to a single subscriber. - Returns True on success, False on any failure (errors are swallowed so - a misconfigured SMTP server can never break the public subscribe flow). + Returns True on success. Exceções (SMTP, template, qualquer) PROPAGAM + para o caller — que é o wrapper Celery `send_welcome_email` em + `tasks.py:57` com `autoretry_for=(Exception,)` + `max_retries=3` + + backoff. Falhas SMTP transientes ganham até 3 tentativas; falhas + permanentes vão pro DLQ + Sentry. + + Fix BUG-2 (CONCERNS / F-40 CA12): antes esta função tinha + `try/except Exception: return False` que silenciosamente matava + o `autoretry_for` do Celery — falhas SMTP nunca davam retry e + subscriber nunca recebia welcome (e ninguém sabia). O view sempre + chamou esta função via `.delay()` (async, ver `views.py:22`), então + NUNCA quebrou o fluxo de subscribe do usuário — o argumento original + do swallow ("não quebrar subscribe público") era falso/historic. """ ctx = { 'site_url': _site_url(), 'unsubscribe_url': _unsubscribe_url(subscriber), } - try: - html = render_to_string('newsletter/emails/welcome.html', ctx) - text = render_to_string('newsletter/emails/welcome.txt', ctx) - msg = EmailMultiAlternatives( - subject='Bem-vindo(a) ao Interpop', - body=text, - from_email=_from_email(), - to=[subscriber.email], - ) - msg.attach_alternative(html, 'text/html') - msg.send(fail_silently=False) - return True - except Exception: - return False + html = render_to_string('newsletter/emails/welcome.html', ctx) + text = render_to_string('newsletter/emails/welcome.txt', ctx) + msg = EmailMultiAlternatives( + subject='Bem-vindo(a) ao Interpop', + body=text, + from_email=_from_email(), + to=[subscriber.email], + ) + msg.attach_alternative(html, 'text/html') + msg.send(fail_silently=False) + return True def _dispatch_article_notification_sync( @@ -87,14 +95,26 @@ def _dispatch_article_notification_sync( if subscribers is None: subscribers = NewsletterSubscriber.objects.filter(is_active=True) + # BUG-1 fix: cover_image.url retorna caminho RELATIVO `/media/...` que + # clientes de email NÃO resolvem contra base — todos os subscribers + # viam placeholder broken-image. Aqui montamos URL ABSOLUTA com SITE_URL + # e passamos via ctx. Template usa `cover_image_absolute_url` em vez de + # `article.cover_image.url`. + cover_image_absolute_url = ( + f"{site_url}{article.cover_image.url}" + if getattr(article, 'cover_image', None) and article.cover_image + else None + ) + sent = 0 failed = 0 for sub in subscribers: ctx = { - 'article': article, - 'article_url': article_url, - 'site_url': site_url, - 'unsubscribe_url': _unsubscribe_url(sub), + 'article': article, + 'article_url': article_url, + 'site_url': site_url, + 'cover_image_absolute_url': cover_image_absolute_url, + 'unsubscribe_url': _unsubscribe_url(sub), } try: html = render_to_string('newsletter/emails/article_notification.html', ctx) diff --git a/backend/apps/newsletter/templates/newsletter/emails/article_notification.html b/backend/apps/newsletter/templates/newsletter/emails/article_notification.html index 91f02074..75f159cc 100644 --- a/backend/apps/newsletter/templates/newsletter/emails/article_notification.html +++ b/backend/apps/newsletter/templates/newsletter/emails/article_notification.html @@ -22,13 +22,16 @@