feat: protect production Snipe-IT operations

This commit is contained in:
2026-08-05 12:37:12 +01:00
parent 26330b75f7
commit 8c69331118
13 changed files with 289 additions and 26 deletions

View File

@@ -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. - 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. - 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 ## Definição de concluído
Uma alteração só está concluída quando: Uma alteração só está concluída quando:

View File

@@ -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. - 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. - 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. - 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 ### 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. - 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. - 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. - 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 ### 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. - 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. - 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. - 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.

View File

@@ -4,7 +4,7 @@ Portal interno para consultar, requisitar, aprovar, entregar e devolver equipame
## Estado atual ## 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. 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 ## 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. 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.

View File

@@ -144,7 +144,7 @@ final class HandoverService
$this->authorizeOperator($operator); $this->authorizeOperator($operator);
$this->assertOperationalConfiguration(); $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, [ if (! in_array($reservation->status, [
ReservationStatus::Delivered, ReservationStatus::Delivered,
ReservationStatus::InUse, ReservationStatus::InUse,
@@ -166,10 +166,23 @@ final class HandoverService
if ($asset->status_id === null) { if ($asset->status_id === null) {
throw ValidationException::withMessages(['operation' => "O equipamento {$asset->asset_tag} não tem estado Snipe-IT sincronizado."]); 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( $response = $this->snipeIt->checkinHardware(
(int) $asset->snipe_asset_id, (int) $asset->snipe_asset_id,
(int) $asset->status_id, (int) $asset->status_id,
(int) $reservation->requester->snipe_user_id,
$asset->location_id === null ? null : (int) $asset->location_id, $asset->location_id === null ? null : (int) $asset->location_id,
$this->externalNote($reservation, $notes), $this->externalNote($reservation, $notes),
); );

View File

@@ -27,6 +27,22 @@ final readonly class AssetData
public ?CarbonImmutable $sourceUpdatedAt, 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> */ /** @return array<string, mixed> */
public function toModelAttributes(): array public function toModelAttributes(): array
{ {

View File

@@ -2,6 +2,7 @@
namespace App\Integrations\SnipeIt; namespace App\Integrations\SnipeIt;
use App\Integrations\SnipeIt\Data\AssetData;
use App\Integrations\SnipeIt\Data\HardwarePage; use App\Integrations\SnipeIt\Data\HardwarePage;
use App\Integrations\SnipeIt\Exceptions\SnipeItApiException; use App\Integrations\SnipeIt\Exceptions\SnipeItApiException;
use App\Integrations\SnipeIt\Exceptions\SnipeItConfigurationException; use App\Integrations\SnipeIt\Exceptions\SnipeItConfigurationException;
@@ -16,6 +17,8 @@ use Throwable;
final class SnipeItClient final class SnipeItClient
{ {
public function __construct(private readonly SnipeItAssetMapper $mapper) {}
public function isConfigured(): bool public function isConfigured(): bool
{ {
return filled(config('services.snipe_it.url')) && filled(config('services.snipe_it.read_token')); 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); } 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> */ /** @return array<string, mixed> */
public function checkoutHardware( public function checkoutHardware(
int $hardwareId, int $hardwareId,
@@ -108,6 +132,13 @@ final class SnipeItClient
): array { ): array {
$this->assertPositiveIdentifiers($hardwareId, $statusId, $assignedUserId); $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 = [ $payload = [
'status_id' => $statusId, 'status_id' => $statusId,
'checkout_to_type' => 'user', 'checkout_to_type' => 'user',
@@ -128,15 +159,23 @@ final class SnipeItClient
public function checkinHardware( public function checkinHardware(
int $hardwareId, int $hardwareId,
int $statusId, int $statusId,
int $expectedAssignedUserId,
?int $locationId = null, ?int $locationId = null,
?string $note = null, ?string $note = null,
): array { ): array {
$this->assertPositiveIdentifiers($hardwareId, $statusId); $this->assertPositiveIdentifiers($hardwareId, $statusId, $expectedAssignedUserId);
if ($locationId !== null && $locationId < 1) { if ($locationId !== null && $locationId < 1) {
throw new SnipeItConfigurationException('O identificador da localização do Snipe-IT tem de ser positivo.'); 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]; $payload = ['status_id' => $statusId];
if ($locationId !== null) { if ($locationId !== null) {

View File

@@ -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`. 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. 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. 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. `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. 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.

View File

@@ -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. 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. 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.

View File

@@ -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 de leitura com menor privilégio.
- [ ] Criar conta/token operacional separado, apenas com checkout/check-in necessários. - [ ] 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. - [ ] 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. - [ ] Confirmar resposta a indisponibilidade, timeout e falha parcial sem retries de escrita.
## 3. Email, domínio e TLS ## 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 | | Área | Responsável | Data | Resultado | Evidência externa |
|---|---|---|---|---| |---|---|---|---|---|
| Entra ID | Por atribuir | — | Pendente | — | | 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 | — | | SMTP/TLS | Por atribuir | — | Pendente | — |
| Monitorização | Por atribuir | — | Pendente | — | | Monitorização | Por atribuir | — | Pendente | — |
| Restauro | Por atribuir | — | Pendente | — | | Restauro | Por atribuir | — | Pendente | — |

View File

@@ -1,7 +1,7 @@
# Estado do projeto e handoff # Estado do projeto e handoff
**Atualizado em:** 4 de agosto de 2026 **Atualizado em:** 5 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) **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 **Último agente:** Codex
## Resultado disponível ## 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. - 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. - 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. - 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. - Á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`. - 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. - 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 ## 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. - 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. - 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. - 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**: Implementar a **Fase 7 — hardening e entrada em produção**:
1. configurar e validar Microsoft Entra no tenant institucional, incluindo Conditional Access/MFA; 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; 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; 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; 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 ## 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; - `docker compose exec -T app composer test`: passou;
- Laravel Pint: 141 ficheiros sem desvios; - Laravel Pint: 141 ficheiros sem desvios;
- PHPStan/Larastan nível 7: 107 ficheiros, zero erros; - 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; - TypeScript (`tsc --noEmit`): passou;
- ESLint: passou; - ESLint: passou;
- Prettier: 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`. - 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; - 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. - 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 ## 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 ## 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.

View File

@@ -74,6 +74,8 @@ docker compose restart queue scheduler
## Integração Snipe-IT ## 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: Definir no ambiente, sem colocar tokens no repositório:
```dotenv ```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. 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: Operações:
```bash ```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. 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 ## 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. 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; 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. 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: Procedimento de devolução:
1. localizar a requisição ou ler a etiqueta; 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; 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. 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. 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 ## Ciclo de vida das pré-reservas

View File

@@ -3,6 +3,8 @@
use App\Domain\Identity\Enums\RoleName; use App\Domain\Identity\Enums\RoleName;
use App\Domain\Identity\Models\Role; use App\Domain\Identity\Models\Role;
use App\Domain\Inventory\Models\SnipeAsset; 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\ReservationItemStatus;
use App\Domain\Reservations\Enums\ReservationStatus; use App\Domain\Reservations\Enums\ReservationStatus;
use App\Domain\Reservations\Models\Reservation; use App\Domain\Reservations\Models\Reservation;
@@ -54,6 +56,8 @@ function approvedOperationReservation(User $requester, array $assets, string $re
'snipe_asset_id' => $asset->id, 'snipe_asset_id' => $asset->id,
'starts_at' => $reservation->starts_at, 'starts_at' => $reservation->starts_at,
'ends_at' => $reservation->ends_at, 'ends_at' => $reservation->ends_at,
'buffer_starts_at' => $reservation->starts_at,
'buffer_ends_at' => $reservation->ends_at,
'status' => ReservationItemStatus::Reserved, 'status' => ReservationItemStatus::Reserved,
]); ]);
} }
@@ -61,6 +65,34 @@ function approvedOperationReservation(User $requester, array $assets, string $re
return $reservation; 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 () { it('restringe e apresenta a fila diária aos operadores', function () {
$reservation = approvedOperationReservation( $reservation = approvedOperationReservation(
User::factory()->create(), 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) expect($reservation->fresh()->status)->toBe(ReservationStatus::Ready)
->and($reservation->items()->firstOrFail()->status)->toBe(ReservationItemStatus::Ready); ->and($reservation->items()->firstOrFail()->status)->toBe(ReservationItemStatus::Ready);
Http::fake(['*' => Http::response(['status' => 'success', 'payload' => ['id' => 501]])]); fakeSuccessfulSnipeOperations();
$this->actingAs($operator) $this->actingAs($operator)
->post("/operacoes/{$reservation->uuid}/entregar", [ ->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', 'condition' => 'Sem danos visíveis',
]) ])
->assertSessionHasErrors('operation'); ->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::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 () { it('regista a devolução no Snipe-IT e conclui o processo', function () {
$operator = inventoryOperator(); $operator = inventoryOperator();
$requester = User::factory()->create(['snipe_user_id' => 77]);
$asset = SnipeAsset::factory()->create([ $asset = SnipeAsset::factory()->create([
'snipe_asset_id' => 601, 'snipe_asset_id' => 601,
'status_id' => 2, 'status_id' => 2,
'location_id' => 9, '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->update(['status' => ReservationStatus::Delivered]);
$reservation->items()->update([ $reservation->items()->update([
'status' => ReservationItemStatus::Delivered, 'status' => ReservationItemStatus::Delivered,
'delivered_at' => now()->subHours(2), '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) $this->actingAs($operator)
->post("/operacoes/{$reservation->uuid}/devolver", [ ->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']); $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 () { it('preserva sucessos e sinaliza incidente quando uma entrega fica parcial', function () {
$operator = inventoryOperator(); $operator = inventoryOperator();
$first = SnipeAsset::factory()->create(['snipe_asset_id' => 701, 'status_id' => 2]); $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]); $reservation->items()->update(['status' => ReservationItemStatus::Ready]);
Http::fakeSequence() Http::fakeSequence()
->push(operationSnipeRow(701))
->push(['status' => 'success', 'payload' => ['id' => 701]]) ->push(['status' => 'success', 'payload' => ['id' => 701]])
->push(operationSnipeRow(702))
->push(['status' => 'error', 'messages' => 'Asset is not deployable']) ->push(['status' => 'error', 'messages' => 'Asset is not deployable'])
->push(operationSnipeRow(702))
->push(['status' => 'success', 'payload' => ['id' => 702]]); ->push(['status' => 'success', 'payload' => ['id' => 702]]);
$this->actingAs($operator) $this->actingAs($operator)

View File

@@ -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 () { it('regista uma entrega com o token operacional e o destinatário Snipe-IT', function () {
Http::fake([ Http::fakeSequence()
'*' => Http::response(['status' => 'success', 'payload' => ['id' => 41]], 200), ->push(snipeClientRow(41))
]); ->push(['status' => 'success', 'payload' => ['id' => 41]]);
$payload = app(SnipeItClient::class)->checkoutHardware(41, 2, 73, '2026-08-10', 'Reserva RR-001'); $payload = app(SnipeItClient::class)->checkoutHardware(41, 2, 73, '2026-08-10', 'Reserva RR-001');
expect($payload['status'])->toBe('success'); 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 { Http::assertSent(function (Request $request): bool {
return $request->method() === 'POST' return $request->method() === 'POST'
&& $request->url() === 'https://inventory.example.test/api/v1/hardware/41/checkout' && $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::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 { Http::assertSent(function (Request $request): bool {
return $request->method() === 'POST' 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::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')) expect(fn () => app(SnipeItClient::class)->checkoutHardware(41, 2, 73, '2026-08-10'))
->toThrow(SnipeItApiException::class, 'recusou a entrega'); ->toThrow(SnipeItApiException::class, 'recusou a entrega');
}); });
/** @return array<string, mixed> */ /** @return array<string, mixed> */
function snipeClientRow(int $id): array function snipeClientRow(int $id, ?int $assignedUserId = null): array
{ {
return [ return [
'id' => $id, 'id' => $id,
'asset_tag' => "EQ-{$id}", 'asset_tag' => "EQ-{$id}",
'name' => "Equipamento {$id}", 'name' => "Equipamento {$id}",
'assigned_to' => $assignedUserId === null ? null : [
'id' => $assignedUserId,
'type' => 'user',
],
]; ];
} }