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 @@

- {% 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 #}
{{ article.title }} NewsletterSubscriber: + return NewsletterSubscriber.objects.create( + email='leitor@example.com', + is_active=True, + ) + + +def test_send_welcome_returns_true_on_success(subscriber): + """Caminho feliz: SMTP entrega → retorna True.""" + with patch( + 'apps.newsletter.services.EmailMultiAlternatives.send', + return_value=1, + ) as mock_send: + assert send_welcome(subscriber) is True + # Confirma que fail_silently=False (queremos que erros propaguem) + mock_send.assert_called_once_with(fail_silently=False) + + +def test_send_welcome_propagates_smtp_error_to_caller(subscriber): + """Fix BUG-2: falha SMTP deve propagar para o Celery autoretry pegar. + + Antes: try/except Exception engolia tudo e retornava False, matando + o autoretry_for=(Exception,) do wrapper task `send_welcome_email`. + Agora: exceção escapa, Celery faz retry conforme política. + """ + from smtplib import SMTPServerDisconnected + + with patch( + 'apps.newsletter.services.EmailMultiAlternatives.send', + side_effect=SMTPServerDisconnected('Connection unexpectedly closed'), + ): + with pytest.raises(SMTPServerDisconnected): + send_welcome(subscriber) + + +def test_send_welcome_propagates_generic_exception(subscriber): + """Defesa em profundidade: qualquer Exception propaga (não só SMTP).""" + with patch( + 'apps.newsletter.services.render_to_string', + side_effect=RuntimeError('template malformado'), + ): + with pytest.raises(RuntimeError, match='template malformado'): + send_welcome(subscriber) + + +# ── BUG-1: cover URL absoluta em article notification (CONCERNS / F-40) ────── + + +@pytest.fixture +def category(db): + """Pega a primeira Category disponível (seeded em migration 0003). + + Migration 0003 cria 5 categorias com slugify(name, allow_unicode=True). + Música vira slug 'música' (não 'musica'), por isso buscamos por nome. + """ + from apps.articles.models import Category + return Category.objects.get(name='Música') + + +@pytest.fixture +def author(db): + from django.contrib.auth import get_user_model + User = get_user_model() + return User.objects.create_user( + username='autora', + email='autora@interpop.com', + password='S3nh@Forte!2026', + ) + + +@pytest.fixture +def article_with_cover(db, category, author): + """Article com cover_image simulada (sem subir arquivo real).""" + from apps.articles.models import Article + from django.utils import timezone + from django.core.files.uploadedfile import SimpleUploadedFile + + article = Article.objects.create( + title='K-pop como Soft Power', + slug='kpop-como-soft-power', + excerpt='Análise editorial sobre Soft Power asiático.', + body='Corpo do artigo.', + author=author, + category=category, + status='published', + published_at=timezone.now(), + ) + article.cover_image = SimpleUploadedFile( + 'cover.jpg', + b'\xff\xd8\xff\xe0fake-jpeg', + content_type='image/jpeg', + ) + article.save() + return article + + +def test_article_notification_uses_absolute_cover_url( + subscriber, article_with_cover, settings +): + """Fix BUG-1: cover_image em email deve ser URL ABSOLUTA com SITE_URL. + + Antes do fix: template usava `{{ article.cover_image.url }}` direto, que + retorna `/media/articles/cover.jpg` (caminho relativo, MEDIA_URL Django). + Clientes de email NÃO resolvem caminhos relativos contra base — todos os + subscribers viam placeholder broken-image em TODA notificação. + + Após fix: ctx do service tem `cover_image_absolute_url` = SITE_URL + + .url. Template usa essa var. + """ + settings.SITE_URL = 'https://interpop.com' + + from apps.newsletter.services import _dispatch_article_notification_sync + + with patch( + 'apps.newsletter.services.EmailMultiAlternatives' + ) as mock_msg_cls: + mock_msg = mock_msg_cls.return_value + sent, failed = _dispatch_article_notification_sync( + article_with_cover, subscribers=[subscriber], + ) + + assert sent == 1 and failed == 0 + + # Captura o HTML attached para verificar a URL absoluta + attach_calls = mock_msg.attach_alternative.call_args_list + assert attach_calls, 'esperado attach_alternative chamado' + html_body = attach_calls[0][0][0] + + assert 'https://interpop.com/media/' in html_body or ( + f'https://interpop.com{article_with_cover.cover_image.url}' in html_body + ), ( + f'cover_image deve aparecer com URL absoluta no email. ' + f'HTML render: {html_body[:500]}' + ) + # E NÃO deve aparecer URL relativa começando com /media/ sem host + relative_marker = f'src="{article_with_cover.cover_image.url}"' + assert relative_marker not in html_body, ( + f'cover_image NÃO deve estar como URL relativa. Found: {relative_marker}' + ) diff --git a/backend/apps/search/tests/test_settings_production.py b/backend/apps/search/tests/test_settings_production.py index 9ae5f097..85e64be4 100644 --- a/backend/apps/search/tests/test_settings_production.py +++ b/backend/apps/search/tests/test_settings_production.py @@ -55,7 +55,11 @@ def _run_settings_load(env: dict[str, str]) -> subprocess.CompletedProcess: def _base_env() -> dict[str, str]: - """Env mínima para `production.py` carregar sem outros erros.""" + """Env mínima para `production.py` carregar sem outros erros. + + Inclui `JWT_SIGNING_KEY` distinta de `SECRET_KEY` para não disparar o + guard S-02 acidentalmente (cada arquivo testa SEU próprio guard). + """ return { **os.environ, 'DJANGO_SETTINGS_MODULE': 'config.settings.production', @@ -69,6 +73,7 @@ def _base_env() -> dict[str, str]: 'EMAIL_HOST': 'smtp.example.com', 'EMAIL_HOST_USER': 'u', 'EMAIL_HOST_PASSWORD': 'p', + 'JWT_SIGNING_KEY': 'distinct-jwt-signing-key-not-secret-key-cafefeed', } diff --git a/backend/apps/users/tests/test_settings_production.py b/backend/apps/users/tests/test_settings_production.py new file mode 100644 index 00000000..0d7c8b02 --- /dev/null +++ b/backend/apps/users/tests/test_settings_production.py @@ -0,0 +1,123 @@ +"""Production settings guards para `users-auth` (fix S-02 do CONCERNS). + +Por que existe: `base.py:150` deixa `SIGNING_KEY` cair em `SECRET_KEY` +como fallback (conveniente em dev). `production.py` precisa recusar essa +configuração — leak de `SECRET_KEY` (via dump, traceback ou dependência +comprometida) compromete sessão **e** JWT simultaneamente, permitindo +forja de access token e impersonação total. + +Réplica do padrão F2-B-03 (`apps.search.tests.test_settings_production`) +que já endureceu `SEARCH_CURSOR_HMAC_SECRET`. Mesmo vetor, mesma +mitigação. + +Estratégia de teste: importação dinâmica de `config.settings.production` +em subprocess isolado, capturando `ImproperlyConfigured`. Necessário +porque settings já está carregado pela sessão pytest atual e +`importlib.reload` quebra apps. +""" +from __future__ import annotations + +import os +import subprocess +import sys +import textwrap +from pathlib import Path + + +# Backend root = …/interpop/backend (contém manage.py + config/). +_BACKEND_ROOT = Path(__file__).resolve().parents[3] + + +def _run_settings_load(env: dict[str, str]) -> subprocess.CompletedProcess: + """Carrega `config.settings.production` num Python isolado.""" + script = textwrap.dedent( + """ + import os, django + from django.core.exceptions import ImproperlyConfigured + os.environ['DJANGO_SETTINGS_MODULE'] = 'config.settings.production' + try: + django.setup() + except ImproperlyConfigured as exc: + print('IMPROPER:' + str(exc)) + raise SystemExit(2) + except Exception as exc: # noqa: BLE001 + print('OTHER:' + repr(exc)) + raise SystemExit(3) + print('OK') + """ + ) + return subprocess.run( + [sys.executable, '-c', script], + capture_output=True, + text=True, + env=env, + cwd=str(_BACKEND_ROOT), + timeout=30, + ) + + +def _base_env() -> dict[str, str]: + """Env mínima para `production.py` carregar — inclui HMAC secret válida + (testes do JWT não devem disparar o guard do F2-B-03 acidentalmente).""" + return { + **os.environ, + 'DJANGO_SETTINGS_MODULE': 'config.settings.production', + 'SECRET_KEY': 'test-secret-key-not-real-prod', + 'ALLOWED_HOSTS': 'interpop.com', + 'CORS_ALLOWED_ORIGINS': 'https://interpop.com', + 'CSRF_TRUSTED_ORIGINS': 'https://interpop.com', + 'DB_NAME': 'interpop', + 'DB_USER': 'interpop', + 'DB_PASSWORD': 'x', + 'EMAIL_HOST': 'smtp.example.com', + 'EMAIL_HOST_USER': 'u', + 'EMAIL_HOST_PASSWORD': 'p', + 'SEARCH_CURSOR_HMAC_SECRET': 'distinct-hmac-secret-not-secret-key-deadbeef', + } + + +def test_production_settings_reject_jwt_signing_key_equal_to_secret_key(): + """JWT_SIGNING_KEY == SECRET_KEY → ImproperlyConfigured (S-02).""" + env = _base_env() + # NÃO setamos JWT_SIGNING_KEY → fallback p/ SECRET_KEY (default em base.py:150). + env.pop('JWT_SIGNING_KEY', None) + + result = _run_settings_load(env) + assert result.returncode == 2, ( + f"Esperado SystemExit(2) (ImproperlyConfigured). " + f"Got rc={result.returncode}\nstdout={result.stdout}\nstderr={result.stderr}" + ) + assert 'JWT_SIGNING_KEY' in result.stdout, ( + f'Mensagem de erro deve citar JWT_SIGNING_KEY. stdout={result.stdout!r}' + ) + assert 'S-02' in result.stdout, ( + f'Mensagem de erro deve referenciar o achado S-02 para rastreabilidade. ' + f'stdout={result.stdout!r}' + ) + + +def test_production_settings_reject_empty_jwt_signing_key(): + """JWT_SIGNING_KEY vazio → ImproperlyConfigured (cai no default=SECRET_KEY).""" + env = _base_env() + env['JWT_SIGNING_KEY'] = '' + + result = _run_settings_load(env) + assert result.returncode == 2, ( + f"Esperado ImproperlyConfigured para JWT_SIGNING_KEY vazia. " + f"rc={result.returncode}\nstdout={result.stdout}\nstderr={result.stderr}" + ) + + +def test_production_settings_accept_distinct_jwt_signing_key(): + """JWT_SIGNING_KEY distinta de SECRET_KEY → load sucesso.""" + env = _base_env() + env['JWT_SIGNING_KEY'] = ( + 'distinct-prod-jwt-signing-key-not-same-as-secret-key-cafefeed' + ) + + result = _run_settings_load(env) + assert result.returncode == 0, ( + f'Esperado rc=0 com JWT_SIGNING_KEY válida distinta de SECRET_KEY. ' + f'rc={result.returncode}\nstdout={result.stdout}\nstderr={result.stderr}' + ) + assert 'OK' in result.stdout diff --git a/backend/config/settings/base.py b/backend/config/settings/base.py index 9359f09b..4512488e 100644 --- a/backend/config/settings/base.py +++ b/backend/config/settings/base.py @@ -194,6 +194,12 @@ 'anon': '100/hour', 'user': '1000/hour', 'auth': '10/minute', + # S-07 (CONCERNS / F-20): anti-flood em POST /comments/. + # 'user'=1000/hour é alto demais para anti-flood imediato em artigo + # viral. Scope dedicado via ScopedRateThrottle aplicado SÓ em POST + # (GET list permanece ilimitado). Limite generoso para legítimo + # (1 a cada 6s), agressivo contra abuso (bot ultrapassa em segundos). + 'comments_create': '10/minute', }, } diff --git a/backend/config/settings/development.py b/backend/config/settings/development.py index 02e15878..8bb7f10a 100644 --- a/backend/config/settings/development.py +++ b/backend/config/settings/development.py @@ -70,5 +70,9 @@ 'search_anon': '10000/hour', 'search_user': '10000/hour', 'search_global': '20000/hour', + # S-07 (CONCERNS / F-20): scope dedicado anti-flood em POST /comments/. + # Em prod: 10/min (base.py). Em dev: relaxa para smoke manual sem 429 + # — TESTES de throttle sobrescrevem com settings fixture. + 'comments_create': '10000/hour', }, } diff --git a/backend/config/settings/production.py b/backend/config/settings/production.py index 4553dbe3..b55ed2bc 100644 --- a/backend/config/settings/production.py +++ b/backend/config/settings/production.py @@ -3,7 +3,7 @@ from decouple import Csv, config from .base import * # noqa: F401, F403 -from .base import SECRET_KEY, SEARCH_CURSOR_HMAC_SECRET +from .base import SECRET_KEY, SEARCH_CURSOR_HMAC_SECRET, SIMPLE_JWT from apps.audit.sentry import init_sentry DEBUG = False @@ -22,6 +22,24 @@ 'Gere com `python -c "import secrets; print(secrets.token_urlsafe(48))"`.' ) +# ── Fix S-02 (CONCERNS / RF-005) — JWT signing key hard-fail em prod ───────── +# Mesma família que F2-B-03 acima: `base.py:150` deixa `SIGNING_KEY` cair em +# `SECRET_KEY` como fallback (conveniente em dev). Em produção isso é vetor +# crítico — leak de `SECRET_KEY` compromete sessão **e** JWT simultaneamente, +# permitindo forja de access token e impersonação total (incluindo de roles +# `dev`, que é imune a ban por design). Defesa em profundidade exige duas +# chaves distintas: comprometer uma não compromete a outra. Hard-fail força +# o operador a setar `JWT_SIGNING_KEY` distinta antes de subir produção. +_JWT_SIGNING_KEY = SIMPLE_JWT.get('SIGNING_KEY') +if not _JWT_SIGNING_KEY or _JWT_SIGNING_KEY == SECRET_KEY: + raise ImproperlyConfigured( + 'JWT_SIGNING_KEY deve estar setada em produção e ser distinta de ' + 'SECRET_KEY (vetor S-02 do CONCERNS). Comprometer uma chave não ' + 'pode comprometer a outra — defesa em profundidade. Gere com ' + '`python -c "import secrets; print(secrets.token_urlsafe(50))"`.' + ) +del _JWT_SIGNING_KEY + # Sentry — no-op silencioso se SENTRY_DSN não estiver no env. # Em prod real: DSN setado, traces 10%, releases taggadas via GIT_SHA. init_sentry(environment='production')