23 KiB
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 deerror.rs. Fechar é dar.context(...)nas queries, uns 46 pontos. - Os bloqueadores de deploy estão em docs/ROADMAP.md e continuam inteiros:
X-Forwarded-Forno rate limit,CorsLayer::permissive(),cmakena imagem de build eMAGIC_LINK_SECRETobrigatório.