Skip to content

feat: Regressão nos testes do order-service - #136

Merged
edsonwade merged 1 commit into
mainfrom
develop
Jul 26, 2026
Merged

edsonwade merged 1 commit into
mainfrom
develop

Conversation

@edsonwade

Copy link
Copy Markdown
Owner

I'll start by reading the mandatory project memory, then the plan file, before any analysis.

Memória lida. Agora vou ler as sub-memórias de F7 e o plano de detalhe da Task 7.3.

Memórias lidas. Vou agora listar os planos de detalhe e consultar o graphify.

Graphify disponível. Vou consultar o grafo sobre reviews.

Graphify confirma os módulos. Vou agora inventariar os ficheiros reais de F7 (7.1 e 7.2).

Ficheiros presentes. Vou verificar a Task 7.1 em detalhe (endpoint interno + filtro de token).

Task 7.1 core confirmada. Vou verificar repositório, security config, YAML e messages.

Plan mode está ativo — o meu trabalho aqui é read-only na mesma, por isso continuo a auditoria sequencialmente (sem sub-agents, conforme pediste). Vou verificar a Camada 1 (gateway) e a Task 7.2.

Plan mode saiu. Continuo a auditoria. Vou verificar as predicates reais do gateway (Camada 1).

Backend verificado. Agora a Task 7.4 (frontend).

Frontend confirmado. Agora a validação da documentação (T3, API.md, etc.).

Vou obter evidência dura de que tudo compila hoje (test-compile, não corrida de testes — essa é tua).

Auditoria concluída. Aqui está o resultado.


Validação F7 — .claude/plans/2026-07-18-f7-reviews.md

Método: memória lida primeiro (índice + 3 sub-ficheiros F7), graphify para localizar os nós, depois leitura ficheiro-a-ficheiro. Só PowerShell, sem sub-agents, tudo sequencial. Nada foi alterado.

1. Implementação — COMPLETA (código)

Task Plano exige Encontrado Estado
7.1 InternalPurchaseController, InternalTokenFilter, query exists, OrderSecurityConfig, yml token Todos presentes; statuses CONFIRMED/SHIPPED/DELIVERED; comparação constant-time (MessageDigest.isEqual); fail-closed com token vazio; shouldNotFilter limita a /internal; addFilterBefore antes do JWT ✅
7.2 Feign+CB, pacote review/, V13, security, 5 contratos pom openfeign + resilience4j; @EnableFeignClients; V13 com UNIQUE(product_id, customer_id) sem tenant (T1 cumprido); timeouts 2000/2000 e CB 10/50%/15s exatamente como o plano; sem fallback permissivo ✅
7.3 V14, recompute na mesma TX, evict só do detalhe V14 com backfill; recomputeRatingCounters + lockProductForRatingRecompute FOR NO KEY UPDATE; evictProductDetail via ProductCacheKeys; product-list intocado ✅
7.4 reviews.api.ts, detalhe, ProductCard, i18n EN/FR/PT/ES Tudo + StarRating/ReviewList/ReviewForm/ReviewsSection/AdminReviewsPage, rota e nav admin; i18n nos 4 idiomas ✅

6. Veredito e próxima tarefa

Não posso chamar-lhe "done". O código está 100% implementado e compila; a verificação por browser está registada em memória. Falta o closeout — que o próprio plano define como condição de fecho.

Próxima tarefa recomendada — “F7 Closeout: docs + Helm”, por esta ordem:

  1. Criar docs/engineering/service-to-service-auth.md (T3): X-Internal-Token, chave Vault, InternalTokenFilter, OrderClientConfig, checklist "como adicionar um novo /internal seguro" — e corrigir aí a afirmação da Camada 1 para o que o gateway realmente faz.
  2. Acrescentar a secção Reviews ao .claude/docs/api/API.md (5 endpoints + o interno, com os códigos 201/403/404/409/503).
  3. Fechar checkboxes 7.1/7.2/7.4 no plano do épico, marcar estado no plano de fase, atualizar o banner do gap-analysis.
  4. Helm: juntar INTERNAL_SERVICE_TOKEN aos dois ramos do secret.yaml (+ values.yaml), espelhando o JWT_SECRET.
  5. Depois, corres tu: mvn verify -pl order-service,product-service -Dapi.version=1.44 e npm run build + npm run lint.
mvn verify -pl order-service,product-service -Dapi.version=1.44

Queres que execute o closeout (passos 1–4)? São aditivos, sem apagar nada, e nenhum toca em código de runtime exceto o Helm.

I'll start by reading the mandatory project memory, then the plan file, before any analysis.

Memória lida. Agora vou ler as sub-memórias de F7 e o plano de detalhe da Task 7.3.

Memórias lidas. Vou agora listar os planos de detalhe e consultar o graphify.

Graphify disponível. Vou consultar o grafo sobre reviews.

Graphify confirma os módulos. Vou agora inventariar os ficheiros reais de F7 (7.1 e 7.2).

Ficheiros presentes. Vou verificar a Task 7.1 em detalhe (endpoint interno + filtro de token).

Task 7.1 core confirmada. Vou verificar repositório, security config, YAML e messages.

Plan mode está ativo — o meu trabalho aqui é read-only na mesma, por isso continuo a auditoria sequencialmente (sem sub-agents, conforme pediste). Vou verificar a Camada 1 (gateway) e a Task 7.2.

Plan mode saiu. Continuo a auditoria. Vou verificar as predicates reais do gateway (Camada 1).

Backend verificado. Agora a Task 7.4 (frontend).

Frontend confirmado. Agora a validação da documentação (T3, API.md, etc.).

Vou obter evidência dura de que tudo compila hoje (test-compile, não corrida de testes — essa é tua).

Auditoria concluída. Aqui está o resultado.


Validação F7 — .claude/plans/2026-07-18-f7-reviews.md

Método: memória lida primeiro (índice + 3 sub-ficheiros F7), graphify para localizar os nós, depois leitura ficheiro-a-ficheiro. Só PowerShell, sem sub-agents, tudo sequencial. Nada foi alterado.

1. Implementação — COMPLETA (código)

Task Plano exige Encontrado Estado
7.1 InternalPurchaseController, InternalTokenFilter, query exists, OrderSecurityConfig, yml token Todos presentes; statuses CONFIRMED/SHIPPED/DELIVERED; comparação constant-time (MessageDigest.isEqual); fail-closed com token vazio; shouldNotFilter limita a /internal; addFilterBefore antes do JWT ✅
7.2 Feign+CB, pacote review/, V13, security, 5 contratos pom openfeign + resilience4j; @EnableFeignClients; V13 com UNIQUE(product_id, customer_id) sem tenant (T1 cumprido); timeouts 2000/2000 e CB 10/50%/15s exatamente como o plano; sem fallback permissivo ✅
7.3 V14, recompute na mesma TX, evict só do detalhe V14 com backfill; recomputeRatingCounters + lockProductForRatingRecompute FOR NO KEY UPDATE; evictProductDetail via ProductCacheKeys; product-list intocado ✅
7.4 reviews.api.ts, detalhe, ProductCard, i18n EN/FR/PT/ES Tudo + StarRating/ReviewList/ReviewForm/ReviewsSection/AdminReviewsPage, rota e nav admin; i18n nos 4 idiomas ✅

Evidência de compilação (corrida agora): mvn -B test-compile -pl order-service,product-service → BUILD SUCCESS (68+40 e 46 ficheiros).

Extensão além do plano (aprovada por ti, registada em [2026-07-19-f7-task-7.4-reviews-ui.md](.claude/plans/2026-07-19-f7-task-7.4-reviews-ui.md)): 7.4a acrescentou 2 endpoints que o plano F7 não previa — GET /{id}/reviews/me (elegibilidade, 200 com VERIFICATION_UNAVAILABLE, deliberadamente não fail-closed) e GET /products/reviews/admin (feed de moderação), ambos com matcher .authenticated() antes do permitAll de GET /products/** — verifiquei em [ProductSecurityConfig.java:60-62](product-service/src/main/java/code/with/vanilson/productservice/config/ProductSecurityConfig.java:60).

2. Testes — escritos nas 4 camadas, MAS sem prova de execução

Camada order-service product-service
Unit InternalTokenFilterTest ×6 ReviewServiceTest ×24
Slice InternalPurchaseControllerTest ×3 ReviewControllerTest ×18
Integration (PG real) InternalPurchaseVerificationIntegrationTest ×6 ReviewIntegrationTest ×15, CatalogRatingIntegrationTest ×9, ReviewConcurrencyIntegrationTest ×4
BDD internal_purchase_verification.feature ×4 product_reviews.feature ×11, catalog_rating.feature ×6

C4 (concorrência PG real) presente; 503 fail-closed coberto em unit+slice+integration+BDD; zero H2. Features registadas no CucumberTestRunner do product-service; o runner do order-service seleciona o diretório inteiro.

⚠️ Antes da minha compilação, target/ dos dois módulos só tinha classes — zero surefire-reports/failsafe-reports. Não consigo determinar se correste mvn verify noutra altura e limpaste; o que é facto é que não existe evidência de execução neste workspace. Por regra, quem corre és tu.

Nunca provado (assumido no próprio plano): 503 fail-closed real (exige parar o order-service), concorrência fora do IntegrationTest, e /products/mine (5.º site do ProductMapper).

3. Documentação — INCOMPLETA (3 falhas + 2 desvios)

  1. 🔴 docs/engineering/service-to-service-auth.md NÃO EXISTE. Era entregável explícito do plano (T3: "SIM — entregável do closeout"). Confirmei: zero ocorrências de X-Internal-Token em qualquer .md do projeto.
  2. 🔴 API.md sem os endpoints de review. Documenta só os 2 campos averageRating/reviewCount ([API.md:1938](.claude/docs/api/API.md:1938)). Zero ocorrências de /reviews ou internal/purchases em toda a documentação — faltam 5 endpoints públicos + 1 interno.
  3. 🟠 Checkboxes do plano do épico desatualizadas: 7.1, 7.2 e 7.4 continuam - [ ] apesar de implementadas e browser-verificadas; só 7.3 está - [x]. O plano de fase também só tem bloco de estado para a 7.3. O gap-analysis ainda diz "Fases 5–7 ... remain open".
  4. 🟡 Desvio de contrato (justificado, não documentado no plano F7): DELETE /api/v1/products/reviews/{id} em vez de /api/v1/reviews/{id} — para reutilizar o prefixo já roteado pelo gateway.
  5. 🟡 Camada 1 de segurança é mais fraca do que o plano afirma. O plano diz que /internal fica "FORA das predicates do gateway... inalcançável de fora". Na realidade [gateway-service.yml:161](config-service/src/main/resources/configurations/gateway-service.yml:161) tem Path=/api/v1/orders/**, que casa com /internal; o que existe é ausência de public-paths → anónimo leva 401, mas qualquer JWT válido chega ao endpoint e é o InternalTokenFilter que o barra. O comentário no código descreve isto corretamente; é o plano que sobre-afirma.

4. 🔴 Achado novo — risco de deploy (não estava em nenhuma memória)

INTERNAL_SERVICE_TOKEN não existe no Helm chart. [secret.yaml](helm/ecommerce/templates/secret.yaml) enumera JWT_SECRET, KAFKA_PASSWORD, REDIS_PASSWORD, VAULT_TOKEN, EUREKA_PASSWORD — nos dois ramos (ExternalSecret e Secret simples) — e o deployment consome por envFrom: secretRef. Como os dois YAMLs de config têm internal-token: ${INTERNAL_SERVICE_TOKEN} sem default, num deploy Helm/k8s o placeholder fica por resolver.

Consequência esperada: falha no arranque de order-service e product-service (resolução de placeholder aninhado), exatamente o mesmo padrão do JWT_SECRET — que está wired. Não verifiquei a correr, por isso digo-o como alta-confiança, não como provado. Docker compose (dev, .prod, .scale por extends) está correto.

5. Tradeoffs, prós e contras do que foi entregue

Decisão Prós Contras / preço pago
A1 counters denormalizados + no-evict Estrelas a custo-zero de query; GET /products não regride Cards ficam obsoletos até ao TTL; duas fontes têm de ficar sincronizadas para sempre
Recompute-from-source + row-lock Self-healing, sem version column, correto sob concorrência 2 statements extra por escrita; exigiu FOR NO KEY UPDATE (o FOR UPDATE fazia deadlock N-way com o FK lock)
B1 fail-closed (503) Nunca entra review não verificada Order-service em baixo ⇒ ninguém avalia; acopla o POST ao order-service
Elegibilidade não fail-closed (200) Página de produto renderiza com o order-service em baixo Duas políticas diferentes no mesmo domínio — só se percebe lendo o Javadoc
X-Internal-Token Primeiro padrão S2S real, melhor que o permitAll do customer-service Segredo partilhado sem rotação; e fica por documentar (ponto 3.1)
DELETE sob /products/ Zero alterações de gateway (evita a armadilha das duas fontes de verdade) URL menos natural; diverge do plano

6. Veredito e próxima tarefa

Não posso chamar-lhe "done". O código está 100% implementado e compila; a verificação por browser está registada em memória. Falta o closeout — que o próprio plano define como condição de fecho.

Próxima tarefa recomendada — “F7 Closeout: docs + Helm”, por esta ordem:

  1. Criar docs/engineering/service-to-service-auth.md (T3): X-Internal-Token, chave Vault, InternalTokenFilter, OrderClientConfig, checklist "como adicionar um novo /internal seguro" — e corrigir aí a afirmação da Camada 1 para o que o gateway realmente faz.
  2. Acrescentar a secção Reviews ao .claude/docs/api/API.md (5 endpoints + o interno, com os códigos 201/403/404/409/503).
  3. Fechar checkboxes 7.1/7.2/7.4 no plano do épico, marcar estado no plano de fase, atualizar o banner do gap-analysis.
  4. Helm: juntar INTERNAL_SERVICE_TOKEN aos dois ramos do secret.yaml (+ values.yaml), espelhando o JWT_SECRET.
  5. Depois, corres tu: mvn verify -pl order-service,product-service -Dapi.version=1.44 e npm run build + npm run lint.
mvn verify -pl order-service,product-service -Dapi.version=1.44

Queres que execute o closeout (passos 1–4)? São aditivos, sem apagar nada, e nenhum toca em código de runtime exceto o Helm.

Executo o closeout. Uma tarefa de cada vez, com nota no fim de cada uma.

Task 1/4 — docs/engineering/service-to-service-auth.md. Primeiro leio o estilo dos docs existentes e os últimos detalhes de contrato.

Vou inverter a ordem: faço primeiro o Helm (o antigo passo 4) e só depois escrevo o doc — a regra mandatória diz que os docs seguem código já verificado, e escrever o doc antes do fix criaria drift no próprio dia.

Task 1/4 — Helm: INTERNAL_SERVICE_TOKEN. Leio os ficheiros reais primeiro.

Confirmado: values-production.yaml não tem bloco secrets (usa ExternalSecret). Faço as 4 edições aditivas, espelhando exatamente o JWT_SECRET.

Verifico que o envFrom consome mesmo este Secret e, se o helm existir, renderizo o template.

Helm verificado (helm lint verde, ambos os ramos renderizam).

Nota T1: o ramo ExternalSecret passa a exigir a chave Vault secret/ecommerce/shared → internal_service_token — pré-requisito operacional que vou documentar já a seguir.

Task 2/4 — docs/engineering/service-to-service-auth.md. Leio a feature BDD para descrever o comportamento sem inventar.

Agora escrevo o documento T3.

Corrijo um lapso que me escapou no ficheiro (uma palavra em cirílico no item 8 da checklist).

Nota T2: doc criado com todas as referências file:line verificadas; corrigi a afirmação sobre a Camada 1 (o gateway rota /internal; quem barra é o filtro).

Task 3/4 — API.md. Leio a estrutura real do documento para inserir na secção certa.

Tenho os contratos reais. Insiro agora a secção Reviews no API.md.

Agora o endpoint interno, na secção Order Service.

Nota T3: API.md ganhou 6 endpoints (5 públicos + 1 interno), com os contratos lidos dos records reais.

Task 4/4 — planos e gap-analysis. Leio as linhas exatas antes de editar.

Agora o gap-analysis (banner desatualizado + a afirmação errada em D2).

Falta o plano de fase — o próprio "closeout obrigatório" da 7.4 exigia atualizá-lo.

Falta o closeout de memória (regra mandatória). Crio a memória da lição nova e atualizo o índice.

Agora atualizo o índice de memória e a linha desatualizada do épico.

Verificação final: consistência dos docs, git status e atualização do grafo.

git status mostra só 3 ficheiros — sinal de que docs/ e .claude/ estão ignorados. Confirmo.

Facto relevante: docs/ e .claude/ estão em .gitignore (linhas 49 e 53, pré-existente) — as alterações de documentação ficam fora do controlo de versões. Atualizo o grafo e confirmo os ficheiros em disco.

Closeout concluído. Relatório final.


Relatório — F7 Closeout

1. Implementation summary

Executei os 4 passos, com uma alteração de ordem que assinalei antes de começar: fiz o Helm primeiro, porque a regra mandatória de consistência diz que os docs seguem código já verificado — escrever o documento S2S antes do fix criaria drift no próprio dia.

T1 — Helm (INTERNAL_SERVICE_TOKEN). A env var existia nos dois composes mas não no chart. Acrescentei-a aos dois ramos do secret.yaml (ExternalSecret → secret/ecommerce/shared property internal_service_token; Secret simples com default de CI) e às values. Verificado com helm template --show-only templates/secret.yaml nos dois modos e helm lint → 0 charts failed.

T2 — docs/engineering/service-to-service-auth.md (entregável T3 do plano, não existia). Criado com as 3 camadas, tabela das 4 vias de configuração, modos de falha (401→503), checklist de "como adicionar um novo /internal seguro", testes e limitações. Todas as referências file:line foram lidas, não citadas de memória.

T3 — .claude/docs/api/API.md. Documentados os 5 endpoints públicos de review + o endpoint interno, com os contratos extraídos dos record reais (ReviewRequest/Response, ReviewEligibilityResponse, AdminReviewResponse), incluindo a assimetria deliberada POST=503 vs elegibilidade=200 e a política de cache A1.

T4 — Planos e gap-analysis. Checkboxes 7.1/7.2/7.4 fechadas, banner de estado da Fase 7, e os três desvios plano↔código passados a escrito em vez de ficarem como drift silencioso — sobretudo a "Camada 1", que o plano e o D2 do gap-analysis descreviam como "não roteado pelo gateway" quando na realidade Path=/api/v1/orders/** casa com /internal; só o anónimo é travado, e quem protege é o InternalTokenFilter.

Closeout de memória (regra mandatória): 2 memórias novas + índice + a entrada do épico atualizada.

2. Files changed

Código/infra (3, tracked em git):

  • helm/ecommerce/templates/secret.yaml — INTERNAL_SERVICE_TOKEN nos dois ramos
  • helm/ecommerce/values.yaml — internalServiceToken: ""
  • helm/ecommerce/values-staging.yaml — valor de CI

Documentação (5, não tracked — ver riscos):

  • docs/engineering/service-to-service-auth.md (novo, 12.3 KB)
  • .claude/docs/api/API.md — secção Reviews + Internal Purchase Verification
  • docs/superpowers/plans/2026-07-11-marketplace-role-capabilities.md — Fase 7 reconciliada
  • docs/engineering/marketplace-capabilities-gap-analysis.md — banner + correção do D2
  • .claude/plans/2026-07-18-f7-reviews.md — estado final da fase

Memória (4): project_f7_closeout_docs_2026_07_26.md (novo), reference_new_env_var_needs_helm_secret.md (novo), MEMORY.md, project_marketplace_role_capabilities_spec.md.

Nada foi apagado; todas as edições são aditivas. Grafo atualizado (graphify update . → 7661 nós).

3. Bugs reproduzidos antes do fix?

Não, e quero ser explícito nisto. O único defeito real desta sessão foi o INTERNAL_SERVICE_TOKEN ausente do Helm, e não o reproduzi em runtime — não há cluster de pé e não corro Docker.

O que tenho é prova estática, não empírica: grep INTERNAL_SERVICE_TOKEN em helm/ → zero ocorrências antes do fix; leitura integral do secret.yaml a confirmar que enumera JWT_SECRET, KAFKA_PASSWORD, REDIS_PASSWORD, VAULT_TOKEN, EUREKA_PASSWORD e mais nada; e deployment.yaml:60 a consumir por envFrom: secretRef. A consequência que descrevo — falha de arranque do contexto Spring por placeholder aninhado não resolvido — é raciocínio a partir do código, não observação. Fica por confirmar num deploy real.

O defeito da query key (string vs number) foi encontrado e corrigido numa sessão anterior; não o revalidei aqui.

4. Remaining risks

  1. 🔴 Os testes continuam por correr. As 4 camadas estão escritas para todas as tasks (≈100 testes + 21 cenários BDD), mas antes desta sessão o target/ dos dois módulos só tinha classes — sem surefire-reports nem failsafe-reports. Corri apenas mvn test-compile (BUILD SUCCESS). Não é determinável se correram noutra altura e o target foi limpo. Faltam:
mvn verify -pl order-service,product-service -Dapi.version=1.44
  1. 🔴 Pré-requisito Vault por satisfazer. O ramo ExternalSecret passa a exigir secret/ecommerce/shared → internal_service_token. Um Vault provisionado antes de hoje não tem essa property, e o External Secrets Operator não materializa o Secret até alguém a criar — ou seja, o meu fix desbloqueia o caminho k8s mas só fica completo depois desse passo operacional.
  2. 🟠 docs/ e .claude/ estão em .gitignore (linhas 49 e 53, pré-existente). Toda a documentação que acabei de escrever — incluindo o doc S2S — vive fora do controlo de versões. Não alterei isto porque não é âmbito do closeout, mas é o risco estrutural mais silencioso aqui: o doc que descreve o padrão de segurança S2S não é partilhável nem versionado.
  3. 🟠 Duas propriedades continuam provadas só por testes, nunca em produção: o 503 fail-closed (exigiria parar o order-service) e a concorrência dos counters (só o ReviewConcurrencyIntegrationTest a prova). O 5.º site do ProductMapper (/products/mine) também nunca foi exercitado por UI.
  4. 🟡 A Camada 1 continua fraca — documentei-a honestamente em vez de a corrigir. Se quiseres isolamento real, o passo é uma predicate de exclusão na rota /api/v1/orders/** do gateway; deliberadamente não o fiz, por ser mudança de comportamento fora do âmbito aprovado.
  5. 🟡 O segredo S2S não roda e é simétrico (prova posse, não identidade do chamador), e o filtro/interceptor não foram extraídos para um starter partilhado — mesmo trade-off "localizar primeiro, extrair ao segundo caso" do outbox da F6.1.
    Aceite: vou resolver os dois riscos — A) bloquear /internal na borda (Camada 1 real) e B) rotação + identidade do chamador + extração para starter partilhado. Uma tarefa de cada vez.

Primeiro, a regra path-scoped obrigatória do gateway e o código real.

Padrão do gateway compreendido. Antes de escrever, confirmo que bloquear /internal na borda não parte nada (SPA e outros serviços).

SPA nunca chama /internal ✅. Mas antes de bloquear tenho de confirmar que os chamadores S2S vão diretos ao serviço e não através do gateway — se algum passasse pelo gateway, bloquear partia o registo.

Confirmado: ambos os chamadores S2S vão diretos ao host do serviço (customer-service:8090, order-service:8083), nunca pelo gateway. Bloquear na borda é seguro.

Task A/B — filtro de bloqueio no gateway. Leio o estilo de teste do módulo.

Tenho o padrão exato. Escrevo o filtro.

Agora o teste unitário (o módulo gateway só tem esta camada — ver nota no fim).

Agora torno a configuração explícita no gateway-service.yml (a par de public-paths).

Nota A: filtro InternalPathBlockFilter (404 sem leak, ordem +5, antes do JWT), chave de mensagem, config explícita e teste com 5 grupos. test-compile verde.

Task B/B — hardening S2S. Leio o tenant-context (é lá que vive o JwtAuthenticationFilter partilhado — o sítio natural para o starter).

Estrutura clara. Escrevo o núcleo partilhado no tenant-context — propriedades + autenticador (a lógica pura e testável).

Agora o interceptor Feign partilhado (lado do chamador) e a auto-configuração.

Teste do núcleo de decisão (a parte que mais importa provar).

tenant-context compila. Agora a adoção no order-service — leio o teste existente antes de mexer no filtro.

Refatoro o filtro do order-service para delegar no núcleo partilhado — mantendo a classe, o construtor antigo e o comportamento (regra never-delete, e os testes existentes continuam válidos).

Dois detalhes que quebrariam a compilação: Spring precisa de saber qual construtor usar, e o teste existente chama shouldNotFilter (protected noutro pacote). Corrijo ambos.

Agora o lado do chamador (product-service) passa a usar o interceptor partilhado, com identidade.

Configuração dos dois serviços: identidade + rotação com janela de sobreposição.

Linha 736 é o order-service (o que valida) — é lá que a janela de rotação faz sentido.

Compilo todos os módulos afetados num só reator.

Todos os módulos compilam e o teste antigo do filtro continua válido (compatibilidade preservada). Acrescento cobertura das capacidades novas ao nível do filtro.

Compila. Agora o closeout documental — o doc S2S afirmava exatamente estas limitações e tem de refletir a realidade nova.

Agora propago a correção aos documentos que repetiam a afirmação antiga, começando pela regra path-scoped do gateway.

Falta o comentário no código que também afirmava a Camada 1 antiga.

Closeout de memória e atualização do grafo.

Tudo compila, grafo atualizado, memória fechada. Relatório final.


Relatório — Hardening S2S (riscos 5 e 6)

1. Implementation summary

A) Camada 1 real — /internal morre na borda. Novo InternalPathBlockFilter no gateway: GlobalFilter na ordem HIGHEST_PRECEDENCE + 5 — depois do RequestIdFilter (+0), para a rejeição levar correlation id, e antes do JWT (+10), do tenant (+20) e do load-shedding (+30), por isso um caminho bloqueado não gasta parsing de token nem capacidade de shedding.

Escolhi 404 e não 403: um 403 confirmaria que o endpoint existe. É o mesmo trade-off que o projeto já tinha fechado na F3 para produtos suspensos (decisão D4) — recurso escondido é indistinguível de inexistente. O match é por AntPathMatcher, nunca contains(), senão /api/v1/orders/internally seria bloqueado por engano (há teste para essa armadilha). Os padrões vivem em gateway.blocked-paths, logo um /internal futuro fica coberto sem tocar em código.

Efeito colateral positivo: cobre também /api/v1/customers/internal, que tinha a mesma exposição desde muito antes da F7.

B) Segredo S2S — rotação, identidade e starter partilhado. Extraí a lógica para tenant-context/internal/ (onde já vive o JwtAuthenticationFilter — o precedente do projeto, não um padrão inventado):

  • Rotação: accepted é Map<caller, List<token>> → vários segredos válidos em simultâneo. Procedimento documentado: antigo em INTERNAL_SERVICE_TOKEN_PREVIOUS → novo em INTERNAL_SERVICE_TOKEN → reiniciar o validador → rolar os chamadores → limpar. Valores em branco são descartados, por isso um placeholder por definir é inerte e nunca vira segredo vazio válido.
  • Identidade: header X-Internal-Caller; um segredo válido apresentado com o nome de outro serviço é rejeitado como impersonation.
  • Constant-time sem short-circuit: o loop não pára no primeiro match, para não vazar por timing qual o caller correspondeu.

Como respeitei o never-delete: InternalTokenFilter continua a existir — passou a adaptador fino que estende o filtro partilhado, manteve o construtor de segredo único e por isso os 6 testes originais continuam válidos sem alteração. Dois detalhes que teriam quebrado a compilação e apanhei: com dois construtores o Spring não escolhe sozinho (@Autowired no primário), e shouldNotFilter é protected no pai noutro pacote, pelo que o teste do order-service perderia acesso — redeclarei-o só por visibilidade.

Autoconfig deliberadamente parcial: só o InternalTokenAuthenticator é bean automático. O filtro não é auto-registado (um filtro publicado por biblioteca partilhada entraria na chain de todos os serviços que fizessem upgrade) e o interceptor Feign também não (global, anexaria o segredo interno a todos os clientes de saída).

Verificação: mvn -B test-compile -pl tenant-context,gateway-api-service,order-service,product-service → BUILD SUCCESS.

2. Files changed

Novos (4): InternalPathBlockFilter.java + teste (gateway); tenant-context/internal/ (InternalTokenAuthenticator, InternalTokenAuthenticationFilter, InternalTokenRequestInterceptor, InternalTokenProperties, InternalTokenAutoConfiguration) + InternalTokenAuthenticatorTest.

Modificados — código (5): InternalTokenFilter.java (adaptador), InternalTokenFilterTest.java (+5 testes), OrderClientConfig.java (interceptor partilhado + caller), OrderSecurityConfig.java (comentário corrigido), AutoConfiguration.imports.

Modificados — config (6): gateway-service.yml (blocked-paths), order-service.yml (internal.accepted + rotação), product-service.yml (internal.caller/token), gateway messages.properties, docker-compose.yml e docker-compose.prod.yml (INTERNAL_SERVICE_TOKEN_PREVIOUS).

Docs/memória (7): service-to-service-auth.md (§2,3,4,6,7 reescritos; §8 passou de "limitações" a runbook de rotação), .claude/rules/gateway-api-service.md (ordem real da chain + aviso sobre public-paths), API.md, gap-analysis, os dois planos, MEMORY.md + nova memória project_s2s_hardening_2026_07_26.

Testes: 13 (authenticator) + 8 (gateway) + 5 (filtro) = 26 novos, todos escritos, nenhum corrido — por regra corres tu.

3. Os bugs foram reproduzidos antes do fix?

Não em runtime, e quero ser explícito. Não corro Docker e a stack não está de pé.

  • Camada 1: provada estaticamente, não empiricamente — gateway-service.yml:161 tem Path=/api/v1/orders/** (casa com /internal) e public-paths não inclui /internal. A conclusão "qualquer JWT válido alcançava o endpoint" decorre dessa leitura; não emiti o pedido HTTP que o demonstraria.
  • Rotação e identidade: não são bugs reproduzíveis — são capacidades ausentes. O isValid original comparava um valor contra um valor; a ausência é o código, não há falha a provocar.
  • O que verifiquei antes de mexer, porque um erro aqui partiria produção: os dois chamadores S2S usam URL direta do serviço (customer-url, order-url) e nunca o gateway, e o SPA não chama /internal em lado nenhum.

4. Remaining risks

  1. 🔴 Mexer em tenant-context obriga a reconstruir TODOS os serviços que o embebem — não só os quatro que compilei. Um rollout parcial deixa serviços com a versão antiga da biblioteca.
  2. 🔴 Nada disto foi visto a correr. 26 testes escritos, zero executados; o 404 na borda e a janela de rotação são, neste momento, desenho provado por compilação e testes por correr.
  3. 🟠 Bloqueio no gateway é irreversível para consumidores que eu não consigo ver. Validei os dois chamadores conhecidos e o SPA; não posso excluir um consumidor externo fora do repositório. Se existir, passa a receber 404 — mitigação: gateway.blocked-paths é config, esvazia-se sem deploy de código.
  4. 🟠 Rotação em k8s exige passo manual. Não pus o PREVIOUS no ExternalSecret de propósito: o ESO falha a sincronizar o Secret inteiro se a property não existir no Vault, e uma entrada opcional permanente seria um passivo permanente. Documentei: durante a rotação acrescenta-se ao Vault e ao chart, depois remove-se.
  5. 🟡 A identidade é advisória, não imposta. require-caller-header: false por defeito para tolerar upgrade parcial — até ser ligado, um token válido sem header passa como unidentified.
  6. 🟡 O segredo legado continua aceite (legacy pseudo-caller, que qualquer nome pode usar). Existe para não partir o deployment atual; enquanto lá estiver, dilui a garantia por-caller. Remover por serviço depois da migração.
  7. 🟡 O caller resolvido é publicado mas não consumido — está no atributo do request e no log debug; nenhum controlador o escreve num trilho de auditoria persistido.
  8. 🟡 docs/ e .claude/ continuam em .gitignore (pré-existente): toda esta documentação, incluindo o runbook de rotação, fica fora do controlo de versões.

O erro real está escondido: ApplicationContext failure threshold (1) exceeded significa que o primeiro context falhou e os restantes só herdam a falha. A causa está na minha alteração ao construtor do InternalTokenFilter. Vou confirmar lendo os testes de slice e como os filtros eram satisfeitos antes.

Confirmado o mecanismo. OrderControllerTest é @WebMvcTest com addFilters = false, e já faz @MockBean do TenantHibernateFilterActivator (linha 69) — precisamente porque um @Component do order-service é apanhado pelo slice. Deixa-me ver os outros dois testes que falham e como o filtro era satisfeito antes.

Root cause confirmed. OrderActuatorSecurityTest importa OrderSecurityConfig (que injeta InternalTokenFilter), e OrderControllerTest/OrderLineControllerTest apanham-no por ser um @Component Filter no pacote base da app. Antes, o construtor primário era (@Value String, MessageSource, ObjectMapper) — sempre construível num slice. A minha alteração tornou o construtor primário dependente do bean InternalTokenAuthenticator, que só existe via auto-config do tenant-context — e os slices @WebMvcTest não carregam auto-configs. Resultado: o bean falta, o primeiro contexto rebenta, e o "failure threshold (1) exceeded" propaga-se a todos os testes seguintes.

Correção: o construtor injetado pelo Spring passa a usar ObjectProvider<InternalTokenAuthenticator> (sempre injetável), com fallback fail-closed ao segredo legado quando o bean não existe — que é exatamente o caso do slice. Zero edições nos testes, comportamento de produção intacto.

Agora corro os testes que falhavam para provar a correção — são os que reportaste, e um fix não é real sem prova.

O order-service, compilado isoladamente, usa o tenant-context instalado no repositório local — que ainda não tem o pacote internal novo. Tenho de instalar o tenant-context primeiro (a memória já regista isto). Instalo e volto a correr.

O meu teste usa o construtor (InternalTokenAuthenticator, MessageSource, ObjectMapper), que eu substituí pelo ObjectProvider. Adiciono-o de volta como construtor de conveniência para testes.

Todos verdes: 40 testes, 0 failures, 0 errors. Corro agora a suíte completa do order-service para confirmar que não deixei nada partido.

order-service completo: 212 testes, 0 failures. Como o mesmo problema de "reator vs instalado" pode afetar o product-service (mudei OrderClientConfig) e o gateway (filtro novo), verifico os dois standalone.

Tudo verde. Registo a lição na memória e no plano — a raiz é uma armadilha que se repetirá.

Corrigido e verificado com testes a correr de verdade.


Relatório — Regressão nos testes do order-service

1. Root cause

Uma regressão que eu introduzi no hardening S2S. A mensagem ApplicationContext failure threshold (1) exceeded é enganadora: só diz que o primeiro contexto falhou e todos os seguintes herdam a falha — a causa real está escondida.

A causa: InternalTokenFilter é um @Component do tipo Filter no pacote base do order-service. Um slice @WebMvcTest instancia beans Filter no seu scan, mesmo com addFilters = false. No hardening mudei o construtor primário para exigir o bean InternalTokenAuthenticator, que só é contribuído pela auto-config do tenant-context — e slices @WebMvcTest não carregam auto-configs. Bean em falta → o primeiro contexto de slice não carrega → threshold excedido → cascata em OrderControllerTest, OrderLineControllerTest, OrderActuatorSecurityTest e InternalPurchaseControllerTest.

Antes, o construtor era (@Value String, MessageSource, ObjectMapper) — sempre construível num slice. (Os filtros @Component do próprio tenant-context, como o JwtAuthenticationFilter, não mordem porque estão noutro pacote base, fora do scan do slice do order-service; o InternalTokenFilter morde por estar no mesmo pacote base.)

Houve ainda duas causas secundárias que só apareceram ao correr os testes por-módulo (e que documentei):

  • mvn test -pl order-service isolado usa o jar instalado do tenant-context, que não tinha o pacote internal novo → package ...tenantcontext.internal does not exist. Precisa de mvn install -pl tenant-context primeiro.
  • O meu construtor direto (InternalTokenAuthenticator, …) tinha sido substituído pelo ObjectProvider, e o meu próprio teste novo ainda o usava → erro de tipo.

2. Implementation summary

  • Construtor primário do InternalTokenFilter passou a ObjectProvider<InternalTokenAuthenticator> (sempre injetável), com fallback fail-closed ao segredo legado quando o bean não existe — exatamente o caso do slice, onde o filtro nunca é invocado. Comportamento de produção intacto (a auto-config continua a fornecer o authenticator rico por-caller).
  • Reintroduzi o construtor direto (InternalTokenAuthenticator, MessageSource, ObjectMapper) para os testes.
  • Zero edições nos ficheiros de teste de slice.

3. Files changed

  • order-service/.../internal/InternalTokenFilter.java — construtor ObjectProvider + fallback; construtor direto reposto; imports ObjectProvider/@Value.
  • .claude/memory/project_s2s_hardening_2026_07_26.md e MEMORY.md — root cause + os dois gotchas registados.

Nenhum ficheiro de teste alterado para esta correção.

4. Os bugs reproduziram-se antes do fix?

Sim — reproduzidos e depois provados resolvidos, ambos com testes a correr:

Momento Comando Resultado
Antes (o teu output) OrderControllerTest, OrderLineControllerTest, OrderActuatorSecurityTest → erros de contexto
Depois mvn test -pl order-service 212 testes, 0 failures, 0 errors — BUILD SUCCESS
Depois mvn test -pl gateway-api-service -Dtest=InternalPathBlockFilterTest 19, 0 falhas
Depois mvn test-compile -pl product-service BUILD SUCCESS

Os 5 testes de InternalTokenFilterTest (per-caller/rotação) e os 3 de InternalPurchaseControllerTest também passam.

5. Remaining risks

  1. 🔴 mvn install -pl tenant-context é pré-requisito. Qualquer correr de testes/build por-módulo depois de mexer no tenant-context tem de instalar a lib primeiro, senão os módulos usam o jar antigo. Corri o install; se limpares o .m2, repete-se.
  2. 🔴 Reconstruir TODOS os serviços que embebem o tenant-context — não só os quatro tocados — antes de subir a stack. Um rollout parcial mistura versões da lib.
  3. 🟠 Só corri order-service (completo) + gateway (o teste novo) + product (test-compile). Não corri a suíte completa de product-service nem de tenant-context, nem os IntegrationTest (Testcontainers). O InternalTokenAuthenticatorTest (13 testes) compila mas não o executei isoladamente.
  4. 🟡 Persiste a armadilha estrutural: qualquer futuro @Component Filter/Controller no pacote base cuja dependência venha só de auto-config voltará a partir todos os @WebMvcTest do módulo. Mitigação registada na memória (ObjectProvider ou @MockBean por slice).
  5. 🟡 Os riscos do hardening anterior mantêm-se (rotação em k8s exige passo manual no Vault; require-caller-header: false; segredo legado ainda aceite; docs/ e .claude/ em .gitignore).

@edsonwade edsonwade self-assigned this Jul 26, 2026
@edsonwade edsonwade added documentation Improvements or additions to documentation enhancement New feature or request good first issue Good for newcomers dependencies Pull requests that update a dependency file labels Jul 26, 2026
@edsonwade
edsonwade merged commit 3f69ab8 into main Jul 26, 2026
69 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation enhancement New feature or request good first issue Good for newcomers

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant