feat(setup): "Claim this frame" checkbox for previously-bound MACs
CI / test (push) Has been cancelled

Use case: old owner sells the device to a friend. Friend holds the BOOT
button to wipe NVS, joins the device's AP, sets new WiFi. The old
owner's account is still bound to the MAC server-side, so without
explicit consent the friend would silently take over (or, worse, the
old owner's photos would keep displaying until claim).

Flow now:
  - GET /setup/{mac} detects MAC bound to anyone and renders a
    "Claim this frame as my own" checkbox + a banner explaining what
    the takeover wipes. Both register and login panels carry the
    checkbox; submitting either form without it bounces back through
    the index with a session-flashed error.
  - DeviceService::linkToUser now requires allowClaim=true to
    transfer ownership. Without it, throws DeviceClaimRequiredException
    that the controller catches and turns into the bounce-with-error.
  - On a successful claim, the takeover wipes:
      * old image-device approvals
      * device_image_history rows for the device
      * name, wakeTimes, currentImage*, lockedImage, nextPollExpectedAt
    so the new owner starts from a fresh slate, not inheriting the
    seller's "Living Room / 4:30 AM" preset.
  - Already-logged-in user visiting /setup/{mac} for someone else's
    device falls through to the form (instead of silently transferring
    on page load) so the checkbox is the only path.

Test matrix:
  - SetupControllerTest: 5 new functional cases — checkbox renders for
    bound MACs, register/login without checkbox bounce + retain old
    ownership, register WITH checkbox transfers + purges, logged-in
    other-user falls through to form.
  - DeviceServiceTest: 3 new unit cases — throw without consent,
    isClaimedByAnotherUser true/false matrix, takeover resets device
    state.

Coverage: 99.70% lines / 98.19% methods backend, 333 frontend tests
green via ddev tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 14:45:52 -04:00
parent a9ad014bd1
commit ece0defe3f
6 changed files with 353 additions and 19 deletions
+59 -2
View File
@@ -61,8 +61,9 @@ class DeviceServiceTest extends AppKernelTestCase
$this->em()->persist($history);
$this->em()->flush();
// Transfer to user2
$this->service->linkToUser('aa:bb:cc:dd:ee:03', $user2);
// Transfer to user2 — must explicitly opt in via allowClaim now,
// otherwise the service throws DeviceClaimRequiredException.
$this->service->linkToUser('aa:bb:cc:dd:ee:03', $user2, allowClaim: true);
// History should be gone
$count = $this->em()->createQueryBuilder()
@@ -79,4 +80,60 @@ class DeviceServiceTest extends AppKernelTestCase
$this->em()->refresh($image);
$this->assertFalse($image->isApprovedForDevice($device));
}
public function test_link_throws_DeviceClaimRequiredException_without_consent(): void
{
$user1 = $this->createUser('claimreq1@example.com');
$user2 = $this->createUser('claimreq2@example.com');
$device = new Device();
$device->setMac('AA:BB:CC:DD:EE:04');
$device->setName('Old');
$device->setUser($user1);
$this->em()->persist($device);
$this->em()->flush();
$this->expectException(\App\Service\DeviceClaimRequiredException::class);
$this->service->linkToUser('AA:BB:CC:DD:EE:04', $user2);
}
public function test_isClaimedByAnotherUser_returns_true_only_when_owned_by_someone_else(): void
{
$owner = $this->createUser('claim-owner@example.com');
$other = $this->createUser('claim-other@example.com');
$device = new Device();
$device->setMac('AA:BB:CC:DD:EE:05');
$device->setName('Frame');
$device->setUser($owner);
$this->em()->persist($device);
$this->em()->flush();
$this->assertFalse($this->service->isClaimedByAnotherUser('AA:BB:CC:DD:EE:05', $owner));
$this->assertTrue($this->service->isClaimedByAnotherUser('AA:BB:CC:DD:EE:05', $other));
// Unknown MAC: not claimed by anyone, so not "claimed by another."
$this->assertFalse($this->service->isClaimedByAnotherUser('FF:FF:FF:FF:FF:FF', $other));
}
public function test_link_resets_device_specific_state_on_takeover(): void
{
$oldOwner = $this->createUser('takeover-old@example.com');
$newOwner = $this->createUser('takeover-new@example.com');
$device = new Device();
$device->setMac('AA:BB:CC:DD:EE:06');
$device->setName('Living Room');
$device->setUser($oldOwner);
$device->setWakeTimes([6 * 60, 22 * 60]);
$this->em()->persist($device);
$this->em()->flush();
$this->service->linkToUser('AA:BB:CC:DD:EE:06', $newOwner, allowClaim: true);
$this->em()->refresh($device);
$this->assertSame('', $device->getName(), 'name reset');
$this->assertSame([], $device->getWakeTimes(), 'wakeTimes reset');
$this->assertNull($device->getCurrentImage(), 'currentImage reset');
$this->assertNull($device->getNextPollExpectedAt(), 'next-poll reset');
}
}