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:

  1. troque and user.tenant == order.tenant por or; espere falha unitária e de integração;
  2. mova repo.save antes do if not allowed; espere estado alterado após 403;
  3. remova apenas o assert do status HTTP; confirme que estado e audit ainda capturam o defeito;
  4. faça o contrato esperar uma chave inexistente; somente o teste contratual deve falhar;
  5. compartilhe um único repositório entre casos sem reset; observe dependência de ordem e corrija isolamento;
  6. 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:

  • red retorna 2 e mostra expected diferente de observed;
  • a primeira divergência é policy->service, sustentando H2;
  • o mesmo teste de regressão falha com buggy_policy e passa com fixed_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.json permite 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

  1. Qual evidência liga o reparo ao defeito preservado?
  2. Por que policy->service é a primeira divergência, e não o save?
  3. Qual teste prova que autorização não existe apenas na interface?
  4. Por que o fake repository é aceitável, mas uma policy mockada seria fraca?
  5. Se remover o controle de tenant não faz teste falhar, o que a mutação mental revela?
  6. Por que a jornada deste laboratório não é um E2E completo de produção?
Teste de fixação

Comprove o que você aprendeu

Responda todas as questões. O gabarito comentado só aparece depois do envio.

1. Qual evidência liga o reparo ao defeito preservado?
2. Para provar que autorização não existe apenas na tela, qual teste deve aparecer na matriz?
3. Ao imaginar a remoção do controle de autorização, nenhum teste deveria falhar. Qual diagnóstico é mais forte?

Consulta universal

O que você quer encontrar?

Títulos, capítulos, conceitos, termos, laboratórios e ferramentas em uma única busca.

Digite pelo menos dois caracteres.