feat: protect production Snipe-IT operations
This commit is contained in:
10
AGENTS.md
10
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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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<string, mixed> */
|
||||
public function toModelAttributes(): array
|
||||
{
|
||||
|
||||
@@ -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<string, mixed> $payload */
|
||||
return $this->mapper->map($payload);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 | — |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<int, int> $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<string, mixed> */
|
||||
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)
|
||||
|
||||
@@ -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<string, mixed> */
|
||||
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',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user