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
@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Tests\Functional\Controller;
use App\Entity\Device;
use App\Entity\DeviceImageHistory;
use App\Entity\Image;
use App\Enum\DeviceModel;
use App\Enum\Orientation;
use App\Tests\Functional\AppWebTestCase;
@@ -173,4 +175,151 @@ class SetupControllerTest extends AppWebTestCase
// Symfony 7 returns 422 for submitted-but-invalid forms
$this->assertResponseStatusCodeSame(422);
}
// ── Sell-to-friend / claim-device flow ───────────────────────────────
private function makeBoundDevice(string $mac, string $ownerEmail): array
{
$owner = $this->createUser($ownerEmail);
$device = new Device();
$device->setMac($mac);
$device->setName('Old Owner Frame');
$device->setUser($owner);
$device->setModel(DeviceModel::V1);
$device->setOrientation(Orientation::Landscape);
$this->em()->persist($device);
$this->em()->flush();
return [$owner, $device];
}
// S-CLAIM-01: GET /setup/{mac} for an already-bound MAC shows the claim
// checkbox so the new owner has to acknowledge what they're erasing.
public function test_setup_index_shows_claim_checkbox_when_mac_already_bound(): void
{
[, $device] = $this->makeBoundDevice(self::MAC, 'old-owner@example.com');
$crawler = $this->client->request('GET', '/setup/' . self::MAC);
$this->assertResponseIsSuccessful();
$this->assertSelectorTextContains('.claim-banner', 'already linked to another account');
// Checkbox appears in BOTH the register and login panels.
$this->assertCount(2, $crawler->filter('input[name="claim_device"][type="checkbox"]'));
}
// S-CLAIM-02: POST /register without claim_device on a bound MAC bounces
// back through the setup index with an error in session.
public function test_register_without_claim_checkbox_bounces_with_error(): void
{
$this->makeBoundDevice(self::MAC, 'old-claim02@example.com');
$this->client->request('POST', '/setup/' . self::MAC . '/register', [
'registration_form' => [
'email' => 'new-claim02@example.com',
'plainPassword' => [
'first' => 'secretpass1',
'second' => 'secretpass1',
],
],
]);
$this->assertResponseRedirects('/setup/' . self::MAC);
// Follow the redirect and assert the error surfaces.
$this->client->followRedirect();
$this->assertSelectorTextContains('.field-error', 'already linked');
// Device ownership should NOT have transferred.
$this->em()->clear();
$reloaded = $this->em()->getRepository(Device::class)->findOneBy(['mac' => self::MAC]);
$this->assertNotNull($reloaded->getUser());
$this->assertSame('old-claim02@example.com', $reloaded->getUser()->getEmail());
}
// S-CLAIM-03: POST /register WITH claim_device=1 transfers ownership and
// purges old image history + image-device approvals.
public function test_register_with_claim_checkbox_transfers_ownership_and_purges_history(): void
{
[$oldOwner, $device] = $this->makeBoundDevice(self::MAC, 'old-claim03@example.com');
// Old owner has an image approved for this device + a history row.
$image = (new Image())->setUser($oldOwner)->setOriginalFilename('p.jpg')->setStoragePath('p');
$image->approveForDevice($device);
$this->em()->persist($image);
$history = new DeviceImageHistory($device, $image);
$this->em()->persist($history);
$device->setName('Living Room')->setWakeTimes([6 * 60]);
$this->em()->flush();
$deviceId = $device->getId();
$this->client->request('POST', '/setup/' . self::MAC . '/register', [
'registration_form' => [
'email' => 'new-claim03@example.com',
'plainPassword' => [
'first' => 'secretpass1',
'second' => 'secretpass1',
],
],
'claim_device' => '1',
]);
$this->assertResponseRedirects('/setup/' . self::MAC . '/configure');
$this->em()->clear();
$reloaded = $this->em()->find(Device::class, $deviceId);
$this->assertSame('new-claim03@example.com', $reloaded->getUser()->getEmail(), 'ownership transferred');
$this->assertSame('', $reloaded->getName(), 'name reset on takeover');
$this->assertSame([], $reloaded->getWakeTimes(), 'wakeTimes reset on takeover');
// Old history is gone.
$count = (int) $this->em()->createQueryBuilder()
->select('COUNT(h.id)')
->from(DeviceImageHistory::class, 'h')
->where('h.device = :d')
->setParameter('d', $reloaded)
->getQuery()
->getSingleScalarResult();
$this->assertSame(0, $count, 'history purged');
// Old image's approval for this device is gone too.
$reloadedImage = $this->em()->find(Image::class, $image->getId());
$approvedIds = array_map(
fn(Device $d) => $d->getId(),
$reloadedImage->getApprovedDevices()->toArray(),
);
$this->assertNotContains($deviceId, $approvedIds, 'image-device approval revoked');
}
// S-CLAIM-04: POST /login without claim_device on a bound MAC bounces
// back with the error (login still happens — the user is now logged in,
// but the device transfer didn't go through).
public function test_login_without_claim_checkbox_bounces_with_error(): void
{
$this->makeBoundDevice(self::MAC, 'old-claim04@example.com');
$newOwner = $this->createUser('new-claim04@example.com');
$this->client->request('POST', '/setup/' . self::MAC . '/login', [
'_username' => 'new-claim04@example.com',
'_password' => 'password',
]);
$this->assertResponseRedirects('/setup/' . self::MAC);
$this->client->followRedirect();
$this->assertSelectorTextContains('.field-error', 'already linked');
$this->em()->clear();
$reloaded = $this->em()->getRepository(Device::class)->findOneBy(['mac' => self::MAC]);
$this->assertSame('old-claim04@example.com', $reloaded->getUser()->getEmail(), 'no transfer without checkbox');
}
// S-CLAIM-05: GET /setup/{mac} for an already-logged-in user who is NOT
// the current owner falls through to the form (showing the checkbox)
// rather than silently transferring on visit. This is the "sold to a
// friend whose phone is already logged in" scenario.
public function test_index_falls_through_to_form_when_logged_in_user_is_not_current_owner(): void
{
$this->makeBoundDevice(self::MAC, 'old-claim05@example.com');
$other = $this->createUser('other-claim05@example.com');
$this->loginAs($other);
$this->client->request('GET', '/setup/' . self::MAC);
$this->assertResponseIsSuccessful();
$this->assertSelectorTextContains('.claim-banner', 'already linked');
}
}