bookingWithAccessLink(); $signer = $this->createSigner(); $signedUrl = $signer->sign($booking); $request = Request::create($signedUrl); self::assertTrue($signer->isValidLinkRequest($request, $booking)); } public function testTamperedQueryParamFails(): void { $booking = $this->bookingWithAccessLink(); $signer = $this->createSigner(); $signedUrl = $signer->sign($booking); self::assertTrue($signer->isValidLinkRequest(Request::create($signedUrl), $booking)); // Flip the `t` value while keeping the original _hash — signature no longer matches. $tamperedUrl = preg_replace('/(?<=[?&]t=)\d+/', '999999999', $signedUrl); self::assertNotNull($tamperedUrl); self::assertFalse($signer->isValidLinkRequest(Request::create($tamperedUrl), $booking)); } public function testRegeneratedLinkInvalidatesThePreviousOne(): void { $booking = $this->bookingWithAccessLink(); $signer = $this->createSigner(); $signedUrl = $signer->sign($booking); $request = Request::create($signedUrl); // Regenerating overwrites accessLinkIssuedAt — the old signed `t` no longer matches. $booking->setAccessLinkIssuedAt(new \DateTimeImmutable('+1 minute')); self::assertFalse($signer->isValidLinkRequest($request, $booking)); } public function testExpiredLinkFails(): void { $booking = new AccommodationBooking(); $booking->setAccessLinkIssuedAt(new \DateTimeImmutable('-91 days')); $signer = $this->createSigner(); $signedUrl = $signer->sign($booking); $request = Request::create($signedUrl); self::assertFalse($signer->isValidLinkRequest($request, $booking)); } public function testMissingAccessLinkIssuedAtFails(): void { $booking = new AccommodationBooking(); $signer = $this->createSigner(); $request = Request::create('https://example.com/groups/booking/offer/'.$booking->getUuid().'?t=123'); self::assertFalse($signer->isValidLinkRequest($request, $booking)); } public function testSignThrowsWithoutAccessLinkIssuedAt(): void { $booking = new AccommodationBooking(); $signer = $this->createSigner(); $this->expectException(\LogicException::class); $signer->sign($booking); } public function testExpiresAtIsNinetyDaysAfterIssuedAt(): void { $issuedAt = new \DateTimeImmutable('2026-01-01T00:00:00+00:00'); $booking = new AccommodationBooking(); $booking->setAccessLinkIssuedAt($issuedAt); $signer = $this->createSigner(); self::assertSame('2026-04-01T00:00:00+00:00', $signer->expiresAt($booking)?->format(\DATE_ATOM)); } public function testSessionIsAuthorizedAfterAuthorizeSession(): void { $booking = $this->bookingWithAccessLink(); $signer = $this->createSigner(); $request = $this->requestWithSession(); self::assertFalse($signer->isSessionAuthorized($request, $booking)); $signer->authorizeSession($request, $booking); self::assertTrue($signer->isSessionAuthorized($request, $booking)); } public function testSessionAuthorizationIsPerBooking(): void { $booking = $this->bookingWithAccessLink(); $otherBooking = $this->bookingWithAccessLink(); $signer = $this->createSigner(); $request = $this->requestWithSession(); $signer->authorizeSession($request, $booking); self::assertTrue($signer->isSessionAuthorized($request, $booking)); self::assertFalse($signer->isSessionAuthorized($request, $otherBooking)); } public function testSessionAuthorizationIsRevokedWhenLinkIsRegenerated(): void { $booking = $this->bookingWithAccessLink(); $signer = $this->createSigner(); $request = $this->requestWithSession(); $signer->authorizeSession($request, $booking); self::assertTrue($signer->isSessionAuthorized($request, $booking)); // Regenerating the access link overwrites accessLinkIssuedAt — the // previously-authorized session no longer matches. $booking->setAccessLinkIssuedAt(new \DateTimeImmutable('+1 minute')); self::assertFalse($signer->isSessionAuthorized($request, $booking)); } public function testSessionAuthorizationFailsPastTtlEvenIfSessionMatches(): void { $booking = new AccommodationBooking(); $booking->setAccessLinkIssuedAt(new \DateTimeImmutable('-91 days')); $signer = $this->createSigner(); $request = $this->requestWithSession(); $signer->authorizeSession($request, $booking); self::assertFalse($signer->isSessionAuthorized($request, $booking)); } public function testSessionAuthorizationFailsWithoutAccessLinkIssuedAt(): void { $booking = new AccommodationBooking(); $signer = $this->createSigner(); $request = $this->requestWithSession(); self::assertFalse($signer->isSessionAuthorized($request, $booking)); } private function requestWithSession(): Request { $request = Request::create('https://example.com/'); $request->setSession(new Session(new MockArraySessionStorage())); return $request; } private function bookingWithAccessLink(): AccommodationBooking { $booking = new AccommodationBooking(); $booking->setPaxCount(10); $booking->setAccessLinkIssuedAt(new \DateTimeImmutable()); return $booking; } private function createSigner(): AccommodationBookingLinkSigner { $urlGenerator = $this->createStub(UrlGeneratorInterface::class); $urlGenerator ->method('generate') ->willReturnCallback(static fn (string $name, array $parameters) => sprintf( 'https://example.com/groups/booking/offer/%s?t=%s', $parameters['uuid'], $parameters['t'], )); return new AccommodationBookingLinkSigner($urlGenerator, 'test-secret'); } }