cdn-cache-purge-tests
Wraps CDN cache-purge testing patterns for Cloudflare (POST /zones/{zone_id}/purge_cache, single-file / everything / cache-tags / hostname / prefix), Fastly (POST purge-by-key / purge-all, surrogate-keys via Surrogate-Key header), and CloudFront (CreateInvalidation API + paths). Covers end-to-end test patterns (write origin → trigger purge → assert edge serves fresh), purge-propagation delay testing (typically 1-30s globally), surrogate-key + cache-tag patterns for group-purge, and Cache-Status header verification (cf-cache-status: HIT/MISS/BYPASS). Also owns the client tier: browser-side Cache-Control verification with Playwright (served-from-cache via CDP, ETag 304 round-trips, Workbox service-worker strategies, reload semantics) in references/browser-cache-control.md. Use when designing or auditing CDN cache-invalidation workflows or browser-tier caching behaviour in E2E tests.
Install with skills.sh (any agent)
npx skills add testland/qa --skill cdn-cache-purge-testscdn-cache-purge-tests
Overview
CDN cache-purge tests verify that the write-origin-then-invalidate-edge sequence works end-to-end - the most-likely-broken cache integration in real deployments.
Per developers.cloudflare.com/cache/how-to/purge-cache/ (opens in new window), Cloudflare offers five purge methods (Single-file, Everything, Cache-tags, Hostname, Prefix). Fastly's surrogate-key pattern and CloudFront's invalidation API offer similar shapes.
When to use
Authoring
Cloudflare - purge by URL
curl -X POST \
"https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/purge_cache" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
--data '{"files":["https://example.com/api/users/1"]}'Response:
{ "success": true, "result": { "id": "...." } }Cloudflare - purge by cache-tag (Enterprise)
curl -X POST "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/purge_cache" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
--data '{"tags":["user-1","posts-feed"]}'The response headers from the origin must have included Cache-Tag: user-1, posts-feed for this to work.
Fastly - purge by surrogate-key
curl -X POST "https://api.fastly.com/service/${SERVICE_ID}/purge/${SURROGATE_KEY}" \
-H "Fastly-Key: ${FASTLY_API_TOKEN}"Origin responses include Surrogate-Key: user-1 posts-feed (space-separated).
CloudFront - invalidation
aws cloudfront create-invalidation \
--distribution-id ${DIST_ID} \
--paths "/api/users/1" "/api/users/1/*"Cloud-side wait + propagation. Returns an InvalidationId; poll status via get-invalidation.
Running - end-to-end test
The canonical purge test:
import requests, time
def test_write_then_purge_serves_fresh():
# 1. Origin write
origin_response = requests.post(
"https://origin.example.com/api/users/1",
json={"name": "Alice"},
headers={"Authorization": f"Bearer {ADMIN_TOKEN}"},
)
assert origin_response.status_code == 200
# 2. Verify edge has the *old* value (might or might not - cache hit)
edge_before = requests.get("https://example.com/api/users/1")
cache_status_before = edge_before.headers.get("cf-cache-status")
# 3. Trigger purge
purge = requests.post(
f"https://api.cloudflare.com/client/v4/zones/{ZONE_ID}/purge_cache",
headers={"Authorization": f"Bearer {CF_API_TOKEN}"},
json={"files": ["https://example.com/api/users/1"]},
)
assert purge.json()["success"]
# 4. Wait for global propagation (Cloudflare typically <30s)
time.sleep(10)
# 5. Verify edge fetches fresh from origin
edge_after = requests.get("https://example.com/api/users/1")
cache_status_after = edge_after.headers.get("cf-cache-status")
# Either MISS (just fetched) or HIT (re-cached fresh value)
assert cache_status_after in ("MISS", "HIT", "EXPIRED")
assert edge_after.json()["name"] == "Alice"Cache-Status header verification
| Header | CDN | Common values |
|---|---|---|
cf-cache-status | Cloudflare | HIT, MISS, EXPIRED, BYPASS, DYNAMIC, REVALIDATED |
x-cache | Fastly | HIT, MISS, HIT-CLUSTER, HIT-CLUSTER-WAIT |
x-cache | CloudFront | Hit from cloudfront, Miss from cloudfront |
age | RFC 9111 standard | Seconds since cached |
Multi-region propagation
EDGES = [
"https://example.com", # default
"https://eu.example.com", # geo-routed
"https://ap.example.com",
]
def test_purge_propagates_globally():
# Pre-cache in each region
for url in EDGES:
requests.get(url + "/api/users/1")
# Trigger purge
purge_url(API, "/api/users/1")
# Wait for global propagation
time.sleep(30)
# Verify each region serves fresh
for url in EDGES:
r = requests.get(url + "/api/users/1")
assert r.json()["name"] == "Alice"Client-tier cache-control
The purge test proves the edge refreshes; the client tier can still serve stale from the browser's own cache. Browser-side verification - asserting the browser acts on Cache-Control as intended (served-from-cache via CDP, ETag If-None-Match → 304 round-trips, Workbox service-worker strategies, normal-vs-hard reload semantics) with Playwright across the Chromium / Firefox / WebKit matrix - is in references/browser-cache-control.md, with deeper recipes in references/playwright-cache-recipes.md. Asserting only which header the server emits needs no browser - keep that in the project's HTTP-level runner.
Parsing results
| Field | Use |
|---|---|
cf-cache-status: HIT | Served from edge cache |
cf-cache-status: MISS | Origin pull just happened |
cf-cache-status: EXPIRED | Stale; revalidated from origin |
cf-cache-status: BYPASS | Cache deliberately skipped (e.g., uncacheable response) |
cf-cache-status: DYNAMIC | Not cached at all |
age: N | Per RFC 9111: seconds since cached |
For tests: assert on the transition (HIT → MISS after purge), not on the absolute state.
CI integration
jobs:
cdn-purge-smoke:
if: github.event_name == 'deployment_status' && github.event.deployment_status.state == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Write to origin
env:
ADMIN_TOKEN: ${{ secrets.ADMIN_TOKEN }}
run: ./scripts/write-test-fixture.sh
- name: Purge edge
env:
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
run: ./scripts/purge-test-paths.sh
- name: Verify edge serves fresh
run: pytest tests/cdn/test_purge_propagation.pyAnti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
time.sleep(60) between purge + assertion | Slow; sometimes shorter / sometimes longer | Poll cf-cache-status until MISS (timeout 30s) |
| Test purge against prod | Pollutes prod cache; rate-limited | Dedicated test domain + zone |
| Purge-everything for smoke test | Massive cache flush; alarming for ops | Single-file or tag-based |
| Don't test multi-region | Edge in test region; user in another sees stale | Verify across regions |
No Cache-Tag / Surrogate-Key on origin | Group-purge has nothing to target | Origin must set tags |
| Use only cf-cache-status to verify fresh | Could be HIT of newly-purged fresh fetch | Compare response body to known fresh state |
| Skip purge-key naming review | Hot keys (all, feed) become noisy | Per-resource tagging strategy |
| Assume purge is synchronous | Cloudflare: <30s; CloudFront: minutes | Plan for async; poll |
Limitations
References
Client-tier (browser) Cache-Control tests
View source (opens in new window)Client-tier (browser) Cache-Control tests
Browser cache tests verify the request side of caching: does the browser actually respect the Cache-Control headers the server sends? Per MDN Cache-Control (opens in new window) the directive set is identical to RFC 9111 (opens in new window), but runtime behaviour differs subtly between Chromium, Firefox, and Safari. Scope: behaviour a browser decides (served-from-cache, revalidation, SW strategy, reload semantics). Asserting only which header the server emits needs no browser - do that in the project's existing HTTP-level runner (supertest, requests, RestAssured, curl -I).
Workflow
Worked example - hashed bundle + uncached API in one spec
import { test, expect } from '@playwright/test';
test('bundle immutable, /api/me uncached', async ({ page }) => {
const seen: Record<string, string> = {};
page.on('response', (resp) => {
const cc = resp.headers()['cache-control'] ?? '';
if (resp.url().match(/\.\w+\.js$/)) seen.bundle = cc;
if (resp.url().endsWith('/api/me')) seen.api = cc;
});
await page.goto('https://example.com/dashboard');
expect(seen.bundle).toMatch(/max-age=\d{6,}/); // ~10+ days
expect(seen.bundle).toContain('immutable'); // per RFC 8246
expect(seen.api).toMatch(/(no-store|private)/);
});The bundle assertion fails if the build drops immutable (silent perf regression); the /api/me assertion catches a proxy adding a public max-age - a leak of per-user data into shared caches.
The deeper recipes - served-from-cache detection via CDP, ETag revalidation round-trips, hard-reload semantics, and service-worker (Workbox) strategies - are in playwright-cache-recipes.md (opens in new window).
Useful response-event surface
| Method | Returns |
|---|---|
resp.status() | HTTP status |
resp.headers() | All response headers |
resp.fromServiceWorker() | Whether a SW intercepted |
resp.request().headers() | Request headers (If-None-Match) |
resp.timing() | Cached fetches have minimal responseEnd - responseStart |
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
status() == 200 to "prove" a cache miss | 304 is also cache-related | Inspect headers / fromDiskCache |
| Fresh browser context per test | Cache starts empty; no "second load" | Reuse the context within a test |
Exact-string cache-control assertions | Vendor directives break it | Regex toMatch |
| Chromium-only runs | Safari + Firefox differ (SW, ITP) | Run the matrix in CI |
| No 304 test | ETag round-trip drift unnoticed | Test the second-load 304 path |
Mocking caches.match() | Bypasses the real storage layer | Real Cache API + Playwright |
Limitations
References
Playwright cache recipes
View source (opens in new window)Playwright cache recipes
Deeper browser-cache authoring recipes for browser-cache-control.md (opens in new window). The basic Cache-Control header assertion lives there; these cover served-from-cache detection, ETag revalidation, hard reload, and service workers.
Verify second-load is from cache
Playwright doesn't expose "from disk cache" directly, but request timing reveals it:
test('static asset second load is from disk cache', async ({ page }) => {
await page.goto('https://example.com'); // first load (network)
const responses: Array<{ url: string; fromCache: boolean }> = [];
page.on('response', (resp) => {
responses.push({
url: resp.url(),
fromCache: resp.fromServiceWorker() || resp.request().redirectedFrom() !== null,
});
});
await page.reload();
// Playwright doesn't expose 'from disk cache' directly, but
// request timing reveals it:
const asset = responses.find((r) => r.url.endsWith('.js'));
// The Network panel `(disk cache)` annotation comes from
// timing.responseEnd === timing.responseStart for cached items.
});For a stronger check, use Chrome DevTools Protocol via Playwright:
const cdp = await page.context().newCDPSession(page);
await cdp.send('Network.enable');
cdp.on('Network.responseReceived', (params) => {
if (params.response.url.endsWith('.js')) {
expect(params.response.fromDiskCache).toBe(true);
}
});
await page.reload();ETag revalidation round-trip
Per RFC 9111 §4.3.1:
test('ETag triggers 304 on revalidation', async ({ page }) => {
let firstEtag: string | undefined;
page.on('response', (resp) => {
if (resp.url() === 'https://example.com/api/feed') {
const etag = resp.headers()['etag'];
if (resp.status() === 200 && !firstEtag) firstEtag = etag;
else if (firstEtag) {
expect(resp.request().headers()['if-none-match']).toBe(firstEtag);
expect(resp.status()).toBe(304);
}
}
});
// First load
await page.goto('https://example.com/dashboard');
// Reload after TTL - browser should send If-None-Match
await page.waitForTimeout(2000);
await page.reload();
});Hard reload (Cmd+Shift+R) semantics
Browsers send Cache-Control: no-cache on hard reload, bypassing the cache. Test:
test('hard reload bypasses cache', async ({ page }) => {
await page.goto('https://example.com');
page.on('request', (req) => {
if (req.url().endsWith('.js')) {
expect(req.headers()['cache-control']).toMatch(/no-cache/);
}
});
// Playwright doesn't have a direct "hard reload"; simulate via CDP:
const cdp = await page.context().newCDPSession(page);
await cdp.send('Page.reload', { ignoreCache: true });
});Service Worker / Workbox
Workbox provides standard strategies; test which is used:
test('offline page uses cache-first strategy', async ({ context, page }) => {
// Go online, populate cache
await page.goto('https://example.com');
// Go offline
await context.setOffline(true);
// Reload - should still work
await page.reload();
await expect(page.locator('h1')).toHaveText('Example');
});
test('api uses network-first with fallback', async ({ context, page }) => {
await page.goto('https://example.com/api-status');
await context.setOffline(true);
await page.reload();
// Stale cached response shown
await expect(page.locator('.api-status')).toBeVisible();
});Related skills
cache-coherence-patterns-reference
Pure-reference catalog of cache-coherence patterns across the request path. Defines the five-tier cache stack (browser → CDN → reverse-proxy → application → data store), the per-tier cache-writing patterns (cache-aside, write-through, write-back, write-around, refresh-ahead), and the canonical invalidation strategies (TTL-only, event-driven purge, surrogate keys, version-tagged URLs, soft purge), plus an anti-pattern table and a worked multi-tenant coherence-test example. Deep detail lives in references/: RFC 9111 Cache-Control / Vary / ETag directive tables, the cross-tier test surface, cache-stampede (thundering-herd) mitigations incl. the XFetch formula, and RFC 5861 stale-while-revalidate / stale-if-error semantics. Use for pattern selection, Cache-Control header design, coherence audits, stampede-refresh strategy, and SWR/SIE window design; use a cache-key-collision check when the question is whether two concrete requests collide on a key scheme.
cache-key-discriminator-audit
Audits whether a cache key carries every discriminator the cached response actually depends on, so two requests that must not share a slot cannot collide. Ranks identity discriminators (tenant, user, authorization scope, plan tier) above presentation ones (locale, region, currency, feature flag), maps each missing discriminator to the data-exposure or wrong-content consequence it causes, classifies each key-and-value pair into a critical / high / medium severity band (including the Python lru_cache-on-an-instance-method trap), and writes the fix as a namespaced key builder plus the matching HTTP Vary header. Use when a cache key is being designed or changed, when a per-user or per-tenant response is about to be stored in a shared cache or CDN, or when investigating a report that one user or tenant saw another's data.
redis-cache-tests
Wraps Redis cache testing: EXPIRE / PEXPIRE / TTL verification (Redis 7+ NX/XX/GT/LT flags), cache-aside write-then-invalidate (write source → DEL key → assert fresh read), eviction under memory pressure (maxmemory + allkeys-lru), pub/sub invalidation across nodes, and tenant key-namespacing. Use when Redis is the app's primary cache. For the CDN/browser HTTP tier use cdn-cache-purge-tests; a runnable test, not the cache-coherence-patterns-reference catalog (which owns the stampede + stale-while-revalidate references).
varnish-test-vtc-syntax
Wraps the varnishtest CLI + VTC (Varnish Test Case) syntax for testing VCL configurations. Covers the VTC test-file format (varnishtest scripts with server { ... } + client { ... } + varnish v1 -vcl+backend { ... } blocks), the grace-mode + saint-mode behaviours (stale-while-revalidate + stale-if-error equivalents in VCL), the PURGE method handler pattern (vcl_purge subroutine + ACL guards), and surrogate-key invalidation via xkey vmod. Use when authoring or testing Varnish-based caching layers.