import { randomUUID } from 'node:crypto' import { env } from 'node:process' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { BASE_URL, getBaseData, headers, PLUGIN_BASE_URL, resetAndSeedAppData, resetAppData, resetAppDataStats, warmEdgeEndpoint } from './test-utils.ts' // Rate limiting uses Cloudflare Workers Cache API, which isn't available in Supabase Edge Functions const USE_CLOUDFLARE = env.USE_CLOUDFLARE_WORKERS === 'true' const OP_LIMIT_PER_SECOND = 10 const id = randomUUID() const APPNAME = `com.ratelimit.${id}` type ChannelSelfMethod = 'POST' | 'PUT' | 'DELETE' type DeviceApiMethod = 'POST' | 'GET' | 'DELETE' async function fetchChannelSelfEndpoint(method: ChannelSelfMethod, bodyIn: object) { const url = new URL(`${PLUGIN_BASE_URL}/channel_self`) if (method === 'DELETE') { for (const [key, value] of Object.entries(bodyIn)) url.searchParams.append(key, value.toString()) } return fetch(url, { method, body: method !== 'DELETE' ? JSON.stringify(bodyIn) : undefined, }) } async function fetchGetChannels(queryParams: Record) { const url = new URL(`${PLUGIN_BASE_URL}/channel_self`) for (const [key, value] of Object.entries(queryParams)) url.searchParams.append(key, value) return fetch(url, { method: 'GET' }) } async function fetchDeviceApi(method: DeviceApiMethod, params: Record) { const url = new URL(`${BASE_URL}/device`) if (method === 'GET') { for (const [key, value] of Object.entries(params)) url.searchParams.append(key, value) } return fetch(url, { method, headers, body: method !== 'GET' ? JSON.stringify(params) : undefined, }) } function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)) } // The limiter counts requests in a one-second window anchored to the first request // (see channelSelfRateLimit.ts). Send limit+1 sequential requests as fast as the runner // allows; when a slow endpoint lets the window expire mid-burst, wait out the counter // and retry with a fresh burst. async function hitRateLimit(makeRequest: (deviceId: string) => Promise, deviceId: string): Promise { for (let round = 0; round < 6; round++) { // Send limit+1 back-to-back; slow runners may span multiple 1s windows. for (let sent = 0; sent < OP_LIMIT_PER_SECOND + 1; sent++) { const response = await makeRequest(deviceId) if (response.status === 429) return response } await sleep(1100) } return null } /** * Reusable test suite for rate limiting behavior. * Tests: first request succeeds, a burst is rate limited, after delay succeeds. */ async function testRateLimitBehavior( name: string, makeRequest: (deviceId: string) => Promise, ) { describe(name, () => { it('should allow first request', async () => { const response = await makeRequest(randomUUID().toLowerCase()) expect(response.status).not.toBe(429) }) it('should rate limit after burst within 1 second', async () => { const deviceId = randomUUID().toLowerCase() const limited = await hitRateLimit(makeRequest, deviceId) expect(limited?.status).toBe(429) }) it('should allow request after 1 second delay', async () => { const deviceId = randomUUID().toLowerCase() const limited = await hitRateLimit(makeRequest, deviceId) expect(limited?.status).toBe(429) await sleep(1100) const response2 = await makeRequest(deviceId) expect(response2.status).not.toBe(429) }) }) } beforeAll(async () => { await resetAndSeedAppData(APPNAME) if (USE_CLOUDFLARE) { await warmEdgeEndpoint(`${PLUGIN_BASE_URL}/channel_self`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(getBaseData(APPNAME)), }) await warmEdgeEndpoint(`${BASE_URL}/device`, { method: 'DELETE', headers, body: JSON.stringify({ app_id: APPNAME, device_id: randomUUID().toLowerCase() }), }) } }) afterAll(async () => { await resetAppData(APPNAME) await resetAppDataStats(APPNAME) }) // Skip all rate limiting tests when not running against Cloudflare Workers // because the Cache API used for rate limiting isn't available in Supabase Edge Functions describe.skipIf(!USE_CLOUDFLARE)('channel_self rate limiting', () => { // For the generic "op-level" tests, avoid the 60s same-channel rule by // ensuring the channel differs on every call per deviceId. const setCallCounts = new Map() testRateLimitBehavior('[POST] set operation', async (deviceId) => { const data = getBaseData(APPNAME) data.device_id = deviceId const count = setCallCounts.get(deviceId) ?? 0 setCallCounts.set(deviceId, count + 1) data.channel = `rl-op-${deviceId}-${count}` return fetchChannelSelfEndpoint('POST', data) }) testRateLimitBehavior('[PUT] get operation', async (deviceId) => { const data = getBaseData(APPNAME) data.device_id = deviceId return fetchChannelSelfEndpoint('PUT', data) }) testRateLimitBehavior('[DELETE] delete operation', async (deviceId) => { const data = getBaseData(APPNAME) data.device_id = deviceId return fetchChannelSelfEndpoint('DELETE', data) }) testRateLimitBehavior('[GET] list operation', async (deviceId) => { const data = getBaseData(APPNAME) data.device_id = deviceId return fetchGetChannels(data as any) }) describe('same channel 1-second rate limit', () => { it('should rate limit same channel set within 1 second', async () => { const deviceId = randomUUID().toLowerCase() const data = getBaseData(APPNAME) data.device_id = deviceId data.channel = 'production' const response1 = await fetchChannelSelfEndpoint('POST', data) expect(response1.status).not.toBe(429) // Immediate duplicate set — same-set (1s), not op burst (limit 10). const response2 = await fetchChannelSelfEndpoint('POST', data) expect(response2.status).toBe(429) }) it('should allow set with different channel immediately', async () => { const deviceId = randomUUID().toLowerCase() const data = getBaseData(APPNAME) data.device_id = deviceId data.channel = 'production' const response1 = await fetchChannelSelfEndpoint('POST', data) expect(response1.status).not.toBe(429) data.channel = 'beta' const response2 = await fetchChannelSelfEndpoint('POST', data) expect(response2.status).not.toBe(429) }) it('should allow same channel set after 1 second', async () => { const deviceId = randomUUID().toLowerCase() const data = getBaseData(APPNAME) data.device_id = deviceId data.channel = 'production' const response1 = await fetchChannelSelfEndpoint('POST', data) expect(response1.status).not.toBe(429) await sleep(1100) const response2 = await fetchChannelSelfEndpoint('POST', data) expect(response2.status).not.toBe(429) }) }) describe('cross-operation independence', () => { it('should NOT rate limit different operations on same device', async () => { const deviceId = randomUUID().toLowerCase() const data = getBaseData(APPNAME) data.device_id = deviceId const responses = await Promise.all([ fetchChannelSelfEndpoint('POST', data), fetchChannelSelfEndpoint('PUT', data), fetchChannelSelfEndpoint('DELETE', data), fetchGetChannels(data as any), ]) responses.forEach(r => expect(r.status).not.toBe(429)) }) it('should rate limit one operation without affecting others', async () => { const deviceId = randomUUID().toLowerCase() const data = getBaseData(APPNAME) data.device_id = deviceId // Exhaust POST let postCall = 0 const postLimited = await hitRateLimit(async () => { postCall += 1 return fetchChannelSelfEndpoint('POST', { ...data, channel: `rl-xop-${deviceId}-${postCall}` }) }, deviceId) expect(postLimited?.status).toBe(429) // PUT should still be allowed (separate bucket) const put = await fetchChannelSelfEndpoint('PUT', data) expect(put.status).not.toBe(429) }) }) }) describe.skipIf(!USE_CLOUDFLARE)('device API rate limiting', () => { // For the generic "op-level" tests, avoid the 60s same-channel rule by // ensuring the channel differs on every call per deviceId. const setCallCounts = new Map() testRateLimitBehavior('[POST] set operation', async (deviceId) => { const count = setCallCounts.get(deviceId) ?? 0 setCallCounts.set(deviceId, count + 1) return fetchDeviceApi('POST', { app_id: APPNAME, device_id: deviceId, channel: `rl-op-${deviceId}-${count}` }) }) testRateLimitBehavior('[GET] get operation', async (deviceId) => { return fetchDeviceApi('GET', { app_id: APPNAME, device_id: deviceId }) }) testRateLimitBehavior('[DELETE] delete operation', async (deviceId) => { return fetchDeviceApi('DELETE', { app_id: APPNAME, device_id: deviceId }) }) describe('cross-operation independence', () => { it('should NOT rate limit different operations on same device', async () => { const deviceId = randomUUID().toLowerCase() const responses = await Promise.all([ fetchDeviceApi('POST', { app_id: APPNAME, device_id: deviceId, channel: 'production' }), fetchDeviceApi('GET', { app_id: APPNAME, device_id: deviceId }), fetchDeviceApi('DELETE', { app_id: APPNAME, device_id: deviceId }), ]) responses.forEach(r => expect(r.status).not.toBe(429)) }) }) })