setUser($user)->setOriginalFilename('x.jpg')->setStoragePath('x'); $this->em()->persist($image); return $image; } private function issueToken(Image $image, TokenType $type, int $ttlDays = 7): Token { $token = new Token($type, $image, null, 'recipient@example.com', $ttlDays); $this->em()->persist($token); return $token; } /** * TK-01a: GET /token/{uuid}/approve with a valid token renders the approve page. */ public function test_approve_show_valid_token_renders_page(): void { $sender = $this->createUser('tk01a_sender@example.com'); $recipient = $this->createUser('tk01a_recip@example.com'); $image = $this->makeImage($sender); $token = $this->issueToken($image, TokenType::ShareApprove); $this->em()->flush(); $client = $this->loginAs($recipient); $client->request('GET', '/token/' . $token->getUuid() . '/approve'); $this->assertResponseIsSuccessful(); // The valid approve page shows "Someone shared a photo" — NOT the invalid page $this->assertSelectorTextContains('h1', 'Someone shared a photo'); } /** * TK-01b: POST /token/{uuid}/approve with a valid token marks SharedImage as approved and consumes the token. */ public function test_approve_submit_valid_token_marks_approved_and_consumes(): void { $sender = $this->createUser('tk01b_sender@example.com'); $recipient = $this->createUser('tk01b_recip@example.com'); $image = $this->makeImage($sender); $token = $this->issueToken($image, TokenType::ShareApprove); // Also create a SharedImage so the controller can update its status $shared = new SharedImage($image, $recipient, $sender); $this->em()->persist($shared); $this->em()->flush(); $tokenUuid = $token->getUuid(); $sharedId = $shared->getId(); $client = $this->loginAs($recipient); $client->request('POST', '/token/' . $tokenUuid . '/approve', [ 'device_ids' => [], ]); // Controller renders approved.html.twig on success (200) $this->assertResponseIsSuccessful(); // Token should now be marked used $this->em()->clear(); $reloaded = $this->em()->find(Token::class, $tokenUuid); $this->assertNotNull($reloaded->getUsedAt(), 'Token should be consumed after successful submit'); // SharedImage status should be Approved $sharedReloaded = $this->em()->find(SharedImage::class, $sharedId); $this->assertSame(SharedImageStatus::Approved, $sharedReloaded->getStatus()); $this->assertSame($recipient->getId(), $sharedReloaded->getRecipientUser()->getId()); } /** * TK-01c: A device_ids list containing an unknown / not-recipient-owned id * MUST be silently skipped (not 500). Locks the "continue on bad id" * branch in TokenActionController::submit. */ public function test_approve_submit_skips_unknown_device_ids(): void { $sender = $this->createUser('tk01c_sender@example.com'); $recipient = $this->createUser('tk01c_recip@example.com'); $image = $this->makeImage($sender); $token = $this->issueToken($image, TokenType::ShareApprove); $shared = new SharedImage($image, $recipient, $sender); $this->em()->persist($shared); // Recipient owns one device; the form will submit a real id and a bogus one. $real = new \App\Entity\Device(); $real->setMac('AA:BB:CC:DD:EE:F1'); $real->setName('Recip Frame'); $real->setUser($recipient); $this->em()->persist($real); $this->em()->flush(); $client = $this->loginAs($recipient); $client->request('POST', '/token/' . $token->getUuid() . '/approve', [ 'device_ids' => [(string) $real->getId(), '999999'], ]); $this->assertResponseIsSuccessful(); // The real device should now have the image approved; the bogus id is a no-op. $this->em()->clear(); $reloadedImage = $this->em()->find(\App\Entity\Image::class, $image->getId()); $approvedIds = array_map( fn(\App\Entity\Device $d) => $d->getId(), $reloadedImage->getApprovedDevices()->toArray(), ); $this->assertContains($real->getId(), $approvedIds); } /** * TK-02: GET /token/{uuid}/approve with a missing UUID renders the invalid page. */ public function test_approve_show_missing_token_renders_invalid_page(): void { $user = $this->createUser('tk02@example.com'); $this->em()->flush(); $client = $this->loginAs($user); $client->request('GET', '/token/00000000-0000-0000-0000-000000000000/approve'); $this->assertResponseIsSuccessful(); // controller returns 200 with invalid.html.twig $this->assertSelectorTextContains('body', 'expired'); } /** * TK-03: GET /token/{uuid}/approve with an already-used token renders the invalid page. * * TokenRepository::findValidToken() filters usedAt IS NULL, so a used token * is indistinguishable from a missing one — both return null → invalid page. */ public function test_approve_show_used_token_renders_invalid_page(): void { $sender = $this->createUser('tk03_sender@example.com'); $image = $this->makeImage($sender); $token = $this->issueToken($image, TokenType::ShareApprove); // Consume the token before the test $token->consume(); $this->em()->flush(); $recipient = $this->createUser('tk03_recip@example.com'); $client = $this->loginAs($recipient); $client->request('GET', '/token/' . $token->getUuid() . '/approve'); $this->assertResponseIsSuccessful(); $this->assertSelectorTextContains('body', 'expired'); } // TK-04: GET /token/{uuid}/approve without login renders the approve page (anonymous) public function test_approve_show_anonymous_shows_page(): void { $sender = $this->createUser('tk04_sender@example.com'); $image = $this->makeImage($sender); $token = $this->issueToken($image, TokenType::ShareApprove); $this->em()->flush(); $this->client->request('GET', '/token/' . $token->getUuid() . '/approve'); $this->assertResponseIsSuccessful(); $this->assertSelectorTextContains('h1', 'Someone shared a photo'); } // TK-05: POST /token/{uuid}/approve unauthenticated → redirects to /login public function test_approve_submit_unauthenticated_redirects_to_login(): void { $sender = $this->createUser('tk05_sender@example.com'); $image = $this->makeImage($sender); $token = $this->issueToken($image, TokenType::ShareApprove); $this->em()->flush(); $this->client->request('POST', '/token/' . $token->getUuid() . '/approve', [ 'device_ids' => [], ]); $this->assertResponseRedirects('/login'); } // TK-06: POST /token/{invalid}/approve → renders invalid page public function test_approve_submit_invalid_token_renders_invalid(): void { $user = $this->createUser('tk06@example.com'); $client = $this->loginAs($user); $client->request('POST', '/token/00000000-0000-0000-0000-000000000000/approve', [ 'device_ids' => [], ]); $this->assertResponseIsSuccessful(); $this->assertSelectorTextContains('body', 'expired'); } // TK-07: GET /token/{uuid}/decline with valid token renders decline page public function test_decline_show_valid_token_renders_page(): void { $sender = $this->createUser('tk07_sender@example.com'); $image = $this->makeImage($sender); $token = $this->issueToken($image, TokenType::ShareDecline); $this->em()->flush(); $this->client->request('GET', '/token/' . $token->getUuid() . '/decline'); $this->assertResponseIsSuccessful(); } // TK-08: GET /token/{invalid}/decline → renders invalid page public function test_decline_show_invalid_token_renders_invalid_page(): void { $this->client->request('GET', '/token/00000000-0000-0000-0000-000000000000/decline'); $this->assertResponseIsSuccessful(); $this->assertSelectorTextContains('body', 'expired'); } // TK-09: POST /token/{uuid}/decline → sets SharedImage status to Declined public function test_decline_submit_updates_shared_image_to_declined(): void { $sender = $this->createUser('tk09_sender@example.com'); $recipient = $this->createUser('tk09_recip@example.com'); $image = $this->makeImage($sender); $token = $this->issueToken($image, TokenType::ShareDecline); $shared = new SharedImage($image, $recipient, $sender); $this->em()->persist($shared); $this->em()->flush(); $sharedId = $shared->getId(); $client = $this->loginAs($recipient); $client->request('POST', '/token/' . $token->getUuid() . '/decline'); $this->assertResponseIsSuccessful(); $this->em()->clear(); $reloaded = $this->em()->find(SharedImage::class, $sharedId); $this->assertSame(\App\Enum\SharedImageStatus::Declined, $reloaded->getStatus()); } // TK-10: POST /token/{uuid}/decline without a matching SharedImage → succeeds public function test_decline_submit_without_shared_image_succeeds(): void { $sender = $this->createUser('tk10_sender@example.com'); $image = $this->makeImage($sender); $token = $this->issueToken($image, TokenType::ShareDecline); $this->em()->flush(); $user = $this->createUser('tk10_user@example.com'); $client = $this->loginAs($user); $client->request('POST', '/token/' . $token->getUuid() . '/decline'); $this->assertResponseIsSuccessful(); } // TK-12: POST /token/{uuid}/approve with recipient's own device → approveForDevice is called public function test_approve_submit_with_owned_device_calls_approve_for_device(): void { $sender = $this->createUser('tk12_sender@example.com'); $recipient = $this->createUser('tk12_recip@example.com'); $image = $this->makeImage($sender); $token = $this->issueToken($image, TokenType::ShareApprove); $ownDevice = new Device(); $ownDevice->setMac('AA:BB:CC:00:55:66')->setName('My Frame'); $ownDevice->setUser($recipient); $this->em()->persist($ownDevice); $shared = new SharedImage($image, $recipient, $sender); $this->em()->persist($shared); $this->em()->flush(); $client = $this->loginAs($recipient); $client->request('POST', '/token/' . $token->getUuid() . '/approve', [ 'device_ids' => [$ownDevice->getId()], ]); $this->assertResponseIsSuccessful(); } // TK-11: POST /token/{uuid}/approve with a device_id not owned by the user → device skipped (continue branch) public function test_approve_submit_with_unowned_device_id_is_skipped(): void { $sender = $this->createUser('tk11_sender@example.com'); $recipient = $this->createUser('tk11_recip@example.com'); $other = $this->createUser('tk11_other@example.com'); $image = $this->makeImage($sender); $token = $this->issueToken($image, TokenType::ShareApprove); $otherDevice = new Device(); $otherDevice->setMac('AA:BB:CC:00:44:55')->setName('Other Frame'); $otherDevice->setUser($other); $this->em()->persist($otherDevice); $this->em()->flush(); $client = $this->loginAs($recipient); $client->request('POST', '/token/' . $token->getUuid() . '/approve', [ 'device_ids' => [$otherDevice->getId()], ]); $this->assertResponseIsSuccessful(); } }