diff --git a/AGENTS.md b/AGENTS.md index 5879b06..8b80b36 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,16 @@ Estas instruções aplicam-se a todo o repositório. - Segredos ficam apenas em variáveis de ambiente; nunca em código, testes, documentação ou logs. - A interface e os textos destinados ao utilizador são em português de Portugal. +## Snipe-IT de produção — restrições obrigatórias + +- A instância configurada é de produção; todas as chamadas remotas devem ser tratadas como operações sobre dados reais. +- São proibidos criação, edição e eliminação de equipamentos/produtos no Snipe-IT. Não implementar nem executar `POST`, `PUT`, `PATCH` ou `DELETE` remotos fora dos endpoints estritos de checkout e check-in já documentados. +- Nunca executar checkout/check-in como teste de conectividade. Diagnóstico, sincronização e validação de tokens usam exclusivamente pedidos `GET`. +- Antes de cada checkout, consultar o equipamento por `GET` no Snipe-IT. Se `assigned_to` estiver preenchido, abortar sem qualquer escrita. +- Um check-in só pode ocorrer para um item cujo checkout tenha sido confirmado pelo próprio portal e cuja atribuição remota atual continue a corresponder ao destinatário registado. Qualquer divergência exige reconciliação humana sem escrita automática. +- Escritas de checkout/check-in nunca têm retry automático. Um timeout ou resultado ambíguo interrompe o fluxo até validação manual no Snipe-IT. +- Nunca revelar URL privada, tokens, payloads sensíveis ou respostas completas de produção em logs, documentação, testes ou mensagens. + ## Definição de concluído Uma alteração só está concluída quando: diff --git a/CHANGELOG.md b/CHANGELOG.md index 99018eb..03b5c88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,8 @@ Todas as alterações materiais são registadas neste ficheiro. O formato segue - Máquina de estados passa a admitir incidentes durante preparação/utilização, atrasos e resolução por devolução. - Formulário de requisição apresenta as políticas atuais e o serviço de domínio volta a validá-las antes da transação. - Notificações passam a obter assunto e mensagem dos modelos administrativos, preservando defaults seguros. +- Checkout Snipe-IT passa a consultar o estado remoto imediatamente antes do movimento e recusa ativos já atribuídos sem enviar escrita. +- Check-in Snipe-IT passa a exigir checkout comprovado pelo portal para o mesmo requisitante e atribuição remota atual ao destinatário esperado. ### Segurança @@ -69,6 +71,7 @@ Todas as alterações materiais são registadas neste ficheiro. O formato segue - Rotas operacionais protegidas por Gate e verificação de perfil também no serviço de domínio; escritas externas não fazem retry automático. - Exportação CSV neutraliza células iniciadas por caracteres de fórmula e limita cada relatório a 366 dias. - Gestão administrativa impede auto-desativação e garante pelo menos um administrador ativo. +- Instância Snipe-IT declarada como produção imutável: são proibidas criações, edições, eliminações e testes de escrita; apenas checkout/check-in operacionais permanecem permitidos. ### Verificado @@ -87,3 +90,6 @@ Todas as alterações materiais são registadas neste ficheiro. O formato segue - Autenticação Microsoft simulada criptograficamente: associação explícita, login, `state`, PKCE, tenant e recusa de autoassociação validados. - Backup real criado e restaurado em PostgreSQL 17 temporário, com 12 migrations confirmadas; liveness/readiness validados através do Nginx. - Login local exercitado de ponta a ponta no browser após deteção e reposição documentada de uma base de desenvolvimento migrada mas sem seeders. +- Suite backend após as salvaguardas de produção: 108 testes, 609 asserções, Pint e PHPStan sem erros. +- Tokens Snipe-IT distintos validados apenas com `GET`; sincronização real de leitura processou 2367 ativos para o cache local sem escrita remota. +- TypeScript, ESLint, Prettier, build Vite, Composer Audit, migrations, liveness, readiness e login HTTP validados novamente. diff --git a/README.md b/README.md index 6666c1b..7cb537b 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Portal interno para consultar, requisitar, aprovar, entregar e devolver equipame ## Estado atual -O projeto concluiu as **Fases 1 a 6** e já inclui a primeira entrega técnica da **Fase 7**: autenticação Microsoft Entra/OIDC com associação explícita, health checks e procedimentos executáveis de backup/restauro. A configuração contra serviços institucionais, observabilidade externa e piloto permanecem tarefas de entrada em produção. +O projeto concluiu as **Fases 1 a 6** e já inclui a primeira entrega técnica da **Fase 7**: autenticação Microsoft Entra/OIDC com associação explícita, health checks, procedimentos executáveis de backup/restauro e sincronização de leitura validada contra o Snipe-IT de produção. Entra/SMTP institucionais, observabilidade externa, validação formal de permissões e piloto permanecem tarefas de entrada em produção. Consulte sempre [docs/PROJECT_STATUS.md](docs/PROJECT_STATUS.md) antes de alterar o projeto. Esse documento contém o ponto de situação, riscos e próxima tarefa recomendada. @@ -78,3 +78,5 @@ docker compose exec app php artisan migrate:fresh --seed ## Princípio central O portal nunca escreve diretamente na base de dados do Snipe-IT. Identificação e estado operacional do equipamento pertencem ao Snipe-IT; períodos, aprovações, conflitos, notificações, entrega/devolução e auditoria pertencem ao portal. + +A instância Snipe-IT configurada é de produção: o portal não cria, edita nem elimina inventário. As únicas escritas remotas permitidas são checkout e check-in operacionais, com validação atual do estado e prova local do movimento; testes e sincronizações são apenas de leitura. diff --git a/app/Domain/Operations/Services/HandoverService.php b/app/Domain/Operations/Services/HandoverService.php index d860368..49e8f01 100644 --- a/app/Domain/Operations/Services/HandoverService.php +++ b/app/Domain/Operations/Services/HandoverService.php @@ -144,7 +144,7 @@ final class HandoverService $this->authorizeOperator($operator); $this->assertOperationalConfiguration(); - $reservation = Reservation::query()->with('items.asset')->findOrFail($reservation->id); + $reservation = Reservation::query()->with(['requester', 'items.asset'])->findOrFail($reservation->id); if (! in_array($reservation->status, [ ReservationStatus::Delivered, ReservationStatus::InUse, @@ -166,10 +166,23 @@ final class HandoverService if ($asset->status_id === null) { throw ValidationException::withMessages(['operation' => "O equipamento {$asset->asset_tag} não tem estado Snipe-IT sincronizado."]); } + if ($reservation->requester->snipe_user_id === null) { + throw ValidationException::withMessages(['operation' => 'O destinatário não tem associação Snipe-IT confirmada.']); + } + $hasPortalCheckout = $item->handoverRecords() + ->where('type', HandoverType::Checkout->value) + ->where('recipient_id', $reservation->requester_id) + ->exists(); + if (! $hasPortalCheckout) { + throw ValidationException::withMessages([ + 'operation' => "O equipamento {$asset->asset_tag} não tem um checkout confirmado pelo portal; o check-in foi bloqueado.", + ]); + } $response = $this->snipeIt->checkinHardware( (int) $asset->snipe_asset_id, (int) $asset->status_id, + (int) $reservation->requester->snipe_user_id, $asset->location_id === null ? null : (int) $asset->location_id, $this->externalNote($reservation, $notes), ); diff --git a/app/Integrations/SnipeIt/Data/AssetData.php b/app/Integrations/SnipeIt/Data/AssetData.php index 6540d65..e55918f 100644 --- a/app/Integrations/SnipeIt/Data/AssetData.php +++ b/app/Integrations/SnipeIt/Data/AssetData.php @@ -27,6 +27,22 @@ final readonly class AssetData public ?CarbonImmutable $sourceUpdatedAt, ) {} + public function isAssigned(): bool + { + return $this->assignedType !== null || $this->assignedId !== null; + } + + public function isAssignedToUser(int $userId): bool + { + if ($this->assignedId !== $userId || $this->assignedType === null) { + return false; + } + + $normalizedType = strtolower(str_replace(['\\', '_', '-', ' '], '', $this->assignedType)); + + return str_ends_with($normalizedType, 'user'); + } + /** @return array */ public function toModelAttributes(): array { diff --git a/app/Integrations/SnipeIt/SnipeItClient.php b/app/Integrations/SnipeIt/SnipeItClient.php index 3784ca5..d96f106 100644 --- a/app/Integrations/SnipeIt/SnipeItClient.php +++ b/app/Integrations/SnipeIt/SnipeItClient.php @@ -2,6 +2,7 @@ namespace App\Integrations\SnipeIt; +use App\Integrations\SnipeIt\Data\AssetData; use App\Integrations\SnipeIt\Data\HardwarePage; use App\Integrations\SnipeIt\Exceptions\SnipeItApiException; use App\Integrations\SnipeIt\Exceptions\SnipeItConfigurationException; @@ -16,6 +17,8 @@ use Throwable; final class SnipeItClient { + public function __construct(private readonly SnipeItAssetMapper $mapper) {} + public function isConfigured(): bool { return filled(config('services.snipe_it.url')) && filled(config('services.snipe_it.read_token')); @@ -98,6 +101,27 @@ final class SnipeItClient } while ($received > 0 && $offset < $page->total); } + public function getHardware(int $hardwareId): AssetData + { + $this->assertPositiveIdentifiers($hardwareId); + + $response = $this->readRequest()->get("/hardware/{$hardwareId}"); + if (! $response->successful()) { + throw new SnipeItApiException( + "O Snipe-IT respondeu com HTTP {$response->status()} ao consultar o equipamento.", + $response->status(), + ); + } + + $payload = $response->json(); + if (! is_array($payload) || ($payload['status'] ?? null) === 'error') { + throw new SnipeItApiException('O Snipe-IT não devolveu um estado válido para o equipamento.'); + } + + /** @var array $payload */ + return $this->mapper->map($payload); + } + /** @return array */ public function checkoutHardware( int $hardwareId, @@ -108,6 +132,13 @@ final class SnipeItClient ): array { $this->assertPositiveIdentifiers($hardwareId, $statusId, $assignedUserId); + $current = $this->getHardware($hardwareId); + if ($current->isAssigned()) { + throw new SnipeItApiException( + "O equipamento {$current->assetTag} já está atribuído no Snipe-IT; o checkout não foi executado.", + ); + } + $payload = [ 'status_id' => $statusId, 'checkout_to_type' => 'user', @@ -128,15 +159,23 @@ final class SnipeItClient public function checkinHardware( int $hardwareId, int $statusId, + int $expectedAssignedUserId, ?int $locationId = null, ?string $note = null, ): array { - $this->assertPositiveIdentifiers($hardwareId, $statusId); + $this->assertPositiveIdentifiers($hardwareId, $statusId, $expectedAssignedUserId); if ($locationId !== null && $locationId < 1) { throw new SnipeItConfigurationException('O identificador da localização do Snipe-IT tem de ser positivo.'); } + $current = $this->getHardware($hardwareId); + if (! $current->isAssignedToUser($expectedAssignedUserId)) { + throw new SnipeItApiException( + "O equipamento {$current->assetTag} não está atribuído ao destinatário esperado; o check-in não foi executado.", + ); + } + $payload = ['status_id' => $statusId]; if ($locationId !== null) { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0c8561f..f76b6e2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -77,11 +77,15 @@ Os relatórios usam consultas de leitura com período máximo de 366 dias. A exp 3. `AssetSyncService` faz upsert por `snipe_asset_id`, calcula `content_hash` e marca cada ativo com `last_seen_sync_run_id`. 4. Apenas após todas as páginas serem recebidas com sucesso, ativos cujo marcador não corresponde à execução atual ficam `active = false`; não são apagados para preservar histórico. 5. Antes de aprovação ou entrega, o ativo é validado novamente. -6. Checkout/check-in usam um token operacional separado e guardam apenas a resposta necessária para auditoria. +6. Imediatamente antes de cada checkout, o portal faz um `GET /hardware/{id}` com o token de leitura e bloqueia a operação se existir qualquer atribuição remota. +7. Checkout/check-in usam um token operacional separado e guardam apenas a resposta necessária para auditoria. +8. Antes de um check-in, o portal exige um checkout confirmado em `handover_records` para o mesmo requisitante e confirma por `GET` que o ativo continua atribuído a esse utilizador Snipe-IT. `HandoverService` mantém a ordem explícita “Snipe-IT primeiro, confirmação local depois” para cada equipamento. Uma requisição com vários itens não é apresentada como concluída se apenas parte dos movimentos for aceite: os itens confirmados preservam o seu `HandoverRecord`, os restantes mantêm o estado anterior e a requisição passa a `incident`. As escritas externas não têm retry automático porque um timeout pode ocorrer depois de o Snipe-IT executar a operação; repetir sem reconciliação poderia criar um resultado enganador. -O checkout associa o ativo a um utilizador Snipe-IT. O operador confirma o ID remoto na primeira entrega e o portal guarda-o em `users.snipe_user_id` para utilizações futuras. O check-in repõe o estado e a localização habitual sincronizados do ativo. As respostas externas ficam em `handover_records.snipe_response`; tokens nunca são persistidos. +O checkout associa o ativo a um utilizador Snipe-IT. O operador confirma o ID remoto na primeira entrega e o portal guarda-o em `users.snipe_user_id` para utilizações futuras. Um ativo já atribuído nunca recebe um checkout do portal. O check-in repõe o estado e a localização habitual sincronizados do ativo, mas apenas quando o movimento de saída foi confirmado pelo portal e a atribuição remota ainda corresponde ao mesmo destinatário. As respostas externas ficam em `handover_records.snipe_response`; tokens nunca são persistidos. + +A instância Snipe-IT ligada ao projeto é de produção e é tratada como inventário imutável. O portal não cria, edita nem elimina ativos, modelos, produtos ou outra informação mestre. Os únicos pedidos de escrita autorizados no cliente são os endpoints estritos de checkout e check-in, invocados por uma ação operacional legítima. Diagnósticos, sincronizações e validações técnicas usam exclusivamente `GET`. Uma falha intermédia pode deixar upserts válidos das páginas anteriores, mas nunca executa a desativação global. A sincronização seguinte converge o cache. Um lock distribuído e um job único impedem duas sincronizações simultâneas. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index d028af6..5b59af0 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -110,3 +110,12 @@ Por defeito, uma identidade Microsoft só pode ser associada a partir de uma con Separar liveness, que prova apenas resposta do processo, de readiness, que confirma PostgreSQL e cache. As respostas públicas contêm apenas o estado agregado. O Nginx usa readiness no Docker Compose. Disponibilizar scripts independentes para `pg_dump` custom e restauro numa base temporária validada. Os scripts usam nomes resolvidos do contentor, restringem os identificadores aceites, geram checksum e nunca substituem a base principal. Encriptação, armazenamento externo, retenção e alertas permanecem responsabilidades explícitas da plataforma de produção. + +## ADR-016 — Snipe-IT de produção imutável salvo movimentos controlados + +**Data:** 2026-08-05 +**Estado:** aceite + +A instância Snipe-IT configurada é de produção. Ficam proibidas, tanto na aplicação como em diagnóstico, operações de criação, edição ou eliminação de ativos, modelos, produtos e restantes dados mestres. Os únicos pedidos remotos de escrita admitidos são checkout e check-in resultantes de uma ação operacional legítima no portal; testes de conectividade e sincronizações são exclusivamente de leitura. + +Cada checkout faz primeiro uma leitura atual de `hardware/{id}` e é recusado sem qualquer escrita se o ativo estiver atribuído. Cada check-in exige um `HandoverRecord` de checkout criado pelo portal para o mesmo requisitante e uma leitura atual que confirme a atribuição ao respetivo `snipe_user_id`. Uma divergência é tratada como incidente para reconciliação manual. Mantém-se a decisão de não repetir escritas automaticamente e de nunca expor tokens, URL privada ou respostas com dados de inventário em documentação, logs de diagnóstico ou mensagens de agentes. diff --git a/docs/PRODUCTION_CHECKLIST.md b/docs/PRODUCTION_CHECKLIST.md index ddd294a..a13090a 100644 --- a/docs/PRODUCTION_CHECKLIST.md +++ b/docs/PRODUCTION_CHECKLIST.md @@ -21,7 +21,10 @@ Referências oficiais: [Authorization Code Flow com PKCE](https://learn.microsof - [ ] Criar conta/token de leitura com menor privilégio. - [ ] Criar conta/token operacional separado, apenas com checkout/check-in necessários. - [ ] Executar sincronização integral e comparar amostra de etiquetas, categorias, estados e localizações. -- [ ] Exercitar checkout/check-in com equipamentos de teste e reconciliar `handover_records`. +- [ ] Confirmar por inspeção e testes simulados que o portal não contém endpoints de criação, edição ou eliminação de inventário. +- [ ] Validar tecnicamente a produção apenas com pedidos `GET`; nunca usar um movimento artificial como teste de conectividade. +- [ ] No primeiro movimento institucional legítimo, usar um ativo existente e não atribuído, verificar o preflight e reconciliar `handover_records`. +- [ ] Confirmar que um ativo já atribuído é bloqueado sem qualquer pedido de escrita. - [ ] Confirmar resposta a indisponibilidade, timeout e falha parcial sem retries de escrita. ## 3. Email, domínio e TLS @@ -72,7 +75,7 @@ Referências oficiais: [Authorization Code Flow com PKCE](https://learn.microsof | Área | Responsável | Data | Resultado | Evidência externa | |---|---|---|---|---| | Entra ID | Por atribuir | — | Pendente | — | -| Snipe-IT | Por atribuir | — | Pendente | — | +| Snipe-IT | Codex (validação técnica) | 2026-08-05 | Parcial: dois tokens distintos responderam a `GET`; sincronização de 2367 ativos; zero escritas | Registo interno em [PROJECT_STATUS.md](PROJECT_STATUS.md); validação institucional pendente | | SMTP/TLS | Por atribuir | — | Pendente | — | | Monitorização | Por atribuir | — | Pendente | — | | Restauro | Por atribuir | — | Pendente | — | diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md index dcfe2de..42394d2 100644 --- a/docs/PROJECT_STATUS.md +++ b/docs/PROJECT_STATUS.md @@ -1,7 +1,7 @@ # Estado do projeto e handoff -**Atualizado em:** 4 de agosto de 2026 -**Fase:** Fases 1 a 6 concluídas; Fase 7 em curso (base técnica de identidade, saúde e recuperação concluída) +**Atualizado em:** 5 de agosto de 2026 +**Fase:** Fases 1 a 6 concluídas; Fase 7 em curso (identidade, saúde, recuperação e ligação de leitura ao Snipe-IT de produção concluídas) **Último agente:** Codex ## Resultado disponível @@ -35,6 +35,9 @@ - Substituição de equipamento apenas por ativo livre da mesma categoria, mantendo a pré-reserva e todo o histórico. - Fila de aprovação com filtros por referência, requisitante, categoria e período, incluindo histórico de ações. - Cliente Snipe-IT operacional com token separado, checkout para utilizador, check-in para a localização habitual e validação de erros transportados em HTTP 200. +- Preflight remoto obrigatório antes de checkout; ativos já atribuídos são recusados sem qualquer pedido de escrita. +- Check-in condicionado a checkout confirmado pelo portal para o mesmo requisitante e a atribuição remota atual ao respetivo utilizador Snipe-IT. +- Snipe-IT de produção formalmente limitado a leitura, checkout e check-in: criar, editar ou eliminar inventário é proibido no código, diagnóstico e procedimentos de agentes. - Área diária do operador protegida pelo perfil `inventory_operator`/`administrator`, com leitura por scanner-teclado ou pesquisa de referência, requisitante, etiqueta e ID Snipe-IT. - Preparação, entrega, devolução e conclusão com máquina de estados, condição física, observações, operador, destinatário e resposta externa em `handover_records`. - Associação persistente entre o utilizador do portal e o respetivo ID no Snipe-IT, confirmada durante uma entrega bem-sucedida. @@ -59,7 +62,7 @@ ## Limites conhecidos -- A integração está implementada e testada contra respostas simuladas; falta configurar e validar URL, versão e token da instância institucional. Até lá, o catálogo utiliza dados fictícios do seeder. +- A URL e os dois tokens distintos estão configurados localmente e responderam com sucesso a pedidos `GET` em 5 de agosto de 2026. A sincronização integral de leitura importou 2367 ativos para o cache local, dos quais 716 estavam atribuídos e 144 marcados como requisitáveis. Não foi executada qualquer escrita real. Falta confirmar institucionalmente a versão, o menor privilégio efetivo dos tokens e as categorias/estados autorizados. - Microsoft Entra ID está implementado e testado com tokens assinados simulados; falta registar/configurar a aplicação no tenant institucional e validar Conditional Access/MFA real. - As notificações estão implementadas com Mailpit em desenvolvimento; falta validar SMTP institucional antes do piloto. - O calendário atual é a vista mensal pessoal do MVP; vistas diária, semanal, global e por equipamento/categoria/localização ficam para as áreas operacional e administrativa. @@ -72,7 +75,7 @@ Implementar a **Fase 7 — hardening e entrada em produção**: 1. configurar e validar Microsoft Entra no tenant institucional, incluindo Conditional Access/MFA; -2. validar a integração contra o Snipe-IT institucional e configurar tokens reais de menor privilégio; +2. confirmar com o responsável Snipe-IT a versão, o âmbito de menor privilégio dos tokens e as categorias/estados requisitáveis; validar checkout/check-in apenas no primeiro movimento institucional legítimo; 3. configurar SMTP institucional, domínio, HTTPS e gestão externa de segredos; 4. ligar os health checks a monitorização/alertas e agendar backups cifrados externos; 5. executar testes de segurança, concorrência, desempenho e end-to-end com equipamentos reais; @@ -81,12 +84,12 @@ Implementar a **Fase 7 — hardening e entrada em produção**: ## Testes da última sessão -Validação concluída em 4 de agosto de 2026: +Validação concluída em 5 de agosto de 2026: - `docker compose exec -T app composer test`: passou; - Laravel Pint: 141 ficheiros sem desvios; - PHPStan/Larastan nível 7: 107 ficheiros, zero erros; -- Pest: 103 testes passaram, 585 asserções e 2 testes de registo ignorados por a funcionalidade estar deliberadamente desativada; +- Pest: 108 testes passaram, 609 asserções e 2 testes de registo ignorados por a funcionalidade estar deliberadamente desativada; - TypeScript (`tsc --noEmit`): passou; - ESLint: passou; - Prettier: passou; @@ -109,6 +112,12 @@ Validação concluída em 4 de agosto de 2026: - smoke tests via Nginx: `/health/live` devolveu `ok` e `/health/ready` devolveu `ready`. - diagnóstico de login local: a base de desenvolvimento estava vazia apesar de migrada; `db:seed --force` repôs 2 utilizadores ativos, 5 perfis e 18 ativos fictícios, sem substituir dados existentes; - login real no browser validado com `demo@escola.pt`: redirecionou para `/dashboard` e não produziu erros de consola. +- testes focados das salvaguardas Snipe-IT: 17 passaram, com 106 asserções, incluindo prova de ausência de `POST` em ativos atribuídos e check-in sem proveniência ou destinatário válidos; +- tokens Snipe-IT confirmados como configurados e distintos; ambos responderam a uma consulta `GET` com sucesso, sem revelar credenciais ou URL; +- sincronização real exclusivamente de leitura concluída: 2367 ativos processados e criados no cache local, sem qualquer pedido de escrita no Snipe-IT; +- cache local após sincronização: 2367 ativos ativos, 716 atribuídos e 144 marcados como requisitáveis; +- frontend: TypeScript, ESLint, Prettier e build Vite passaram novamente; Composer Audit sem avisos conhecidos; +- smoke tests após a sincronização: liveness `ok`, readiness `ready` e `/login` HTTP 200. ## Decisões pendentes do contexto institucional @@ -124,4 +133,4 @@ Estas decisões não bloqueiam o desenvolvimento com defaults seguros, mas devem ## Próximo ficheiro a abrir -Começar em `docs/PRODUCTION_CHECKLIST.md`. A próxima alteração de código pode preparar testes de concorrência/desempenho e observabilidade local; as validações Entra, Snipe-IT e SMTP só ficam concluídas quando existirem serviços institucionais concretos, sem guardar os respetivos segredos no repositório. +Começar em `docs/PRODUCTION_CHECKLIST.md`. Para Snipe-IT, confirmar versão, categorias/estados e permissões efetivas com o responsável institucional; não executar testes de escrita. A próxima alteração de código pode preparar testes de concorrência/desempenho e observabilidade local. Entra, SMTP e o primeiro movimento Snipe-IT só ficam concluídos com validação institucional, sem guardar segredos no repositório. diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md index dffa2e5..415aa4f 100644 --- a/docs/RUNBOOK.md +++ b/docs/RUNBOOK.md @@ -74,6 +74,8 @@ docker compose restart queue scheduler ## Integração Snipe-IT +> **Instância de produção:** é proibido criar, editar ou eliminar produtos, ativos ou quaisquer dados mestres. Diagnósticos e testes técnicos fazem apenas pedidos `GET`. Checkout e check-in só podem ocorrer como movimentos operacionais legítimos iniciados por um operador no portal. Um ativo que já esteja atribuído não pode ser usado para testar ou repetir um checkout. + Definir no ambiente, sem colocar tokens no repositório: ```dotenv @@ -88,6 +90,8 @@ SNIPE_IT_RETRY_DELAY_MS=250 O token de leitura deve pertencer a uma conta técnica com acesso apenas de leitura aos ativos. O token operacional deve ser separado e ter apenas as permissões necessárias para checkout/check-in. A aplicação acrescenta `/api/v1` automaticamente quando a URL não o contém. +Nunca validar o token operacional através de um checkout/check-in artificial. A validade de leitura pode ser confirmada pelo teste de ligação administrativo ou por uma sincronização. A permissão operacional é confirmada apenas no primeiro movimento institucional legítimo, sobre um ativo existente, atualmente não atribuído e previamente reservado no portal. + Operações: ```bash @@ -103,6 +107,8 @@ docker compose exec app php artisan schedule:list Administradores podem testar a ligação, iniciar uma execução e consultar as últimas dez sincronizações em `http://localhost:8000/administracao/snipe-it`. Uma execução falhada mantém o último catálogo válido e não desativa registos ausentes. Consultar `sync_runs`, os logs do worker e o painel antes de repetir. +O teste de ligação e `snipe:sync-assets` consultam a API e alteram apenas o cache local do portal. Não executar comandos improvisados com `POST`, `PUT`, `PATCH` ou `DELETE` contra a URL configurada. Nunca copiar para terminais partilhados, tickets ou documentação os tokens, a URL privada ou corpos integrais devolvidos pela API. + ## Microsoft Entra ID Registar uma aplicação **single tenant** do tipo Web. O redirect URI tem de corresponder exatamente ao domínio do portal e ao caminho `/auth/microsoft/callback`. Em produção é obrigatório HTTPS. @@ -139,6 +145,8 @@ Procedimento de entrega: 4. registar a entrega e verificar a mensagem de sucesso; 5. se existir erro, não voltar a submeter antes de consultar o estado do ativo no Snipe-IT. +Antes do `POST` de checkout, o portal consulta o estado remoto atual. Se o ativo já estiver atribuído — ao destinatário esperado ou a qualquer outro — a entrega é bloqueada sem escrita no Snipe-IT. Não contornar este bloqueio nem alterar o ativo diretamente para prosseguir. + Procedimento de devolução: 1. localizar a requisição ou ler a etiqueta; @@ -147,6 +155,8 @@ Procedimento de devolução: 4. criar um incidente e bloqueio temporal quando o ativo não deva voltar ao catálogo; 5. concluir o processo apenas depois de todos os itens estarem devolvidos. +O check-in só é enviado se existir um checkout confirmado pelo portal para esse item e requisitante, e se a atribuição atual no Snipe-IT corresponder ao mesmo utilizador. Se alguma destas provas falhar, não efetuar o check-in manualmente a partir do portal; reconciliar o histórico com o responsável do inventário. + Uma operação parcial fica em `incident`. Consultar `handover_records` para movimentos confirmados e os eventos `reservation.delivery_failed`/`reservation.return_failed` em `audit_logs`. Comparar cada item com o estado atual no Snipe-IT; corrigir apenas os itens ainda `ready` ou `delivered`. Não alterar estados diretamente na base de dados nem repetir em bloco, porque as escritas externas são deliberadamente não idempotentes e não têm retry automático. ## Ciclo de vida das pré-reservas diff --git a/tests/Feature/OperationsWorkflowTest.php b/tests/Feature/OperationsWorkflowTest.php index 15ba7dd..3c2a243 100644 --- a/tests/Feature/OperationsWorkflowTest.php +++ b/tests/Feature/OperationsWorkflowTest.php @@ -3,6 +3,8 @@ use App\Domain\Identity\Enums\RoleName; use App\Domain\Identity\Models\Role; use App\Domain\Inventory\Models\SnipeAsset; +use App\Domain\Operations\Enums\HandoverType; +use App\Domain\Operations\Models\HandoverRecord; use App\Domain\Reservations\Enums\ReservationItemStatus; use App\Domain\Reservations\Enums\ReservationStatus; use App\Domain\Reservations\Models\Reservation; @@ -54,6 +56,8 @@ function approvedOperationReservation(User $requester, array $assets, string $re 'snipe_asset_id' => $asset->id, 'starts_at' => $reservation->starts_at, 'ends_at' => $reservation->ends_at, + 'buffer_starts_at' => $reservation->starts_at, + 'buffer_ends_at' => $reservation->ends_at, 'status' => ReservationItemStatus::Reserved, ]); } @@ -61,6 +65,34 @@ function approvedOperationReservation(User $requester, array $assets, string $re return $reservation; } +/** @param array $assignedUsers */ +function fakeSuccessfulSnipeOperations(array $assignedUsers = []): void +{ + Http::fake(function (Request $request) use ($assignedUsers) { + if ($request->method() === 'GET' && preg_match('#/hardware/(\d+)$#', $request->url(), $matches) === 1) { + $hardwareId = (int) $matches[1]; + + return Http::response(operationSnipeRow($hardwareId, $assignedUsers[$hardwareId] ?? null)); + } + + return Http::response(['status' => 'success', 'payload' => ['id' => 1]]); + }); +} + +/** @return array */ +function operationSnipeRow(int $hardwareId, ?int $assignedUserId = null): array +{ + return [ + 'id' => $hardwareId, + 'asset_tag' => "EQ-{$hardwareId}", + 'name' => "Equipamento {$hardwareId}", + 'assigned_to' => $assignedUserId === null ? null : [ + 'id' => $assignedUserId, + 'type' => 'user', + ], + ]; +} + it('restringe e apresenta a fila diária aos operadores', function () { $reservation = approvedOperationReservation( User::factory()->create(), @@ -94,7 +126,7 @@ it('prepara e entrega todos os equipamentos com registo auditável', function () expect($reservation->fresh()->status)->toBe(ReservationStatus::Ready) ->and($reservation->items()->firstOrFail()->status)->toBe(ReservationItemStatus::Ready); - Http::fake(['*' => Http::response(['status' => 'success', 'payload' => ['id' => 501]])]); + fakeSuccessfulSnipeOperations(); $this->actingAs($operator) ->post("/operacoes/{$reservation->uuid}/entregar", [ @@ -123,24 +155,56 @@ it('prepara e entrega todos os equipamentos com registo auditável', function () 'condition' => 'Sem danos visíveis', ]) ->assertSessionHasErrors('operation'); + Http::assertSentCount(2); +}); + +it('não toca num equipamento que já esteja em checkout no Snipe-IT', function () { + $operator = inventoryOperator(); + $asset = SnipeAsset::factory()->create(['snipe_asset_id' => 551, 'status_id' => 2]); + $reservation = approvedOperationReservation(User::factory()->create(), [$asset], 'REQ-OPS-REMOTE-ASSIGNED'); + $reservation->update(['status' => ReservationStatus::Ready]); + $reservation->items()->update(['status' => ReservationItemStatus::Ready]); + fakeSuccessfulSnipeOperations([551 => 999]); + + $this->actingAs($operator) + ->post("/operacoes/{$reservation->uuid}/entregar", [ + 'snipe_user_id' => 77, + 'condition' => 'Sem danos visíveis', + ]) + ->assertSessionHasErrors('operation'); + + expect($reservation->fresh()->status)->toBe(ReservationStatus::Ready) + ->and($reservation->items()->firstOrFail()->status)->toBe(ReservationItemStatus::Ready); Http::assertSentCount(1); + Http::assertSent(fn (Request $request): bool => $request->method() === 'GET'); + $this->assertDatabaseCount('handover_records', 0); }); it('regista a devolução no Snipe-IT e conclui o processo', function () { $operator = inventoryOperator(); + $requester = User::factory()->create(['snipe_user_id' => 77]); $asset = SnipeAsset::factory()->create([ 'snipe_asset_id' => 601, 'status_id' => 2, 'location_id' => 9, ]); - $reservation = approvedOperationReservation(User::factory()->create(), [$asset], 'REQ-OPS-RETURN'); + $reservation = approvedOperationReservation($requester, [$asset], 'REQ-OPS-RETURN'); $reservation->update(['status' => ReservationStatus::Delivered]); $reservation->items()->update([ 'status' => ReservationItemStatus::Delivered, 'delivered_at' => now()->subHours(2), ]); - Http::fake(['*' => Http::response(['status' => 'success', 'payload' => ['id' => 601]])]); + $item = $reservation->items()->firstOrFail(); + HandoverRecord::create([ + 'reservation_item_id' => $item->id, + 'type' => HandoverType::Checkout, + 'operator_id' => $operator->id, + 'recipient_id' => $requester->id, + 'condition' => 'Bom estado', + 'snipe_response' => ['status' => 'success'], + ]); + fakeSuccessfulSnipeOperations([601 => 77]); $this->actingAs($operator) ->post("/operacoes/{$reservation->uuid}/devolver", [ @@ -164,6 +228,49 @@ it('regista a devolução no Snipe-IT e conclui o processo', function () { $this->assertDatabaseHas('audit_logs', ['event' => 'reservation.completed']); }); +it('bloqueia check-in sem checkout previamente confirmado pelo portal', function () { + $operator = inventoryOperator(); + $requester = User::factory()->create(['snipe_user_id' => 77]); + $asset = SnipeAsset::factory()->create(['snipe_asset_id' => 651, 'status_id' => 2]); + $reservation = approvedOperationReservation($requester, [$asset], 'REQ-OPS-UNTRACKED-CHECKIN'); + $reservation->update(['status' => ReservationStatus::Delivered]); + $reservation->items()->update(['status' => ReservationItemStatus::Delivered]); + Http::fake(); + + $this->actingAs($operator) + ->post("/operacoes/{$reservation->uuid}/devolver", ['condition' => 'Bom estado']) + ->assertSessionHasErrors('operation'); + + Http::assertNothingSent(); + expect($reservation->items()->firstOrFail()->status)->toBe(ReservationItemStatus::Delivered); +}); + +it('bloqueia check-in quando o checkout do portal pertence a outro destinatário', function () { + $operator = inventoryOperator(); + $requester = User::factory()->create(['snipe_user_id' => 77]); + $otherRecipient = User::factory()->create(['snipe_user_id' => 88]); + $asset = SnipeAsset::factory()->create(['snipe_asset_id' => 652, 'status_id' => 2]); + $reservation = approvedOperationReservation($requester, [$asset], 'REQ-OPS-WRONG-RECIPIENT'); + $reservation->update(['status' => ReservationStatus::Delivered]); + $reservation->items()->update(['status' => ReservationItemStatus::Delivered]); + HandoverRecord::create([ + 'reservation_item_id' => $reservation->items()->firstOrFail()->id, + 'type' => HandoverType::Checkout, + 'operator_id' => $operator->id, + 'recipient_id' => $otherRecipient->id, + 'condition' => 'Bom estado', + 'snipe_response' => ['status' => 'success'], + ]); + Http::fake(); + + $this->actingAs($operator) + ->post("/operacoes/{$reservation->uuid}/devolver", ['condition' => 'Bom estado']) + ->assertSessionHasErrors('operation'); + + Http::assertNothingSent(); + expect($reservation->items()->firstOrFail()->status)->toBe(ReservationItemStatus::Delivered); +}); + it('preserva sucessos e sinaliza incidente quando uma entrega fica parcial', function () { $operator = inventoryOperator(); $first = SnipeAsset::factory()->create(['snipe_asset_id' => 701, 'status_id' => 2]); @@ -173,8 +280,11 @@ it('preserva sucessos e sinaliza incidente quando uma entrega fica parcial', fun $reservation->items()->update(['status' => ReservationItemStatus::Ready]); Http::fakeSequence() + ->push(operationSnipeRow(701)) ->push(['status' => 'success', 'payload' => ['id' => 701]]) + ->push(operationSnipeRow(702)) ->push(['status' => 'error', 'messages' => 'Asset is not deployable']) + ->push(operationSnipeRow(702)) ->push(['status' => 'success', 'payload' => ['id' => 702]]); $this->actingAs($operator) diff --git a/tests/Feature/SnipeItClientTest.php b/tests/Feature/SnipeItClientTest.php index 9f2435f..4498b6d 100644 --- a/tests/Feature/SnipeItClientTest.php +++ b/tests/Feature/SnipeItClientTest.php @@ -63,14 +63,18 @@ it('recusa erros transportados num HTTP 200', function () { }); it('regista uma entrega com o token operacional e o destinatário Snipe-IT', function () { - Http::fake([ - '*' => Http::response(['status' => 'success', 'payload' => ['id' => 41]], 200), - ]); + Http::fakeSequence() + ->push(snipeClientRow(41)) + ->push(['status' => 'success', 'payload' => ['id' => 41]]); $payload = app(SnipeItClient::class)->checkoutHardware(41, 2, 73, '2026-08-10', 'Reserva RR-001'); expect($payload['status'])->toBe('success'); + Http::assertSent(fn (Request $request): bool => $request->method() === 'GET' + && $request->url() === 'https://inventory.example.test/api/v1/hardware/41' + && $request->hasHeader('Authorization', 'Bearer read-token-for-tests')); + Http::assertSent(function (Request $request): bool { return $request->method() === 'POST' && $request->url() === 'https://inventory.example.test/api/v1/hardware/41/checkout' @@ -85,12 +89,24 @@ it('regista uma entrega com o token operacional e o destinatário Snipe-IT', fun }); }); -it('regista uma devolução com estado e localização no Snipe-IT', function () { +it('bloqueia checkout quando o equipamento já está atribuído sem enviar escrita', function () { Http::fake([ - '*' => Http::response(['status' => 'success', 'payload' => ['id' => 41]], 200), + '*' => Http::response(snipeClientRow(41, 99)), ]); - app(SnipeItClient::class)->checkinHardware(41, 2, 9, 'Devolvido sem danos'); + expect(fn () => app(SnipeItClient::class)->checkoutHardware(41, 2, 73, '2026-08-10')) + ->toThrow(SnipeItApiException::class, 'já está atribuído'); + + Http::assertSentCount(1); + Http::assertSent(fn (Request $request): bool => $request->method() === 'GET'); +}); + +it('regista uma devolução com estado e localização no Snipe-IT', function () { + Http::fakeSequence() + ->push(snipeClientRow(41, 73)) + ->push(['status' => 'success', 'payload' => ['id' => 41]]); + + app(SnipeItClient::class)->checkinHardware(41, 2, 73, 9, 'Devolvido sem danos'); Http::assertSent(function (Request $request): bool { return $request->method() === 'POST' @@ -103,21 +119,37 @@ it('regista uma devolução com estado e localização no Snipe-IT', function () }); }); -it('recusa erros operacionais transportados num HTTP 200', function () { +it('bloqueia check-in se a atribuição remota não corresponder ao destinatário', function () { Http::fake([ - '*' => Http::response(['status' => 'error', 'messages' => 'Asset is not deployable'], 200), + '*' => Http::response(snipeClientRow(41, 99)), ]); + expect(fn () => app(SnipeItClient::class)->checkinHardware(41, 2, 73, 9)) + ->toThrow(SnipeItApiException::class, 'não está atribuído ao destinatário esperado'); + + Http::assertSentCount(1); + Http::assertSent(fn (Request $request): bool => $request->method() === 'GET'); +}); + +it('recusa erros operacionais transportados num HTTP 200', function () { + Http::fakeSequence() + ->push(snipeClientRow(41)) + ->push(['status' => 'error', 'messages' => 'Asset is not deployable']); + expect(fn () => app(SnipeItClient::class)->checkoutHardware(41, 2, 73, '2026-08-10')) ->toThrow(SnipeItApiException::class, 'recusou a entrega'); }); /** @return array */ -function snipeClientRow(int $id): array +function snipeClientRow(int $id, ?int $assignedUserId = null): array { return [ 'id' => $id, 'asset_tag' => "EQ-{$id}", 'name' => "Equipamento {$id}", + 'assigned_to' => $assignedUserId === null ? null : [ + 'id' => $assignedUserId, + 'type' => 'user', + ], ]; }