f30a6a8f87
CI / test (push) Has been cancelled
A freshly-claimed device (BOOT-button reset → buyer logs in / registers
→ linkToUser sets noon-daily wakeTimes default) was polling every 15s
per the firmware's FIRST_IMAGE_POLL bootstrap, but the server's
schedule-gating refused to run advance() because we weren't at noon
yet. Result: panel sat dark from claim until the next wakeTime fired,
which could be hours away.
Add a third bypass case in DeviceImageController::image: when the
device sends no X-Current-Image-Id header (i.e. its NVS img_id is
still -1, meaning it has never successfully painted an image),
treat the poll as a bootstrap and advance() regardless of schedule.
Once the panel paints, the next poll carries X-Current-Image-Id and
schedule-gating resumes.
Compatible with all the existing bypass logic:
- Locked image still wins.
- Cold-boot resync (X-Boot-Reason: cold) still bypasses.
- The just-provisioned + stale-binding 204 returns BEFORE this
branch, so a stranger device still can't pull the seller's image.
Test: bootstrap_poll_advances_even_when_schedule_says_not_due — sets
wakeTimes such that schedule says not-due, then polls without the
X-Current-Image-Id header and verifies a new history row was written.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
600 lines
24 KiB
PHP
600 lines
24 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Functional\Controller;
|
|
|
|
use App\Entity\Device;
|
|
use App\Entity\Image;
|
|
use App\Entity\RenderedAsset;
|
|
use App\Enum\DeviceModel;
|
|
use App\Enum\Orientation;
|
|
use App\Enum\RenderStatus;
|
|
use App\Tests\Functional\AppWebTestCase;
|
|
|
|
class DeviceImageControllerTest extends AppWebTestCase
|
|
{
|
|
private const MAC = 'AA:BB:CC:11:22:33';
|
|
private const BIN_PATH = 'var/storage/images/test-img/v1_landscape.bin';
|
|
|
|
private string $binAbsPath;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
$projectDir = static::getContainer()->getParameter('kernel.project_dir');
|
|
$this->binAbsPath = $projectDir . '/' . self::BIN_PATH;
|
|
|
|
$dir = dirname($this->binAbsPath);
|
|
if (!is_dir($dir)) {
|
|
mkdir($dir, 0755, true);
|
|
}
|
|
file_put_contents($this->binAbsPath, 'FAKEBIN');
|
|
}
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
if (file_exists($this->binAbsPath)) {
|
|
unlink($this->binAbsPath);
|
|
}
|
|
parent::tearDown();
|
|
}
|
|
|
|
private function createTestSetup(bool $approveImage = true, bool $withReadyAsset = true): array
|
|
{
|
|
$user = $this->createUser('devimg@example.com');
|
|
|
|
$device = new Device();
|
|
$device->setMac(self::MAC);
|
|
$device->setName('Test Frame');
|
|
$device->setUser($user);
|
|
$device->setModel(DeviceModel::V1);
|
|
$device->setOrientation(Orientation::Landscape);
|
|
$device->setRotationIntervalMinutes(60);
|
|
$this->em()->persist($device);
|
|
|
|
$image = new Image();
|
|
$image->setUser($user)->setOriginalFilename('test.jpg')->setStoragePath('x');
|
|
if ($approveImage) {
|
|
$image->approveForDevice($device);
|
|
}
|
|
$this->em()->persist($image);
|
|
|
|
if ($withReadyAsset) {
|
|
$asset = (new RenderedAsset())
|
|
->setImage($image)
|
|
->setDeviceModel(DeviceModel::V1)
|
|
->setOrientation(Orientation::Landscape)
|
|
->setStatus(RenderStatus::Ready)
|
|
->setFilePath(self::BIN_PATH)
|
|
->setRenderedAt(new \DateTimeImmutable());
|
|
$this->em()->persist($asset);
|
|
}
|
|
|
|
$this->em()->flush();
|
|
|
|
return ['device' => $device, 'image' => $image, 'user' => $user];
|
|
}
|
|
|
|
public function test_returns_404_for_unknown_mac(): void
|
|
{
|
|
$this->client->request('GET', '/api/device/FF:FF:FF:FF:FF:FF/image');
|
|
|
|
$this->assertResponseStatusCodeSame(404);
|
|
}
|
|
|
|
public function test_returns_204_when_no_images(): void
|
|
{
|
|
$user = $this->createUser('noimg@example.com');
|
|
$device = new Device();
|
|
$device->setMac('AA:BB:CC:44:55:66');
|
|
$device->setName('No Image Device');
|
|
$device->setUser($user);
|
|
$this->em()->persist($device);
|
|
$this->em()->flush();
|
|
|
|
$this->client->request('GET', '/api/device/AA:BB:CC:44:55:66/image');
|
|
|
|
$this->assertResponseStatusCodeSame(204);
|
|
}
|
|
|
|
public function test_returns_200_with_binary_body_and_headers(): void
|
|
{
|
|
$this->createTestSetup();
|
|
|
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
|
|
|
|
$this->assertResponseStatusCodeSame(200);
|
|
$response = $this->client->getResponse();
|
|
$this->assertNotEmpty($response->headers->get('X-Image-Id'));
|
|
$this->assertNotEmpty($response->headers->get('X-Interval-Ms'));
|
|
$this->assertSame('application/octet-stream', $response->headers->get('Content-Type'));
|
|
}
|
|
|
|
public function test_returns_304_when_current_image_id_matches(): void
|
|
{
|
|
$setup = $this->createTestSetup();
|
|
$imageId = $setup['image']->getId();
|
|
|
|
// First call to advance rotation and get the image
|
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
|
|
$this->assertResponseStatusCodeSame(200);
|
|
|
|
// Second call with matching X-Current-Image-Id
|
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [
|
|
'HTTP_X-Current-Image-Id' => (string) $imageId,
|
|
]);
|
|
$this->assertResponseStatusCodeSame(304);
|
|
}
|
|
|
|
public function test_returns_200_when_current_image_id_is_stale(): void
|
|
{
|
|
$this->createTestSetup();
|
|
|
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [
|
|
'HTTP_X-Current-Image-Id' => '99999',
|
|
]);
|
|
|
|
$this->assertResponseStatusCodeSame(200);
|
|
}
|
|
|
|
public function test_locked_image_served_without_rotation_advance(): void
|
|
{
|
|
$setup = $this->createTestSetup();
|
|
$device = $setup['device'];
|
|
$deviceId = $device->getId();
|
|
$image = $setup['image'];
|
|
$imageId = $image->getId();
|
|
|
|
$device->setLockedImage($image);
|
|
$this->em()->flush();
|
|
|
|
$this->assertNull($device->getCurrentImage());
|
|
|
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
|
|
|
|
$this->assertResponseStatusCodeSame(200);
|
|
|
|
// After the locked-image poll, currentImage now points at the locked
|
|
// image — the controller sets it directly because RotationService::
|
|
// advance() was bypassed (lock takes precedence). This is what makes
|
|
// Home's preview reflect what the frame is actually showing. The
|
|
// "without rotation advance" guarantee is verified separately by
|
|
// checking that no DeviceImageHistory row was written.
|
|
$this->em()->clear();
|
|
$device = $this->em()->find(\App\Entity\Device::class, $deviceId);
|
|
$this->assertNotNull($device->getCurrentImage());
|
|
$this->assertSame($imageId, $device->getCurrentImage()->getId());
|
|
|
|
$historyCount = (int) $this->em()->createQueryBuilder()
|
|
->select('COUNT(h.id)')
|
|
->from(\App\Entity\DeviceImageHistory::class, 'h')
|
|
->where('h.device = :d')
|
|
->setParameter('d', $device)
|
|
->getQuery()
|
|
->getSingleScalarResult();
|
|
$this->assertSame(0, $historyCount, 'lock path must NOT write a history row');
|
|
}
|
|
|
|
public function test_returns_304_when_locked_image_matches_current_image_id(): void
|
|
{
|
|
$setup = $this->createTestSetup();
|
|
$device = $setup['device'];
|
|
$image = $setup['image'];
|
|
$imageId = $image->getId();
|
|
|
|
// Simulate the device having already received this image at the current
|
|
// orientation and rendered_at — the 304 path now requires all three
|
|
// (image id, orientation, rendered_at) to match.
|
|
$asset = $this->em()->getRepository(RenderedAsset::class)->findOneBy(['image' => $image]);
|
|
$device->setLockedImage($image);
|
|
$device->setCurrentImageOrientation(Orientation::Landscape);
|
|
$device->setCurrentRenderedAt($asset->getRenderedAt());
|
|
$this->em()->flush();
|
|
|
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [
|
|
'HTTP_X-Current-Image-Id' => (string) $imageId,
|
|
]);
|
|
|
|
$this->assertResponseStatusCodeSame(304);
|
|
}
|
|
|
|
public function test_orientation_flip_returns_200_even_when_image_id_matches(): void
|
|
{
|
|
$setup = $this->createTestSetup();
|
|
$device = $setup['device'];
|
|
$image = $setup['image'];
|
|
$imageId = $image->getId();
|
|
|
|
// Seed: device receives the image at landscape orientation.
|
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
|
|
$this->assertResponseStatusCodeSame(200);
|
|
|
|
// User flips device to portrait and a portrait render is ready.
|
|
$this->em()->clear();
|
|
$device = $this->em()->find(Device::class, $setup['device']->getId());
|
|
$device->setOrientation(Orientation::Portrait);
|
|
$portraitAsset = (new RenderedAsset())
|
|
->setImage($this->em()->find(Image::class, $imageId))
|
|
->setDeviceModel(DeviceModel::V1)
|
|
->setOrientation(Orientation::Portrait)
|
|
->setStatus(RenderStatus::Ready)
|
|
->setFilePath(self::BIN_PATH);
|
|
$this->em()->persist($portraitAsset);
|
|
$this->em()->flush();
|
|
|
|
// Same image ID, but device's stored orientation (landscape) no longer
|
|
// matches the device's current orientation (portrait) → must re-send.
|
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [
|
|
'HTTP_X-Current-Image-Id' => (string) $imageId,
|
|
]);
|
|
$this->assertResponseStatusCodeSame(200);
|
|
}
|
|
|
|
public function test_re_render_returns_200_even_when_image_id_and_orientation_match(): void
|
|
{
|
|
$setup = $this->createTestSetup();
|
|
$deviceId = $setup['device']->getId();
|
|
$imageId = $setup['image']->getId();
|
|
|
|
// Seed: device receives the image once, server stores currentRenderedAt.
|
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
|
|
$this->assertResponseStatusCodeSame(200);
|
|
|
|
// Simulate a re-render: the asset's rendered_at advances (e.g. user
|
|
// re-cropped the image, RenderImageMessageHandler ran again).
|
|
$this->em()->clear();
|
|
$asset = $this->em()->getRepository(RenderedAsset::class)->findOneBy([
|
|
'image' => $this->em()->find(Image::class, $imageId),
|
|
'orientation' => Orientation::Landscape,
|
|
]);
|
|
$asset->setRenderedAt(new \DateTimeImmutable('+1 minute'));
|
|
$this->em()->flush();
|
|
|
|
// Same image id, same orientation — but the bytes have changed, so
|
|
// the 304 cache must invalidate and the device must re-fetch.
|
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [
|
|
'HTTP_X-Current-Image-Id' => (string) $imageId,
|
|
]);
|
|
$this->assertResponseStatusCodeSame(200);
|
|
}
|
|
|
|
public function test_poll_advances_current_image(): void
|
|
{
|
|
$setup = $this->createTestSetup();
|
|
$deviceId = $setup['device']->getId();
|
|
$imageId = $setup['image']->getId();
|
|
|
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
|
|
|
|
$this->assertResponseStatusCodeSame(200);
|
|
|
|
// Re-fetch from DB after request clears EM state
|
|
$this->em()->clear();
|
|
$device = $this->em()->find(\App\Entity\Device::class, $deviceId);
|
|
$this->assertNotNull($device->getCurrentImage());
|
|
$this->assertSame($imageId, $device->getCurrentImage()->getId());
|
|
}
|
|
|
|
public function test_x_interval_ms_equals_rotation_interval_minutes_times_60000(): void
|
|
{
|
|
$setup = $this->createTestSetup();
|
|
$device = $setup['device'];
|
|
|
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
|
|
|
|
$response = $this->client->getResponse();
|
|
$intervalMs = (int) $response->headers->get('X-Interval-Ms');
|
|
|
|
$this->assertSame($device->getRotationIntervalMinutes() * 60 * 1000, $intervalMs);
|
|
}
|
|
|
|
public function test_last_seen_at_updated_after_200_poll(): void
|
|
{
|
|
$setup = $this->createTestSetup();
|
|
$deviceId = $setup['device']->getId();
|
|
|
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
|
|
|
|
$this->assertResponseStatusCodeSame(200);
|
|
|
|
$this->em()->clear();
|
|
$device = $this->em()->find(\App\Entity\Device::class, $deviceId);
|
|
$this->assertNotNull($device->getLastSeenAt());
|
|
}
|
|
|
|
public function test_last_seen_at_updated_after_304_poll(): void
|
|
{
|
|
$setup = $this->createTestSetup();
|
|
$deviceId = $setup['device']->getId();
|
|
$imageId = $setup['image']->getId();
|
|
|
|
// Seed rotation first
|
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
|
|
|
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [
|
|
'HTTP_X-Current-Image-Id' => (string) $imageId,
|
|
]);
|
|
$this->assertResponseStatusCodeSame(304);
|
|
|
|
// Re-fetch from DB since the EM may have been cleared by the request
|
|
$this->em()->clear();
|
|
$device = $this->em()->find(\App\Entity\Device::class, $deviceId);
|
|
$this->assertNotNull($device->getLastSeenAt());
|
|
}
|
|
|
|
// Returns 204 when image is approved but no Ready RenderedAsset exists
|
|
public function test_returns_204_when_no_ready_asset_for_approved_image(): void
|
|
{
|
|
$this->createTestSetup(true, false);
|
|
|
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
|
|
|
|
$this->assertResponseStatusCodeSame(204);
|
|
}
|
|
|
|
// Returns 204 when RenderedAsset has Ready status but the file is missing from disk
|
|
public function test_returns_204_when_bin_file_missing_from_disk(): void
|
|
{
|
|
$setup = $this->createTestSetup(true, false);
|
|
|
|
$asset = (new RenderedAsset())
|
|
->setImage($setup['image'])
|
|
->setDeviceModel(DeviceModel::V1)
|
|
->setOrientation(Orientation::Landscape)
|
|
->setStatus(RenderStatus::Ready)
|
|
->setFilePath('var/storage/images/nonexistent/missing.bin');
|
|
$this->em()->persist($asset);
|
|
$this->em()->flush();
|
|
|
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
|
|
|
|
$this->assertResponseStatusCodeSame(204);
|
|
}
|
|
|
|
// When wakeTimes is set and one slot has already passed today, X-Interval-Ms
|
|
// should be > 0 and <= 24h. Use [0] (midnight) so the slot is always past
|
|
// regardless of wall-clock time at test execution.
|
|
public function test_wake_times_interval_used_when_set(): void
|
|
{
|
|
$setup = $this->createTestSetup();
|
|
$device = $setup['device'];
|
|
|
|
$device->setWakeTimes([0])->setTimezone('UTC');
|
|
$this->em()->flush();
|
|
|
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
|
|
|
|
$this->assertResponseStatusCodeSame(200);
|
|
$intervalMs = (int) $this->client->getResponse()->headers->get('X-Interval-Ms');
|
|
$this->assertGreaterThan(0, $intervalMs);
|
|
$this->assertLessThanOrEqual(24 * 60 * 60 * 1000, $intervalMs);
|
|
}
|
|
|
|
// With multiple wake times, X-Interval-Ms must point to the *earliest*
|
|
// upcoming time, not just the first in the list. Use evenly-spaced slots
|
|
// including 00:00 so at least one is always past regardless of run time.
|
|
public function test_wake_times_picks_earliest_upcoming(): void
|
|
{
|
|
$setup = $this->createTestSetup();
|
|
$device = $setup['device'];
|
|
|
|
// 00:00, 08:00, 16:00 — gap to next slot is at most 8h regardless of
|
|
// when the test runs, and at least one is always in the past.
|
|
$device->setWakeTimes([0, 8 * 60, 16 * 60])->setTimezone('UTC');
|
|
$this->em()->flush();
|
|
|
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
|
|
|
|
$this->assertResponseStatusCodeSame(200);
|
|
$intervalMs = (int) $this->client->getResponse()->headers->get('X-Interval-Ms');
|
|
$this->assertGreaterThan(0, $intervalMs);
|
|
$this->assertLessThanOrEqual(8 * 60 * 60 * 1000, $intervalMs);
|
|
}
|
|
|
|
// FORCE-RESYNC FEATURE: a cold-boot poll (X-Boot-Reason: cold) MUST
|
|
// advance the rotation even when the schedule says we're not due. This
|
|
// is documented behavior — unplug-and-replug = manual refresh — and
|
|
// this test exists specifically to keep the feature from regressing.
|
|
// See feedback_force_resync_via_powercycle.md in agent memory.
|
|
public function test_cold_boot_force_resyncs_off_schedule(): void
|
|
{
|
|
$setup = $this->createTestSetup();
|
|
$device = $setup['device'];
|
|
|
|
// Establish a current image, then configure wakeTimes such that the
|
|
// most-recent-past slot has already been served — schedule says
|
|
// we're NOT due for another rotation right now.
|
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
|
|
$this->assertResponseStatusCodeSame(200);
|
|
$imageId = $this->client->getResponse()->headers->get('X-Image-Id');
|
|
$device->setWakeTimes([0])->setTimezone('UTC');
|
|
$this->em()->flush();
|
|
|
|
// Sanity-check: a *timer* wake at this point would 304 (no rotation).
|
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [
|
|
'HTTP_X_Boot_Reason' => 'timer',
|
|
'HTTP_X_Current_Image_Id' => $imageId,
|
|
]);
|
|
$this->assertResponseStatusCodeSame(304, 'timer wake must respect the schedule');
|
|
|
|
// The actual feature test: a cold-boot poll force-rotates regardless.
|
|
// It returns 200 (or 304 if the rotation happened to land on the same
|
|
// image again — depends on pool size). The proof is that it ran
|
|
// through the rotation path, which we verify by checking that a new
|
|
// history row was written.
|
|
$beforeCount = $this->countHistory($device);
|
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [
|
|
'HTTP_X_Boot_Reason' => 'cold',
|
|
'HTTP_X_Current_Image_Id' => $imageId,
|
|
]);
|
|
$afterCount = $this->countHistory($device);
|
|
|
|
$this->assertSame(
|
|
$beforeCount + 1,
|
|
$afterCount,
|
|
'cold-boot poll must advance the rotation (write a fresh history row) even off-schedule',
|
|
);
|
|
}
|
|
|
|
private function countHistory(\App\Entity\Device $device): int
|
|
{
|
|
return (int) $this->em()
|
|
->createQueryBuilder()
|
|
->select('COUNT(h.id)')
|
|
->from(\App\Entity\DeviceImageHistory::class, 'h')
|
|
->where('h.device = :d')
|
|
->setParameter('d', $device)
|
|
->getQuery()
|
|
->getSingleScalarResult();
|
|
}
|
|
|
|
// BOOTSTRAP BYPASS: a freshly-claimed device has no NVS img_id and
|
|
// therefore sends no X-Current-Image-Id header. The schedule
|
|
// (defaulting to noon-daily) would otherwise refuse to advance for
|
|
// up to 24h, and the buyer would see a dark panel after registering.
|
|
// Bypass schedule-gating in that case and serve whatever's available.
|
|
public function test_bootstrap_poll_advances_even_when_schedule_says_not_due(): void
|
|
{
|
|
$setup = $this->createTestSetup();
|
|
$device = $setup['device'];
|
|
|
|
// First, take the device through a poll cycle so it has currentImage,
|
|
// then configure wakeTimes such that the most-recent past slot has
|
|
// already been served. From this state, an off-schedule timer-wake
|
|
// poll WITH X-Current-Image-Id would 304 (covered elsewhere). But a
|
|
// bootstrap poll (no X-Current-Image-Id, simulating fresh NVS) MUST
|
|
// still advance — the user just claimed and is waiting for an image.
|
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
|
|
$this->assertResponseStatusCodeSame(200);
|
|
$device->setWakeTimes([0])->setTimezone('UTC');
|
|
$this->em()->flush();
|
|
|
|
$beforeCount = (int) $this->em()->createQueryBuilder()
|
|
->select('COUNT(h.id)')
|
|
->from(\App\Entity\DeviceImageHistory::class, 'h')
|
|
->where('h.device = :d')
|
|
->setParameter('d', $device)
|
|
->getQuery()
|
|
->getSingleScalarResult();
|
|
|
|
// No X-Current-Image-Id header — bootstrap.
|
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
|
|
$this->assertResponseIsSuccessful();
|
|
|
|
$afterCount = (int) $this->em()->createQueryBuilder()
|
|
->select('COUNT(h.id)')
|
|
->from(\App\Entity\DeviceImageHistory::class, 'h')
|
|
->where('h.device = :d')
|
|
->setParameter('d', $device)
|
|
->getQuery()
|
|
->getSingleScalarResult();
|
|
$this->assertSame($beforeCount + 1, $afterCount, 'bootstrap poll wrote a fresh history row → advance() ran');
|
|
}
|
|
|
|
// Off-schedule poll (wakeTimes set, but the most-recent past slot has
|
|
// already been served): the controller MUST NOT advance the rotation.
|
|
// It returns 304 (device's X-Current-Image-Id matches the existing
|
|
// currentImage) instead of a fresh 200.
|
|
public function test_off_schedule_poll_returns_304_without_advancing(): void
|
|
{
|
|
$setup = $this->createTestSetup();
|
|
$device = $setup['device'];
|
|
|
|
// First poll establishes a current image while wakeTimes is unset
|
|
// (so isDue is true and rotation runs).
|
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
|
|
$this->assertResponseStatusCodeSame(200);
|
|
$imageId = $this->client->getResponse()->headers->get('X-Image-Id');
|
|
|
|
// Now configure wakeTimes such that the most-recent past slot has
|
|
// already been served (the poll above wrote a history entry just now,
|
|
// and 00:00 is the most-recent past slot in UTC).
|
|
$device->setWakeTimes([0])->setTimezone('UTC');
|
|
$this->em()->flush();
|
|
|
|
// A second poll right now is "off-schedule" — server should NOT
|
|
// advance, and since we report the current image, server should 304.
|
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [
|
|
'HTTP_X_Current_Image_Id' => $imageId,
|
|
]);
|
|
$this->assertResponseStatusCodeSame(304);
|
|
}
|
|
|
|
// ── X-Just-Provisioned gate ──────────────────────────────────────────
|
|
//
|
|
// The device sets a "just provisioned" flag in NVS at WiFi-setup time
|
|
// and sends X-Just-Provisioned: 1 on every poll until it sees X-Claimed
|
|
// back. The server's contract:
|
|
//
|
|
// - Header set + binding is stale → 204 (force the device to its
|
|
// setup-QR screen instead of leaking the prior owner's photo).
|
|
// - Header set + binding is fresh → normal response + X-Claimed: 1
|
|
// so the firmware clears the flag and resumes regular operation.
|
|
// - Header absent → no special behavior (legacy + steady-state polls).
|
|
|
|
public function test_just_provisioned_with_stale_binding_returns_204(): void
|
|
{
|
|
$setup = $this->createTestSetup();
|
|
$device = $setup['device'];
|
|
|
|
// Backdate linkedAt to look stale (older than the FRESH_CLAIM window).
|
|
$ref = new \ReflectionProperty(\App\Entity\Device::class, 'linkedAt');
|
|
$ref->setAccessible(true);
|
|
$ref->setValue($device, new \DateTimeImmutable('-1 hour'));
|
|
$this->em()->flush();
|
|
|
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [
|
|
'HTTP_X_Just_Provisioned' => '1',
|
|
]);
|
|
|
|
$this->assertResponseStatusCodeSame(204, 'stale binding + just-provisioned must NOT serve the prior owner\'s image');
|
|
$this->assertFalse(
|
|
$this->client->getResponse()->headers->has('X-Claimed'),
|
|
'X-Claimed must not be sent until the binding is fresh',
|
|
);
|
|
}
|
|
|
|
public function test_just_provisioned_with_fresh_binding_serves_with_X_Claimed(): void
|
|
{
|
|
$setup = $this->createTestSetup();
|
|
// createTestSetup() doesn't backdate linkedAt — it's now-ish, well
|
|
// within the FRESH_CLAIM window — so this exercises the post-claim
|
|
// transition branch.
|
|
|
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [
|
|
'HTTP_X_Just_Provisioned' => '1',
|
|
]);
|
|
|
|
$this->assertResponseIsSuccessful();
|
|
$this->assertSame('1', $this->client->getResponse()->headers->get('X-Claimed'));
|
|
}
|
|
|
|
public function test_no_just_provisioned_header_means_no_X_Claimed_response_header(): void
|
|
{
|
|
$this->createTestSetup();
|
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
|
|
$this->assertResponseIsSuccessful();
|
|
$this->assertFalse($this->client->getResponse()->headers->has('X-Claimed'));
|
|
}
|
|
|
|
// Returns 204 when RenderedAsset has Ready status but filePath is null (device.poll.no_asset path)
|
|
public function test_returns_204_when_ready_asset_has_null_file_path(): void
|
|
{
|
|
$setup = $this->createTestSetup(true, false);
|
|
|
|
$asset = (new RenderedAsset())
|
|
->setImage($setup['image'])
|
|
->setDeviceModel(DeviceModel::V1)
|
|
->setOrientation(Orientation::Landscape)
|
|
->setStatus(RenderStatus::Ready)
|
|
->setFilePath(null);
|
|
$this->em()->persist($asset);
|
|
$this->em()->flush();
|
|
|
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
|
|
|
|
$this->assertResponseStatusCodeSame(204);
|
|
}
|
|
}
|