feat(devices): owner can mark a frame as sold and unlink it pre-emptively
CI / test (push) Has been cancelled

Pairs with the new claim-on-takeover checkbox: now the seller can purge
their data BEFORE handing the device over, so even if they forget to
hold the BOOT button to wipe NVS, the next owner can't accidentally pull
their photos.

Backend:
  - DELETE /api/devices/{id}: owner-only (404 for cross-tenant). Revokes
    image-device approvals, drops history rows, removes the Device row
    entirely so the MAC is unclaimed. The next poll from that physical
    frame returns 404 → setup QR for the next owner.
  - DeviceService::deleteDeviceForOwner extracts the cleanup so the
    controller stays thin.
  - Mercure publish on delete sends {id, deleted: true} so any other
    open PWA tabs splice the row out instantly.

Frontend:
  - Settings sheet (BaseBottomSheet): "Remove this frame" link below
    Save, in danger red with an explanatory hint about when to use it.
  - Native window.confirm gate — destructive + irreversible, the
    weight of native-confirm is honest. (A bespoke modal would be
    polish.)
  - useDeviceMercure: handles the {id, deleted: true} sentinel — splices
    the device out + closes its own EventSource for that topic.
  - useDevicesStore.removeDevice: DELETE + local store filter.

Tests added:
  - DeviceApiControllerTest: 4 cases — happy-path delete purges
    everything, 404 cross-tenant, anon redirects to login, and
    post-delete the device-poll endpoint 404s (fresh-MAC guarantee).
  - HomeView.test.ts: confirm-yes calls store + closes sheet,
    confirm-cancel does NOT call removeDevice.
  - useDeviceMercure.test.ts: deletion sentinel splices the device
    out and closes the EventSource.

Coverage: 99.71% lines / 98.21% methods backend, 98.31% lines frontend.
558 tests total via ddev tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 14:53:51 -04:00
parent ece0defe3f
commit 920de623a0
8 changed files with 291 additions and 2 deletions
@@ -576,6 +576,94 @@ class DeviceApiControllerTest extends AppWebTestCase
@unlink(preg_replace('/\.bin$/', '.png', $absPath));
}
// ── DELETE /api/devices/{id} (sell/give-away) ────────────────────────
public function test_delete_removes_device_and_purges_history_and_approvals(): void
{
$owner = $this->createUser('del@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:F0', $owner);
$deviceId = $device->getId();
// Pre-existing approvals + history we expect the delete to wipe.
$image = $this->makeImage($owner);
$image->approveForDevice($device);
$history = new \App\Entity\DeviceImageHistory($device, $image);
$this->em()->persist($history);
$this->em()->flush();
$client = $this->loginAs($owner);
$client->request('DELETE', '/api/devices/' . $deviceId);
$this->assertResponseStatusCodeSame(204);
$this->em()->clear();
$this->assertNull($this->em()->find(\App\Entity\Device::class, $deviceId), 'device row removed');
// Approval revoked: image still exists, but no longer approved for the deleted device id.
$reloadedImage = $this->em()->find(\App\Entity\Image::class, $image->getId());
$approvedIds = array_map(
fn(\App\Entity\Device $d) => $d->getId(),
$reloadedImage->getApprovedDevices()->toArray(),
);
$this->assertNotContains($deviceId, $approvedIds);
// History rows for the deleted device cascaded out.
$count = (int) $this->em()->createQueryBuilder()
->select('COUNT(h.id)')
->from(\App\Entity\DeviceImageHistory::class, 'h')
->where('h.device = :id')
->setParameter('id', $deviceId)
->getQuery()
->getSingleScalarResult();
$this->assertSame(0, $count);
}
public function test_delete_404_for_other_users_device(): void
{
$owner = $this->createUser('del-own@example.com');
$other = $this->createUser('del-other@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:F1', $owner);
$deviceId = $device->getId();
$client = $this->loginAs($other);
$client->request('DELETE', '/api/devices/' . $deviceId);
$this->assertResponseStatusCodeSame(404);
// Owner's device must still exist — no cross-tenant nuking.
$this->em()->clear();
$this->assertNotNull($this->em()->find(\App\Entity\Device::class, $deviceId));
}
public function test_delete_unauthenticated_returns_unauthorized(): void
{
$owner = $this->createUser('del-anon@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:F2', $owner);
$this->client->request('DELETE', '/api/devices/' . $device->getId());
// Anon hits ROLE_USER firewall first — login redirect.
$this->assertResponseRedirects();
}
// After the seller deletes the device, the next poll from the physical
// hardware (with that MAC) sees an unknown MAC and the firmware shows the
// setup QR. Verifies the "delete leaves no server-side claim" guarantee.
public function test_after_delete_device_poll_returns_404(): void
{
$owner = $this->createUser('del-poll@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:F3', $owner);
$mac = $device->getMac();
$client = $this->loginAs($owner);
$client->request('DELETE', '/api/devices/' . $device->getId());
$this->assertResponseStatusCodeSame(204);
// Anonymous device-poll endpoint — same MAC, must now 404.
$client->request('GET', '/api/device/' . $mac . '/image');
$this->assertResponseStatusCodeSame(404);
}
public function test_preview_404_when_image_is_soft_deleted(): void
{
$user = $this->createUser('pdeleted@example.com');