diff --git a/docs/audits/CLIENT_FUNCTIONAL_AUDIT_2026-09-25.md b/docs/audits/CLIENT_FUNCTIONAL_AUDIT_2026-09-25.md new file mode 100644 index 0000000..7ecbb95 --- /dev/null +++ b/docs/audits/CLIENT_FUNCTIONAL_AUDIT_2026-09-25.md @@ -0,0 +1,103 @@ +# Auditoria funcional do perfil cliente — 25/09/2026 + +## Base, execução e limites da evidência + +- Checkout auditado: branch `work`, commit inicial + `2232175a64a529de88f9615ca6d1b793d530be3d` (`AJUSTE`), com árvore limpa. +- O repositório fixa o SDK .NET `10.0.400` (`rollForward=latestPatch`) e Node >= 24. + O executor disponível possui Node `20.20.2`, mas não possui `dotnet`, `docker`, `psql` + nem servidor PostgreSQL. Por isso nenhum host foi iniciado e nenhuma evidência de + navegador ou banco foi produzida nesta execução. +- O schema canônico é `odca`, com versão corrente 028. A aplicação exige PostgreSQL, + API, Web e Worker; o Bootstrap é o migrador/provisionador. Nenhum banco foi limpo, + recriado ou alterado. +- O perfil previsto para homologação é `cliente.teste@odca.local`, role + `tenant-client`, organização **Cliente Teste ODCA**. O provisionamento e a senha não + foram testados nesta máquina. Uma decisão de revisão requer ainda um membro sintético + ativo com `tenant.reviews.decide`. + +Assim, “validada funcional” abaixo só é usado quando já existe evidência executável no +checkout que não depende deste incremento. “Corrigida e validada” exige banco e browser +e não é atribuído sem essa evidência. A inspeção de código não é apresentada como teste +de persistência. + +## Causas e correções imediatas + +1. **SQLSTATE 42601:** o literal raw do `SELECT` de importações terminava imediatamente + antes do fragmento `WHERE`, formando `u.id=i.requested_byWHERE`. A composição agora + insere quebras de linha explícitas, tipa `requester` nulo como `uuid` e usa ordenação + determinística por `created_at, id`. Contagem e listagem continuam usando exatamente + o mesmo filtro parametrizado. +2. **SQLSTATE 42883:** o helper `Allowed` da fila chamava a função inexistente + `odca.has_tenant_permission`. A função canônica definida e concedida nas migrations é + `odca.tenant_actor_has_permission(actor, tenant, permission)`. O helper passou a usar + essa função, sem alias de compatibilidade, bypass ou mudança de grants. + +Não é necessária migration para as duas correções: ambas são divergências no código da +API em relação ao schema já canônico. A busca no checkout depois da correção não encontra +consumidor executável de `has_tenant_permission`; permanece apenas uma menção histórica +em documentação, que não é chamada pelo runtime. + +## Matriz completa das superfícies do cliente + +| Tela / superfície | Rota Web | Permissão principal | Consulta e ações expostas | Persistência | Resultado / classificação | Correção necessária / evidência final | +|---|---|---|---|---|---|---| +| Login | `/entrar` | pública | autenticar | sessão, lockout e auditoria | **Ainda incompleta nesta auditoria** | Executar login real, troca de senha e reload; ambiente sem hosts. | +| Troca de senha / MFA / recuperação | `/alterar-senha`, `/mfa/inscricao`, `/mfa/desafio` | usuário autenticando | trocar senha, inscrever e desafiar MFA | usuário, segredo protegido, recovery codes e sessão | **Ainda incompleta nesta auditoria** | Validar pelo browser e banco com a conta Development. | +| Seleção de organização | `/organizacoes` | membership ativo | listar e selecionar por nome | contexto explícito na URL/sessão BFF | **Ainda incompleta nesta auditoria** | Validar manutenção do tenant e negativa cruzada. | +| Início do cliente / primeiros passos | `/cliente`, `/organizacoes/{tenantId}/primeiros-passos` | membership/capacidades | resumo e atalhos reais | leitura | **Ainda incompleta nesta auditoria** | Requer API e banco. | +| Organização | `/organizacoes/{tenantId}/editar` | `tenant.organization.manage` | consultar e editar cadastro | tenant + versão | **Ainda incompleta nesta auditoria** | Testar conflito otimista e releitura. | +| Equipe — pessoas | `/organizacoes/{tenantId}/equipe?tab=pessoas` | `tenant.team.read/manage` | listar, alterar vínculo e papéis | memberships/member_roles/auditoria | **Ainda incompleta nesta auditoria** | Testar último administrador, assentos e tenant cruzado. | +| Equipe — convites | `/organizacoes/{tenantId}/equipe?tab=convites`, `/convites/aceitar` | `tenant.team.manage` | criar, reenviar, revogar e aceitar | convite/outbox/membership | **Bloqueada por dependência identificada** | Entrega depende do transporte; registro não prova mensagem entregue. | +| Equipe — perfis | `/organizacoes/{tenantId}/equipe?tab=perfis` | `tenant.team.read/manage` | listar/criar/editar/arquivar perfil | roles/role_permissions | **Ainda incompleta nesta auditoria** | Testar catálogo delegável e proteção administrativa. | +| Pacientes | `/organizacoes/{tenantId}/pacientes` | `tenant.patients.read` | listar, buscar, paginação e inativos | leitura tenant-scoped | **Ainda incompleta nesta auditoria** | Requer execução com dois tenants. | +| Novo/editar paciente e representante | `/pacientes/novo`, `/pacientes/{id}/editar` | `tenant.patients.manage` | cadastro mínimo, edição e representante | patients/patient_representatives + versão | **Ainda incompleta nesta auditoria** | UI/API existem; validar 400/409, reload e auditoria. | +| Ficha e situação do paciente | `/pacientes/{id}` | `tenant.patients.read/manage` | ficha, acervo, inativar/restaurar | patient + histórico preservado | **Ainda incompleta nesta auditoria** | Testar conflito de identificador e versão concorrente. | +| Estúdio / catálogo | `/organizacoes/{tenantId}/estudio` | `tenant.templates.read`, `tenant.contract_drafts.*` | catálogo publicado, criar minuta (com ou sem paciente) | template/draft/snapshot | **Ainda incompleta nesta auditoria** | Confirmar jornada paciente e preservar B2B sem paciente. | +| Edição da minuta | `/estudio/minutas/{id}` | `tenant.contract_drafts.manage` | campos, blocos, pendências, comentários, autosave | draft + row version | **Ainda incompleta nesta auditoria** | Testar reload, idempotência e concorrência. | +| Versão imutável / PDF | `/estudio/versoes/{id}` | leitura da minuta/documento | visualizar, gerar/baixar PDF, revisão | generated version + hash/PDF | **Bloqueada por dependência identificada** | Renderização e download precisam dos hosts; verificar bytes/hash e autorização. | +| Participantes da versão | `/estudio/versoes/{id}#participantes` | gestão da minuta | incluir, editar, ordenar, retirar, confirmar e reabrir | preparation revisions/events | **Ainda incompleta nesta auditoria** | UI e API existem; confirmar contra PDF, histórico, comparação e replay no banco. | +| Solicitações de revisão — fila | `/organizacoes/{tenantId}/solicitacoes` | `tenant.reviews.read/decide` | filtros, escopos, responsáveis | leitura paginada | **Ainda incompleta** | Helper usa função canônica; testar perfis permitido/negado, vínculo bloqueado e tenant inativo. | +| Solicitação de revisão — detalhe | `/solicitacoes/{reviewId}` | `tenant.reviews.read/decide` | comentário, reatribuição, aprovar/ajustes | comments/steps/events/notifications | **Ainda incompleta** | O bloqueio 42883 foi removido; ainda requer requester e reviewer reais. | +| Importações — lista | `/organizacoes/{tenantId}/importacoes` | `tenant.imports.read` | status, solicitante, minhas, revisão, datas | leitura paginada | **Ainda incompleta** | SQL 42601 corrigido; executar todas as combinações pedidas no PostgreSQL. | +| Importação — revisão/preview | `/importacoes/{id}`, `/importacoes/preview/...` | `tenant.imports.manage/confirm`, documentos | diagnóstico, sugestões/manual, confirmar/cancelar | import/events/audit/contract | **Bloqueada por dependência identificada** | Scanner/OCR não podem ser simulados; testar original, estados e contrato resultante. | +| Ficha de contrato | `/organizacoes/{tenantId}/contratos/{contractId}` | permissão da finalidade + atribuição | documentos, revisão, obrigações, renovação | múltiplos agregados | **Ainda incompleta nesta auditoria** | Validar cada ação e download sob role restrita. | +| Obrigações / histórico / vistas | `/organizacoes/{tenantId}/obrigacoes` e `/obrigacoes/{id}/historico` | `tenant.obligations.*`, `tenant.saved_views.manage` | filtros, vistas, criar, atribuir, concluir, reabrir, cancelar | obligation/events/saved views | **Ainda incompleta nesta auditoria** | Testar ações pela ficha, reload, agenda e caixa. | +| Agenda | `/organizacoes/{tenantId}/agenda` | permissões das fontes | mês, filtros e origem | leitura derivada | **Ainda incompleta nesta auditoria** | Testar timezone e atualização sem duplicatas. | +| Caixa operacional / vistas | `/organizacoes/{tenantId}/caixa` | permissões por fonte | filtros, origem, salvar/padrão/inativar vista | saved views; leitura derivada | **Ainda incompleta nesta auditoria** | Testar escopo de cada item e navegação de retorno. | +| Renovações e aditivos | `/organizacoes/{tenantId}/renovacoes` | `tenant.renewals.*` | preparar, submeter, formalizar, aplicar/cancelar | change request/review/events/term | **Ainda incompleta nesta auditoria** | Validar condições, versões e obrigações anteriores. | +| Plano e consumo | `/organizacoes/{tenantId}/plano-e-consumo` | contexto tenant/consumo | uso, limites, pacotes, solicitar adicional | request/ledger/audit | **Ainda incompleta nesta auditoria** | Confirmar dados reais; solicitação não equivale a pagamento/aprovação. | +| Planos públicos | `/conheca-os-planos`, `/planos` | pública/autenticada | catálogo | leitura | **Ainda incompleta nesta auditoria** | Requer hosts; não há pagamento simulado. | +| Privacidade | `/privacidade` | pública | registrar solicitação | privacy request/protocolo | **Ainda incompleta nesta auditoria** | Fora da jornada prioritária; validar persistência sem enumeração. | +| Assinaturas | `/modulos/assinaturas` | autenticada | apenas estado do módulo | nenhuma assinatura externa | **Bloqueada por dependência identificada** | Não existe provedor de assinatura real; preparação interna não declara envio/assinatura. | + +## Roteiro de homologação reproduzível + +1. Em máquina com .NET 10.0.400, Node 24+ e PostgreSQL 18, aplicar o migrador incremental + e executar `provision-test-access` conforme `README.md`; nunca resetar a base existente. +2. Iniciar API, Web e Worker. Entrar como `cliente.teste@odca.local`, concluir a troca de + senha/MFA exigida, selecionar **Cliente Teste ODCA** pelo nome e guardar o tenant da URL. +3. Criar um paciente mínimo com representante; reler, editar, provocar conflito de versão, + inativar e restaurar. Conferir as linhas e eventos com conexão administrativa somente + para observação, enquanto as operações usam a role da aplicação. +4. Escolher modelo publicado, criar minuta vinculada, salvar/reler, gerar versão e PDF, + baixar e comparar hash. Alterar cadastro e confirmar que a versão anterior não mudou. +5. Com revisor sintético elegível, solicitar revisão; comentar, pedir ajustes, gerar nova + versão e aprovar somente a nova. Conferir steps/events e negativa entre tenants. +6. Preparar participantes, salvar/retomar, reordenar, confirmar o hash do PDF, comparar + revisões e reabrir com justificativa. Repetir a mesma chave para conferir idempotência. +7. Executar a lista de importações sem filtro e com cada filtro isolado/combinado, nenhum + resultado e tenant B. Só avançar upload/extrator com ClamAV/OCR configurados. +8. Percorrer obrigação → agenda → caixa e renovação → revisão → formalização → aplicação, + sempre verificando resposta, reload, persistência e evento de auditoria. + +## Dependências e conclusão honesta + +- ClamAV, Poppler e Tesseract são dependências reais do Worker. Ausência/falha deve manter + `scan_failed`/erro configuracional; nenhum arquivo foi marcado seguro nesta auditoria. +- O transporte de convite em Development é file pickup; fora dele é necessário provedor. +- Não foi localizado provedor real de assinatura eletrônica. O escopo operacional existente + termina na preparação/confirmação interna dos participantes. +- O incremento recupera os dois bloqueios reproduzíveis por inspeção, mas **não demonstra + que todas as telas são funcionais**. Sem SDK, PostgreSQL e browser, as classificações + pendentes acima não podem ser promovidas para “validada funcional”. diff --git a/docs/execution/CONSULTANCY_REVIEW_QUEUE.md b/docs/execution/CONSULTANCY_REVIEW_QUEUE.md index b9df042..0ea78dc 100644 --- a/docs/execution/CONSULTANCY_REVIEW_QUEUE.md +++ b/docs/execution/CONSULTANCY_REVIEW_QUEUE.md @@ -16,7 +16,7 @@ O BFF atende em `/organizacoes/{tenantId}/solicitacoes`. A API canônica atende em `/api/v1/organizations/{tenantId}/reviews`. O identificador da organização na URL -não concede acesso: todas as operações chamam `odca.has_tenant_permission` e, antes +não concede acesso: todas as operações chamam `odca.tenant_actor_has_permission` e, antes de acessar tabelas protegidas, configuram os contextos `odca.tenant_id` e `odca.user_id` usados por RLS. diff --git a/src/Odca.Api/Controllers/ContractImportsController.cs b/src/Odca.Api/Controllers/ContractImportsController.cs index b03e6a6..2e63d89 100644 --- a/src/Odca.Api/Controllers/ContractImportsController.cs +++ b/src/Odca.Api/Controllers/ContractImportsController.cs @@ -56,20 +56,27 @@ public async Task List(Guid tenantId, [FromQuery] string? status, const string filter = """ WHERE i.tenant_id=@tenantId AND (CAST(@status AS text) IS NULL OR i.status=CAST(@status AS text)) - AND (@requester IS NULL OR i.requested_by=@requester) + AND (CAST(@requester AS uuid) IS NULL OR i.requested_by=CAST(@requester AS uuid)) AND (NOT @mine OR i.requested_by=@actor) AND (NOT @awaitingReview OR i.status='awaiting_review') AND (CAST(@FromInclusive AS timestamptz) IS NULL OR i.created_at>=CAST(@FromInclusive AS timestamptz)) AND (CAST(@ToExclusive AS timestamptz) IS NULL OR i.created_at(new CommandDefinition(""" + const string selectSql = """ SELECT i.id AS Id,v.display_name AS DocumentName,u.display_name AS Requester,i.created_at AS CreatedAt, i.status AS Status,i.current_step AS CurrentStep,i.safe_diagnostic_code AS DiagnosticCode, i.result_contract_id AS ResultContractId,i.review_version AS ReviewVersion FROM odca.contract_imports i JOIN odca.document_versions v ON v.tenant_id=i.tenant_id AND v.id=i.document_version_id JOIN odca.users u ON u.id=i.requested_by - """ + filter + " ORDER BY i.created_at DESC", args, tx, cancellationToken: ct)); + """; + var sql = selectSql + + Environment.NewLine + + filter + + Environment.NewLine + + "ORDER BY i.created_at DESC, i.id DESC"; + var items = await connection.QueryAsync(new CommandDefinition( + sql, args, tx, cancellationToken: ct)); var total = await connection.ExecuteScalarAsync(new CommandDefinition( "SELECT count(*)::integer FROM odca.contract_imports i " + filter, args, tx, cancellationToken: ct)); await tx.CommitAsync(ct); return Ok(new ContractImportPage(items.AsList(), total)); diff --git a/src/Odca.Api/Controllers/ReviewRequestsController.cs b/src/Odca.Api/Controllers/ReviewRequestsController.cs index cc5b692..cca28f2 100644 --- a/src/Odca.Api/Controllers/ReviewRequestsController.cs +++ b/src/Odca.Api/Controllers/ReviewRequestsController.cs @@ -270,7 +270,7 @@ ON CONFLICT(tenant_id,deduplication_key) DO NOTHING } private Guid? Actor()=>Guid.TryParse(User.FindFirstValue("sub"),out var id)?id:null; - private static async Task Allowed(NpgsqlConnection c,Guid actor,Guid tenant,string permission,CancellationToken ct)=>await c.ExecuteScalarAsync(new CommandDefinition("SELECT odca.has_tenant_permission(@actor,@tenant,@permission)",new{actor,tenant,permission},cancellationToken:ct)); + private static Task Allowed(NpgsqlConnection c,Guid actor,Guid tenant,string permission,CancellationToken ct)=>c.ExecuteScalarAsync(new CommandDefinition("SELECT odca.tenant_actor_has_permission(@actor,@tenant,@permission)",new{actor,tenant,permission},cancellationToken:ct)); private static Task SetTenant(NpgsqlConnection c,Guid tenant,Guid actor,CancellationToken ct)=>c.ExecuteAsync(new CommandDefinition("SELECT set_config('odca.tenant_id',@tenant::text,false),set_config('odca.user_id',@actor::text,false)",new{tenant,actor},cancellationToken:ct)); private static Task SetTenant(NpgsqlConnection c,Guid tenant,Guid actor,NpgsqlTransaction tx,CancellationToken ct)=>c.ExecuteAsync(new CommandDefinition("SELECT set_config('odca.tenant_id',@tenant::text,true),set_config('odca.user_id',@actor::text,true)",new{tenant,actor},tx,cancellationToken:ct)); private static ReviewQueueItem Map(QueueRow r)=>new(r.Id,r.ContractId,r.Contract,r.Status,r.Requester,r.Assignee,r.OpenedAt,r.UpdatedAt,r.DueAt,r.Version,r.PublicMessages,r.PendingComments); diff --git a/tests/Odca.IntegrationTests/RuntimeQueryRegressionTests.cs b/tests/Odca.IntegrationTests/RuntimeQueryRegressionTests.cs index 40a4cd0..c8aa242 100644 --- a/tests/Odca.IntegrationTests/RuntimeQueryRegressionTests.cs +++ b/tests/Odca.IntegrationTests/RuntimeQueryRegressionTests.cs @@ -120,6 +120,29 @@ public void DashboardActiveUsersHasExplicitIntegerCastAndAlias() Assert.Contains("active_users::int AS \"ActiveUsers\"", source, StringComparison.Ordinal); } + [Fact] + public void ContractImportListSeparatesSqlFragmentsTypesNullableRequesterAndUsesStableOrder() + { + var source = ReadSource("Odca.Api", "Controllers", "ContractImportsController.cs"); + + Assert.Contains("+ Environment.NewLine\n + filter\n + Environment.NewLine", source, StringComparison.Ordinal); + Assert.Contains("CAST(@requester AS uuid) IS NULL", source, StringComparison.Ordinal); + Assert.Contains("i.requested_by=CAST(@requester AS uuid)", source, StringComparison.Ordinal); + Assert.Contains("ORDER BY i.created_at DESC, i.id DESC", source, StringComparison.Ordinal); + } + + [Fact] + public void ReviewQueueUsesCanonicalAuthorizationFunctionWithActorTenantPermissionOrder() + { + var source = ReadSource("Odca.Api", "Controllers", "ReviewRequestsController.cs"); + + Assert.DoesNotContain("odca.has_tenant_permission", source, StringComparison.Ordinal); + Assert.Contains( + "odca.tenant_actor_has_permission(@actor,@tenant,@permission)", + source, + StringComparison.Ordinal); + } + private static string ReadSource(params string[] path) { var current = new DirectoryInfo(AppContext.BaseDirectory);