mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
* 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
274 lines
10 KiB
TypeScript
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)
|
|
})
|
|
})
|