Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions backend/apps/articles/admin.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from django.contrib import admin, messages
from django.utils import timezone

from apps.newsletter.tasks import send_article_notification

Expand Down Expand Up @@ -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
Expand Down
124 changes: 124 additions & 0 deletions backend/apps/articles/tests/test_admin.py
Original file line number Diff line number Diff line change
@@ -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}'
)
14 changes: 14 additions & 0 deletions backend/apps/comments/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
83 changes: 82 additions & 1 deletion backend/apps/comments/tests/test_views.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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}'
)
11 changes: 11 additions & 0 deletions backend/apps/comments/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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')

Expand Down
60 changes: 40 additions & 20 deletions backend/apps/newsletter/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,16 @@ <h1 style="margin:0 0 16px 0;font-family:Georgia,'Times New Roman',serif;font-si
{% if article.published_at %} · {{ article.published_at|date:"d \d\e F \d\e Y" }}{% endif %}
</p>

{% if article.cover_image %}
{% if cover_image_absolute_url %}
{# BUG-1 fix: usar cover_image_absolute_url (montada no service com
SITE_URL prepended) em vez de article.cover_image.url, que é
relativa (/media/...) e clientes de email NÃO resolvem #}
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin:0 0 24px 0;">
<tr>
<td style="border-radius:8px;overflow:hidden;">
<a href="{{ article_url }}" style="display:block;">
<img
src="{{ article.cover_image.url }}"
src="{{ cover_image_absolute_url }}"
alt="{{ article.title }}"
width="520"
style="display:block;width:100%;max-width:520px;height:auto;border-radius:8px;"
Expand Down
Loading
Loading