tests/cloudflare-snippet.unit.test.ts
Rama main · réplica de lectura del repositorio original.
import { tryParse } from '@std/semver'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import snippet from '../cloudflare_workers/snippet/index.js'
import { PROVIDER_INFRA_ASNS } from '../supabase/functions/_backend/plugin_runtime/utils/providerAsns.ts'
import { decodeSnippetEdgeStat } from '../supabase/functions/_backend/plugin_runtime/utils/snippetEdgeReplay.ts'
import { updatesEdgeCacheBucket } from '../supabase/functions/_backend/plugin_runtime/utils/updatesEdgeCache.ts'
import { fixSemver } from '../supabase/functions/_backend/plugin_runtime/utils/utils.ts'
const PLAIN_SEMVER_RE = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/
const APP_ID = 'com.edge.app'
const DEVICE_ID = '00000000-0000-4000-8000-000000000001'
const OTHER_DEVICE_ID = '00000000-0000-4000-8000-000000000002'
const NO_NEW_BODY = { error: 'no_new_version_available', message: 'No new version available', kind: 'up_to_date' }
function updatesBody(overrides: Record<string, unknown> = {}) {
return {
app_id: APP_ID,
device_id: DEVICE_ID,
platform: 'ios',
version_name: '1.2.3',
version_build: '2.0',
plugin_version: '7.40.0',
is_emulator: false,
is_prod: true,
defaultChannel: 'production',
...overrides,
}
}
function statsEvent(overrides: Record<string, unknown> = {}) {
return {
app_id: APP_ID,
device_id: DEVICE_ID,
platform: 'android',
version_name: '1.2.3',
version_os: '14',
is_emulator: false,
is_prod: true,
action: 'app_moved_to_foreground',
...overrides,
}
}
function buildRequest(path: string, body: unknown, headers: Record<string, string> = {}, options: { host?: string, colo?: string, asn?: number } = {}) {
const request = new Request(`https://${options.host ?? 'plugin.capgo.app'}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'cf-connecting-ip': '203.0.113.7', ...headers },
body: JSON.stringify(body),
})
Object.defineProperty(request, 'cf', { value: { colo: options.colo ?? 'CDG', asn: options.asn ?? 3215 } })
return request
}
function fillHeader(fill: Record<string, unknown>) {
return encodeURIComponent(JSON.stringify({ v: 2, bps: 10000, ttl: 900, tags: 'capgo-updates-com.edge.app,capgo-updates-com.edge.app:versions,capgo-snippet-edge-answers', ...fill }))
}
const updatesFill = fillHeader({ e: 'updates', n: '1.2.3', k: null, cs: true })
const statsFill = fillHeader({ e: 'stats', actions: ['ping', 'app_moved_to_foreground', 'app_moved_to_background'] })
function noNewWithFill(fill = updatesFill) {
return new Response(JSON.stringify(NO_NEW_BODY), { status: 200, headers: { 'Content-Type': 'application/json', 'X-Capgo-Edge-Fill': fill } })
}
/** The snippet's /stats learning sample: FNV bucket of app, device and minute under 500 bps. */
function isLearnSample(deviceId: string, minute: number) {
return updatesEdgeCacheBucket(APP_ID, `${deviceId}:${minute}`) < 500
}
/** A time (ms) in the first minute that matches. */
function minuteWhere(predicate: (minute: number) => boolean) {
for (let minute = 29_700_000; ; minute++) {
if (predicate(minute))
return minute * 60_000
}
}
/** Both test devices teach the worker: /stats without a fill reach it. */
const LEARN_TIME = minuteWhere(minute => isLearnSample(DEVICE_ID, minute) && isLearnSample(OTHER_DEVICE_ID, minute))
/** Neither does: /stats without a fill are answered at the edge. */
const ANSWER_TIME = minuteWhere(minute => !isLearnSample(DEVICE_ID, minute) && !isLearnSample(OTHER_DEVICE_ID, minute))
function buildCache() {
const store = new Map<string, Response>()
const keyOf = (key: RequestInfo | URL) => key instanceof Request ? key.url : String(key)
return {
store,
match: vi.fn(async (key: RequestInfo | URL) => store.get(keyOf(key))?.clone()),
put: vi.fn(async (key: RequestInfo | URL, response: Response) => {
store.set(keyOf(key), response.clone())
}),
delete: vi.fn(async (key: RequestInfo | URL) => store.delete(keyOf(key))),
}
}
type Cache = ReturnType<typeof buildCache>
function subrequests(cache: Cache, fetchMock: ReturnType<typeof vi.fn>) {
return cache.match.mock.calls.length + cache.put.mock.calls.length + cache.delete.mock.calls.length + fetchMock.mock.calls.length
}
describe('cloudflare snippet edge answers', () => {
let cache: Cache
beforeEach(() => {
vi.spyOn(console, 'log').mockImplementation(() => {})
// /stats without a fill: the test devices are in the learning sample, so
// the fill tests below reach the worker first.
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(LEARN_TIME)
cache = buildCache()
vi.stubGlobal('caches', { default: cache })
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
it('learns an up-to-date answer from the worker and serves it without a worker', async () => {
const fetchMock = vi.fn(async () => new Response(JSON.stringify(NO_NEW_BODY), {
status: 200,
headers: { 'Content-Type': 'application/json', 'X-Capgo-Edge-Fill': updatesFill },
}))
vi.stubGlobal('fetch', fetchMock)
const first = await snippet.fetch(buildRequest('/updates', updatesBody()))
expect(first.headers.get('X-Capgo-Edge-Fill')).toBeNull()
expect(await first.json()).toEqual(NO_NEW_BODY)
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(subrequests(cache, fetchMock)).toBeLessThanOrEqual(5)
cache.match.mockClear()
cache.put.mockClear()
const body = updatesBody({ device_id: OTHER_DEVICE_ID })
const second = await snippet.fetch(buildRequest('/updates', body))
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(second.status).toBe(200)
expect(second.headers.get('X-Capgo-Edge')).toBe('answer')
expect(await second.json()).toEqual(NO_NEW_BODY)
// Hit: the app entry plus the IP limit lookup.
expect(cache.match).toHaveBeenCalledTimes(2)
expect(cache.put).not.toHaveBeenCalled()
const stat = decodeSnippetEdgeStat(second.headers.get('X-Capgo-Edge-Stat')!)
// No owner org in a header the device can read.
expect(stat).toEqual({ e: 'updates', b: JSON.stringify(body), n: '1.2.3' })
})
it('sends devices that are not on the served bundle, or not answerable, to the worker', async () => {
const fetchMock = vi.fn(async () => new Response(JSON.stringify(NO_NEW_BODY), {
status: 200,
headers: { 'X-Capgo-Edge-Fill': updatesFill },
}))
vi.stubGlobal('fetch', fetchMock)
await snippet.fetch(buildRequest('/updates', updatesBody()))
const forwarded = [
updatesBody({ version_name: '1.2.2' }),
updatesBody({ defaultChannel: 'beta' }),
updatesBody({ platform: 'android' }),
// Legacy channel_self store plugins (cs) and deprecated plugins keep the worker path.
updatesBody({ plugin_version: '6.30.0' }),
updatesBody({ plugin_version: '4.20.0' }),
updatesBody({ version_build: 'unknown' }),
updatesBody({ version_build: '1.0-beta.1' }),
updatesBody({ is_prod: 'yes' }),
updatesBody({ device_id: 'not-a-uuid' }),
]
for (const body of forwarded)
await snippet.fetch(buildRequest('/updates', body))
expect(fetchMock).toHaveBeenCalledTimes(1 + forwarded.length)
// A browser caller needs the worker's CORS answer.
await snippet.fetch(buildRequest('/updates', updatesBody(), { Origin: 'https://example.com' }))
expect(fetchMock).toHaveBeenCalledTimes(2 + forwarded.length)
})
it('stops answering an IP the update enumeration guard limited', async () => {
const resetAt = Math.floor(Date.now() / 1000) + 600
let limited = false
const fetchMock = vi.fn(async () => {
if (limited) {
return new Response(JSON.stringify({ error: 'on_premise_app', message: 'On-premise app detected' }), {
status: 429,
headers: { 'Cache-Control': 'private, no-store', 'X-RateLimit-Reset': String(resetAt), 'X-Capgo-Edge-Ip-Limit': String(resetAt) },
})
}
return new Response(JSON.stringify(NO_NEW_BODY), { status: 200, headers: { 'X-Capgo-Edge-Fill': updatesFill } })
})
vi.stubGlobal('fetch', fetchMock)
await snippet.fetch(buildRequest('/updates', updatesBody()))
limited = true
const limitedAnswer = await snippet.fetch(buildRequest('/updates', updatesBody({ version_name: '0.0.1' })))
expect(limitedAnswer.status).toBe(429)
expect(limitedAnswer.headers.get('X-Capgo-Edge-Ip-Limit')).toBeNull()
// The IP limit is not an app-wide on-prem answer.
expect([...cache.store.keys()].some(key => key.includes('/edge-ip-limit-v1/'))).toBe(true)
const guess = await snippet.fetch(buildRequest('/updates', updatesBody()))
expect(guess.status).toBe(429)
expect(fetchMock).toHaveBeenCalledTimes(3)
// Another IP still gets the edge answer.
await snippet.fetch(buildRequest('/updates', updatesBody(), { 'cf-connecting-ip': '198.51.100.9' }))
expect(fetchMock).toHaveBeenCalledTimes(3)
})
it('leaves provider networks to the worker for apps that block provider infrastructure', async () => {
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ status: 'ok' }), {
status: 200,
headers: { 'X-Capgo-Edge-Fill': fillHeader({ v: 3, e: 'stats', actions: ['app_moved_to_foreground'], pa: [15169, 714] }) },
}))
vi.stubGlobal('fetch', fetchMock)
await snippet.fetch(buildRequest('/stats', statsEvent()))
expect(fetchMock).toHaveBeenCalledTimes(1)
const answered = await snippet.fetch(buildRequest('/stats', statsEvent()))
expect(answered.headers.get('X-Capgo-Edge')).toBe('answer')
expect(fetchMock).toHaveBeenCalledTimes(1)
await snippet.fetch(buildRequest('/stats', statsEvent(), {}, { asn: 15169 }))
await snippet.fetch(buildRequest('/stats', statsEvent(), {}, { asn: 714 }))
const unknownNetwork = new Request('https://plugin.capgo.app/stats', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(statsEvent()) })
Object.defineProperty(unknownNetwork, 'cf', { value: { colo: 'CDG' } })
await snippet.fetch(unknownNetwork)
expect(fetchMock).toHaveBeenCalledTimes(4)
})
it('ignores provider networks a v2 fill carries, and v3 fills without them', async () => {
for (const fill of [fillHeader({ e: 'stats', actions: ['app_moved_to_foreground'], pa: [15169] }), fillHeader({ v: 3, e: 'stats', actions: ['app_moved_to_foreground'] })]) {
cache.store.clear()
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ status: 'ok' }), { status: 200, headers: { 'X-Capgo-Edge-Fill': fill } }))
vi.stubGlobal('fetch', fetchMock)
await snippet.fetch(buildRequest('/stats', statsEvent()))
await snippet.fetch(buildRequest('/stats', statsEvent()))
expect(fetchMock).toHaveBeenCalledTimes(2)
}
})
it('answers devices outside the sampled share through the worker', async () => {
const fetchMock = vi.fn(async () => new Response(JSON.stringify(NO_NEW_BODY), {
status: 200,
headers: { 'X-Capgo-Edge-Fill': fillHeader({ e: 'updates', n: '1.2.3', k: null, cs: false, bps: 1 }) },
}))
vi.stubGlobal('fetch', fetchMock)
for (let i = 0; i < 5; i++)
await snippet.fetch(buildRequest('/updates', updatesBody({ device_id: `00000000-0000-4000-8000-00000000001${i}` })))
expect(fetchMock).toHaveBeenCalledTimes(5)
})
it('answers /stats batches once learned, keeping installs and failures live', async () => {
const fetchMock = vi.fn(async (_url: unknown, init?: RequestInit) => {
const events = JSON.parse(new TextDecoder().decode(init?.body as ArrayBuffer)) as unknown[]
return new Response(JSON.stringify({ status: 'ok', results: events.map((_, index) => ({ status: 'ok', index })) }), {
status: 200,
headers: { 'X-Capgo-Edge-Fill': statsFill },
})
})
vi.stubGlobal('fetch', fetchMock)
await snippet.fetch(buildRequest('/stats', [statsEvent(), statsEvent({ action: 'app_moved_to_background' })]))
expect(fetchMock).toHaveBeenCalledTimes(1)
const batch = [statsEvent({ action: 'app_moved_to_background' }), statsEvent()]
const answered = await snippet.fetch(buildRequest('/stats', batch))
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(await answered.json()).toEqual({ status: 'ok', results: [{ status: 'ok', index: 0 }, { status: 'ok', index: 1 }] })
expect(decodeSnippetEdgeStat(answered.headers.get('X-Capgo-Edge-Stat')!)).toEqual({ e: 'stats', b: JSON.stringify(batch) })
// Actions the fill does not list are replayed (the handler rejects unknown
// ones). Installs, failures (also inside a batch), native versions the
// worker would reject and actions that are not a word go to the worker.
const unknown = await snippet.fetch(buildRequest('/stats', [statsEvent({ action: 'unknown_action' })]))
expect(unknown.headers.get('X-Capgo-Edge')).toBe('answer')
expect(fetchMock).toHaveBeenCalledTimes(1)
await snippet.fetch(buildRequest('/stats', [statsEvent({ action: 'set' })]))
await snippet.fetch(buildRequest('/stats', [statsEvent({ action: 'download_fail' })]))
await snippet.fetch(buildRequest('/stats', [statsEvent(), statsEvent({ action: 'update_fail' })]))
await snippet.fetch(buildRequest('/stats', [statsEvent({ version_build: 'bad-version' })]))
await snippet.fetch(buildRequest('/stats', [statsEvent({ action: 'not an action' })]))
expect(fetchMock).toHaveBeenCalledTimes(6)
await snippet.fetch(buildRequest('/stats', [statsEvent({ version_build: '2.1' })]))
expect(fetchMock).toHaveBeenCalledTimes(6)
})
it('answers /stats native versions only when the worker parses them', async () => {
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ status: 'ok' }), { status: 200, headers: { 'X-Capgo-Edge-Fill': statsFill } }))
vi.stubGlobal('fetch', fetchMock)
await snippet.fetch(buildRequest('/stats', [statsEvent()]))
expect(fetchMock).toHaveBeenCalledTimes(1)
// parsePluginBody: plain x.y.z, or @std/semver tryParse(fixSemver(version)).
const workerParses = (version: string) => PLAIN_SEMVER_RE.test(version) || tryParse(fixSemver(version)) !== undefined
const versions = [
'0.9.315-native.0.0.1',
'0.9.314-native.93140001',
'1.2.3+ota.8016.ios',
'1.2.3-beta.1+build.5',
'v1.2.3',
'2.1',
'1-rc',
'V1.2.3',
'1.2.3-a..b',
'1.2.3+a+b',
'1.2.3--x',
'1.0-beta.1',
'1.2.3-01',
'1.2.3-',
'1.2.3+',
'1.2.3_4',
'01.2.3',
' 1.2.3',
'1.2.3.4',
'9007199254740993.0.0',
`1.2.3-${'a'.repeat(260)}`,
]
let forwarded = 1
for (const version_build of versions) {
const response = await snippet.fetch(buildRequest('/stats', [statsEvent({ version_build })]))
const answered = response.headers.get('X-Capgo-Edge') === 'answer'
if (!answered)
forwarded++
expect(fetchMock).toHaveBeenCalledTimes(forwarded)
// Never answer a version the worker rejects with semver_error.
if (answered)
expect(workerParses(version_build), version_build).toBe(true)
}
// The prerelease and build tags of real plugins are answered.
for (const version_build of versions.slice(0, 6)) {
const response = await snippet.fetch(buildRequest('/stats', [statsEvent({ version_build })]))
expect(response.headers.get('X-Capgo-Edge'), version_build).toBe('answer')
}
})
it('answers /stats of an app it learned nothing about, outside the learning share', async () => {
vi.setSystemTime(ANSWER_TIME)
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ status: 'ok' }), { status: 200 }))
vi.stubGlobal('fetch', fetchMock)
const single = statsEvent({ action: 'app_launch_ready' })
const answered = await snippet.fetch(buildRequest('/stats', single))
expect(answered.status).toBe(200)
expect(answered.headers.get('X-Capgo-Edge')).toBe('answer')
expect(await answered.json()).toEqual({ status: 'ok' })
expect(decodeSnippetEdgeStat(answered.headers.get('X-Capgo-Edge-Stat')!)).toEqual({ e: 'stats', b: JSON.stringify(single) })
const batch = [statsEvent({ action: 'app_moved_to_background' }), statsEvent({ device_id: OTHER_DEVICE_ID })]
const answeredBatch = await snippet.fetch(buildRequest('/stats', batch))
expect(await answeredBatch.json()).toEqual({ status: 'ok', results: [{ status: 'ok', index: 0 }, { status: 'ok', index: 1 }] })
expect(fetchMock).not.toHaveBeenCalled()
// One lookup each, nothing stored.
expect(cache.match).toHaveBeenCalledTimes(2)
expect(cache.put).not.toHaveBeenCalled()
// Not answerable: installs and failures, browsers, other app ids in a
// batch, bad schema, empty batches.
await snippet.fetch(buildRequest('/stats', statsEvent({ action: 'set' })))
await snippet.fetch(buildRequest('/stats', statsEvent({ action: 'download_fail' })))
await snippet.fetch(buildRequest('/stats', statsEvent(), { Origin: 'https://example.com' }))
await snippet.fetch(buildRequest('/stats', [statsEvent(), statsEvent({ app_id: 'com.other.app' })]))
await snippet.fetch(buildRequest('/stats', statsEvent({ device_id: 'not-a-uuid' })))
await snippet.fetch(buildRequest('/stats', []))
expect(fetchMock).toHaveBeenCalledTimes(6)
})
it('sends /stats of provider networks to the worker while nothing is learned', async () => {
vi.setSystemTime(ANSWER_TIME)
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ error: 'provider_infrastructure_request_blocked' }), { status: 429 }))
vi.stubGlobal('fetch', fetchMock)
// Every PROVIDER_INFRA_ASNS network, and a request of unknown network.
for (const asn of PROVIDER_INFRA_ASNS)
expect((await snippet.fetch(buildRequest('/stats', statsEvent(), {}, { asn }))).status).toBe(429)
const unknownNetwork = new Request('https://plugin.capgo.app/stats', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(statsEvent()) })
Object.defineProperty(unknownNetwork, 'cf', { value: { colo: 'CDG' } })
await snippet.fetch(unknownNetwork)
expect(fetchMock).toHaveBeenCalledTimes(PROVIDER_INFRA_ASNS.length + 1)
// A blocked answer teaches nothing: the next one still goes to the worker.
await snippet.fetch(buildRequest('/stats', statsEvent(), {}, { asn: PROVIDER_INFRA_ASNS[0] }))
expect(fetchMock).toHaveBeenCalledTimes(PROVIDER_INFRA_ASNS.length + 2)
const answered = await snippet.fetch(buildRequest('/stats', statsEvent()))
expect(answered.headers.get('X-Capgo-Edge')).toBe('answer')
})
it('sends the learning share of /stats without a fill to the worker', async () => {
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ status: 'ok' }), { status: 200 }))
vi.stubGlobal('fetch', fetchMock)
await snippet.fetch(buildRequest('/stats', statsEvent()))
expect(fetchMock).toHaveBeenCalledTimes(1)
// A batch follows its first event: answered when only a later device is sampled...
vi.setSystemTime(minuteWhere(minute => !isLearnSample(DEVICE_ID, minute) && isLearnSample(OTHER_DEVICE_ID, minute)))
const answered = await snippet.fetch(buildRequest('/stats', [statsEvent(), statsEvent({ device_id: OTHER_DEVICE_ID })]))
expect(answered.headers.get('X-Capgo-Edge')).toBe('answer')
expect(fetchMock).toHaveBeenCalledTimes(1)
// ...and sent to the worker when its first device is.
await snippet.fetch(buildRequest('/stats', [statsEvent({ device_id: OTHER_DEVICE_ID }), statsEvent()]))
expect(fetchMock).toHaveBeenCalledTimes(2)
// About 5% of devices per minute.
const sampled = Array.from({ length: 4000 }, (_, i) => isLearnSample(`00000000-0000-4000-8000-${String(i).padStart(12, '0')}`, 29_700_000)).filter(Boolean).length
expect(sampled).toBeGreaterThan(120)
expect(sampled).toBeLessThan(280)
})
it('sends /stats to the worker when the app entry lookup fails', async () => {
vi.setSystemTime(ANSWER_TIME)
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ status: 'ok' }), { status: 200 }))
vi.stubGlobal('fetch', fetchMock)
cache.match.mockRejectedValueOnce(new Error('cache unavailable'))
const response = await snippet.fetch(buildRequest('/stats', statsEvent()))
expect(response.headers.get('X-Capgo-Edge')).toBeNull()
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('keeps serving a learned on-prem /stats 429 instead of answering it', async () => {
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ error: 'on_premise_app', message: 'On-premise app detected', isOnprem: true }), {
status: 429,
headers: { 'Content-Type': 'application/json', 'Retry-After': '600', 'Cache-Control': 'public, max-age=600' },
}))
vi.stubGlobal('fetch', fetchMock)
await snippet.fetch(buildRequest('/stats', statsEvent()))
expect(fetchMock).toHaveBeenCalledTimes(1)
vi.setSystemTime(ANSWER_TIME)
const cached = await snippet.fetch(buildRequest('/stats', statsEvent()))
expect(cached.status).toBe(429)
expect(cached.headers.get('X-Capgo-Edge-Stat')).toBeNull()
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('answers a single /stats event with the worker body', async () => {
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ status: 'ok' }), {
status: 200,
headers: { 'X-Capgo-Edge-Fill': statsFill },
}))
vi.stubGlobal('fetch', fetchMock)
await snippet.fetch(buildRequest('/stats', statsEvent()))
const answered = await snippet.fetch(buildRequest('/stats', statsEvent({ device_id: OTHER_DEVICE_ID })))
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(await answered.json()).toEqual({ status: 'ok' })
})
it('sends /stats to the worker when the stat header would be cut by Logpush', async () => {
const fetchMock = vi.fn(async (_url: unknown, init?: RequestInit) => {
const events = JSON.parse(new TextDecoder().decode(init?.body as ArrayBuffer)) as unknown[]
return new Response(JSON.stringify({ status: 'ok', results: events.map((_, index) => ({ status: 'ok', index })) }), {
status: 200,
headers: { 'X-Capgo-Edge-Fill': statsFill },
})
})
vi.stubGlobal('fetch', fetchMock)
await snippet.fetch(buildRequest('/stats', [statsEvent()]))
// Under the 6000 byte body cap, but JSON escaping plus base64 passes 8000.
const batch = Array.from({ length: 28 }, () => statsEvent())
expect(JSON.stringify(batch).length).toBeLessThan(6000)
const response = await snippet.fetch(buildRequest('/stats', batch))
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(response.headers.get('X-Capgo-Edge-Stat')).toBeNull()
})
it('keeps every path within the 5 subrequest snippet budget', async () => {
let failPrimary = true
const fetchMock = vi.fn(async (url: unknown) => {
if (failPrimary && new URL(String(url)).origin === 'https://plugin.eu.capgo.app')
return new Response('upstream error', { status: 502 })
return new Response(JSON.stringify(NO_NEW_BODY), { status: 200, headers: { 'X-Capgo-Edge-Fill': updatesFill } })
})
vi.stubGlobal('fetch', fetchMock)
const response = await snippet.fetch(buildRequest('/updates', updatesBody()))
expect(response.status).toBe(200)
expect(subrequests(cache, fetchMock)).toBeLessThanOrEqual(5)
// The answer seen after a failed worker is served but not learned.
expect([...cache.store.keys()].some(key => key.includes('/edge-v4/'))).toBe(false)
failPrimary = false
cache.match.mockClear()
cache.put.mockClear()
cache.delete.mockClear()
fetchMock.mockClear()
await snippet.fetch(buildRequest('/updates', updatesBody({ device_id: OTHER_DEVICE_ID })))
expect(subrequests(cache, fetchMock)).toBeLessThanOrEqual(5)
})
it('never brings back an answer a purge removed when another channel refills', async () => {
const fetchMock = vi.fn(async (_url: unknown, init?: RequestInit) => {
const body = JSON.parse(new TextDecoder().decode(init?.body as ArrayBuffer))
return noNewWithFill(body.defaultChannel === 'beta' ? fillHeader({ e: 'updates', n: '9.9.9', k: null, cs: true }) : updatesFill)
})
vi.stubGlobal('fetch', fetchMock)
await snippet.fetch(buildRequest('/updates', updatesBody()))
// A channel change purges the app tags.
cache.store.clear()
await snippet.fetch(buildRequest('/updates', updatesBody({ defaultChannel: 'beta', version_name: '9.9.9' })))
expect(fetchMock).toHaveBeenCalledTimes(2)
await snippet.fetch(buildRequest('/updates', updatesBody({ device_id: OTHER_DEVICE_ID })))
expect(fetchMock).toHaveBeenCalledTimes(3)
})
it('replaces the answer of a channel with the newer fill', async () => {
let served = '1.2.3'
const fetchMock = vi.fn(async () => noNewWithFill(fillHeader({ e: 'updates', n: served, k: null, cs: true })))
vi.stubGlobal('fetch', fetchMock)
await snippet.fetch(buildRequest('/updates', updatesBody()))
served = '2.0.0'
await snippet.fetch(buildRequest('/updates', updatesBody({ version_name: '2.0.0' })))
expect(fetchMock).toHaveBeenCalledTimes(2)
await snippet.fetch(buildRequest('/updates', updatesBody({ device_id: OTHER_DEVICE_ID, version_name: '2.0.0' })))
expect(fetchMock).toHaveBeenCalledTimes(2)
await snippet.fetch(buildRequest('/updates', updatesBody({ device_id: OTHER_DEVICE_ID })))
expect(fetchMock).toHaveBeenCalledTimes(3)
})
it('shares IP limits between the hosts of the same workers', async () => {
const resetAt = Math.floor(Date.now() / 1000) + 600
let limited = false
const fetchMock = vi.fn(async () => limited
? new Response(JSON.stringify({ error: 'on_premise_app' }), { status: 429, headers: { 'Cache-Control': 'private, no-store', 'X-Capgo-Edge-Ip-Limit': String(resetAt) } })
: noNewWithFill())
vi.stubGlobal('fetch', fetchMock)
await snippet.fetch(buildRequest('/updates', updatesBody(), {}, { host: 'api.capgo.app' }))
limited = true
await snippet.fetch(buildRequest('/updates', updatesBody({ version_name: '0.0.1' })))
const guess = await snippet.fetch(buildRequest('/updates', updatesBody(), {}, { host: 'api.capgo.app' }))
expect(guess.status).toBe(429)
expect(fetchMock).toHaveBeenCalledTimes(3)
})
it('calls the worker when the IP limit lookup fails', async () => {
const fetchMock = vi.fn(async () => noNewWithFill())
vi.stubGlobal('fetch', fetchMock)
await snippet.fetch(buildRequest('/updates', updatesBody()))
const match = cache.match.getMockImplementation()!
cache.match.mockImplementation(async (key) => {
if (String(key instanceof Request ? key.url : key).includes('/edge-ip-limit-v1/'))
throw new Error('cache unavailable')
return match(key)
})
await snippet.fetch(buildRequest('/updates', updatesBody({ device_id: OTHER_DEVICE_ID })))
expect(fetchMock).toHaveBeenCalledTimes(2)
})
it('stores the IP limit even after a worker failure', async () => {
const resetAt = Math.floor(Date.now() / 1000) + 600
let limited = false
const fetchMock = vi.fn(async (url: unknown) => {
if (!limited)
return noNewWithFill()
if (new URL(String(url)).origin === 'https://plugin.eu.capgo.app')
return new Response('upstream error', { status: 502 })
return new Response(JSON.stringify({ error: 'on_premise_app' }), { status: 429, headers: { 'Cache-Control': 'private, no-store', 'X-Capgo-Edge-Ip-Limit': String(resetAt) } })
})
vi.stubGlobal('fetch', fetchMock)
await snippet.fetch(buildRequest('/updates', updatesBody()))
limited = true
cache.match.mockClear()
cache.put.mockClear()
cache.delete.mockClear()
fetchMock.mockClear()
await snippet.fetch(buildRequest('/updates', updatesBody({ version_name: '0.0.1' })))
expect(subrequests(cache, fetchMock)).toBeLessThanOrEqual(5)
expect([...cache.store.keys()].some(key => key.includes('/edge-ip-limit-v1/'))).toBe(true)
})
it('reaches the third worker of a zone when the first two fail', async () => {
const fetchMock = vi.fn(async (url: unknown) => new URL(String(url)).origin === 'https://plugin.as.capgo.app'
? noNewWithFill()
: new Response('upstream error', { status: 500 }))
vi.stubGlobal('fetch', fetchMock)
const response = await snippet.fetch(buildRequest('/updates', updatesBody(), {}, { colo: 'HKG' }))
expect(response.status).toBe(200)
expect(fetchMock.mock.calls.map(([url]) => new URL(String(url)).origin)).toEqual(['https://plugin.hk.capgo.app', 'https://plugin.jp.capgo.app', 'https://plugin.as.capgo.app'])
expect(subrequests(cache, fetchMock)).toBeLessThanOrEqual(5)
})
it('answers only the paths Logpush ships', async () => {
const fetchMock = vi.fn(async () => noNewWithFill())
vi.stubGlobal('fetch', fetchMock)
await snippet.fetch(buildRequest('/updates/', updatesBody()))
await snippet.fetch(buildRequest('/updates/', updatesBody({ device_id: OTHER_DEVICE_ID })))
expect(fetchMock).toHaveBeenCalledTimes(2)
})
it('skips the edge entry for a default channel too long to key on', async () => {
const fetchMock = vi.fn(async () => noNewWithFill())
vi.stubGlobal('fetch', fetchMock)
const body = updatesBody({ defaultChannel: 'x'.repeat(200) })
await snippet.fetch(buildRequest('/updates', body))
await snippet.fetch(buildRequest('/updates', { ...body, device_id: OTHER_DEVICE_ID }))
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(cache.put).not.toHaveBeenCalled()
})
it('ignores fills of the previous format', async () => {
const fetchMock = vi.fn(async () => noNewWithFill(encodeURIComponent(JSON.stringify({ v: 1, bps: 10000, ttl: 900, tags: 't', e: 'updates', n: '1.2.3', k: null, o: 'org-1', a: true, cs: true }))))
vi.stubGlobal('fetch', fetchMock)
await snippet.fetch(buildRequest('/updates', updatesBody()))
await snippet.fetch(buildRequest('/updates', updatesBody({ device_id: OTHER_DEVICE_ID })))
expect(fetchMock).toHaveBeenCalledTimes(2)
})
})
function onPremRequest(body: Record<string, unknown>) {
return buildRequest('/updates', body, {}, { host: 'api.capgo.app', colo: 'SFO' })
}
function onPremResponse(headers: Record<string, string> = { 'Cache-Control': 'public, max-age=60' }, body: Record<string, unknown> = {}) {
return new Response(JSON.stringify({ error: 'on_premise_app', message: 'On-premise app detected', ...body }), { status: 429, headers })
}
function edgeCachePuts(cache: Cache) {
return cache.put.mock.calls.map(([key]) => key instanceof Request ? key.url : String(key)).filter(key => key.includes('/__internal__/edge-v4/'))
}
describe('cloudflare snippet on-prem fallback', () => {
let cache: Cache
beforeEach(() => {
vi.spyOn(console, 'log').mockImplementation(() => {})
cache = buildCache()
vi.stubGlobal('caches', { default: cache })
})
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
it('returns and caches on-prem from the first worker without a confirming fetch', async () => {
const body = { app_id: 'com.external.app' }
const fetchMock = vi.fn(async (url: string | URL | Request, _init?: RequestInit) => {
const target = new URL(String(url))
if (target.origin === 'https://plugin.na.capgo.app')
return onPremResponse()
throw new Error(`Unexpected fetch target ${target.href}`)
})
vi.stubGlobal('fetch', fetchMock)
const response = await snippet.fetch(onPremRequest(body))
expect(response.status).toBe(429)
expect(response.headers.get('X-Onprem-Cached')).toBe('false')
expect(response.headers.get('X-Onprem-App-Id')).toBe('com.external.app')
// One worker fetch only: the snippet subrequest budget (5 on Enterprise) has no room for a confirm.
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(String(fetchMock.mock.calls[0][0])).toContain('https://plugin.na.capgo.app/updates')
await expect(new Response(fetchMock.mock.calls[0][1]?.body).json()).resolves.toEqual(body)
expect(edgeCachePuts(cache).length).toBeGreaterThan(0)
})
it('returns on-prem from a fallback worker without caching when the primary fails', async () => {
const fetchMock = vi.fn(async (url: string | URL | Request) => {
const target = new URL(String(url))
if (target.origin === 'https://plugin.na.capgo.app')
return new Response('upstream error', { status: 502 })
if (target.origin === 'https://plugin.eu.capgo.app')
return onPremResponse()
throw new Error(`Unexpected fetch target ${target.href}`)
})
vi.stubGlobal('fetch', fetchMock)
const response = await snippet.fetch(onPremRequest({ app_id: 'com.external.app' }))
expect(response.status).toBe(429)
expect(response.headers.get('X-Onprem-App-Id')).toBe('com.external.app')
expect(fetchMock).toHaveBeenCalledTimes(2)
// Partial outage: serve the on-prem answer but never cache it.
expect(edgeCachePuts(cache)).toEqual([])
})
it('passes cloud responses through after a single worker fetch', async () => {
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ status: 'ok' }), { status: 200 }))
vi.stubGlobal('fetch', fetchMock)
const response = await snippet.fetch(onPremRequest({ app_id: 'com.cloud.valid' }))
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ status: 'ok' })
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(edgeCachePuts(cache)).toEqual([])
})
it('retains Retry-After on cached on-prem responses and skips the worker', async () => {
const resetAt = Date.now() + 3_600_000
const fetchMock = vi.fn(async () => onPremResponse({
'Cache-Control': 'public, max-age=3600',
'Retry-After': '3600',
'X-RateLimit-Reset': String(Math.ceil(resetAt / 1000)),
}, { moreInfo: { rateLimitResetAt: resetAt, retryAfterSeconds: 3600 } }))
vi.stubGlobal('fetch', fetchMock)
const body = { app_id: 'com.external.app' }
const first = await snippet.fetch(onPremRequest(body))
expect(first.status).toBe(429)
expect(first.headers.get('Retry-After')).toBe('3600')
expect(first.headers.get('X-RateLimit-Reset')).toBe(String(Math.ceil(resetAt / 1000)))
expect(edgeCachePuts(cache)).toHaveLength(1)
const second = await snippet.fetch(onPremRequest(body))
expect(second.status).toBe(429)
const retryAfter = Number.parseInt(second.headers.get('Retry-After') || '0', 10)
expect(retryAfter).toBeGreaterThan(3500)
expect(second.headers.get('X-RateLimit-Reset')).toBe(String(Math.ceil(resetAt / 1000)))
expect(second.headers.get('Cache-Control')).toBe(`public, max-age=${retryAfter}`)
const secondBody = await second.json() as { moreInfo?: { retryAfterSeconds?: number } }
expect(secondBody.moreInfo?.retryAfterSeconds).toBe(retryAfter)
// Second request must be served from edge cache — no extra worker fetch.
expect(fetchMock).toHaveBeenCalledTimes(1)
})
})