diff --git a/.gitattributes b/.gitattributes index 29e5d24..aec0fd5 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,6 +1,10 @@ # Exclude tests and development configuration from distributed archives tests/ export-ignore phpunit.xml export-ignore +phpstan.neon export-ignore .phpunit.cache/ export-ignore .github/ export-ignore .idea/ export-ignore +REVIEW.md export-ignore +DECISIONS.md export-ignore + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cadbb68..c14eb03 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,8 +34,36 @@ jobs: php-version: ${{ matrix.php }} extensions: mbstring, xml, curl + - name: Validate composer.json + run: composer validate --strict + - name: Install dependencies run: composer install --no-progress --prefer-dist + - name: PHPStan + run: composer phpstan + + - name: Run tests + run: composer test + + prefer-lowest: + runs-on: ubuntu-latest + timeout-minutes: 15 + name: Tests (PHP 8.2, prefer-lowest) + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Setup PHP + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + with: + php-version: '8.2' + extensions: mbstring, xml, curl + + - name: Install lowest dependencies + run: composer update --no-progress --prefer-lowest --prefer-stable + - name: Run tests run: composer test diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f56754..aa86d7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,36 @@ # Changelog +## Unreleased + +Breaking 0.4.0 reshape. Placement data and sidecar configuration are split: +`EmbedRequest` is a placement value object; `SsrClient` is built once and +shared by `Renderer` and `SsrPublish`. `render()` returns `RenderedEmbed` +(the HTML-only method is gone). `SsrPublish::purge()` replaces +`afterFeatureSourcePublish()` and returns `PurgeResult`. + +- No implicit `$_SERVER` / `getenv` / `config` key fallbacks. `config` is + opaque; the library still writes documented v1 option keys over it. +- Share params (`feature`, `module` by default) whitelist `requestUrl` for + the cache key, sidecar payload, and pageMeta gate. +- `psr/log` and `psr/simple-cache` are the only runtime dependencies. + Optional `Psr16SsrResultCache`. Cache `set()` takes a TTL (default 3600 s). +- `SsrTransport::send()` returns raw HTTP. The client owns the v1 contract. +- `RenderedEmbed` exposes `ssr` / `ssrReason` / `ssrDurationMs` and fragment + parts (`stylesheetHtml`, `preloadHtml`, `containerHtml`, `bootScriptHtml`). +- Validate `preset`, `containerId`, `requestId`, `assetVersion`. Forward + `locale` / `deviceClass`. Optional `scriptNonce`. JS strings use + `json_encode` + `JSON_HEX_*`. +- Cache before breaker. Breaker counts only connect / timeout / 5xx. +- v1 HTML must start with `<[A-Za-z]` and match `containerId`. JSON only, + BOM stripped, response and state size caps. `curl_close()` removed. +- CSS `?v=`, `modulepreload`, transport protocol / `Expect` / no-follow + hardening. `health()` and `warm()`. `Testing\FakeSsrTransport`. +- PHPStan at max, `composer validate --strict`, `--prefer-lowest` CI, + `failOnDeprecation`. `SECURITY.md`. +- Streams fallback reads `$http_response_header` in the `file_get_contents` + caller (PHP < 8.4). That identifier lives in a class loaded only then, so + PHP 8.5 does not compile the deprecation. `$GLOBALS` is empty there. + ## 0.3.0 — 2026-09-12 First public Packagist release. Composer name is `mapsight/embed` (MIT). diff --git a/README.md b/README.md index 8514ab2..5545d89 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,17 @@ # mapsight/embed PHP adapter for the Mapsight **embed protocol**. It emits a fragment you splice -into a page you already own: stylesheet, mount container, optional SSR try, -`mountEmbed` boot. +into a page you already own: stylesheet, modulepreload, mount container, +optional SSR try, `mountEmbed` boot. + +Composer package: `mapsight/embed`. Source: `open-mapsight/embed` (GitHub org). +Those names differ on purpose — Packagist vendor vs GitHub org. Preset name and config are opaque. **You** own wrappers, first-paint chrome, and ``. This package does not. ```bash -composer require mapsight/embed:^0.3 +composer require mapsight/embed:^0.4 ``` Requires PHP 8.2+. MIT. @@ -23,6 +26,7 @@ Requires PHP 8.2+. MIT. │ │ │ ┌────────────── this library ──────────────┐ │ │ │ │ │ +│ │ │ │ │ │
← empty on miss │ │ │ │ HTML; } - private function moduleUrl(string $assetBase, string $file, ?string $assetVersion): string + private function assetUrl(string $assetBase, string $file, ?string $assetVersion): string { $url = $assetBase . '/assets/' . $file; if ($assetVersion !== null && $assetVersion !== '') { @@ -212,12 +132,16 @@ private function escapeAttr(string $value): string return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); } - private function escapeJsDoubleQuoted(string $value): string + private function jsString(string $value): string { - return str_replace( - ['\\', '"', "\n", "\r"], - ['\\\\', '\\"', '\\n', '\\r'], + return json_encode( $value, + JSON_THROW_ON_ERROR + | JSON_UNESCAPED_SLASHES + | JSON_HEX_TAG + | JSON_HEX_AMP + | JSON_HEX_APOS + | JSON_HEX_QUOT, ); } } diff --git a/src/Embed/SsrCacheKey.php b/src/Embed/SsrCacheKey.php index f8f6dff..6baf228 100644 --- a/src/Embed/SsrCacheKey.php +++ b/src/Embed/SsrCacheKey.php @@ -6,16 +6,15 @@ /** * Stable key for a v1 SSR placement: config + locale/deviceClass + assetVersion - * + requestUrl + contract. requestUrl must be in the key so `?module=` / - * `?feature=` (and similar search) does not reuse another URL's HTML. - * pageOrigin / ogImage are in the key so cached pageMeta stays absolute. + * + normalised requestUrl + contract. pageOrigin / ogImage stay in the key + * so cached pageMeta stays absolute. */ final class SsrCacheKey { - public static function for(EmbedRequest $request): string + public static function for(EmbedRequest $request, ?string $requestUrl): string { $payload = [ - 'v' => 1, + 'v' => SsrContract::VERSION, 'preset' => $request->preset, 'containerId' => $request->containerId, 'containerClassName' => $request->containerClassName, @@ -23,9 +22,9 @@ public static function for(EmbedRequest $request): string 'assetVersion' => $request->assetVersion, 'locale' => $request->locale, 'deviceClass' => $request->deviceClass, - 'requestUrl' => $request->resolvedRequestUrl(), - 'pageOrigin' => $request->resolvedPageOrigin(), - 'ogImage' => $request->resolvedOgImage(), + 'requestUrl' => $requestUrl, + 'pageOrigin' => self::nonEmpty($request->pageOrigin), + 'ogImage' => self::nonEmpty($request->ogImage), ]; return hash( @@ -34,6 +33,15 @@ public static function for(EmbedRequest $request): string ); } + private static function nonEmpty(?string $value): ?string + { + if ($value === null || $value === '') { + return null; + } + + return $value; + } + private static function normalize(mixed $value): mixed { if (!is_array($value)) { diff --git a/src/Embed/SsrClient.php b/src/Embed/SsrClient.php new file mode 100644 index 0000000..bfd0b40 --- /dev/null +++ b/src/Embed/SsrClient.php @@ -0,0 +1,456 @@ + */ + public const DEFAULT_SHARE_PARAMS = ['feature', 'module']; + + private readonly SsrTransport $transport; + + private readonly SsrCircuitBreaker $breaker; + + private readonly LoggerInterface $logger; + + /** + * @param list $shareParams + */ + public function __construct( + private readonly string $ssrUrl, + private readonly float $timeoutSeconds = 2.0, + private readonly float $connectTimeoutSeconds = 0.1, + ?SsrTransport $transport = null, + private readonly ?SsrResultCache $resultCache = null, + private readonly int $cacheTtlSeconds = self::DEFAULT_CACHE_TTL, + ?SsrCircuitBreaker $breaker = null, + ?LoggerInterface $logger = null, + private readonly int $maxResponseBytes = self::DEFAULT_MAX_RESPONSE_BYTES, + private readonly int $maxStateBytes = self::DEFAULT_MAX_STATE_BYTES, + private readonly array $shareParams = self::DEFAULT_SHARE_PARAMS, + ) { + if ($this->ssrUrl === '') { + throw new \InvalidArgumentException('ssrUrl must not be empty'); + } + if ($this->timeoutSeconds <= 0 || $this->connectTimeoutSeconds <= 0) { + throw new \InvalidArgumentException('SSR timeouts must be positive'); + } + if ($this->connectTimeoutSeconds > $this->timeoutSeconds) { + throw new \InvalidArgumentException('connectTimeoutSeconds must not exceed timeoutSeconds'); + } + if ($this->cacheTtlSeconds < 1) { + throw new \InvalidArgumentException('cacheTtlSeconds must be >= 1'); + } + $this->transport = $transport ?? new NativeSsrTransport(); + $this->breaker = $breaker ?? new ProcessSsrCircuitBreaker(); + $this->logger = $logger ?? new NullLogger(); + } + + public function resolve(EmbedRequest $request, bool $force = false): SsrRenderResult + { + $started = microtime(true); + $requestUrl = $this->normalizeRequestUrl($request->requestUrl); + $cacheKey = SsrCacheKey::for($request, $requestUrl); + + if (!$force) { + $cached = $this->resultCache?->get($cacheKey); + if ($cached !== null && $cached->html !== '') { + $this->logger->debug('mapsight ssr cache hit', [ + 'request_id' => $request->requestId, + 'url' => rtrim($this->ssrUrl, '/') . '/v1/render', + ]); + + return new SsrRenderResult( + SsrOutcome::Cached, + $this->withPageMetaGate($cached, $requestUrl), + null, + $this->elapsedMs($started), + ); + } + + if (!$this->breaker->allow()) { + $this->logger->warning('mapsight ssr skipped', [ + 'reason' => 'breaker_open', + 'status' => null, + 'url' => rtrim($this->ssrUrl, '/') . '/v1/render', + 'elapsed_ms' => $this->elapsedMs($started), + 'request_id' => $request->requestId, + ]); + + return new SsrRenderResult( + SsrOutcome::SkippedBreaker, + null, + 'breaker_open', + $this->elapsedMs($started), + ); + } + } + + try { + $document = $this->fetchDocument($request, $requestUrl); + $this->breaker->recordSuccess(); + $this->resultCache?->set($cacheKey, $document, $this->cacheTtlSeconds); + + return new SsrRenderResult( + SsrOutcome::Rendered, + $this->withPageMetaGate($document, $requestUrl), + null, + $this->elapsedMs($started), + ); + } catch (SsrUnavailable $error) { + $this->noteBreakerFailure(); + $this->logSkip($request, $error, $started); + + return new SsrRenderResult( + SsrOutcome::SkippedError, + null, + $error->getMessage(), + $this->elapsedMs($started), + ); + } catch (\Throwable $error) { + $this->logSkip($request, $error, $started); + + return new SsrRenderResult( + SsrOutcome::SkippedError, + null, + $error->getMessage(), + $this->elapsedMs($started), + ); + } + } + + public function warm(EmbedRequest $request): SsrOutcome + { + return $this->resolve($request, true)->outcome; + } + + public function health(): bool + { + try { + $response = $this->transport->send(new SsrHttpRequest( + 'GET', + rtrim($this->ssrUrl, '/') . '/health', + ['Accept' => 'application/json, text/plain'], + null, + $this->timeoutSeconds, + $this->connectTimeoutSeconds, + )); + + return $response->status >= 200 && $response->status < 300; + } catch (\Throwable) { + return false; + } + } + + /** + * @param list $urls + */ + public function purge(array $urls = []): PurgeResult + { + $payload = $this->purgePayload($urls); + try { + $json = $payload === [] + ? '{}' + : json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + $response = $this->transport->send(new SsrHttpRequest( + 'POST', + rtrim($this->ssrUrl, '/') . '/purge', + ['Accept' => 'application/json'], + $json, + $this->timeoutSeconds, + $this->connectTimeoutSeconds, + )); + $deleted = $this->decodePurgeResponse($response); + } catch (\Throwable $error) { + $this->logger->warning('mapsight ssr purge failed', [ + 'reason' => $error->getMessage(), + 'url' => rtrim($this->ssrUrl, '/') . '/purge', + ]); + + return new PurgeResult(false, []); + } + + $this->resultCache?->flush(); + + return new PurgeResult(true, $deleted); + } + + public function normalizeRequestUrl(?string $url): ?string + { + if ($url === null || $url === '') { + return null; + } + + if (strlen($url) > self::MAX_REQUEST_URL_BYTES) { + $path = parse_url($url, PHP_URL_PATH); + + return is_string($path) && $path !== '' ? $path : '/'; + } + + $parts = parse_url($url); + if (!is_array($parts)) { + return null; + } + + $path = $parts['path'] ?? ''; + $kept = []; + $query = $parts['query'] ?? ''; + if ($query !== '') { + parse_str($query, $params); + foreach ($this->shareParams as $name) { + $value = $params[$name] ?? null; + if (is_string($value) && $value !== '') { + $kept[$name] = $value; + } + } + ksort($kept); + } + + $out = $path; + if ($kept !== []) { + $out .= '?' . http_build_query($kept); + } + + return $out !== '' ? $out : null; + } + + public function requestHasPageMetaParam(?string $normalizedRequestUrl): bool + { + if ($normalizedRequestUrl === null) { + return false; + } + $query = parse_url($normalizedRequestUrl, PHP_URL_QUERY); + if (!is_string($query) || $query === '') { + return false; + } + parse_str($query, $params); + foreach ($this->shareParams as $name) { + $value = $params[$name] ?? null; + if (is_string($value) && $value !== '') { + return true; + } + } + + return false; + } + + private function fetchDocument(EmbedRequest $request, ?string $requestUrl): SsrDocument + { + $payload = [ + 'v' => SsrContract::VERSION, + 'preset' => $request->preset, + 'options' => $this->ssrOptions($request, $requestUrl), + ]; + if ($request->requestId !== null && $request->requestId !== '') { + $payload['requestId'] = $request->requestId; + } + if ($request->assetVersion !== null && $request->assetVersion !== '') { + $payload['assetVersion'] = $request->assetVersion; + } + + try { + $json = json_encode($payload, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new SsrClientError('SSR request body is not JSON', 0, $e); + } + if (strlen($json) > self::MAX_BODY_BYTES) { + throw new SsrClientError('SSR request body exceeds size cap'); + } + + $headers = ['Accept' => 'application/json']; + if ($request->requestId !== null && $request->requestId !== '') { + $headers['X-Request-Id'] = $request->requestId; + } + if ($request->assetVersion !== null && $request->assetVersion !== '') { + $headers['X-Mapsight-Asset-Version'] = $request->assetVersion; + } + + $url = rtrim($this->ssrUrl, '/') . '/v1/render'; + $response = $this->transport->send(new SsrHttpRequest( + 'POST', + $url, + $headers, + $json, + $this->timeoutSeconds, + $this->connectTimeoutSeconds, + )); + + if (strlen($response->body) > $this->maxResponseBytes) { + throw new SsrClientError('SSR response exceeds size cap'); + } + if ($response->status >= 400 && $response->status < 500) { + throw new SsrClientError('SSR HTTP status ' . $response->status); + } + if ($response->status < 200 || $response->status >= 300) { + throw new SsrUnavailable('SSR HTTP status ' . $response->status); + } + if (!$this->isJsonContentType($response->contentType)) { + throw new SsrClientError('SSR response Content-Type must be application/json'); + } + + return SsrV1Document::fromResponse( + $response->body, + $request->containerId, + $this->maxStateBytes, + ); + } + + /** + * @return array + */ + private function ssrOptions(EmbedRequest $request, ?string $requestUrl): array + { + $options = $request->config; + $options['containerId'] = $request->containerId; + if ($request->containerClassName !== '') { + $options['containerClassName'] = $request->containerClassName; + } + if ($requestUrl !== null) { + $options['requestUrl'] = $requestUrl; + } + if ($request->pageOrigin !== null && $request->pageOrigin !== '') { + $options['pageOrigin'] = rtrim($request->pageOrigin, '/'); + } + if ($request->ogImage !== null && $request->ogImage !== '') { + $options['ogImage'] = $request->ogImage; + } + if ($request->locale !== null && $request->locale !== '') { + $options['locale'] = $request->locale; + } + if ($request->deviceClass !== null && $request->deviceClass !== '') { + $options['deviceClass'] = $request->deviceClass; + } + + return $options; + } + + private function withPageMetaGate(SsrDocument $document, ?string $requestUrl): SsrDocument + { + if ($this->requestHasPageMetaParam($requestUrl)) { + return $document; + } + + return new SsrDocument($document->html, null); + } + + /** + * @param list $urls + * @return array + */ + private function purgePayload(array $urls): array + { + if ($urls === []) { + return []; + } + + $filtered = array_values(array_filter( + $urls, + static fn (string $url): bool => $url !== '', + )); + if ($filtered === []) { + throw new \InvalidArgumentException( + 'purge received only empty urls; refusing to purge the whole sidecar cache', + ); + } + + return ['urls' => $filtered]; + } + + /** + * @return list + */ + private function decodePurgeResponse(SsrHttpResponse $response): array + { + if ($response->status === 204) { + return []; + } + if ($response->status < 200 || $response->status >= 300) { + throw new SsrUnavailable('SSR purge HTTP status ' . $response->status); + } + + $trimmed = trim($response->body); + if ($trimmed === '') { + return []; + } + + try { + $data = json_decode($trimmed, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new SsrClientError('SSR purge response is not JSON', 0, $e); + } + if (!is_array($data) || !array_is_list($data)) { + throw new SsrClientError('SSR purge response must be a JSON array'); + } + + $keys = []; + foreach ($data as $item) { + if (!is_string($item)) { + throw new SsrClientError('SSR purge response must be a JSON string array'); + } + $keys[] = $item; + } + + return $keys; + } + + private function isJsonContentType(?string $contentType): bool + { + if ($contentType === null || $contentType === '') { + return false; + } + + return str_starts_with(strtolower($contentType), 'application/json'); + } + + private function noteBreakerFailure(): void + { + $wasOpen = !$this->breaker->allow(); + $this->breaker->recordFailure(); + if (!$wasOpen && !$this->breaker->allow()) { + $this->logger->info('mapsight ssr breaker opened', []); + } + } + + private function logSkip(EmbedRequest $request, \Throwable $error, float $started): void + { + $this->logger->warning('mapsight ssr skipped', [ + 'reason' => $error->getMessage(), + 'status' => self::statusFromMessage($error->getMessage()), + 'url' => rtrim($this->ssrUrl, '/') . '/v1/render', + 'elapsed_ms' => $this->elapsedMs($started), + 'request_id' => $request->requestId, + ]); + } + + private static function statusFromMessage(string $message): ?int + { + if (preg_match('/SSR HTTP status (\d+)/', $message, $matches) === 1) { + return (int) $matches[1]; + } + + return null; + } + + private function elapsedMs(float $started): float + { + return round((microtime(true) - $started) * 1000, 3); + } +} diff --git a/src/Embed/SsrClientError.php b/src/Embed/SsrClientError.php new file mode 100644 index 0000000..0a79edd --- /dev/null +++ b/src/Embed/SsrClientError.php @@ -0,0 +1,13 @@ + $headers + */ +final class SsrHttpRequest +{ + /** + * @param array $headers + */ + public function __construct( + public readonly string $method, + public readonly string $url, + public readonly array $headers = [], + public readonly ?string $body = null, + public readonly float $timeoutSeconds = 2.0, + public readonly float $connectTimeoutSeconds = 0.1, + ) { + } +} diff --git a/src/Embed/SsrHttpResponse.php b/src/Embed/SsrHttpResponse.php new file mode 100644 index 0000000..99582b1 --- /dev/null +++ b/src/Embed/SsrHttpResponse.php @@ -0,0 +1,18 @@ +|null $urls - * @return list deleted sidecar cache keys (empty when sidecar is unset or fail-open) - */ - public function afterFeatureSourcePublish(?array $urls = null): array - { - $deleted = $this->purgeSidecar($urls); - $this->resultCache?->flush(); - - return $deleted; - } - - public static function fromEnv(?SsrResultCache $resultCache = null): self - { - $ssrUrl = getenv('MAPSIGHT_SSR_URL'); - if (!is_string($ssrUrl) || $ssrUrl === '') { - $ssrUrl = null; - } - - return new self($ssrUrl, $resultCache); - } - - /** - * @param list|null $urls - * @return list + * A list that filters down to no URLs (e.g. `['']`) is an error, not a + * purge-all. A failed sidecar POST does not flush the PHP cache. + * + * @param list $urls */ - private function purgeSidecar(?array $urls): array + public function purge(array $urls = []): PurgeResult { - if ($this->ssrUrl === null || $this->ssrUrl === '') { - return []; - } - - $payload = []; - if ($urls !== null && $urls !== []) { - $payload['urls'] = array_values(array_filter($urls, static fn (mixed $url): bool => is_string($url) && $url !== '')); - } - - try { - $transport = $this->transport ?? new NativeSsrPurgeTransport(); - - return $transport->postPurge( - rtrim($this->ssrUrl, '/') . '/purge', - $payload, - $this->timeoutSeconds, - $this->connectTimeoutSeconds, - ); - } catch (\Throwable $error) { - error_log('mapsight ssr purge failed: ' . $error->getMessage()); - - return []; - } + return $this->ssr->purge($urls); } } diff --git a/src/Embed/SsrPurgeTransport.php b/src/Embed/SsrPurgeTransport.php deleted file mode 100644 index 3446021..0000000 --- a/src/Embed/SsrPurgeTransport.php +++ /dev/null @@ -1,22 +0,0 @@ - $payload - * - * @return list - * - * @throws \Throwable when the sidecar is unreachable or returns a non-success body - */ - public function postPurge( - string $url, - array $payload, - float $timeoutSeconds, - float $connectTimeoutSeconds = 0.1, - ): array; -} diff --git a/src/Embed/SsrRenderResult.php b/src/Embed/SsrRenderResult.php new file mode 100644 index 0000000..01a5d6f --- /dev/null +++ b/src/Embed/SsrRenderResult.php @@ -0,0 +1,19 @@ + $payload - * @param array $headers - * - * @throws \Throwable when the sidecar is unreachable or returns a non-success body - */ - public function postJson( - string $url, - array $payload, - float $timeoutSeconds, - array $headers = [], - float $connectTimeoutSeconds = 0.1, - ): SsrDocument; + public function send(SsrHttpRequest $request): SsrHttpResponse; } diff --git a/src/Embed/SsrUnavailable.php b/src/Embed/SsrUnavailable.php new file mode 100644 index 0000000..a07e78d --- /dev/null +++ b/src/Embed/SsrUnavailable.php @@ -0,0 +1,13 @@ +html; + public static function containerHtmlFromResponse( + string $body, + string $containerId = 'mapsight-embed-1', + int $maxStateBytes = 262144, + ): string { + return self::fromResponse($body, $containerId, $maxStateBytes)->html; } - public static function withDehydratedState(string $html, mixed $state): string - { + public static function withDehydratedState( + string $html, + mixed $state, + string $containerId, + int $maxStateBytes = 262144, + ): string { $json = json_encode($state, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + if (strlen($json) > $maxStateBytes) { + throw new SsrClientError('SSR v1 state exceeds size cap'); + } $escaped = htmlspecialchars($json, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); $html = trim($html); $end = self::openingTagEnd($html); $opening = substr($html, 0, $end + 1); + self::assertContainerId($opening, $containerId); $opening = preg_replace( - '/\sdata-dehydrated-state=(?:"[^"]*"|\'[^\']*\')/', + '/\sdata-dehydrated-state=(?:"[^"]*"|\'[^\']*\'|[^\s>]+)/', '', $opening, ) ?? $opening; if (!str_ends_with($opening, '>')) { - throw new \RuntimeException('SSR v1 html has no opening element'); + throw new SsrClientError('SSR v1 html has no opening element'); } $opening = substr($opening, 0, -1) . ' data-dehydrated-state="' . $escaped . '">'; @@ -71,8 +92,8 @@ public static function withDehydratedState(string $html, mixed $state): string /** First `>` that is not inside a quoted attribute (OSM attribution has raw `>`). */ private static function openingTagEnd(string $html): int { - if ($html === '' || $html[0] !== '<') { - throw new \RuntimeException('SSR v1 html has no opening element'); + if ($html === '' || preg_match('/^<[A-Za-z]/', $html) !== 1) { + throw new SsrClientError('SSR v1 html has no opening element'); } $quote = null; @@ -94,6 +115,20 @@ private static function openingTagEnd(string $html): int } } - throw new \RuntimeException('SSR v1 html has no opening element'); + throw new SsrClientError('SSR v1 html has no opening element'); + } + + private static function assertContainerId(string $opening, string $containerId): void + { + if (preg_match('/\sid=(?:"([^"]*)"|\'([^\']*)\'|([^\s>]+))/', $opening, $matches) !== 1) { + throw new SsrClientError('SSR v1 html root id does not match containerId'); + } + $quoted = $matches[1]; + $single = $matches[2] ?? ''; + $unquoted = $matches[3] ?? ''; + $id = $quoted !== '' ? $quoted : ($single !== '' ? $single : $unquoted); + if ($id !== $containerId) { + throw new SsrClientError('SSR v1 html root id does not match containerId'); + } } } diff --git a/src/Embed/Testing/FakeSsrTransport.php b/src/Embed/Testing/FakeSsrTransport.php new file mode 100644 index 0000000..e8fdaf9 --- /dev/null +++ b/src/Embed/Testing/FakeSsrTransport.php @@ -0,0 +1,64 @@ + */ + private array $queue = []; + + /** @var list */ + public array $requests = []; + + public function queue(SsrHttpResponse|\Throwable $next): void + { + $this->queue[] = $next; + } + + /** + * @param array $state + * @param array|null $pageMeta + */ + public function queueV1( + string $html, + array $state = [], + ?array $pageMeta = null, + int $status = 200, + string $contentType = 'application/json', + ): void { + $this->queue(new SsrHttpResponse( + $status, + $contentType, + json_encode([ + 'v' => SsrContract::VERSION, + 'html' => $html, + 'state' => $state, + 'pageMeta' => $pageMeta, + ], JSON_THROW_ON_ERROR), + )); + } + + public function send(SsrHttpRequest $request): SsrHttpResponse + { + $this->requests[] = $request; + if ($this->queue === []) { + throw new \RuntimeException('FakeSsrTransport queue is empty'); + } + $next = array_shift($this->queue); + if ($next instanceof \Throwable) { + throw $next; + } + + return $next; + } +} diff --git a/tests/EmbedRequestTest.php b/tests/EmbedRequestTest.php new file mode 100644 index 0000000..fdc40ea --- /dev/null +++ b/tests/EmbedRequestTest.php @@ -0,0 +1,100 @@ +expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('preset must be a JavaScript identifier'); + + new EmbedRequest( + preset: 'my-map', + containerId: 'mapsight-embed-1', + config: [], + ); + } + + public function test_rejects_reserved_word_preset(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('preset must not be a JavaScript reserved word'); + + new EmbedRequest( + preset: 'default', + containerId: 'mapsight-embed-1', + config: [], + ); + } + + public function test_rejects_invalid_container_id(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('containerId must match'); + + new EmbedRequest( + preset: 'simpleMap', + containerId: '1bad', + config: [], + ); + } + + public function test_rejects_request_id_with_crlf(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('requestId must match'); + + new EmbedRequest( + preset: 'simpleMap', + containerId: 'mapsight-embed-1', + config: [], + requestId: "abc\r\nX-Injected: yes", + ); + } + + public function test_rejects_asset_version_with_spaces(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('assetVersion must match'); + + new EmbedRequest( + preset: 'simpleMap', + containerId: 'mapsight-embed-1', + config: [], + assetVersion: 'assets 9', + ); + } + + public function test_does_not_read_server_or_config_fallbacks(): void + { + $_SERVER['REQUEST_URI'] = '/from-sapi'; + $request = new EmbedRequest( + preset: 'simpleMap', + containerId: 'mapsight-embed-1', + config: ['requestUrl' => '/from-config'], + ); + + $this->assertNull($request->requestUrl); + unset($_SERVER['REQUEST_URI']); + } + + public function test_accepts_header_safe_tokens(): void + { + $request = new EmbedRequest( + preset: 'simpleMap', + containerId: 'mapsight-embed-1', + config: [], + requestId: 'req-1.2_3', + assetVersion: 'assets-9', + ); + + $this->assertSame('req-1.2_3', $request->requestId); + $this->assertSame('assets-9', $request->assetVersion); + } +} diff --git a/tests/NativeSsrTransportTest.php b/tests/NativeSsrTransportTest.php new file mode 100644 index 0000000..65db590 --- /dev/null +++ b/tests/NativeSsrTransportTest.php @@ -0,0 +1,161 @@ + ['file', '/dev/null', 'r'], + 1 => ['file', '/dev/null', 'w'], + 2 => ['file', '/dev/null', 'w'], + ], + $pipes, + ); + if (self::$process === false) { + self::markTestSkipped('could not start php -S'); + } + + $ready = false; + for ($i = 0; $i < 50; $i++) { + $fp = @fsockopen('127.0.0.1', self::$port, $errno, $errstr, 0.1); + if (is_resource($fp)) { + fclose($fp); + $ready = true; + break; + } + usleep(50000); + } + if (!$ready) { + self::stopServer(); + self::markTestSkipped('php -S did not become ready'); + } + } + + public static function tearDownAfterClass(): void + { + self::stopServer(); + } + + public function test_posts_json_and_returns_status_and_content_type(): void + { + $response = (new NativeSsrTransport())->send(new SsrHttpRequest( + 'POST', + $this->url('/v1/render'), + ['Accept' => 'application/json', 'X-Request-Id' => 'req-1'], + '{"v":1}', + 2.0, + 0.5, + )); + + $this->assertSame(200, $response->status); + $this->assertNotFalse(stripos((string) $response->contentType, 'application/json')); + $data = json_decode($response->body, true, 512, JSON_THROW_ON_ERROR); + $this->assertSame(1, $data['v'] ?? null); + $this->assertSame('req-1', $data['state']['headers']['x-request-id'] ?? null); + } + + public function test_returns_4xx_and_5xx_without_throwing(): void + { + $transport = new NativeSsrTransport(); + $bad = $transport->send(new SsrHttpRequest('POST', $this->url('/v1/render?status=400'), [], '{}')); + $down = $transport->send(new SsrHttpRequest('POST', $this->url('/v1/render?status=503'), [], '{}')); + + $this->assertSame(400, $bad->status); + $this->assertSame(503, $down->status); + } + + public function test_health_get(): void + { + $response = (new NativeSsrTransport())->send(new SsrHttpRequest( + 'GET', + $this->url('/health'), + )); + + $this->assertSame(200, $response->status); + $this->assertSame('ok', $response->body); + } + + public function test_timeout_throws_unavailable(): void + { + $this->expectException(SsrUnavailable::class); + (new NativeSsrTransport())->send(new SsrHttpRequest( + 'GET', + $this->url('/sleep'), + [], + null, + 0.05, + 0.05, + )); + } + + public function test_raw_html_content_type_is_preserved(): void + { + $response = (new NativeSsrTransport())->send(new SsrHttpRequest( + 'POST', + $this->url('/v1/render?raw=1'), + [], + '{}', + )); + + $this->assertSame(200, $response->status); + $this->assertNotFalse(stripos((string) $response->contentType, 'text/html')); + $this->assertStringContainsString('data-dehydrated-state', $response->body); + } + + public function test_streams_fallback_reads_status_and_content_type(): void + { + $transport = new NativeSsrTransport(); + $withStreams = new \ReflectionMethod($transport, 'withStreams'); + $response = $withStreams->invoke($transport, new SsrHttpRequest( + 'POST', + $this->url('/v1/render'), + ['Accept' => 'application/json', 'X-Request-Id' => 'req-streams'], + '{"v":1}', + 2.0, + 0.5, + )); + + $this->assertSame(200, $response->status); + $this->assertNotFalse(stripos((string) $response->contentType, 'application/json')); + $data = json_decode($response->body, true, 512, JSON_THROW_ON_ERROR); + $this->assertSame(1, $data['v'] ?? null); + $this->assertSame('req-streams', $data['state']['headers']['x-request-id'] ?? null); + } + + private function url(string $path): string + { + return 'http://127.0.0.1:' . self::$port . $path; + } + + private static function stopServer(): void + { + if (self::$process !== false) { + proc_terminate(self::$process); + proc_close(self::$process); + self::$process = false; + } + } +} diff --git a/tests/PageMetaTagsTest.php b/tests/PageMetaTagsTest.php index 9a2563c..7a6bad0 100644 --- a/tests/PageMetaTagsTest.php +++ b/tests/PageMetaTagsTest.php @@ -19,6 +19,7 @@ public function test_html_emits_canonical_og_and_json_ld(): void '', $html, ); + $this->assertStringContainsString('name="description" content="An example place."', $html); $this->assertStringContainsString('property="og:title" content="Town Hall"', $html); $this->assertStringContainsString('property="og:type" content="place"', $html); $this->assertStringContainsString( @@ -39,6 +40,32 @@ public function test_try_from_fails_open_on_garbage(): void $this->assertNull(PlacePageMeta::tryFrom('Town Hall')); } + public function test_try_from_rejects_unknown_og_type_and_non_http_urls(): void + { + $base = [ + 'title' => 'Town Hall', + 'description' => 'An example place.', + 'canonicalUrl' => 'https://www.example.com/map?feature=poi-1', + 'og' => [ + 'title' => 'Town Hall', + 'description' => 'An example place.', + 'url' => 'https://www.example.com/map?feature=poi-1', + 'type' => 'article', + 'image' => 'https://www.example.com/plan/img/og-default.png', + ], + 'jsonLd' => ['@type' => 'Place'], + ]; + $this->assertNull(PlacePageMeta::tryFrom($base)); + + $base['og']['type'] = 'place'; + $base['canonicalUrl'] = '/map?feature=poi-1'; + $this->assertNull(PlacePageMeta::tryFrom($base)); + + $base['canonicalUrl'] = 'https://www.example.com/map?feature=poi-1'; + $base['og']['image'] = 'javascript:alert(1)'; + $this->assertNull(PlacePageMeta::tryFrom($base)); + } + private static function sample(): PlacePageMeta { return PlacePageMeta::tryFrom([ diff --git a/tests/ProcessSsrCircuitBreakerTest.php b/tests/ProcessSsrCircuitBreakerTest.php new file mode 100644 index 0000000..bf2e363 --- /dev/null +++ b/tests/ProcessSsrCircuitBreakerTest.php @@ -0,0 +1,45 @@ + $clock->now); + + $breaker->recordFailure(); + $this->assertFalse($breaker->allow()); + + $clock->now = 10.0; + $this->assertTrue($breaker->allow()); + + $breaker->recordSuccess(); + $this->assertTrue($breaker->allow()); + $clock->now = 10.1; + $this->assertTrue($breaker->allow()); + } + + public function test_failure_after_cooldown_reopens_immediately(): void + { + $clock = new class { + public float $now = 0.0; + }; + $breaker = new ProcessSsrCircuitBreaker(1, 10.0, fn () => $clock->now); + + $breaker->recordFailure(); + $clock->now = 10.0; + $this->assertTrue($breaker->allow()); + + $breaker->recordFailure(); + $this->assertFalse($breaker->allow()); + } +} diff --git a/tests/RendererTest.php b/tests/RendererTest.php index 7386b7f..7c7e714 100644 --- a/tests/RendererTest.php +++ b/tests/RendererTest.php @@ -6,13 +6,14 @@ use OpenMapsight\Embed\ArraySsrResultCache; use OpenMapsight\Embed\EmbedRequest; -use OpenMapsight\Embed\PlacePageMeta; -use OpenMapsight\Embed\PlacePageMetaOg; use OpenMapsight\Embed\ProcessSsrCircuitBreaker; use OpenMapsight\Embed\Renderer; use OpenMapsight\Embed\SsrCacheKey; -use OpenMapsight\Embed\SsrDocument; -use OpenMapsight\Embed\SsrTransport; +use OpenMapsight\Embed\SsrClient; +use OpenMapsight\Embed\SsrClientError; +use OpenMapsight\Embed\SsrOutcome; +use OpenMapsight\Embed\SsrUnavailable; +use OpenMapsight\Embed\Testing\FakeSsrTransport; use PHPUnit\Framework\TestCase; final class RendererTest extends TestCase @@ -20,186 +21,122 @@ final class RendererTest extends TestCase public function test_rejects_connect_timeout_longer_than_total(): void { $this->expectException(\InvalidArgumentException::class); - new EmbedRequest( - preset: 'infosite', - containerId: 'mapsight-embed-bad-timeout', - config: [], - ssrTimeoutSeconds: 0.05, - ssrConnectTimeoutSeconds: 0.1, + new SsrClient( + ssrUrl: 'http://ssr:4123', + timeoutSeconds: 0.05, + connectTimeoutSeconds: 0.1, + transport: new FakeSsrTransport(), ); } - public function test_client_only_emits_css_container_and_mount_boot(): void + public function test_client_only_emits_css_preload_container_and_mount_boot(): void { - $html = (new Renderer())->render(new EmbedRequest( - preset: 'infosite', + $result = (new Renderer())->render(new EmbedRequest( + preset: 'simpleMap', containerId: 'mapsight-embed-demo', config: [ 'imagesUrl' => '/mapsight/plan/img/', 'enableMap' => true, - 'enableList' => true, - 'enableTagSwitcher' => true, - 'startCoordinates' => [10.53, 52.27], - 'startZoom' => 12, 'view' => 'desktop', ], )); - $this->assertStringContainsString( - 'href="/mapsight/plan/assets/mapsight.css"', - $html, - ); - $this->assertStringContainsString( - 'id="mapsight-embed-demo"', - $html, - ); - $this->assertStringContainsString( - 'import {mountEmbed} from "/mapsight/plan/assets/embed.js"', - $html, - ); - $this->assertStringContainsString( - 'import {infosite} from "/mapsight/plan/assets/infosite.js"', - $html, - ); - $this->assertStringContainsString('mountEmbed("mapsight-embed-demo"', $html); - $this->assertStringContainsString('infosite(', $html); - $this->assertStringContainsString('"enableList":true', $html); - $this->assertStringContainsString('"view":"desktop"', $html); + $html = $result->html; + $this->assertSame(SsrOutcome::Disabled, $result->ssr); + $this->assertStringContainsString('href="/mapsight/plan/assets/mapsight.css"', $html); + $this->assertStringContainsString('rel="modulepreload" href="/mapsight/plan/assets/embed.js"', $html); + $this->assertStringContainsString('rel="modulepreload" href="/mapsight/plan/assets/simpleMap.js"', $html); $this->assertStringContainsString('
', $html); + $this->assertStringContainsString('import {mountEmbed} from "/mapsight/plan/assets/embed.js"', $html); + $this->assertStringContainsString('import {simpleMap} from "/mapsight/plan/assets/simpleMap.js"', $html); + $this->assertStringContainsString('mountEmbed("mapsight-embed-demo"', $html); + $this->assertStringContainsString('"enableMap":true', $html); $this->assertStringNotContainsString('data-dehydrated-state', $html); - $this->assertStringNotContainsString('ms3-', $html); - $this->assertStringNotContainsString('stadtplan', $html); $this->assertStringNotContainsString('mapsight-ssr-skipped', $html); + $this->assertStringNotContainsString('stadtplan', $html); + $this->assertSame($result->stylesheetHtml, $result->html === '' ? '' : trim(explode("\n", $html)[0])); } public function test_host_supplies_container_class_on_empty_mount(): void { $html = (new Renderer())->render(new EmbedRequest( - preset: 'infosite', + preset: 'simpleMap', containerId: 'mapsight-embed-demo', config: [], containerClassName: 'host-embed', - )); + ))->html; $this->assertStringContainsString('
', $html); - $this->assertStringNotContainsString('ms3-', $html); } public function test_ssr_success_injects_dehydrated_fragment_and_boot(): void { - $transport = new class implements SsrTransport { - public function postJson( - string $url, - array $payload, - float $timeoutSeconds, - array $headers = [], - float $connectTimeoutSeconds = 0.1, - ): SsrDocument { - TestCase::assertSame('http://ssr:4123/v1/render', $url); - TestCase::assertSame(2.0, $timeoutSeconds); - TestCase::assertSame(0.1, $connectTimeoutSeconds); - TestCase::assertSame(1, $payload['v'] ?? null); - TestCase::assertSame('req-1', $payload['requestId'] ?? null); - TestCase::assertSame('assets-9', $payload['assetVersion'] ?? null); - TestCase::assertSame('application/json', $headers['Accept'] ?? null); - TestCase::assertSame('req-1', $headers['X-Request-Id'] ?? null); - TestCase::assertSame('assets-9', $headers['X-Mapsight-Asset-Version'] ?? null); - TestCase::assertSame('infosite', $payload['preset'] ?? null); - TestCase::assertSame( - 'mapsight-embed-ssr', - $payload['options']['containerId'] ?? null, - ); - - return new SsrDocument('
'); - } - }; - - $html = (new Renderer($transport))->render(new EmbedRequest( - preset: 'infosite', + $transport = new FakeSsrTransport(); + $transport->queueV1( + '
', + ['app' => ['title' => 'ok']], + ); + $result = $this->renderer($transport)->render(new EmbedRequest( + preset: 'simpleMap', containerId: 'mapsight-embed-ssr', config: ['imagesUrl' => '/mapsight/plan/img/', 'enableMap' => true], - ssrUrl: 'http://ssr:4123', requestId: 'req-1', assetVersion: 'assets-9', )); + $html = $result->html; + $this->assertSame(SsrOutcome::Rendered, $result->ssr); $this->assertStringContainsString('data-dehydrated-state=', $html); - $this->assertStringContainsString( - 'import {mountEmbed} from "/mapsight/plan/assets/embed.js?v=assets-9"', - $html, - ); - $this->assertStringContainsString( - 'import {infosite} from "/mapsight/plan/assets/infosite.js?v=assets-9"', - $html, - ); + $this->assertStringContainsString('href="/mapsight/plan/assets/mapsight.css?v=assets-9"', $html); + $this->assertStringContainsString('import {mountEmbed} from "/mapsight/plan/assets/embed.js?v=assets-9"', $html); + $this->assertStringContainsString('import {simpleMap} from "/mapsight/plan/assets/simpleMap.js?v=assets-9"', $html); $this->assertStringContainsString('mountEmbed("mapsight-embed-ssr"', $html); $this->assertStringNotContainsString('mapsight-ssr-skipped', $html); $this->assertSame(1, substr_count($html, 'id="mapsight-embed-ssr"')); + + $payload = json_decode((string) $transport->requests[0]->body, true, 512, JSON_THROW_ON_ERROR); + $this->assertSame('req-1', $payload['requestId'] ?? null); + $this->assertSame('assets-9', $payload['assetVersion'] ?? null); + $this->assertSame('req-1', $transport->requests[0]->headers['X-Request-Id'] ?? null); + $this->assertSame(2.0, $transport->requests[0]->timeoutSeconds); + $this->assertSame(0.1, $transport->requests[0]->connectTimeoutSeconds); } public function test_ssr_failure_falls_back_to_client_only(): void { - $transport = new class implements SsrTransport { - public function postJson( - string $url, - array $payload, - float $timeoutSeconds, - array $headers = [], - float $connectTimeoutSeconds = 0.1, - ): SsrDocument { - throw new \RuntimeException('connection refused'); - } - }; - - $html = (new Renderer($transport))->render(new EmbedRequest( - preset: 'infosite', + $transport = new FakeSsrTransport(); + $transport->queue(new SsrUnavailable('connection refused')); + $result = $this->renderer($transport)->render(new EmbedRequest( + preset: 'simpleMap', containerId: 'mapsight-embed-fallback', config: ['imagesUrl' => '/mapsight/plan/img/'], - ssrUrl: 'http://ssr:4123', )); - $this->assertStringContainsString('', $html); - $this->assertStringContainsString('
', $html); - $this->assertStringNotContainsString('data-dehydrated-state', $html); - $this->assertStringNotContainsString('ms3-', $html); - $this->assertStringContainsString( - 'import {mountEmbed} from "/mapsight/plan/assets/embed.js"', - $html, - ); - $this->assertStringContainsString('mountEmbed("mapsight-embed-fallback"', $html); + $this->assertSame(SsrOutcome::SkippedError, $result->ssr); + $this->assertSame('connection refused', $result->ssrReason); + $this->assertStringContainsString('', $result->html); + $this->assertStringContainsString('
', $result->html); + $this->assertStringNotContainsString('data-dehydrated-state', $result->html); } public function test_passes_split_timeouts_to_transport(): void { - $transport = new class implements SsrTransport { - public float $timeout = 0; - public float $connect = 0; - - public function postJson( - string $url, - array $payload, - float $timeoutSeconds, - array $headers = [], - float $connectTimeoutSeconds = 0.1, - ): SsrDocument { - $this->timeout = $timeoutSeconds; - $this->connect = $connectTimeoutSeconds; - - return new SsrDocument('
'); - } - }; - - (new Renderer($transport))->render(new EmbedRequest( - preset: 'infosite', - containerId: 'mapsight-embed-timeouts', - config: ['imagesUrl' => '/mapsight/plan/img/'], + $transport = new FakeSsrTransport(); + $transport->queueV1('
'); + $client = new SsrClient( ssrUrl: 'http://ssr:4123', - ssrTimeoutSeconds: 3.0, - ssrConnectTimeoutSeconds: 0.05, + timeoutSeconds: 3.0, + connectTimeoutSeconds: 0.05, + transport: $transport, + ); + (new Renderer($client))->render(new EmbedRequest( + preset: 'simpleMap', + containerId: 'mapsight-embed-timeouts', + config: [], )); - $this->assertSame(3.0, $transport->timeout); - $this->assertSame(0.05, $transport->connect); + $this->assertSame(3.0, $transport->requests[0]->timeoutSeconds); + $this->assertSame(0.05, $transport->requests[0]->connectTimeoutSeconds); } public function test_open_circuit_skips_transport_until_cooldown(): void @@ -207,72 +144,45 @@ public function test_open_circuit_skips_transport_until_cooldown(): void $clock = new class { public float $now = 1000.0; }; - $transport = new class implements SsrTransport { - public int $calls = 0; - - public function postJson( - string $url, - array $payload, - float $timeoutSeconds, - array $headers = [], - float $connectTimeoutSeconds = 0.1, - ): SsrDocument { - $this->calls++; - throw new \RuntimeException('sidecar down'); - } - }; - $renderer = new Renderer( + $transport = new FakeSsrTransport(); + $transport->queue(new SsrUnavailable('sidecar down')); + $transport->queue(new SsrUnavailable('sidecar down')); + $transport->queue(new SsrUnavailable('sidecar down')); + $renderer = $this->renderer( $transport, - new ProcessSsrCircuitBreaker(2, 10.0, fn () => $clock->now), + breaker: new ProcessSsrCircuitBreaker(2, 10.0, fn () => $clock->now), ); $request = new EmbedRequest( - preset: 'infosite', + preset: 'simpleMap', containerId: 'mapsight-embed-breaker', - config: ['imagesUrl' => '/mapsight/plan/img/'], - ssrUrl: 'http://ssr:4123', + config: [], ); - $first = $renderer->render($request); - $second = $renderer->render($request); + $renderer->render($request); + $renderer->render($request); $skipped = $renderer->render($request); - $this->assertSame(2, $transport->calls); - $this->assertStringContainsString('', $first); - $this->assertStringContainsString('', $second); - $this->assertStringContainsString('', $skipped); + $this->assertCount(2, $transport->requests); + $this->assertSame(SsrOutcome::SkippedBreaker, $skipped->ssr); + $this->assertStringContainsString('', $skipped->html); $clock->now = 1010.0; $afterCooldown = $renderer->render($request); - $this->assertSame(3, $transport->calls); - $this->assertStringContainsString('', $afterCooldown); + $this->assertCount(3, $transport->requests); + $this->assertSame(SsrOutcome::SkippedError, $afterCooldown->ssr); } public function test_cache_hit_skips_node_and_does_not_mark_skipped(): void { - $transport = new class implements SsrTransport { - public int $calls = 0; - - public function postJson( - string $url, - array $payload, - float $timeoutSeconds, - array $headers = [], - float $connectTimeoutSeconds = 0.1, - ): SsrDocument { - $this->calls++; - - return new SsrDocument('
'); - } - }; + $transport = new FakeSsrTransport(); + $transport->queueV1('
', ['app' => ['n' => 1]]); + $transport->queueV1('
', ['app' => ['n' => 2]]); $cache = new ArraySsrResultCache(); - $renderer = new Renderer($transport, new ProcessSsrCircuitBreaker(), $cache); + $renderer = $this->renderer($transport, $cache); $request = new EmbedRequest( - preset: 'infosite', + preset: 'simpleMap', containerId: 'mapsight-embed-cache', - config: ['enableList' => true, 'imagesUrl' => '/mapsight/plan/img/'], - ssrUrl: 'http://ssr:4123', + config: ['enableList' => true], assetVersion: 'assets-1', locale: 'de', deviceClass: 'desktop', @@ -281,21 +191,25 @@ public function postJson( $first = $renderer->render($request); $second = $renderer->render($request); - $this->assertSame(1, $transport->calls); - $this->assertStringContainsString('data-dehydrated-state=', $first); - $this->assertSame($first, $second); - $this->assertStringNotContainsString('mapsight-ssr-skipped', $second); + $this->assertCount(1, $transport->requests); + $payload = json_decode((string) $transport->requests[0]->body, true, 512, JSON_THROW_ON_ERROR); + $this->assertSame('de', $payload['options']['locale'] ?? null); + $this->assertSame('desktop', $payload['options']['deviceClass'] ?? null); + $this->assertSame(SsrOutcome::Rendered, $first->ssr); + $this->assertSame(SsrOutcome::Cached, $second->ssr); + $this->assertSame($first->containerHtml, $second->containerHtml); + $this->assertStringNotContainsString('mapsight-ssr-skipped', $second->html); $cache->flush(); $third = $renderer->render($request); - $this->assertSame(2, $transport->calls); - $this->assertStringContainsString('data-dehydrated-state=', $third); + $this->assertCount(2, $transport->requests); + $this->assertSame(SsrOutcome::Rendered, $third->ssr); } public function test_cache_key_ignores_config_key_order(): void { $left = new EmbedRequest( - preset: 'infosite', + preset: 'simpleMap', containerId: 'mapsight-embed-key', config: ['b' => 2, 'a' => 1], assetVersion: 'v1', @@ -303,7 +217,7 @@ public function test_cache_key_ignores_config_key_order(): void deviceClass: 'mobile', ); $right = new EmbedRequest( - preset: 'infosite', + preset: 'simpleMap', containerId: 'mapsight-embed-key', config: ['a' => 1, 'b' => 2], assetVersion: 'v1', @@ -311,146 +225,98 @@ public function test_cache_key_ignores_config_key_order(): void deviceClass: 'mobile', ); - $this->assertSame(SsrCacheKey::for($left), SsrCacheKey::for($right)); + $this->assertSame(SsrCacheKey::for($left, null), SsrCacheKey::for($right, null)); } - public function test_ssr_payload_forwards_request_url(): void + public function test_ssr_payload_forwards_request_url_and_share_params(): void { - $transport = new class implements SsrTransport { - /** @var array|null */ - public ?array $payload = null; - - public function postJson( - string $url, - array $payload, - float $timeoutSeconds, - array $headers = [], - float $connectTimeoutSeconds = 0.1, - ): SsrDocument { - $this->payload = $payload; - - return new SsrDocument('
'); - } - }; - - (new Renderer($transport))->render(new EmbedRequest( - preset: 'stadtplan', + $transport = new FakeSsrTransport(); + $transport->queueV1('
'); + $this->renderer($transport)->render(new EmbedRequest( + preset: 'simpleMap', containerId: 'mapsight-embed-url', - config: ['imagesUrl' => '/mapsight/plan/img/'], - ssrUrl: 'http://ssr:4123', - requestUrl: '/map/?module=baustellen-verkehr', + config: [], + requestUrl: '/map/?module=traffic&utm_source=x', )); - $this->assertSame( - '/map/?module=baustellen-verkehr', - $transport->payload['options']['requestUrl'] ?? null, - ); + $payload = json_decode((string) $transport->requests[0]->body, true, 512, JSON_THROW_ON_ERROR); + $this->assertSame('/map/?module=traffic', $payload['options']['requestUrl'] ?? null); } - public function test_cache_key_includes_request_url(): void + public function test_utm_and_click_ids_do_not_change_the_cache_key(): void { - $home = new EmbedRequest( - preset: 'stadtplan', - containerId: 'mapsight-embed-key', - config: ['imagesUrl' => '/mapsight/plan/img/'], - requestUrl: '/map/', - ); - $verkehr = new EmbedRequest( - preset: 'stadtplan', - containerId: 'mapsight-embed-key', - config: ['imagesUrl' => '/mapsight/plan/img/'], - requestUrl: '/map/?module=baustellen-verkehr', - ); - - $this->assertNotSame(SsrCacheKey::for($home), SsrCacheKey::for($verkehr)); + $client = new SsrClient(ssrUrl: 'http://ssr:4123', transport: new FakeSsrTransport()); + $plain = $client->normalizeRequestUrl('/map'); + $utm = $client->normalizeRequestUrl('/map?utm_source=x'); + $feature = $client->normalizeRequestUrl('/map?feature=1'); + $featureClick = $client->normalizeRequestUrl('/map?feature=1&fbclid=z'); + + $this->assertSame($plain, $utm); + $this->assertSame($feature, $featureClick); + $this->assertNotSame($plain, $feature); } public function test_ssr_payload_forwards_page_origin_and_og_image(): void { - $transport = new class implements SsrTransport { - /** @var array|null */ - public ?array $payload = null; - - public function postJson( - string $url, - array $payload, - float $timeoutSeconds, - array $headers = [], - float $connectTimeoutSeconds = 0.1, - ): SsrDocument { - $this->payload = $payload; - - return new SsrDocument('
'); - } - }; - - (new Renderer($transport))->render(new EmbedRequest( - preset: 'infosite', + $transport = new FakeSsrTransport(); + $transport->queueV1('
'); + $this->renderer($transport)->render(new EmbedRequest( + preset: 'simpleMap', containerId: 'mapsight-embed-origin', - config: ['imagesUrl' => '/mapsight/plan/img/'], - ssrUrl: 'http://ssr:4123', + config: [], requestUrl: '/map?feature=poi-1', pageOrigin: 'https://www.example.com', ogImage: 'https://www.example.com/plan/img/og-default.png', )); - $this->assertSame( - 'https://www.example.com', - $transport->payload['options']['pageOrigin'] ?? null, - ); + $payload = json_decode((string) $transport->requests[0]->body, true, 512, JSON_THROW_ON_ERROR); + $this->assertSame('https://www.example.com', $payload['options']['pageOrigin'] ?? null); $this->assertSame( 'https://www.example.com/plan/img/og-default.png', - $transport->payload['options']['ogImage'] ?? null, - ); - $this->assertSame( - '/map?feature=poi-1', - $transport->payload['options']['requestUrl'] ?? null, + $payload['options']['ogImage'] ?? null, ); + $this->assertSame('/map?feature=poi-1', $payload['options']['requestUrl'] ?? null); } - public function test_render_document_exposes_page_meta_when_feature_or_module_is_on_request_url(): void + public function test_render_exposes_page_meta_when_share_param_is_on_request_url(): void { - $meta = self::samplePageMeta(); - $transport = new class($meta) implements SsrTransport { - public function __construct(private readonly PlacePageMeta $meta) - { - } - - public function postJson( - string $url, - array $payload, - float $timeoutSeconds, - array $headers = [], - float $connectTimeoutSeconds = 0.1, - ): SsrDocument { - return new SsrDocument( - '
', - $this->meta, - ); - } - }; - - $withFeature = (new Renderer($transport))->renderDocument(new EmbedRequest( - preset: 'infosite', + $meta = [ + 'title' => 'Town Hall', + 'description' => 'An example place.', + 'canonicalUrl' => 'https://www.example.com/map?feature=poi-1', + 'og' => [ + 'title' => 'Town Hall', + 'description' => 'An example place.', + 'url' => 'https://www.example.com/map?feature=poi-1', + 'type' => 'place', + 'image' => 'https://www.example.com/plan/img/og-default.png', + ], + 'jsonLd' => ['@type' => 'Place', 'name' => 'Town Hall'], + ]; + $transport = new FakeSsrTransport(); + $transport->queueV1('
', [], $meta); + $transport->queueV1('
', [], $meta); + $transport->queueV1('
', [], $meta); + $renderer = $this->renderer($transport); + + $withFeature = $renderer->render(new EmbedRequest( + preset: 'simpleMap', containerId: 'mapsight-embed-meta', - config: ['imagesUrl' => '/mapsight/plan/img/'], - ssrUrl: 'http://ssr:4123', + config: [], requestUrl: '/map?feature=poi-1', pageOrigin: 'https://www.example.com', )); - $withModule = (new Renderer($transport))->renderDocument(new EmbedRequest( - preset: 'stadtplan', + $withModule = $renderer->render(new EmbedRequest( + preset: 'simpleMap', containerId: 'mapsight-embed-meta', - config: ['imagesUrl' => '/mapsight/plan/img/'], - ssrUrl: 'http://ssr:4123', - requestUrl: '/plan/?module=parken', + config: [], + requestUrl: '/plan/?module=parking', pageOrigin: 'https://www.example.com', )); - $withoutShareable = (new Renderer($transport))->renderDocument(new EmbedRequest( - preset: 'infosite', + $withoutShareable = $renderer->render(new EmbedRequest( + preset: 'simpleMap', containerId: 'mapsight-embed-meta', - config: ['imagesUrl' => '/mapsight/plan/img/'], - ssrUrl: 'http://ssr:4123', + config: [], requestUrl: '/map', pageOrigin: 'https://www.example.com', )); @@ -462,24 +328,79 @@ public function postJson( $this->assertStringContainsString('data-dehydrated-state=', $withoutShareable->html); } - private static function samplePageMeta(): PlacePageMeta + public function test_open_circuit_still_serves_warm_cache(): void { - return new PlacePageMeta( - 'Town Hall', - 'An example place.', - 'https://www.example.com/map?feature=poi-1', - new PlacePageMetaOg( - 'Town Hall', - 'An example place.', - 'https://www.example.com/map?feature=poi-1', - 'place', - 'https://www.example.com/plan/img/og-default.png', - ), - [ - '@context' => 'https://schema.org', - '@type' => 'Place', - 'name' => 'Town Hall', - ], + $transport = new FakeSsrTransport(); + $transport->queueV1('
', ['n' => 1]); + $cache = new ArraySsrResultCache(); + $breaker = new ProcessSsrCircuitBreaker(1, 60.0); + $renderer = $this->renderer($transport, $cache, $breaker); + $request = new EmbedRequest( + preset: 'simpleMap', + containerId: 'mapsight-embed-warm', + config: [], ); + + $warm = $renderer->render($request); + $breaker->recordFailure(); + $this->assertFalse($breaker->allow()); + $served = $renderer->render($request); + + $this->assertCount(1, $transport->requests); + $this->assertSame(SsrOutcome::Cached, $served->ssr); + $this->assertSame($warm->containerHtml, $served->containerHtml); + $this->assertStringNotContainsString('mapsight-ssr-skipped', $served->html); + } + + public function test_client_errors_do_not_trip_the_breaker(): void + { + $transport = new FakeSsrTransport(); + $transport->queue(new SsrClientError('SSR v1 error VALIDATION')); + $transport->queue(new SsrClientError('SSR v1 error VALIDATION')); + $transport->queue(new SsrClientError('SSR v1 error VALIDATION')); + $renderer = $this->renderer( + $transport, + breaker: new ProcessSsrCircuitBreaker(2, 10.0), + ); + $request = new EmbedRequest( + preset: 'simpleMap', + containerId: 'mapsight-embed-client-error', + config: [], + ); + + $renderer->render($request); + $renderer->render($request); + $third = $renderer->render($request); + + $this->assertCount(3, $transport->requests); + $this->assertSame(SsrOutcome::SkippedError, $third->ssr); + } + + public function test_script_nonce_and_json_encoded_urls(): void + { + $result = (new Renderer())->render(new EmbedRequest( + preset: 'simpleMap', + containerId: 'mapsight-embed-nonce', + config: [], + assetBase: '/x', + scriptNonce: 'abc-1', + )); + + $this->assertStringContainsString('', $result->bootScriptHtml); + } + + private function renderer( + FakeSsrTransport $transport, + ?ArraySsrResultCache $cache = null, + ?ProcessSsrCircuitBreaker $breaker = null, + ): Renderer { + return new Renderer(new SsrClient( + ssrUrl: 'http://ssr:4123', + transport: $transport, + resultCache: $cache, + breaker: $breaker, + )); } } diff --git a/tests/SsrClientTest.php b/tests/SsrClientTest.php new file mode 100644 index 0000000..923f2f3 --- /dev/null +++ b/tests/SsrClientTest.php @@ -0,0 +1,121 @@ +queue(new SsrHttpResponse(200, 'text/plain', 'ok')); + $client = new SsrClient(ssrUrl: 'http://ssr:4123', transport: $transport); + + $this->assertTrue($client->health()); + $this->assertSame('GET', $transport->requests[0]->method); + $this->assertSame('http://ssr:4123/health', $transport->requests[0]->url); + } + + public function test_health_is_false_on_transport_error(): void + { + $transport = new FakeSsrTransport(); + $client = new SsrClient(ssrUrl: 'http://ssr:4123', transport: $transport); + + $this->assertFalse($client->health()); + } + + public function test_warm_forces_a_sidecar_call_and_stores_the_result(): void + { + $transport = new FakeSsrTransport(); + $transport->queueV1('
', ['n' => 1]); + $transport->queueV1('
', ['n' => 2]); + $cache = new ArraySsrResultCache(); + $client = new SsrClient( + ssrUrl: 'http://ssr:4123', + transport: $transport, + resultCache: $cache, + ); + $request = new EmbedRequest( + preset: 'simpleMap', + containerId: 'mapsight-embed-warm', + config: [], + ); + + $this->assertSame(SsrOutcome::Rendered, $client->resolve($request)->outcome); + $this->assertSame(SsrOutcome::Rendered, $client->warm($request)); + $this->assertCount(2, $transport->requests); + $this->assertSame(SsrOutcome::Cached, $client->resolve($request)->outcome); + $this->assertCount(2, $transport->requests); + } + + public function test_rejects_non_json_content_type(): void + { + $transport = new FakeSsrTransport(); + $transport->queue(new SsrHttpResponse( + 200, + 'text/html', + '
', + )); + $result = (new SsrClient(ssrUrl: 'http://ssr:4123', transport: $transport)) + ->resolve(new EmbedRequest( + preset: 'simpleMap', + containerId: 'mapsight-embed-1', + config: [], + )); + + $this->assertSame(SsrOutcome::SkippedError, $result->outcome); + $this->assertStringContainsString('Content-Type', (string) $result->reason); + } + + public function test_four_xx_does_not_count_as_unavailable(): void + { + $transport = new FakeSsrTransport(); + $transport->queue(new SsrHttpResponse(400, 'application/json', '{"v":1,"error":{"code":"VALIDATION"}}')); + $transport->queueV1('
'); + $client = new SsrClient( + ssrUrl: 'http://ssr:4123', + transport: $transport, + breaker: new \OpenMapsight\Embed\ProcessSsrCircuitBreaker(1, 60.0), + ); + $request = new EmbedRequest( + preset: 'simpleMap', + containerId: 'mapsight-embed-1', + config: [], + ); + + $this->assertSame(SsrOutcome::SkippedError, $client->resolve($request)->outcome); + $this->assertSame(SsrOutcome::Rendered, $client->resolve($request)->outcome); + } + + public function test_normalizes_long_urls_to_path_only(): void + { + $client = new SsrClient(ssrUrl: 'http://ssr:4123', transport: new FakeSsrTransport()); + $url = '/map?' . str_repeat('x=1&', 600) . 'feature=1'; + + $this->assertSame('/map', $client->normalizeRequestUrl($url)); + } + + public function test_array_cache_expires_and_evicts(): void + { + $cache = new ArraySsrResultCache(); + $cache->set('old', new \OpenMapsight\Embed\SsrDocument('
'), 1); + $this->assertNotNull($cache->get('old')); + sleep(2); + $this->assertNull($cache->get('old')); + + for ($i = 0; $i < ArraySsrResultCache::MAX_ENTRIES + 2; $i++) { + $cache->set('k' . $i, new \OpenMapsight\Embed\SsrDocument('
'), 60); + } + $this->assertNull($cache->get('k0')); + $this->assertNotNull($cache->get('k' . (ArraySsrResultCache::MAX_ENTRIES + 1))); + } +} diff --git a/tests/SsrPublishTest.php b/tests/SsrPublishTest.php index 56fc71d..429ebe8 100644 --- a/tests/SsrPublishTest.php +++ b/tests/SsrPublishTest.php @@ -5,98 +5,94 @@ namespace OpenMapsight\Tests; use OpenMapsight\Embed\ArraySsrResultCache; +use OpenMapsight\Embed\SsrClient; use OpenMapsight\Embed\SsrDocument; +use OpenMapsight\Embed\SsrHttpResponse; use OpenMapsight\Embed\SsrPublish; -use OpenMapsight\Embed\SsrPurgeTransport; +use OpenMapsight\Embed\SsrUnavailable; +use OpenMapsight\Embed\Testing\FakeSsrTransport; use PHPUnit\Framework\TestCase; final class SsrPublishTest extends TestCase { public function test_purges_sidecar_then_flushes_php_fragments(): void { - $transport = new class implements SsrPurgeTransport { - public int $calls = 0; - - /** @var list}> */ - public array $requests = []; - - public function postPurge( - string $url, - array $payload, - float $timeoutSeconds, - float $connectTimeoutSeconds = 0.1, - ): array { - $this->calls++; - $this->requests[] = ['url' => $url, 'payload' => $payload]; - - return ['doc::https://example.test/schools.geojson']; - } - }; + $transport = new FakeSsrTransport(); + $transport->queue(new SsrHttpResponse( + 200, + 'application/json', + '["doc::https://example.test/schools.geojson"]', + )); $cache = new ArraySsrResultCache(); - $cache->set('k1', new SsrDocument('
')); - $hook = new SsrPublish('http://ssr:4123', $cache, $transport); + $cache->set('k1', new SsrDocument('
'), 60); + $hook = new SsrPublish($this->client($transport, $cache)); - $deleted = $hook->afterFeatureSourcePublish([ - 'https://example.test/schools.geojson', - ]); + $result = $hook->purge(['https://example.test/schools.geojson']); - $this->assertSame(['doc::https://example.test/schools.geojson'], $deleted); - $this->assertSame(1, $transport->calls); - $this->assertSame('http://ssr:4123/purge', $transport->requests[0]['url'] ?? null); + $this->assertTrue($result->sidecarPurged); + $this->assertSame(['doc::https://example.test/schools.geojson'], $result->deletedKeys); + $this->assertCount(1, $transport->requests); + $this->assertSame('http://ssr:4123/purge', $transport->requests[0]->url); $this->assertSame( ['urls' => ['https://example.test/schools.geojson']], - $transport->requests[0]['payload'] ?? null, + json_decode((string) $transport->requests[0]->body, true, 512, JSON_THROW_ON_ERROR), ); $this->assertNull($cache->get('k1')); } public function test_omitted_urls_clears_sidecar_and_still_flushes_php(): void { - $transport = new class implements SsrPurgeTransport { - /** @var array|null */ - public ?array $payload = null; - - public function postPurge( - string $url, - array $payload, - float $timeoutSeconds, - float $connectTimeoutSeconds = 0.1, - ): array { - $this->payload = $payload; - - return ['doc::all']; - } - }; + $transport = new FakeSsrTransport(); + $transport->queue(new SsrHttpResponse(200, 'application/json', '["doc::all"]')); $cache = new ArraySsrResultCache(); - $cache->set('k1', new SsrDocument('
')); + $cache->set('k1', new SsrDocument('
'), 60); - $deleted = (new SsrPublish('http://ssr:4123', $cache, $transport)) - ->afterFeatureSourcePublish(); + $result = (new SsrPublish($this->client($transport, $cache)))->purge(); - $this->assertSame(['doc::all'], $deleted); - $this->assertSame([], $transport->payload); + $this->assertTrue($result->sidecarPurged); + $this->assertSame(['doc::all'], $result->deletedKeys); + $this->assertSame('{}', $transport->requests[0]->body); $this->assertNull($cache->get('k1')); } - public function test_sidecar_failure_still_flushes_php(): void + public function test_sidecar_failure_does_not_flush_php(): void { - $transport = new class implements SsrPurgeTransport { - public function postPurge( - string $url, - array $payload, - float $timeoutSeconds, - float $connectTimeoutSeconds = 0.1, - ): array { - throw new \RuntimeException('connection refused'); - } - }; + $transport = new FakeSsrTransport(); + $transport->queue(new SsrUnavailable('connection refused')); $cache = new ArraySsrResultCache(); - $cache->set('k1', new SsrDocument('
')); + $cache->set('k1', new SsrDocument('
'), 60); - $deleted = (new SsrPublish('http://ssr:4123', $cache, $transport)) - ->afterFeatureSourcePublish(['https://example.test/a.geojson']); + $result = (new SsrPublish($this->client($transport, $cache))) + ->purge(['https://example.test/a.geojson']); - $this->assertSame([], $deleted); - $this->assertNull($cache->get('k1')); + $this->assertFalse($result->sidecarPurged); + $this->assertSame([], $result->deletedKeys); + $this->assertNotNull($cache->get('k1')); + } + + public function test_blank_urls_are_not_a_purge_all(): void + { + $transport = new FakeSsrTransport(); + $cache = new ArraySsrResultCache(); + $cache->set('k1', new SsrDocument('
'), 60); + + try { + (new SsrPublish($this->client($transport, $cache)))->purge(['']); + $this->fail('expected InvalidArgumentException'); + } catch (\InvalidArgumentException $e) { + $this->assertStringContainsString('only empty urls', $e->getMessage()); + } + + $this->assertSame([], $transport->requests); + $this->assertNotNull($cache->get('k1')); + } + + private function client(FakeSsrTransport $transport, ArraySsrResultCache $cache): SsrClient + { + return new SsrClient( + ssrUrl: 'http://ssr:4123', + transport: $transport, + resultCache: $cache, + ); } } diff --git a/tests/SsrV1DocumentTest.php b/tests/SsrV1DocumentTest.php index c0c3f85..cef673d 100644 --- a/tests/SsrV1DocumentTest.php +++ b/tests/SsrV1DocumentTest.php @@ -4,6 +4,7 @@ namespace OpenMapsight\Tests; +use OpenMapsight\Embed\SsrClientError; use OpenMapsight\Embed\SsrV1Document; use PHPUnit\Framework\TestCase; @@ -15,7 +16,6 @@ public function test_injects_json_state_into_dehydrated_attribute(): void 'v' => 1, 'html' => '
', 'state' => ['app' => ['title' => 'ok & "x"']], - 'meta' => ['preset' => 'infosite'], ], JSON_THROW_ON_ERROR)); $this->assertStringContainsString('id="mapsight-embed-1"', $html); @@ -26,6 +26,15 @@ public function test_injects_json_state_into_dehydrated_attribute(): void $this->assertStringNotContainsString('assertSame(['app' => ['ssr' => 'v1']], $this->dehydratedState($document->html)); + $this->assertNull($document->pageMeta); + } + public function test_from_response_keeps_page_meta_and_fails_open_when_incomplete(): void { $document = SsrV1Document::fromResponse(json_encode([ @@ -45,7 +54,7 @@ public function test_from_response_keeps_page_meta_and_fails_open_when_incomplet ], 'jsonLd' => ['@type' => 'Place', 'name' => 'Town Hall'], ], - ], JSON_THROW_ON_ERROR)); + ], JSON_THROW_ON_ERROR), 'x'); $this->assertSame('Town Hall', $document->pageMeta?->title); $this->assertSame( @@ -59,7 +68,7 @@ public function test_from_response_keeps_page_meta_and_fails_open_when_incomplet 'html' => '
', 'state' => ['app' => ['ssr' => 'v1']], 'pageMeta' => ['title' => 'incomplete'], - ], JSON_THROW_ON_ERROR)); + ], JSON_THROW_ON_ERROR), 'x'); $this->assertNull($withoutMeta->pageMeta); } @@ -69,12 +78,24 @@ public function test_replaces_state_already_on_the_fragment(): void 'v' => 1, 'html' => '
', 'state' => ['app' => ['ssr' => 'v1']], - ], JSON_THROW_ON_ERROR)); + ], JSON_THROW_ON_ERROR), 'x'); $this->assertSame(['app' => ['ssr' => 'v1']], $this->dehydratedState($html)); $this->assertStringNotContainsString('stub', $html); } + public function test_strips_unquoted_dehydrated_attribute(): void + { + $html = SsrV1Document::containerHtmlFromResponse(json_encode([ + 'v' => 1, + 'html' => '
', + 'state' => ['app' => ['ssr' => 'v1']], + ], JSON_THROW_ON_ERROR), 'x'); + + $this->assertSame(1, substr_count($html, 'data-dehydrated-state=')); + $this->assertSame(['app' => ['ssr' => 'v1']], $this->dehydratedState($html)); + } + public function test_replaces_node_state_when_json_attribute_contains_gt(): void { $attribution = 'OpenStreetMap-Mitwirkende.'; @@ -92,7 +113,7 @@ public function test_replaces_node_state_when_json_attribute_contains_gt(): void 'map' => ['layers' => ['street' => ['attribution' => $attribution]]], 'app' => ['ssr' => 'v1'], ], - ], JSON_THROW_ON_ERROR)); + ], JSON_THROW_ON_ERROR), 'x'); $this->assertSame( [ @@ -105,24 +126,79 @@ public function test_replaces_node_state_when_json_attribute_contains_gt(): void $this->assertStringContainsString('

shell

', $html); } - /** @return array */ - private function dehydratedState(string $html): array + public function test_rejects_error_payload(): void { - $this->assertSame(1, preg_match('/data-dehydrated-state="([^"]*)"/', $html, $matches)); - $decoded = html_entity_decode($matches[1], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); - $state = json_decode($decoded, true, 512, JSON_THROW_ON_ERROR); - $this->assertIsArray($state); + $this->expectException(SsrClientError::class); - return $state; + SsrV1Document::containerHtmlFromResponse(json_encode([ + 'v' => 1, + 'error' => ['code' => 'RENDER_FAILED', 'message' => 'render failed'], + ], JSON_THROW_ON_ERROR)); } - public function test_rejects_error_payload(): void + public function test_rejects_html_that_starts_with_a_comment(): void { - $this->expectException(\RuntimeException::class); + $this->expectException(SsrClientError::class); + $this->expectExceptionMessage('SSR v1 html has no opening element'); SsrV1Document::containerHtmlFromResponse(json_encode([ 'v' => 1, - 'error' => ['code' => 'RENDER_FAILED', 'message' => 'render failed'], + 'html' => '
', + 'state' => ['a' => 1], + ], JSON_THROW_ON_ERROR), 'c'); + } + + public function test_rejects_root_id_mismatch(): void + { + $this->expectException(SsrClientError::class); + $this->expectExceptionMessage('SSR v1 html root id does not match containerId'); + + SsrV1Document::containerHtmlFromResponse(json_encode([ + 'v' => 1, + 'html' => '
', + 'state' => ['a' => 1], + ], JSON_THROW_ON_ERROR), 'mapsight-embed-1'); + } + + public function test_accepts_utf8_bom_before_json(): void + { + $html = SsrV1Document::containerHtmlFromResponse("\xEF\xBB\xBF" . json_encode([ + 'v' => 1, + 'html' => '
', + 'state' => ['app' => ['ssr' => 'v1']], ], JSON_THROW_ON_ERROR)); + + $this->assertSame(['app' => ['ssr' => 'v1']], $this->dehydratedState($html)); + } + + public function test_rejects_raw_html_body(): void + { + $this->expectException(SsrClientError::class); + $this->expectExceptionMessage('SSR v1 response is not JSON'); + + SsrV1Document::fromResponse('
', 'c'); + } + + public function test_rejects_state_over_the_size_cap(): void + { + $this->expectException(SsrClientError::class); + $this->expectExceptionMessage('SSR v1 state exceeds size cap'); + + SsrV1Document::fromResponse(json_encode([ + 'v' => 1, + 'html' => '
', + 'state' => ['blob' => str_repeat('a', 200)], + ], JSON_THROW_ON_ERROR), 'x', 16); + } + + /** @return array */ + private function dehydratedState(string $html): array + { + $this->assertSame(1, preg_match('/data-dehydrated-state="([^"]*)"/', $html, $matches)); + $decoded = html_entity_decode($matches[1], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $state = json_decode($decoded, true, 512, JSON_THROW_ON_ERROR); + $this->assertIsArray($state); + + return $state; } } diff --git a/tests/fixtures/http-server.php b/tests/fixtures/http-server.php new file mode 100644 index 0000000..4d8067d --- /dev/null +++ b/tests/fixtures/http-server.php @@ -0,0 +1,82 @@ +
'; + exit; + } + if (($query['bom'] ?? '') === '1') { + http_response_code(200); + header('Content-Type: application/json'); + echo "\xEF\xBB\xBF" . '{"v":1,"html":"
","state":{"bom":true}}'; + exit; + } + + http_response_code(200); + header('Content-Type: application/json'); + $payload = json_decode((string) file_get_contents('php://input'), true); + echo json_encode([ + 'v' => 1, + 'html' => '
', + 'state' => [ + 'echo' => $payload, + 'headers' => [ + 'x-request-id' => $_SERVER['HTTP_X_REQUEST_ID'] ?? null, + 'x-mapsight-asset-version' => $_SERVER['HTTP_X_MAPSIGHT_ASSET_VERSION'] ?? null, + ], + ], + 'pageMeta' => null, + ], JSON_THROW_ON_ERROR); + exit; +} + +if ($path === '/purge') { + if (($query['status'] ?? '') === '204') { + http_response_code(204); + exit; + } + http_response_code(200); + header('Content-Type: application/json'); + echo '["doc::1"]'; + exit; +} + +http_response_code(404); +header('Content-Type: text/plain'); +echo 'not found'; diff --git a/tests/fixtures/v1-render.json b/tests/fixtures/v1-render.json new file mode 100644 index 0000000..e5e83ba --- /dev/null +++ b/tests/fixtures/v1-render.json @@ -0,0 +1,10 @@ +{ + "v": 1, + "html": "
", + "state": { + "app": { + "ssr": "v1" + } + }, + "pageMeta": null +}