tests/channel-rate-limit.test.ts
Rama main · réplica de lectura del repositorio original.
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<string, string>) {
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<string, string>) {
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<void> {
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<Response>, deviceId: string): Promise<Response | null> {
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<Response>,
) {
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<string, number>()
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<string, number>()
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))
})
})
})