Caderno operacional — caçar uma falha e provar o reparo
Este laboratório reproduz uma autorização incorreta em um serviço de ordens de serviço: um supervisor do shopping A consegue aprovar uma OS do shopping B. Primeiro, o mesmo teste de regressão falha contra a versão defeituosa. Um trace pequeno compara expected e observed em cada fronteira e localiza a primeira divergência na policy, sem alterar cinco componentes ao mesmo tempo. A correção mínima acrescenta tenant e estado à regra; uma suíte unitária, de integração, contrato e jornada prova comportamentos complementares. Um repositório em memória e um audit spy controlam infraestrutura sem mockar a autorização. Mutações reintroduzem os defeitos e devem ser mortas. O relatório final registra ambiente, hipótese, vermelho, verde, limites e comandos reproduzíveis.
Caderno operacional — caçar uma falha e provar o reparo
Objetivo e hipótese
Você investigará um defeito no fluxo de aprovação de ordens de serviço. O comportamento esperado é: somente supervisor do mesmo tenant pode aprovar uma OS PENDING. O observado é: qualquer supervisor consegue aprovar qualquer tenant.
Hipótese H1: a API perde a identidade do tenant.
Hipótese H2: a API entrega identidade e OS corretas, mas a policy verifica apenas o papel.
Hipótese H3: a policy nega corretamente, porém o repositório grava antes da decisão.
Experimento discriminante: registrar os valores nas fronteiras API → serviço, serviço → policy e policy → repositório. A primeira divergência entre esperado e observado selecionará a hipótese.
Duração: 90 a 150 minutos.
Ambiente: Python 3.11 ou superior, somente biblioteca padrão.
Rede e dados: nenhuma rede; tenants e OS são sintéticos.
Condição de parada: red falha pelo motivo esperado; prove passa; todos os mutantes relevantes são mortos; o relatório preserva limitações.
Matriz risco → teste
Antes do código, fixe a prova:
| Risco | Teste principal | Observação |
|---|---|---|
| policy esquece tenant ou estado | unitário | decisão booleana nas bordas |
| API não chama policy ou grava na negação | integração | 403, estado intacto, auditoria vazia |
| cliente depende do formato de erro | contrato | status e chaves do payload |
| jornada nominal está desconectada | ponta a ponta didático | request → serviço → repo → auditoria |
| suíte não sente remoção do controle | mutação | mutante reintroduzido deve falhar |
O teste de integração é indispensável para provar que autorização não existe apenas na tela. Um snapshot do botão escondido pode passar enquanto uma chamada direta modifica o banco.
Código do laboratório
Crie uma pasta vazia e salve o bloco como debug_lab.py.
from __future__ import annotations
import json
import platform
import sys
from dataclasses import asdict, dataclass, replace
from pathlib import Path
from typing import Callable
@dataclass(frozen=True)
class User:
user_id: str
role: str
tenant: str
@dataclass(frozen=True)
class WorkOrder:
order_id: str
tenant: str
status: str
version: int
class FakeOrderRepository:
"""Test double da persistência; não decide autorização."""
def __init__(self, order: WorkOrder) -> None:
self._orders = {order.order_id: order}
def get(self, order_id: str) -> WorkOrder:
return self._orders[order_id]
def save(self, order: WorkOrder) -> None:
self._orders[order.order_id] = order
class AuditSpy:
"""Registra efeitos para o teste observar; não devolve decisão pronta."""
def __init__(self) -> None:
self.events: list[dict[str, str]] = []
def record(self, event: dict[str, str]) -> None:
self.events.append(event)
Policy = Callable[[User, WorkOrder], bool]
def buggy_policy(user: User, order: WorkOrder) -> bool:
# Defeito: papel basta; tenant e estado são ignorados.
return user.role == "supervisor"
def fixed_policy(user: User, order: WorkOrder) -> bool:
return (
user.role == "supervisor"
and user.tenant == order.tenant
and order.status == "PENDING"
)
def mutant_ignores_tenant(user: User, order: WorkOrder) -> bool:
return user.role == "supervisor" and order.status == "PENDING"
def mutant_ignores_state(user: User, order: WorkOrder) -> bool:
return user.role == "supervisor" and user.tenant == order.tenant
def mutant_always_denies(user: User, order: WorkOrder) -> bool:
return False
class ApprovalService:
def __init__(self, repo: FakeOrderRepository, audit: AuditSpy, policy: Policy) -> None:
self.repo = repo
self.audit = audit
self.policy = policy
def approve(self, user: User, order_id: str, trace: list[dict]) -> WorkOrder:
order = self.repo.get(order_id)
trace.append({
"boundary": "service->policy",
"user_tenant": user.tenant,
"order_tenant": order.tenant,
"role": user.role,
"status": order.status,
})
allowed = self.policy(user, order)
trace.append({"boundary": "policy->service", "allowed": allowed})
if not allowed:
raise PermissionError("forbidden")
updated = replace(order, status="APPROVED", version=order.version + 1)
self.repo.save(updated)
self.audit.record({"type": "work_order.approved", "order_id": order_id})
trace.append({"boundary": "service->repository", "saved": True})
return updated
def api_approve(service: ApprovalService, user: User, order_id: str) -> tuple[dict, list[dict]]:
trace = [{
"boundary": "api->service",
"user_tenant": user.tenant,
"order_id": order_id,
}]
try:
order = service.approve(user, order_id, trace)
return {
"status": 200,
"body": {"id": order.order_id, "status": order.status, "version": order.version},
}, trace
except PermissionError:
return {"status": 403, "body": {"error": "forbidden"}}, trace
def fixture(policy: Policy, order_status: str = "PENDING"):
order = WorkOrder("742", "shopping-b", order_status, 3)
repo = FakeOrderRepository(order)
audit = AuditSpy()
service = ApprovalService(repo, audit, policy)
return repo, audit, service
def regression_cross_tenant(policy: Policy) -> None:
repo, audit, service = fixture(policy)
foreign_supervisor = User("u-1", "supervisor", "shopping-a")
response, _ = api_approve(service, foreign_supervisor, "742")
assert response == {"status": 403, "body": {"error": "forbidden"}}
assert repo.get("742") == WorkOrder("742", "shopping-b", "PENDING", 3)
assert audit.events == []
def reproduce(policy: Policy) -> dict:
repo, audit, service = fixture(policy)
user = User("u-1", "supervisor", "shopping-a")
response, trace = api_approve(service, user, "742")
expected = {
"response": {"status": 403, "body": {"error": "forbidden"}},
"state": asdict(WorkOrder("742", "shopping-b", "PENDING", 3)),
"audit": [],
}
observed = {
"response": response,
"state": asdict(repo.get("742")),
"audit": audit.events,
}
return {"input": asdict(user), "expected": expected, "observed": observed, "trace": trace}
def first_divergence(report: dict) -> dict:
api_input = report["trace"][0]
policy_input = report["trace"][1]
policy_output = report["trace"][2]
assert api_input["user_tenant"] == "shopping-a"
assert policy_input["user_tenant"] == "shopping-a"
assert policy_input["order_tenant"] == "shopping-b"
assert policy_output["allowed"] is True
return {
"boundary": "policy->service",
"expected": {"allowed": False},
"observed": policy_output,
"supports": "H2",
"weakens": ["H1", "H3"],
}
def run_suite(policy: Policy) -> list[str]:
passed: list[str] = []
# Unitários: executam a regra real, sem mocká-la.
own_pending = WorkOrder("742", "shopping-a", "PENDING", 3)
foreign_pending = WorkOrder("742", "shopping-b", "PENDING", 3)
own_closed = WorkOrder("742", "shopping-a", "CLOSED", 3)
supervisor = User("u-1", "supervisor", "shopping-a")
operator = User("u-2", "operator", "shopping-a")
assert policy(supervisor, own_pending) is True
assert policy(supervisor, foreign_pending) is False
assert policy(supervisor, own_closed) is False
assert policy(operator, own_pending) is False
passed.append("unit:policy-role-tenant-state")
# Integração em processo: adapter, serviço, policy, fake repo e audit spy.
regression_cross_tenant(policy)
passed.append("integration:denial-preserves-state")
# Contrato: cliente pode depender deste status e destas chaves públicas.
repo, audit, service = fixture(policy)
response, _ = api_approve(service, supervisor, "742") # OS pertence a shopping-b
assert response["status"] == 403
assert set(response["body"]) == {"error"}
assert response["body"]["error"] == "forbidden"
passed.append("contract:forbidden-response")
# Jornada ponta a ponta didática, ainda em processo e sem navegador real.
own_order = WorkOrder("900", "shopping-a", "PENDING", 7)
own_repo = FakeOrderRepository(own_order)
own_audit = AuditSpy()
own_service = ApprovalService(own_repo, own_audit, policy)
success, _ = api_approve(own_service, supervisor, "900")
assert success["status"] == 200
assert success["body"]["status"] == "APPROVED"
assert own_repo.get("900").version == 8
assert own_audit.events == [{"type": "work_order.approved", "order_id": "900"}]
passed.append("journey:own-order-approved")
return passed
def mutation_check() -> list[dict]:
results = []
for name, mutant in (
("ignore-tenant", mutant_ignores_tenant),
("ignore-state", mutant_ignores_state),
("always-deny", mutant_always_denies),
):
try:
run_suite(mutant)
except AssertionError:
results.append({"mutant": name, "status": "killed"})
else:
results.append({"mutant": name, "status": "survived"})
return results
def red_mode() -> int:
report = reproduce(buggy_policy)
try:
regression_cross_tenant(buggy_policy)
except AssertionError:
print(json.dumps({
"regression": "RED",
"expected": report["expected"],
"observed": report["observed"],
"first_divergence": first_divergence(report),
}, ensure_ascii=False, indent=2))
return 2
print("ERRO: a regressão não detectou o defeito", file=sys.stderr)
return 1
def prove_mode() -> int:
before = reproduce(buggy_policy)
try:
regression_cross_tenant(buggy_policy)
except AssertionError:
before_status = "RED"
else:
before_status = "FALSE-GREEN"
passed = run_suite(fixed_policy)
mutants = mutation_check()
evidence = {
"problem": "supervisor de outro tenant aprova OS 742",
"environment": {"python": platform.python_version(), "network": "disabled"},
"hypotheses": {
"H1": "API perde tenant",
"H2": "policy verifica apenas papel",
"H3": "repositório grava antes da negação",
},
"before": before_status,
"expected": before["expected"],
"observed_before_fix": before["observed"],
"first_divergence": first_divergence(before),
"minimal_fix": "role AND same tenant AND PENDING",
"after": "GREEN",
"tests_passed": passed,
"mutations": mutants,
"limitations": [
"fake em memória não prova transação de um banco real",
"contrato em processo não verifica um provedor implantado",
"jornada didática não substitui E2E com navegador e autenticação reais",
"três mutantes não representam todo defeito possível",
],
"commands": ["python debug_lab.py red", "python debug_lab.py prove"],
}
accepted = (
before_status == "RED"
and evidence["after"] == "GREEN"
and all(item["status"] == "killed" for item in mutants)
)
evidence["accepted"] = accepted
output_dir = Path("lab-output")
output_dir.mkdir(exist_ok=True)
(output_dir / "evidence.json").write_text(
json.dumps(evidence, ensure_ascii=False, indent=2), encoding="utf-8"
)
print(json.dumps(evidence, ensure_ascii=False, indent=2))
return 0 if accepted else 1
def main() -> int:
mode = sys.argv[1] if len(sys.argv) > 1 else "prove"
if mode == "red":
return red_mode()
if mode == "prove":
return prove_mode()
print("uso: python debug_lab.py [red|prove]", file=sys.stderr)
return 64
if __name__ == "__main__":
raise SystemExit(main())Etapa 1 — provoque o vermelho
Execute:
python debug_lab.py red
if ($LASTEXITCODE -ne 2) { throw "a versão defeituosa deveria falhar" }O exit code 2 representa falha esperada. Leia o JSON: esperado é 403, OS PENDING versão 3 e auditoria vazia; observado é 200, APPROVED versão 4 e evento de aprovação. Esse trio prova consequência, não somente mensagem.
O relatório localiza policy->service como primeira divergência. A API preservou shopping-a; a policy recebeu usuário A e OS B; mesmo assim devolveu allowed: true. H2 ganha evidência. H1 e H3 enfraquecem: identidade chegou correta e o allow ocorreu antes do save.
Etapa 2 — examine a correção mínima
Compare buggy_policy e fixed_policy. A mudança acrescenta exatamente os invariantes ausentes: mesmo tenant e estado PENDING. Não houve alteração em UI, repositório, audit ou resposta da API. Essa separação permite atribuir a mudança observada à regra.
Não conclua que H1 e H3 são impossíveis em todo sistema. O experimento apenas mostra que não explicam esta reprodução. Depuração causal reduz incerteza para um caso definido; não concede conhecimento universal.
Etapa 3 — prove verde e níveis complementares
python debug_lab.py prove
if ($LASTEXITCODE -ne 0) { throw "a correção e suas provas deveriam passar" }O relatório deve mostrar:
{
"before": "RED",
"first_divergence": {"boundary": "policy->service", "supports": "H2"},
"minimal_fix": "role AND same tenant AND PENDING",
"after": "GREEN",
"accepted": true
}tests_passed contém quatro provas. O unitário verifica combinações da policy. A integração comprova resposta, estado e auditoria pela composição real do serviço. O contrato preserva formato do 403. A jornada nominal prova aprovação do próprio tenant pelo caminho completo disponível nesta fixture.
O nome “journey” é deliberado: como tudo roda no mesmo processo, isso não é E2E de produção. Um E2E real iniciaria a aplicação implantada, autenticaria e exercitaria navegador ou cliente externo. Declarar essa limitação é parte da evidência.
Test double sem mockar a regra
FakeOrderRepository substitui persistência para tornar estado controlável. AuditSpy registra eventos para o teste observar. Nenhum deles decide se a ação é permitida. A policy real é executada.
Se o teste programasse mock_policy.return_value = False, passaria mesmo quando fixed_policy ignorasse tenant. Isso prova wiring, não segurança. Doubles devem ficar nas dependências que não são o objeto da afirmação. Em produção, complemente o fake com integração em banco e teste contratual de ERP.
Etapa 4 — mutação mental materializada
mutation_check executa a mesma suíte contra três alterações:
- remover tenant;
- remover estado;
- negar sempre.
Todos devem aparecer como killed. Os dois primeiros são mortos pelos casos negativos; o terceiro, pela jornada nominal. Isso explica por que uma suíte precisa de exemplos onde a ação deve acontecer e onde não deve.
Faça uma quarta mutação: remova assert audit.events == [] da regressão e altere o serviço para auditar antes da policy. Se nenhum teste perceber, o mutante sobreviveu e falta oráculo de efeito. Restaure tudo depois.
Casos negativos e diagnóstico
Execute uma mutação por vez:
- troque
and user.tenant == order.tenantporor; espere falha unitária e de integração; - mova
repo.saveantes doif not allowed; espere estado alterado após 403; - remova apenas o assert do status HTTP; confirme que estado e audit ainda capturam o defeito;
- faça o contrato esperar uma chave inexistente; somente o teste contratual deve falhar;
- compartilhe um único repositório entre casos sem reset; observe dependência de ordem e corrija isolamento;
- rode
python debug_lab.py desconhecido; espere exit code 64 e mensagem de uso.
Registre qual teste falhou primeiro e por quê. Se todos falham para qualquer mutação, a suíte pode estar excessivamente acoplada. Se nenhum falha, o oráculo é fraco.
Cobertura como sinal limitado
O laboratório não instala ferramenta de cobertura. Faça a pergunta conceitual: todas as linhas de buggy_policy são executadas no modo red, mas a implementação continua errada. Cobertura indicaria alcance, não validade.
Depois, imagine remover todos os asserts e continuar chamando as funções. Muitas linhas ainda seriam cobertas. A mutação revela a diferença: sem oráculos, defeitos sobrevivem. Em projeto real, combine cobertura de branches com mutation testing seletivo em regras críticas, sem transformar ambos em competição por percentual.
Pacote de evidência
Abra lab-output/evidence.json. Ele contém problema, runtime, hipóteses, expected, observed, primeira divergência, correção mínima, testes, mutações, limitações e comandos. Outra pessoa consegue auditar a conclusão sem ler o chat.
O pacote ainda não inclui commit porque este exercício roda fora de um repositório próprio. Numa entrega real, acrescente SHA, diff, configuração não secreta, horário, seed, logs redigidos, versão do banco e links de CI. Não guarde token, senha, payload pessoal ou credencial no trace.
Limpeza
Dentro da pasta exclusiva do laboratório:
Remove-Item -Recurse -Force -LiteralPath './lab-output'O script-fonte pode ser preservado como exercício. Confirme o caminho antes de remover qualquer diretório.
Critérios de aceite
O laboratório termina quando:
redretorna 2 e mostra expected diferente de observed;- a primeira divergência é
policy->service, sustentando H2; - o mesmo teste de regressão falha com
buggy_policye passa comfixed_policy; - a suíte contém unitário, integração, contrato e jornada, cada qual com limite explicado;
- fake e spy controlam infraestrutura sem substituir a policy;
- os três mutantes são mortos;
- pelo menos três casos negativos foram executados;
evidence.jsonpermite reprodução e declara o que não foi provado.
Solução comentada
O defeito está na expressão user.role == "supervisor". Ela responde uma pergunta menor que a regra de negócio. O trace mostra dados corretos chegando à policy e decisão errada saindo dela; portanto a correção causal amplia a expressão para papel, tenant e estado.
regression_cross_tenant é a peça principal: o mesmo código vermelho/verde observa resposta, estado e auditoria. run_suite distribui riscos entre níveis. mutation_check restaura defeitos plausíveis para verificar sensibilidade. prove_mode empacota fatos e limitações em vez de imprimir apenas “passou”.
Este formato segue a disciplina hipotético-dedutiva descrita pelo Google SRE. A biblioteca padrão unittest poderia organizar os mesmos asserts numa suíte maior; o laboratório usa funções para deixar o fluxo causal visível. Em qualquer runner, evidência vem do oráculo, não do nome da ferramenta.
Recuperação ativa
- Qual evidência liga o reparo ao defeito preservado?
- Por que
policy->serviceé a primeira divergência, e não o save? - Qual teste prova que autorização não existe apenas na interface?
- Por que o fake repository é aceitável, mas uma policy mockada seria fraca?
- Se remover o controle de tenant não faz teste falhar, o que a mutação mental revela?
- Por que a jornada deste laboratório não é um E2E completo de produção?
Comprove o que você aprendeu
Responda todas as questões. O gabarito comentado só aparece depois do envio.