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

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

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 () {
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',
],
];
}