Files
orca/cloud/apps/push/src/push-server-limits.test.ts
T
Jinwoo Hong eb2f2d52ae feat(cloud): native push gateway and dedicated infrastructure (1/3) (#19912)
* refactor(cloud): share PostgreSQL schema startup between services

* feat(cloud): add durable native push notification gateway

* infra(push): define dedicated gateway resources and operational checks

* fix(push): bound cross-host admission and simplify gateway configuration

* fix(push): validate deploy configuration and preserve topic-error registrations
2026-09-10 17:59:46 -04:00

274 lines
10 KiB
TypeScript

import { PUSH_LIMITS } from '@orca-cloud/push-contract'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createPushHostKeypair, hostPublicKeyB64 } from './host-challenge-answering.test-fixture.js'
import {
createPushServerHarness,
FCM_TOKEN,
notification
} from './push-server-harness.test-fixture.js'
const CLIENT_IP = '203.0.113.7'
const OTHER_CLIENT_IP = '198.51.100.9'
function oversizedChallengeBody(): string {
return JSON.stringify({ v: 1, filler: 'x'.repeat(PUSH_LIMITS.maxHttpBodyBytes) })
}
function chunkedRequest(path: string, body: string): Request {
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(body))
controller.close()
}
})
return new Request(`http://push.test${path}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: stream,
duplex: 'half'
} as RequestInit)
}
describe('push gateway request limits', () => {
let harness: Awaited<ReturnType<typeof createPushServerHarness>>
beforeEach(async () => {
harness = await createPushServerHarness()
})
afterEach(async () => {
await harness.close()
})
it('refuses an oversized chunked body that declares no content length', async () => {
const request = chunkedRequest('/v1/host/challenge', oversizedChallengeBody())
expect(request.headers.get('content-length')).toBeNull()
const response = await harness.server.app.request(request)
expect(response.status).toBe(413)
expect(await response.json()).toEqual({ error: 'request_too_large' })
})
it('still refuses an oversized body that declares a content length', async () => {
const body = oversizedChallengeBody()
const response = await harness.server.app.request('/v1/host/challenge', {
method: 'POST',
headers: {
'content-type': 'application/json',
'content-length': String(Buffer.byteLength(body))
},
body
})
expect(response.status).toBe(413)
expect(await response.json()).toEqual({ error: 'request_too_large' })
})
it('lets a chunked body under the cap through to schema validation', async () => {
const response = await harness.server.app.request(
chunkedRequest(
'/v1/host/challenge',
JSON.stringify({ v: 1, hostPublicKeyB64: hostPublicKeyB64(createPushHostKeypair(60)) })
)
)
expect(response.status).toBe(200)
})
it('caps an authenticated oversized send as well', async () => {
const sessionToken = await harness.signIn(createPushHostKeypair(61))
const response = await harness.server.app.request(
new Request('http://push.test/v1/send', {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${sessionToken}`
},
body: new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(oversizedChallengeBody()))
controller.close()
}
}),
duplex: 'half'
} as RequestInit)
)
expect(response.status).toBe(413)
expect(await response.json()).toEqual({ error: 'request_too_large' })
})
it('rate limits one client ip across both unauthenticated routes', async () => {
const body = JSON.stringify({
v: 1,
hostPublicKeyB64: hostPublicKeyB64(createPushHostKeypair(62))
})
// Cloud Run appends the peer, so the caller's own IP is the last value.
const headers = {
'content-type': 'application/json',
'x-forwarded-for': `10.0.0.1, ${CLIENT_IP}`
}
for (let index = 0; index < PUSH_LIMITS.unauthenticatedRequestsPerMinutePerIp; index++) {
const allowed = await harness.server.app.request('/v1/host/challenge', {
method: 'POST',
headers,
body
})
expect(allowed.status).toBe(200)
}
const limited = await harness.server.app.request('/v1/host/challenge', {
method: 'POST',
headers,
body
})
expect(limited.status).toBe(429)
expect(await limited.json()).toEqual({ error: 'rate_limited' })
// The session route draws on the same bucket, so a flood cannot simply move.
const session = await harness.server.app.request('/v1/host/session', {
method: 'POST',
headers,
body: JSON.stringify({ v: 1, challengeId: 'anything', proofB64: 'x'.repeat(44) })
})
expect(session.status).toBe(429)
const other = await harness.server.app.request('/v1/host/challenge', {
method: 'POST',
headers: { ...headers, 'x-forwarded-for': `10.0.0.1, ${OTHER_CLIENT_IP}` },
body
})
expect(other.status).toBe(200)
// A caller rewriting the left of the chain lands in its own bucket anyway.
const spoofed = await harness.server.app.request('/v1/host/challenge', {
method: 'POST',
headers: { ...headers, 'x-forwarded-for': `198.51.100.250, ${CLIENT_IP}` },
body
})
expect(spoofed.status).toBe(429)
})
it('lets a throttled client back in once the window refills', async () => {
const body = JSON.stringify({
v: 1,
hostPublicKeyB64: hostPublicKeyB64(createPushHostKeypair(63))
})
const headers = { 'content-type': 'application/json', 'x-forwarded-for': CLIENT_IP }
for (let index = 0; index < PUSH_LIMITS.unauthenticatedRequestsPerMinutePerIp; index++) {
await harness.server.app.request('/v1/host/challenge', { method: 'POST', headers, body })
}
expect(
(await harness.server.app.request('/v1/host/challenge', { method: 'POST', headers, body }))
.status
).toBe(429)
harness.advanceClock(60_000)
expect(
(await harness.server.app.request('/v1/host/challenge', { method: 'POST', headers, body }))
.status
).toBe(200)
})
it('limits authenticated hosts independently behind the same IP', async () => {
const sessionToken = await harness.signIn(createPushHostKeypair(64))
const headers = { 'x-forwarded-for': CLIENT_IP }
for (let index = 0; index < 600; index++) {
const listed = await harness.authorized('/v1/devices', { headers }, sessionToken)
expect(listed.status).toBe(200)
}
const limited = await harness.authorized('/v1/devices', { headers }, sessionToken)
expect(limited.status).toBe(429)
const otherToken = await harness.signIn(createPushHostKeypair(68))
expect((await harness.authorized('/v1/devices', { headers }, otherToken)).status).toBe(200)
// The handshake bucket is untouched by any of that.
const challenge = await harness.server.app.request('/v1/host/challenge', {
method: 'POST',
headers: { ...headers, 'content-type': 'application/json' },
body: JSON.stringify({ v: 1, hostPublicKeyB64: hostPublicKeyB64(createPushHostKeypair(67)) })
})
expect(challenge.status).toBe(200)
})
it('stops repeated forged bearers after the invalid-auth budget is exhausted', async () => {
const headers = { 'x-forwarded-for': CLIENT_IP }
const [before] = await harness.database.query('SELECT COUNT(*) AS sessions FROM push_sessions')
for (let index = 0; index < PUSH_LIMITS.unauthenticatedRequestsPerMinutePerIp; index++) {
const refused = await harness.authorized('/v1/send', { method: 'POST', headers }, 'forged')
expect(refused.status).toBe(401)
}
const limited = await harness.authorized('/v1/send', { method: 'POST', headers }, 'forged')
expect(limited.status).toBe(429)
expect(await limited.json()).toEqual({ error: 'rate_limited' })
expect(harness.server.unauthenticatedIps.trackedIpCount()).toBe(0)
const [after] = await harness.database.query('SELECT COUNT(*) AS sessions FROM push_sessions')
expect(Number(after?.sessions)).toBe(Number(before?.sessions))
})
it('answers 409 once a host has registered its device allowance', async () => {
const sessionToken = await harness.signIn(createPushHostKeypair(66))
for (let index = 0; index < PUSH_LIMITS.maxDevicesPerHost; index++) {
const accepted = await harness.post(
'/v1/devices',
{
v: 1,
deviceId: `device-${index}`,
platform: 'android',
token: FCM_TOKEN
},
sessionToken
)
expect(accepted.status).toBe(200)
}
const refused = await harness.post(
'/v1/devices',
{ v: 1, deviceId: 'one-too-many', platform: 'android', token: FCM_TOKEN },
sessionToken
)
expect(refused.status).toBe(409)
expect(await refused.json()).toEqual({ error: 'too_many_devices' })
const listed = await harness.authorized('/v1/devices', {}, sessionToken)
expect(((await listed.json()) as { devices: unknown[] }).devices).toHaveLength(
PUSH_LIMITS.maxDevicesPerHost
)
})
// Why: a database error carries the failing row in its message. The response
// and the log must both stop at the error's name.
it('answers an unexpected route failure with a bare 500 and logs only the name', async () => {
const sessionToken = await harness.signIn(createPushHostKeypair(66))
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
try {
await harness.database.close()
const response = await harness.authorized('/v1/devices', {}, sessionToken)
expect(response.status).toBe(500)
expect(await response.json()).toEqual({ error: 'internal' })
const logged = warn.mock.calls.map((call) => String(call[0])).join('\n')
expect(logged).toContain('"event":"orca_push_request_failed"')
expect(logged).not.toContain('SELECT')
expect(logged).not.toContain('push_devices')
expect(harness.server.observability.consume().request_error).toBe(1)
} finally {
warn.mockRestore()
}
})
it('charges a repeated registration id once and returns one result', async () => {
const sessionToken = await harness.signIn(createPushHostKeypair(65))
const registrationId = await harness.registerAndroid(sessionToken)
const response = await harness.post(
'/v1/send',
{
v: 1,
registrationIds: [registrationId, registrationId, registrationId],
notification: notification()
},
sessionToken
)
expect(await response.json()).toEqual({ results: [{ registrationId, status: 'queued' }] })
expect(await harness.server.deliveryStore.pendingCount(registrationId)).toBe(1)
const [row] = await harness.database.query('SELECT COUNT(*) AS sends FROM push_events')
expect(Number(row?.sends)).toBe(1)
})
})