Files

23 KiB

Bugs conhecidos

Levantamento feito em 2026-07-28 por revisão adversarial: um agente listou tudo o que parecia errado, outro tentou derrubar cada achado lendo o código, e um terceiro julgou os dois. Dos 55 reportados, 53 se confirmaram e 2 caíram como falso positivo.

Um segundo passe em 2026-07-29, com o mesmo método (caça, ceticismo, arbitragem) e reprodução empírica dos dois panics no chrono travado do repo, somou 41 achados: 40 confirmados e 1 descartado (bugs 57 a 97). Nenhum sobreviveu como crítico, e todos entram em aberto abaixo.

Como ler:

  • A referência é arquivo e função, nunca número de linha. Linha envelhece a cada commit, e um documento que aponta para o lugar errado é pior que documento nenhum (ver docs/database.dbml).
  • Severidade é o impacto julgado, não a facilidade de corrigir.
  • Fechado traz o commit. Aberto traz por que importa, que é o que decide a fila.

Placar

Crítico Médio Baixo Total
Fechado 3 29 58 90
Aberto 0 0 5 5

Os cinco abertos restantes: quatro esperam decisão de produto (75, 76, 77, 84) e um é contrato de API (92, paginação).

Nenhum crítico nem médio em aberto: sobraram só baixos.

Abertos

Baixos

# Onde O que é Por que importa
75 controllers/professional.rs::get_by_id e ::get_professional_by_email A existência é checada antes da autorização: id/e-mail desconhecido dá 404, de outra empresa dá 403 Oráculo de enumeração de contas na plataforma inteira, e o find_by_email sobre e-mail arbitrário. Autenticado e revela só existência (mesma classe do bug 23)
76 controllers/appointment.rs::update status só é validado contra o enum, sem máquina de estados: cancelado ou concluído volta a confirmed, no_show pode ser marcado antes da hora A appointments_no_overlap barra reconfirmar sobre horário retomado (23P01 → 400); sobra a inconsistência de estado (faturamento, no-show). Único gate é require_read_company
77 controllers/client.rs::update Exige só require_read_company, enquanto delete_by_id exige require_manage_company Profissional comum reescreve nome/telefone/e-mail de qualquer cliente da empresa. Mas create usa a mesma régua de leitura, então pode ser política de balcão deliberada: decisão de produto (ver 84)
84 controllers/appointment.rs::delete (cancelar) Exige só require_read_company, enquanto a listagem por profissional exige manage Qualquer profissional cancela agendamento de colega. Mas o comentário do update declara a política permissiva de propósito, então o furo real é contra a listagem: decisão de produto (ver 77)
92 repositories/appointment.rs::get_appointments_by_professional_id SELECT * FROM appointments WHERE professional_id = $1, sem filtro de data, paginação ou ordenação Cresce para sempre. Mesma classe do bug 19, fechado só para o caminho de conflito

Fechados

# Severidade O que era Commit
1 Crítico Admin de qualquer empresa apagava folga de qualquer outra: o handler checava a empresa do próprio token e o DELETE filtrava só por id 92d75f5
2 Crítico O PATCH de agendamento reapontava profissional e cliente para outra empresa, ocupando a agenda de lá 92d75f5
3 Crítico active não era checado em lugar nenhum: desativar não tirava acesso 92d75f5, 8d02368
4 Médio Access e refresh token eram idênticos: o refresh de 5 dias autenticava qualquer rota, e um token vazado se renovava sozinho para sempre. Agora cada um carrega a claim purpose, e token antigo obriga novo login 50ae909
5 Médio O UPDATE de agendamento descartava status e service_id respondendo ok 8d02368
6 Médio completed e no_show eram inalcançáveis pela API 8d02368
7 Médio O UPDATE de profissional não escrevia company_id: mover de empresa era no-op silencioso respondendo ok; verificado com servidor real e SELECT 5993abe
8 Médio per_second(5) é um request a cada 5s, não 5 por segundo: o limite público era 15x mais apertado que o pretendido 34b43a8
9 Médio Login e refresh ficavam fora do rate limit 8d02368
10 Médio O mapa por IP do rate limiter nunca era podado 34b43a8
11 Médio client_id não era conferido contra a empresa na criação de agendamento 8d02368
12 Médio Com serviço escolhido só o start era validado e o end entrava livre, ocupando até 8 horas como "validado"; create e update agora recusam end diferente de start + duração do serviço 8c42252
13 Médio timezone entrava sem validação e travava a agenda da empresa em runtime d450b59
14 Médio Remarcar não zerava reminder_sent_at: agendamento já lembrado e movido de dia ficava sem o lembrete novo; o UPDATE agora re-arma quando o start muda, verificado com servidor real 90979b4
15 Médio Busca pelo telefone cru e insert do telefone trimado: espaço na ponta derrubava o agendamento público com 500 8d02368
16 Médio Telefone e e-mail sem validação de tamanho contra varchar(11) e varchar(70) 8d02368
17 Médio O upload gravava em upload_dir, a coluna guardava caminho de fonte do Vite (/src/assets/...) e nenhuma rota servia o diretório; o main agora serve /api/v1/avatars a partir de upload_dir e a coluna grava essa URL e58628b
18 Médio O temporário do upload usava o nome vindo do cliente num /tmp previsível (colisão e symlink), e decode mais resize travavam o executor async; o fluxo agora é todo em memória, sem /tmp, com o processamento em spawn_blocking b95e674
19 Médio A checagem de conflito carregava todos os agendamentos do profissional para sempre; create e update agora usam get_busy_between, cujo WHERE espelha a constraint bc248b3
20 Médio Toda instalação nascia com o mesmo super-admin e hash de senha versionado; o boot agora troca o hash público pela ADMIN_PASSWORD (obrigatória enquanto ele existir), verificado nos três cenários com servidor real 0096866
21 Médio Papel e empresa vinham congelados do JWT: rebaixar ou mover alguém não tinha efeito até o token expirar 8d02368
22 Baixo 3660 * hours: a hora tinha 61 minutos 92d75f5
23 Baixo E-mail desconhecido dava 404 e senha errada 401, enumerando usuário; agora os dois respondem igual e o argon2 roda nos dois ramos 3276896
24 Baixo Token inválido respondia 406, e permissão negada respondia 401 d450b59
25 Baixo Erro de uma entidade respondia sobre outra, e a causa era descartada 1eb37ed
26 Baixo A primeira camada CORS era aplicada a um router vazio (código morto); removida, vale só a permissiva 970d809
27 Baixo O tracing subia depois do pool e das migrations, perdendo o log do boot; agora sobe primeiro, verificado com 136 linhas de sqlx antes do listening aabba2c
28 Baixo Profissional apagado com token válido dava 500 em toda rota 8d02368
29 Baixo active com serde default false: cadastro sem o campo nascia invisível d450b59
30 Baixo Multipart sem nenhum campo pulava a gravação e mesmo assim apontava o avatar para um arquivo que nunca existiu; agora responde 400 b95e674
31 Baixo Se o encode PNG falhava, o cleanup removia o nome errado e o arquivo parcial ficava; o encode agora acontece em memória, antes do único write no destino b95e674
32 Baixo Delete barrado por FK respondia "não existe" sobre algo que existe 1eb37ed
33 Baixo appointments.company_id nunca teve FK e aceitava empresa inexistente; criada e verificada contra banco descartável 0df7a50
34 Baixo Sem CHECK (start < end), intervalo vazio era invisível para a constraint de sobreposição, a garantia real (ADR 0002); fechado no banco fce8f70
35 Baixo O backfill de slug quebrava com nome só de símbolo, duplicata normalizada ou nome longo; reescrito com truncagem, fallback e desempate por id. Banco já migrado precisa reconciliar o checksum da 20260728120200 d04b422
36 Baixo clear_db.sh usava set -Ux (fish) num script bash e senha em texto puro; agora lê o DATABASE_URL do ambiente ou do .env, verificado contra banco descartável 3188de7
37 Baixo CI vermelho por construção: clippy com 8 warnings e rustfmt instável no job estável ca9aec2, e45efee, fe491cc
38 Baixo request.http e docs/database.dbml descreviam API e schema que não existem mais; reescritos contra as rotas e o psql \d reais 45a9c34
39 Baixo O login devolvia manage all para todo papel d450b59
40 Baixo /metrics respondia sem autenticação no listener público; agora só loopback e faixas privadas (via X-Forwarded-For atrás do proxy), 404 pro resto, verificado com curl c8197ee
41 Baixo Janela com o FIM no buraco do horário de verão era descartada inteira; a borda inexistente agora resolve para a saída do salto e o começo válido sobrevive d840208
42 Baixo A validade do magic link congelava na assinatura e remarcar para depois vencia o link do cliente; a remarcação agora manda e-mail com a hora nova e link novo, verificado com SMTP real 958208c
43 Baixo Falha transitória entre reivindicar e enviar perdia o lembrete para sempre; a preparação que falha agora devolve a linha para a próxima varredura a5e69f0
44 Baixo Os e-mails eram aguardados dentro do request público e relay lento atrasava a resposta; agora saem por task em background (falha vai pro log), resposta em ~20 ms no smoke 958208c
45 Baixo O parser de SMTP_URL não fazia percent-decode, descartava usuário sem senha e quebrava em IPv6 literal; porta inválida agora é erro em vez de default silencioso 096e47f
46 Baixo Remarcar não validava a grade nem com serviço: caía no almoço ou fora do expediente; o update agora espelha o create, excluindo o próprio horário antigo do ocupado 91d0b91
48 Baixo 25 dbg! em caminho de produção eram a única telemetria 1eb37ed
50 Baixo O profissional criava a própria folga mas não podia apagá-la; o delete agora usa a mesma regra do create (dono ou quem gerencia a empresa) 0b043f3
51 Baixo Colega era legível na listagem da empresa e no find_by_email, mas negado no GET por id; as três rotas agora usam a mesma política de leitura 28265e3
52 Baixo company_id omitido virava 0 pelo serde default, passava na checagem do super-admin e morria na FK; agora é 400 antes de qualquer escrita a53deeb
53 Baixo O coalesce fazia null significar "mantém" e e-mail de cliente nunca voltava a NULL; o PATCH agora é merge padrão (ausente mantém, null limpa, valor troca), verificado com SELECT em servidor real ae95c2c
54 Baixo Quando o índice único barrava a corrida do e-mail no UPDATE, a resposta era 404 de profissional; agora é o mesmo 400 do pré-check aadd00a
55 Baixo O avatar anterior nunca era apagado: um arquivo por profissional por dia, para sempre; o handler agora o recolhe depois de o cadastro apontar para o novo, verificado com servidor real b95e674
56 Baixo Todo erro de multipart virava 500 no upload: corpo acima do limite agora responde 413 (via MultipartError::status(), que o axum já classifica), multipart malformado 400, e arquivo que não decodifica como imagem 400 com mensagem clara 3facbfb
57 Médio Data extrema mas parseável (NaiveDate::MAX) na rota pública de slots dava panic em dois pontos: o laço de sondagem do DST em to_utc e o passo start + duration da varredura; as duas somas agora são checadas e a janela irresolvível é descartada, com os dois panics reproduzidos em teste antes do fix f70919f
58 Médio PATCH só com service_id prendia serviço de outra empresa (ou inativo, ou sem vínculo com o profissional): a checagem de dono morava em compute_slots, que só roda com start no corpo, e o único freio era a duração bater. O update agora valida o service_id do corpo com o mesmo critério e as mesmas respostas do compute_slots (404 fora da empresa ou inativo, 400 sem vínculo) 430186f
59 Médio "password":"" no PATCH de profissional pulava o hash mas o coalesce gravava a string vazia: conta trancada de vez respondendo 200 (o login morre em PasswordHash::new("")); agora todo campo de texto vazio vira None num ponto único, UpdateProfessional::treat_empty_as_absent 94af968
60 Médio "email":"" apagava o e-mail de login pela mesma raiz do 59 (o && só protegia a checagem de duplicata, não o UPDATE); mesma normalização 94af968
61 Médio Cliente do painel entrava sem normalização de telefone nem validação de tamanho: telefone formatado ou e-mail acima do varchar(70) respondia 500, e o telefone formatado nunca mais era achado pela busca normalizada da rota pública. Create e update agora usam a mesma normalize_phone do bug 16 (movida para utils) e checam os limites das colunas 0438941
62 Médio POST /professional (e o PATCH) gravava o telefone como veio contra o varchar(11): formatado respondia "internal server error". Mesma regra da rota pública: só dígitos, 10 ou 11, DDD incluso 0438941
63 Médio Bomba de descompressão no upload: um PNG minúsculo podia declarar dimensões que decodificam para centenas de MB dentro do default do crate (512 MB, sem teto de largura/altura) e uploads simultâneos derrubavam o processo por OOM; o decode agora usa Limits explícitos com teto de 4096 por lado, recusando no cabeçalho com 400, reproduzido em teste antes do fix 50aff87
64 Médio update_company colapsava toda falha em 500 e gravava nome vazio via coalesce. Nome vazio agora significa "mantém" (convenção do painel), slug tomado responde 400 e id inexistente 404 (o repositório passou a reportar zero linhas como NotFound, mesmo contrato do delete) 0438941
65 Médio O caminho de fonte do Vite (/src/assets/..., que nenhuma rota serve) vivia no INSERT, no DEFAULT da coluna e nas linhas já criadas (seed incluso): imagem quebrada na página pública. O INSERT agora grava o que o model traz ('' = sem foto), e uma migration troca o default e faz o backfill; verificado com servidor real e SELECT 8de22c7
66 Médio Refresh vazado valia 5 dias, se auto-renovava para sempre e trocar a senha não cortava nada. Dois cortes stateless: password_changed_at (coluna nova, bump no UPDATE de senha) recusa todo token com issued_at anterior, no refresh e no extractor (mesmo RETURNING, sem ida extra ao banco); e a claim login_at viaja intacta pela cadeia de renovação, com teto de 30 dias do login original. Deploy invalida todo token antigo, precedente do bug 4. Verificado com servidor real aaba575
67 Médio Nome de serviço sem checagem contra o varchar(80) (500 no nome longo) e update sem checar nome nenhum ({"name":""} apagava em silêncio um nome da página pública). Limite conferido em caracteres, não bytes (acento é comum em nome de serviço), e vazio no update significa "mantém" 0438941
68 Baixo min_lead_minutes/max_horizon_days só tinham CHECK de sinal, e ~95 milhões de dias estouravam now + horizon em available_slots, derrubando todo cálculo de slot da empresa; create e update agora limitam a 1 ano de horizonte e 1 semana de antecedência cd1cabf
69 Baixo service_id do PATCH de agendamento usava Option simples com coalesce: null significava "mantém" e não havia como destacar um serviço. Agora é merge padrão via double_option genérico compartilhado (mesmo padrão do e-mail de Client, bug 53): ausente mantém, null destaca (vira encaixe), valor troca com as validações de sempre; verificado com servidor real 0f47c52
70 Baixo A coluna avatar era gravável à mão pelo corpo do PATCH (vandalismo entre colegas: o próximo upload apagava o arquivo do valor gravado); o campo virou skip_deserializing, só o handler de upload escreve 0f47c52
71 Baixo O claim de lembretes sem LIMIT marcava o backlog inteiro como enviado antes de qualquer e-mail sair: restart no meio do laço serial perdia o resto pra sempre. Agora reivindica no máximo 50 por vez (REMINDER_CLAIM_BATCH, o teto da perda num crash) e o sweep drena lote a lote: cheio pede outro, não-cheio encerra sem ida extra ao banco f87955e
72 Baixo Slug já tomado no create_company caía em Err(_) => 500, descartando o Conflict do classificador; agora responde o mesmo 400 do update 7ca1fcd
73 Baixo Qualquer falha do get_company_by_id respondia "empresa não existe", banco fora do ar incluso; agora só NotFound é 404 e infra é 500 7ca1fcd
74 Baixo Id repetido em set_professional_services batia na PK composta e respondia 500; a lista agora é deduplicada antes (duplicata num conjunto é a mesma coisa dita duas vezes) 865e47d
78 Baixo Duas marcações públicas simultâneas do mesmo telefone novo corriam no índice (phone, company_id) e a perdedora levava 500 num agendamento correto; o Conflict agora rebusca por telefone e segue com o cliente que o vencedor criou, e só NotFound abre o ramo de criação (infra na busca respondia como se fosse cliente novo) 5029d5e
79 Baixo "role":"" escapava do filter de vazio e morria no CHECK do banco com 500; mesma normalização do 59, e os guards redundantes do handler foram simplificados para depender do invariante 94af968
80 Baixo rows_affected() == 0 em update/delete de profissional e cliente devolvia TechnicalError (500) onde o handler casa NotFound (404); agora os quatro pontos seguem o contrato que appointment::update e service::update já seguiam. A corrida em si não é alcançável pelo harness de mock: a mudança apoia-se nos testes de 404 existentes dos handlers 7ca1fcd
81 Baixo O FOR UPDATE SKIP LOCKED do claim de lembretes sem OF a travava também as linhas de companies do JOIN; agora trava só appointments. Nunca reproduzido contra banco: a mudança é semântica padrão da cláusula de lock 6dc68b2
82 Baixo O PUT da grade não tinha teto de janelas antes do laço O(n²) e gravava tudo: dezenas de milhares cabiam nos 2 MB de corpo e queimavam CPU; agora no máximo 50 janelas por semana, checado antes de qualquer outra coisa cd1cabf
83 Baixo A listagem pública de profissionais fazia uma query por id (N+1); virou uma busca só com id = ANY, e os filtros de empresa/ativo ficam no handler como redundância deliberada contra deriva do invariante de professionals_offering; verificado com servidor real 6dc68b2
85 Baixo Um PATCH só com end empurrava o fim de um encaixe em andamento para o passado (o start antigo não vem no corpo e escapava da trava; sem serviço, nada segura o intervalo); o end ganhou a mesma trava de relógio do start, também só quando vem no corpo, preservando completed/no_show depois da hora 6acf557
86 Baixo Deploy novo não tinha o upload_dir: o primeiro upload dava 500 e o ServeDir de /avatars não servia nada; o boot agora faz create_dir_all e falha alto se não conseguir, verificado com servidor real subindo em diretório inexistente 3292187
87 Baixo Cliente sem e-mail não recebe nada, mas o contador do sweep somava mesmo assim e o log "lembretes enviados" superestimava a entrega; a linha pula antes de contar e fica marcada, como no encaixe 865e47d
88 Baixo Janelas sobrepostas (só alcançáveis mexendo direto na tabela) geravam slots duplicados; o resultado ordenado agora é deduplicado no módulo puro cd1cabf
89 Baixo O create do painel (e o PATCH que reaponta) marcava na agenda de profissional desativado, ao contrário da rota pública; os dois agora respondem 400 f9acbff
90 Baixo E-mail de login case-sensitive: Alexandre@ não logava na conta alexandre@ e podia virar segunda conta. Linhas existentes minusculadas por migration (que falha alto se já houver duplicata de caixa), índice único trocado por lower(email), create/update gravam a forma canônica e o login compara lower() dos dois lados; verificado com servidor real 6d6f413
91 Baixo O compute_slots carregava a semana inteira e filtrava o weekday em Rust apesar do índice (professional_id, weekday); o filtro desceu ao SQL (get_working_hours_for_weekday), e o GET da grade mantém a query da semana que realmente precisa; verificado com servidor real (8 slots da janela de 4h) 6dc68b2
93 Baixo O upload lia só o primeiro campo do multipart: formulário com campo de texto antes do arquivo levava 400 "Missing file name"; campos de texto agora são pulados até o campo de arquivo, e multipart sem arquivo nenhum responde "a file field is required" 865e47d
94 Baixo Falha do UPDATE depois de o arquivo do avatar já estar gravado deixava um órfão no disco até o upload do dia seguinte sobrescrever; o caminho de erro agora o recolhe 0f47c52
95 Baixo "@" sozinho passava como e-mail na marcação pública e entrava em toda notificação; o mínimo agora é parte local não-vazia e domínio com ponto interno, e o nome (sem limite no schema) ganhou teto de 100 caracteres 865e47d
96 Baixo Mover profissional para empresa inexistente batia na FK (StillReferenced) e respondia 500; o update ganhou o mesmo braço de 400 que o create já tinha 7ca1fcd
98 Baixo Irmão do 58 pela outra porta: PATCH que troca só o professional_id não conferia se o novo profissional oferece o serviço do agendamento; a validação agora cobre o par resultante, inclusive quando só o profissional muda f9acbff

Descartados na revisão

# O que era Por que não é bug
47 max_connections(100) igual ao padrão do Postgres É dimensionamento de deploy, não defeito: o pool abre conexão sob demanda e o app é o único consumidor
49 MAGIC_LINK_SECRET só falha no primeiro uso O panic tardio no primeiro uso do segredo é decisão documentada, igual à do JWT_SECRET
97 utils.rs::get_timestamp_from_now: o unwrap de duration_since(UNIX_EPOCH) e o hours: u8 O unwrap só quebra com relógio anterior a 1970 (irreal, e quebraria a validação de JWT antes), e o u8 é escolha de tipo: os chamadores passam 8 e 120, e valor acima de 255 nem compila. Nenhum comportamento errado hoje, e o bug real dessa função (3660 * hours) já foi o bug 22

Fora desta lista

  • Os três repositórios em anyhow (agendamento, disponibilidade, serviço) não registram causa nenhuma: só os dois caminhos de criação de agendamento passam pelo classificador de error.rs. Fechar é dar .context(...) nas queries, uns 46 pontos.
  • Os bloqueadores de deploy estão em docs/ROADMAP.md e continuam inteiros: X-Forwarded-For no rate limit, CorsLayer::permissive(), cmake na imagem de build e MAGIC_LINK_SECRET obrigatório.