From 2f2afa41a7264cd60f2be832d7ed234b56078821 Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Tue, 10 Nov 2026 18:21:57 +0000 Subject: [PATCH] Add Craft Cloud static caching for Craft 6 Tag pages with their Craft cache tags and derive CDN-Cache-Control from the final response headers. Purge on every TagDependency invalidation, except drafts and revisions, with one queued PurgeStaticCache job per request, command or queue job, signed with #213's RequestSigner. Add a Clear Caches option and a single cloud:purge command. Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-6551416a-ce7c-4e90-adac-8c2edc72248c --- README.md | 15 + src/CloudServiceProvider.php | 2 + src/staticcache/CacheResponse.php | 87 ++++ src/staticcache/CdnCacheControl.php | 53 +++ src/staticcache/PurgeCommand.php | 34 ++ src/staticcache/PurgeStaticCache.php | 57 +++ src/staticcache/StaticCache.php | 104 +++++ .../StaticCacheServiceProvider.php | 53 +++ tests/Feature/StaticCacheTest.php | 393 ++++++++++++++++++ tests/TestCase.php | 5 +- 10 files changed, 802 insertions(+), 1 deletion(-) create mode 100644 src/staticcache/CacheResponse.php create mode 100644 src/staticcache/CdnCacheControl.php create mode 100644 src/staticcache/PurgeCommand.php create mode 100644 src/staticcache/PurgeStaticCache.php create mode 100644 src/staticcache/StaticCache.php create mode 100644 src/staticcache/StaticCacheServiceProvider.php create mode 100644 tests/Feature/StaticCacheTest.php diff --git a/README.md b/README.md index 42b64b18..f3c4d025 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,18 @@ # Craft Cloud Package Run Craft CMS on Craft Cloud. + +## Static cache + +On Craft Cloud, the gateway and CDN cache site pages and purge them with Craft's own cache tags. + +Cloud's middleware runs outside Craft's `SetHeaders` and Laravel's cookie middleware. For successful GET and HEAD site responses, it sets: + +- `CDN-Cache-Control` from the response's `Cache-Control` (`s-maxage`, then `max-age` or `Expires`), so `{% expires %}`, `ResponseHeaders::setCache()` and Symfony's `setSharedMaxAge()` control the CDN. Responses without a lifetime are cached for a year, with an hour of `stale-while-revalidate`. Lifetimes are capped at the earliest expiry of the elements the page used. `private`, `no-cache` and `no-store` responses aren't cached, and an explicit `CDN-Cache-Control` header is left alone. +- `Cache-Tag` with the page's Craft cache tags, including the matched element's. + +Responses aren't cached when the request has a session cookie or `Authorization` header, the response sets a cookie, or the request used a route token or site token. + +Every `TagDependency::invalidate()` purges the pages with those tags, apart from draft and revision changes. This covers element saves, `ElementCaches::collectCacheTags()` with custom tags, and `craft:invalidate-tags`. Purges are sent once per request, command or queue job, as a queued `PurgeStaticCache` job. + +The Caches utility's “Craft Cloud static cache” option (`craft:clear-caches:cloud-static-cache`) purges everything. `php craft cloud:purge` does the same, or purges specific Cloudflare cache tags (`--tag`) or URL prefixes (`--prefix`). diff --git a/src/CloudServiceProvider.php b/src/CloudServiceProvider.php index 4145ac22..d26ad653 100644 --- a/src/CloudServiceProvider.php +++ b/src/CloudServiceProvider.php @@ -4,6 +4,7 @@ namespace craft\cloud; +use craft\cloud\staticcache\StaticCacheServiceProvider; use craft\cloud\transforms\AssetTransforms; use Illuminate\Support\Facades\Config; use Illuminate\Support\ServiceProvider; @@ -21,6 +22,7 @@ public function register(): void return; } + $this->app->register(StaticCacheServiceProvider::class); $this->configureQueue(); $this->configureCache(); $this->configureLogging(); diff --git a/src/staticcache/CacheResponse.php b/src/staticcache/CacheResponse.php new file mode 100644 index 00000000..379316bc --- /dev/null +++ b/src/staticcache/CacheResponse.php @@ -0,0 +1,87 @@ +isMethodCacheable() || $request->isCpRequest()) { + return $next($request); + } + + $this->elementCaches->startCollectingCacheInfo(); + + try { + $response = $next($request); + $element = MatchedElement::get(); + + if ($element) { + $this->elementCaches->collectCacheInfoForElement($element); + } + } finally { + [$dependency, $expiry] = $this->elementCaches->stopCollectingCacheInfo(); + } + + if (!$response instanceof Response) { + return $response; + } + + if ($this->isPersonal($request, $response)) { + $response->headers->set('CDN-Cache-Control', 'no-store'); + + return $response; + } + + if ($response->isOk() && $response->getContent() !== false) { + if (!$response->headers->has('CDN-Cache-Control')) { + $response->headers->set('CDN-Cache-Control', CdnCacheControl::for($response, $expiry)); + } + + if ($dependency !== null) { + $response->headers->set('Cache-Tag', StaticCache::header($dependency->tags)); + } + } + + return $response; + } + + /** + * Whether the response may differ per visitor, or comes from a link that isn't public. + * + * Site routes start their session lazily, so the session and `XSRF-TOKEN` cookies are only sent when the page + * used the session. Core only marks preview token responses as uncacheable, so other token routes, and site + * tokens, which can serve disabled sites, are excluded here. + */ + private function isPersonal(Request $request, Response $response): bool + { + return ( + $request->cookies->has((string) config('session.cookie')) + || $request->headers->has('Authorization') + || $response->headers->getCookies() !== [] + || Context::getHidden(HandleTokenRequest::HAD_TOKEN_KEY) === true + || Context::getHidden(ResolveSite::HAD_SITE_TOKEN_KEY) === true + ); + } +} diff --git a/src/staticcache/CdnCacheControl.php b/src/staticcache/CdnCacheControl.php new file mode 100644 index 00000000..dafcb406 --- /dev/null +++ b/src/staticcache/CdnCacheControl.php @@ -0,0 +1,53 @@ +headers; + $headers->addCacheControlDirective('public'); + + if (array_any(['no-store', 'no-cache', 'private'], $headers->hasCacheControlDirective(...))) { + return 'no-store'; + } + + $directives = new HeaderBag(['Cache-Control' => $headers->get('Cache-Control')]); + $directives->removeCacheControlDirective('s-maxage'); + $maxAge = $response->getMaxAge(); + + if ($maxAge === null) { + $maxAge = self::DEFAULT_MAX_AGE; + + if (!$directives->hasCacheControlDirective('stale-while-revalidate')) { + $directives->addCacheControlDirective( + 'stale-while-revalidate', + (string) self::DEFAULT_STALE_WHILE_REVALIDATE, + ); + } + } + + $directives->addCacheControlDirective('max-age', (string) min(max(0, $maxAge), $expiry ?? PHP_INT_MAX)); + + return (string) $directives->get('Cache-Control'); + } +} diff --git a/src/staticcache/PurgeCommand.php b/src/staticcache/PurgeCommand.php new file mode 100644 index 00000000..4d34fb65 --- /dev/null +++ b/src/staticcache/PurgeCommand.php @@ -0,0 +1,34 @@ +option('tag'); + $prefixes = (array) $this->option('prefix'); + + dispatch_sync( + $tags === [] && $prefixes === [] + ? PurgeStaticCache::everything() + : new PurgeStaticCache(array_values($tags), array_values($prefixes)), + ); + + $this->components->info('Purged the static cache.'); + + return self::SUCCESS; + } +} diff --git a/src/staticcache/PurgeStaticCache.php b/src/staticcache/PurgeStaticCache.php new file mode 100644 index 00000000..36c867cb --- /dev/null +++ b/src/staticcache/PurgeStaticCache.php @@ -0,0 +1,57 @@ + */ + public array $backoff = [10, 60]; + + /** + * @param list $tags + * @param list $prefixes + */ + public function __construct( + public readonly array $tags = [], + public readonly array $prefixes = [], + ) {} + + public static function everything(): self + { + $environmentId = StaticCache::environmentId(); + + return new self(["{$environmentId}:uri", "{$environmentId}:cdn", "{$environmentId}:rasterize"]); + } + + public function handle(RequestSigner $signer): void + { + $baseUrl = rtrim($_SERVER['CRAFT_CLOUD_GATEWAY_BASE_URL'] ?? 'https://gateway.craft.cloud', characters: '/'); + + Http::withRequestMiddleware($signer->sign(...)) + ->timeout(40) + ->post("{$baseUrl}/api/" . rawurlencode(StaticCache::environmentId()) . '/cache/purge', array_filter([ + 'tags' => $this->tags, + 'prefixes' => $this->prefixes, + ])) + ->throw(); + } +} diff --git a/src/staticcache/StaticCache.php b/src/staticcache/StaticCache.php new file mode 100644 index 00000000..5340a8d4 --- /dev/null +++ b/src/staticcache/StaticCache.php @@ -0,0 +1,104 @@ + */ + private array $tags = []; + + /** + * Cloudflare cache tags are shared by every environment in a zone, and limited to 16 KiB per response, so Craft + * tags are hashed and prefixed with the start of the environment ID. + */ + public static function tag(string $craftTag): string + { + return substr(self::environmentId(), offset: 0, length: 8) . dechex(crc32($craftTag)); + } + + /** + * Returns the `Cache-Tag` header for a page's Craft tags, within Cloudflare's limits of 1,000 tags and 16 KiB. + * + * @param list $craftTags + */ + public static function header(array $craftTags): string + { + $tags = array_values(array_unique(array_map(self::tag(...), $craftTags))); + $header = implode(',', $tags); + + if (count($tags) <= self::MAX_TAGS && strlen($header) <= self::MAX_TAG_BYTES) { + return $header; + } + + // Pages that lose tags are purged with every invalidation instead. + $header = implode(',', [self::overflowTag(), ...array_slice($tags, offset: 0, length: self::MAX_TAGS - 1)]); + + return strlen($header) <= self::MAX_TAG_BYTES + ? $header + : substr( + $header, + offset: 0, + length: (int) strrpos(substr($header, offset: 0, length: self::MAX_TAG_BYTES + 1), needle: ','), + ); + } + + /** + * Added to pages that have more tags than fit in the `Cache-Tag` header, and purged with every invalidation. + */ + public static function overflowTag(): string + { + return self::environmentId() . ':overflow'; + } + + public static function environmentId(): string + { + return (string) ($_SERVER['CRAFT_CLOUD_ENVIRONMENT_ID'] ?? ''); + } + + public function handleTagsInvalidated(TagsInvalidated $event): void + { + $tags = (array) $event->tags; + + // Core invalidates `element::{class}::*` for every draft autosave, which would purge every listing page. + if (preg_grep('/^element::.+::(drafts|revisions)$/', $tags)) { + return; + } + + // Core's element index and search query caches, which pages never depend on, are invalidated on every save. + foreach (preg_grep('/^element-(index|search)-query::/', $tags, PREG_GREP_INVERT) as $tag) { + $this->tags[self::tag($tag)] = true; + } + } + + public function flush(): void + { + if ($this->tags === []) { + return; + } + + $tags = [...array_keys($this->tags), self::overflowTag()]; + $this->tags = []; + + try { + PurgeStaticCache::dispatch($tags); + } catch (Throwable $exception) { + // On Lambda, termination runs before the response is returned, so don't fail a request that succeeded. + report($exception); + } + } +} diff --git a/src/staticcache/StaticCacheServiceProvider.php b/src/staticcache/StaticCacheServiceProvider.php new file mode 100644 index 00000000..7ea97fd4 --- /dev/null +++ b/src/staticcache/StaticCacheServiceProvider.php @@ -0,0 +1,53 @@ +app->scoped(StaticCache::class); + $this->app->singleton( + RequestSigner::class, + static fn(): RequestSigner => new RequestSigner($_SERVER['CRAFT_CLOUD_SIGNING_KEY'] ?? ''), + ); + } + + public function boot(): void + { + Event::listen(TagsInvalidated::class, [StaticCache::class, 'handleTagsInvalidated']); + + $flush = fn() => $this->app->make(StaticCache::class)->flush(); + $this->app->terminating($flush); + // Queue workers don't terminate between jobs, and forget scoped instances before the next one. + Event::listen([JobProcessed::class, JobExceptionOccurred::class], $flush); + + ClearCaches::add('cloud-static-cache', [ + 'label' => 'Craft Cloud static cache', + 'action' => static fn() => dispatch_sync(PurgeStaticCache::everything()), + ]); + + $this->commands([PurgeCommand::class]); + + // Craft creates the `craft` group when it boots. Its `SetHeaders` middleware applies `{% expires %}` and + // `noCache()`, so Cloud's middleware has to run outside it. Laravel's priority list then moves it outside the + // `web` group's cookie and session middleware too, so it sees the cookies the response ends up with. + $this->app->booted(function (): void { + $this->app->make(Router::class)->prependMiddlewareToGroup('craft', CacheResponse::class); + $this->app->make(Kernel::class)->addToMiddlewarePriorityBefore(EncryptCookies::class, CacheResponse::class); + }); + } +} diff --git a/tests/Feature/StaticCacheTest.php b/tests/Feature/StaticCacheTest.php new file mode 100644 index 00000000..7dceac62 --- /dev/null +++ b/tests/Feature/StaticCacheTest.php @@ -0,0 +1,393 @@ +app['env'] = 'testing'; + $this->app->instance('log', new NullLogger()); + $this->app->instance(GeneralConfig::class, GeneralConfig::create()); + $this->app->singleton('cache', fn($app) => new CacheManager($app)); + $this->app->instance('encrypter', new Encrypter(random_bytes(32), 'aes-256-cbc')); + $this->app->singleton('cookie', fn() => new CookieJar()); + $this->app->register(BusServiceProvider::class); + Facade::clearResolvedInstances(); + Request::mixin(new RequestMixin()); + $this->config->set('session.cookie', 'craft_session'); + $this->router = $this->app->make(Router::class); + $this->app->instance(KernelContract::class, new Kernel($this->app, $this->router)); + // The groups core defines before Cloud's provider boots, with the middleware that sets or removes headers. + $this->router->middlewareGroup('web', [EncryptCookies::class, AddQueuedCookiesToResponse::class]); + $this->router->middlewareGroup('craft', [SetHeaders::class]); + $this->router->middlewareGroup('craft.web', []); + $this->server('CRAFT_CLOUD', '1'); + $this->server('CRAFT_CLOUD_ENVIRONMENT_ID', '12345678-abcd'); + $this->server('CRAFT_CLOUD_SIGNING_KEY', 'test-key'); + $this->app->register(CloudServiceProvider::class); + $this->app->boot(); + Bus::fake(); + Carbon::setTestNow('2026-11-10 12:00:00 UTC'); + + // Runs a site route through the same middleware stack and priority sorting as Laravel's router. + $this->respond = function (Closure $handler, ?Request $request = null): Response { + $request ??= Request::create('/news'); + $this->app->instance('request', $request); + $route = $this->router->get('/news', $handler)->middleware(['web', 'craft', 'craft.web']); + $request->setRouteResolver(fn() => $route); + + return new Pipeline($this->app) + ->send($request) + ->through($this->router->gatherRouteMiddleware($route)) + ->then(fn() => $handler()); + }; +}); + +afterEach(function (): void { + Carbon::setTestNow(); + Request::flushMacros(); +}); + +function entry(): Entry +{ + $entry = test()->createStub(Entry::class); + $entry->id = 42; + $entry->method('getRoute')->willReturn('templates/render'); + + return $entry; +} + +function purgedTags(): array +{ + $tags = []; + Bus::assertDispatched(PurgeStaticCache::class, function (PurgeStaticCache $job) use (&$tags): bool { + $tags = $job->tags; + + return true; + }); + + return $tags; +} + +it('runs outside core and cookie middleware on site routes', function (array $groups, array $expected): void { + $route = $this->router->get('/news', fn() => 'news')->middleware($groups); + + expect($this->router->gatherRouteMiddleware($route))->toBe($expected); +})->with([ + 'web' => [ + ['web', 'craft', 'craft.web'], + [CacheResponse::class, EncryptCookies::class, AddQueuedCookiesToResponse::class, SetHeaders::class], + ], + 'without cookies' => [ + ['craft', 'craft.web'], + [CacheResponse::class, SetHeaders::class], + ], +]); + +it('caches responses without a policy for a year, capped at element expiry', function (?int $expiry): void { + $response = ($this->respond)(function () use ($expiry): Response { + if ($expiry !== null) { + $this->app->make(ElementCaches::class)->setCacheExpiryDate(now()->addSeconds($expiry)); + } + + return new Response('page'); + }); + + expect($response->headers->get('CDN-Cache-Control')) + ->toBe('max-age=' . ($expiry ?? 31_536_000) . ', public, stale-while-revalidate=3600') + ->and($response->headers->get('Cache-Control')) + ->toBe('no-cache, private'); +})->with([null, 90]); + +it('follows the lifetime set with core response headers', function (): void { + $response = ($this->respond)(function (): Response { + $this->app->make(ResponseHeaders::class)->setCache(600); + $this->app->make(ElementCaches::class)->setCacheExpiryDate(now()->addSeconds(900)); + + return new Response('page'); + }); + + expect($response->headers->get('CDN-Cache-Control')) + ->toBe('max-age=600, public') + ->and($response->headers->getCacheControlDirective('max-age')) + ->toBe('600'); +}); + +it('follows the response’s own lifetime, preferring s-maxage', function ( + array $headers, + ?int $expiry, + string $expected, +): void { + $response = ($this->respond)(function () use ($headers, $expiry): Response { + if ($expiry !== null) { + $this->app->make(ElementCaches::class)->setCacheExpiryDate(now()->addSeconds($expiry)); + } + + return new Response('page', headers: $headers); + }); + + expect($response->headers->get('CDN-Cache-Control')) + ->toBe($expected) + ->and($response->headers->get('Cache-Control')) + ->toBe(new Response(headers: $headers)->headers->get('Cache-Control')); +})->with([ + 'max-age' => [['Cache-Control' => 'public, max-age=60'], null, 'max-age=60, public'], + 's-maxage' => [ + ['Cache-Control' => 'public, max-age=60, s-maxage=600, stale-if-error=30'], + null, + 'max-age=600, public, stale-if-error=30', + ], + 'capped' => [['Cache-Control' => 'public, max-age=60, s-maxage=600'], 90, 'max-age=90, public'], + 'shorter than expiry' => [['Cache-Control' => 'public, max-age=60'], 90, 'max-age=60, public'], + 'expires' => [ + ['Expires' => 'Tue, 10 Nov 2026 12:05:00 GMT', 'Date' => 'Tue, 10 Nov 2026 12:00:00 GMT'], + null, + 'max-age=300, public', + ], +]); + +it('leaves an explicit CDN-Cache-Control header alone', function (): void { + $response = ($this->respond)(fn() => new Response('page', headers: ['CDN-Cache-Control' => 'max-age=5'])); + + expect($response->headers->get('CDN-Cache-Control'))->toBe('max-age=5'); +}); + +it('keeps restricted responses out of the CDN', function (Closure $policy): void { + $response = ($this->respond)(function () use ($policy): Response { + $response = new Response('page'); + $policy($this->app, $response); + + return $response; + }); + + expect($response->headers->get('CDN-Cache-Control'))->toBe('no-store'); +})->with([ + 'noCache()' => fn($app) => $app->make(ResponseHeaders::class)->noCache(), + 'setCache(0)' => fn($app) => $app->make(ResponseHeaders::class)->setCache(0), + 'private' => fn($app, Response $response) => $response->setPrivate()->setMaxAge(60), + 'no-cache' => fn($app, Response $response) => $response->headers->set('Cache-Control', 'public, no-cache'), +]); + +it('keeps personal and tokenized responses out of the CDN', function (string $scenario): void { + $request = Request::create('/news', cookies: $scenario === 'session cookie' ? ['craft_session' => 'x'] : []); + + match ($scenario) { + 'route token' => Context::addHidden(HandleTokenRequest::HAD_TOKEN_KEY, true), + 'site token' => Context::addHidden(ResolveSite::HAD_SITE_TOKEN_KEY, true), + 'authorization' => $request->headers->set('Authorization', 'Bearer x'), + default => null, + }; + + $response = ($this->respond)(function () use ($scenario): Response { + $response = new Response('page', headers: ['CDN-Cache-Control' => 'public, max-age=600']); + $this->app->make(ResponseHeaders::class)->setCache(600); + + if ($scenario === 'response cookie') { + $response->headers->setCookie(new Cookie('preference', 'dark')); + } + + if ($scenario === 'queued cookie') { + $this->app->make('cookie')->queue('preference', 'dark'); + } + + return $response; + }, $request); + + expect($response->headers->get('CDN-Cache-Control')) + ->toBe('no-store') + ->and($response->headers->has('Cache-Tag')) + ->toBeFalse(); +})->with(['session cookie', 'response cookie', 'queued cookie', 'route token', 'site token', 'authorization']); + +it('caches requests with unrelated cookies, without tags when the page has none', function (): void { + $response = ($this->respond)(fn() => new Response('page'), Request::create('/news', cookies: ['analytics' => 'x'])); + + expect($response->headers->get('CDN-Cache-Control')) + ->toStartWith('max-age=31536000') + ->and($response->headers->has('Cache-Tag')) + ->toBeFalse(); +}); + +it('leaves unsafe methods and error responses alone', function (string $method, int $status): void { + $response = ($this->respond)( + function () use ($status): Response { + $this->app->make(ElementCaches::class)->collectCacheTags(['element::1']); + + return new Response('page', $status); + }, + Request::create('/news', $method), + ); + + expect($response->headers->has('CDN-Cache-Control')) + ->toBeFalse() + ->and($response->headers->has('Cache-Tag')) + ->toBeFalse() + ->and($this->app->make(ElementCaches::class)->isCollectingCacheInfo()) + ->toBeFalse(); +})->with([['POST', 200], ['GET', 404]]); + +it('tags responses with the collected and matched element tags', function (): void { + $entry = entry(); + $response = ($this->respond)(function () use ($entry): Response { + MatchedElement::set($entry); + $this->app->make(ElementCaches::class)->collectCacheTags(['news', 'element::42']); + + return new Response('page'); + }); + + // The first 8 characters of the environment ID, plus the hex CRC32 of `news`, `element::42`, `element`, and + // `element::{class}`. + expect(explode(',', $response->headers->get('Cache-Tag')))->toBe([ + '123456781dd39950', + '12345678fe708f7a', + '1234567841405e39', + '12345678' . hash('crc32b', 'element::' . $entry::class), + ]); +}); + +it('adds the overflow tag when tags exceed Cloudflare’s limits', function (int $count): void { + $response = ($this->respond)(function () use ($count): Response { + $this->app->make(ElementCaches::class)->collectCacheTags(array_map(strval(...), range(1, end: $count))); + + return new Response('page'); + }); + $tags = explode(',', $response->headers->get('Cache-Tag')); + + expect($tags[0]) + ->toBe('12345678-abcd:overflow') + ->and(count($tags)) + ->toBeLessThanOrEqual(1000) + ->and(strlen($response->headers->get('Cache-Tag'))) + ->toBeLessThanOrEqual(16_384) + ->and(array_slice($tags, offset: 1)) + ->each->toMatch('/^12345678[0-9a-f]{1,8}$/'); +})->with([1001, 5000]); + +it('queues one purge per request for every invalidated tag except drafts and revisions', function (): void { + TagDependency::invalidate(['news']); + $this->app->make(ElementCaches::class)->invalidateAll(); + TagDependency::invalidate([ + 'element::42', + 'element::' . Entry::class . '::*', + 'element::' . Entry::class . '::drafts', + ]); + TagDependency::invalidate(['element::43', 'element::' . Entry::class . '::revisions']); + TagDependency::invalidate(['element-search-query::' . Entry::class, 'element-index-query::' . Entry::class]); + Bus::assertNothingDispatched(); + + $this->app->terminate(); + + Bus::assertDispatchedTimes(PurgeStaticCache::class, 1); + expect(purgedTags())->toBe(['123456781dd39950', '1234567841405e39', '12345678-abcd:overflow']); +}); + +it('queues a purge after each queue job', function (): void { + TagDependency::invalidate(['news']); + Illuminate\Support\Facades\Event::dispatch( + new JobProcessed('sync', test()->createStub(Illuminate\Contracts\Queue\Job::class)), + ); + + Bus::assertDispatchedTimes(PurgeStaticCache::class, 1); + $this->app->terminate(); + Bus::assertDispatchedTimes(PurgeStaticCache::class, 1); +}); + +it('sends purges to the gateway with a signed request', function (array $tags, array $prefixes, array $payload): void { + $this->server('CRAFT_CLOUD_GATEWAY_BASE_URL', 'https://gateway.example/'); + Http::fake(['*' => Http::response('', 204)]); + + new PurgeStaticCache($tags, $prefixes)->handle($this->app->make(RequestSigner::class)); + + Http::assertSent(function (ClientRequest $request) use ($payload): bool { + expect($request->url()) + ->toBe('https://gateway.example/api/12345678-abcd/cache/purge') + ->and($request->data()) + ->toBe($payload) + ->and(new Verifier(new HmacSha256('test-key'))->verify($request->toPsrRequest())) + ->toBeTrue(); + + return true; + }); +})->with([ + [['a', 'b'], [], ['tags' => ['a', 'b']]], + [[], ['example.com/blog'], ['prefixes' => ['example.com/blog']]], +]); + +it('fails the job when the gateway rejects a purge, so the queue retries it', function (): void { + Http::fake(['*' => Http::response('', 500)]); + + new PurgeStaticCache(['a'])->handle($this->app->make(RequestSigner::class)); +})->throws(RequestException::class); + +it('purges everything from the Caches utility', function (): void { + $option = collect(ClearCaches::cacheOptions())->firstWhere('key', 'cloud-static-cache'); + + $option['action'](); + + Bus::assertDispatchedSync( + PurgeStaticCache::class, + fn(PurgeStaticCache $job): bool => ( + $job->tags === [ + '12345678-abcd:uri', + '12345678-abcd:cdn', + '12345678-abcd:rasterize', + ] + ), + ); +}); + +it('purges from the console', function (array $options, array $tags, array $prefixes): void { + $command = new PurgeCommand(); + $command->setLaravel($this->app); + + expect($command->run(new ArrayInput($options), new BufferedOutput()))->toBe(0); + Bus::assertDispatchedSync( + PurgeStaticCache::class, + fn(PurgeStaticCache $job): bool => $job->tags === $tags && $job->prefixes === $prefixes, + ); +})->with([ + 'everything' => [[], ['12345678-abcd:uri', '12345678-abcd:cdn', '12345678-abcd:rasterize'], []], + 'tags' => [['--tag' => ['a', 'b']], ['a', 'b'], []], + 'prefixes' => [['--prefix' => ['example.com/blog']], [], ['example.com/blog']], +]); diff --git a/tests/TestCase.php b/tests/TestCase.php index 4734629b..dfa5830c 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -8,6 +8,7 @@ use craft\cloud\tests\Support\DnsRecords; use Illuminate\Config\Repository; use Illuminate\Container\Container; +use Illuminate\Foundation\Application; use Illuminate\Support\Facades\Facade; use PHPUnit\Framework\TestCase as BaseTestCase; @@ -16,6 +17,8 @@ abstract class TestCase extends BaseTestCase private const SERVER_KEYS = [ 'CRAFT_CLOUD', 'CRAFT_CLOUD_CACHE_SRV', + 'CRAFT_CLOUD_ENVIRONMENT_ID', + 'CRAFT_CLOUD_GATEWAY_BASE_URL', 'CRAFT_CLOUD_SIGNING_KEY', 'CRAFT_CLOUD_SQS_URL', ]; @@ -42,7 +45,7 @@ protected function setUp(): void DnsRecords::reset(); - $this->app = new Container(); + $this->app = new Application(dirname(__DIR__)); $this->config = new Repository([ 'cache' => [ 'default' => 'array',