Skip to content

Commit b0b9daa

Browse files
committed
Ping cron job for keep-alive with Upstash and Suprabase
1 parent 83ed1b1 commit b0b9daa

3 files changed

Lines changed: 287 additions & 0 deletions

File tree

src/pages/api/cron/__tests__/cleanup.spec.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,15 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
55
import type { APIRoute } from 'astro'
66
import { GET as cleanupDsar } from '@pages/api/cron/cleanup-dsar-requests'
77
import { GET as cleanupConfirmations } from '@pages/api/cron/cleanup-confirmations'
8+
import { GET as pingIntegrations } from '@pages/api/cron/ping-integrations'
89

910
const supabaseAdminMock = vi.hoisted(() => ({
1011
from: vi.fn(),
1112
}))
1213

1314
const getCronSecretMock = vi.hoisted(() => vi.fn(() => 'cron-secret'))
15+
const getUpstashApiUrlMock = vi.hoisted(() => vi.fn(() => 'https://example.upstash.io'))
16+
const getUpstashApiTokenMock = vi.hoisted(() => vi.fn(() => 'upstash-token'))
1417

1518
vi.mock('@pages/api/_utils', () => ({
1619
supabaseAdmin: supabaseAdminMock,
@@ -23,6 +26,8 @@ vi.mock('@pages/api/_environment/environmentApi', async () => {
2326
return {
2427
...actual,
2528
getCronSecret: getCronSecretMock,
29+
getUpstashApiUrl: getUpstashApiUrlMock,
30+
getUpstashApiToken: getUpstashApiTokenMock,
2631
}
2732
})
2833

@@ -58,6 +63,30 @@ const createDeleteChain = (options?: { data?: unknown[]; error?: unknown }): Del
5863
return chain
5964
}
6065

66+
type SelectChain = {
67+
select: (...args: unknown[]) => SelectChain
68+
limit: (...args: unknown[]) => Promise<{ data?: unknown[]; error?: unknown; count?: number }>
69+
}
70+
71+
const createSelectChain = (options?: {
72+
data?: unknown[]
73+
error?: unknown
74+
count?: number
75+
}): SelectChain => {
76+
const response = {
77+
data: options?.data ?? [],
78+
error: options?.error ?? null,
79+
count: options?.count ?? options?.data?.length ?? 0,
80+
}
81+
82+
const chain: SelectChain = {
83+
select: () => chain,
84+
limit: () => Promise.resolve({ ...response }),
85+
}
86+
87+
return chain
88+
}
89+
6190
describe('Cron cleanup endpoints', () => {
6291
let warnSpy: ReturnType<typeof vi.spyOn>
6392
let logSpy: ReturnType<typeof vi.spyOn>
@@ -183,4 +212,115 @@ describe('Cron cleanup endpoints', () => {
183212
expect(body.error.code).toBe('CRON_DELETE_CONFIRMED_TOKENS_FAILED')
184213
})
185214
})
215+
216+
describe('ping-integrations', () => {
217+
const run = (request: Request) =>
218+
pingIntegrations(buildContext(request) as unknown as Parameters<APIRoute>[0])
219+
220+
let fetchMock: ReturnType<typeof vi.fn>
221+
222+
const buildRequest = (headers?: HeadersInit) =>
223+
new Request('http://localhost/api/cron/ping-integrations', {
224+
method: 'GET',
225+
headers,
226+
})
227+
228+
const createFetchResponse = (overrides?: Partial<Response>): Response => ({
229+
ok: true,
230+
status: 200,
231+
json: vi.fn().mockResolvedValue({ result: null }),
232+
text: vi.fn().mockResolvedValue(''),
233+
headers: new Headers(),
234+
redirected: false,
235+
statusText: 'OK',
236+
type: 'basic',
237+
url: getUpstashApiUrlMock(),
238+
clone: vi.fn(() => createFetchResponse(overrides)),
239+
body: null,
240+
bodyUsed: false,
241+
arrayBuffer: vi.fn(),
242+
blob: vi.fn(),
243+
formData: vi.fn(),
244+
...overrides,
245+
}) as unknown as Response
246+
247+
beforeEach(() => {
248+
fetchMock = vi.fn()
249+
vi.stubGlobal('fetch', fetchMock)
250+
})
251+
252+
afterEach(() => {
253+
vi.unstubAllGlobals()
254+
})
255+
256+
it('rejects requests without valid secret', async () => {
257+
const response = await run(buildRequest())
258+
const body = await response.json()
259+
260+
expect(response.status).toBe(401)
261+
expect(body.error.code).toBe('UNAUTHORIZED')
262+
expect(fetchMock).not.toHaveBeenCalled()
263+
})
264+
265+
it('pings Upstash and Supabase once authorized', async () => {
266+
fetchMock.mockResolvedValue(createFetchResponse())
267+
supabaseAdminMock.from.mockReturnValueOnce(createSelectChain({ count: 5 }))
268+
269+
const response = await run(
270+
buildRequest({
271+
authorization: 'Bearer cron-secret',
272+
}),
273+
)
274+
const body = await response.json()
275+
276+
expect(response.status).toBe(200)
277+
expect(body.success).toBe(true)
278+
expect(typeof body.upstash.durationMs).toBe('number')
279+
expect(body.supabase.rowsChecked).toBe(5)
280+
expect(fetchMock).toHaveBeenCalledTimes(1)
281+
282+
const fetchArgs = fetchMock.mock.calls[0]![1] as RequestInit | undefined
283+
const headers = fetchArgs?.headers as Record<string, string> | undefined
284+
expect(headers?.Authorization ?? headers?.authorization).toBe('Bearer upstash-token')
285+
})
286+
287+
it('returns upstream error details when Upstash responds with failure', async () => {
288+
fetchMock.mockResolvedValue(
289+
createFetchResponse({
290+
ok: false,
291+
status: 503,
292+
statusText: 'Service Unavailable',
293+
json: vi.fn().mockResolvedValue({ error: 'unavailable' }),
294+
}),
295+
)
296+
supabaseAdminMock.from.mockReturnValueOnce(createSelectChain())
297+
298+
const response = await run(
299+
buildRequest({
300+
authorization: 'Bearer cron-secret',
301+
}),
302+
)
303+
const body = await response.json()
304+
305+
expect(response.status).toBe(503)
306+
expect(body.error.code).toBe('CRON_UPSTASH_PING_FAILED')
307+
})
308+
309+
it('surfaces Supabase errors when ping fails', async () => {
310+
fetchMock.mockResolvedValue(createFetchResponse())
311+
supabaseAdminMock.from.mockReturnValueOnce(
312+
createSelectChain({ error: { message: 'boom' } }),
313+
)
314+
315+
const response = await run(
316+
buildRequest({
317+
authorization: 'Bearer cron-secret',
318+
}),
319+
)
320+
const body = await response.json()
321+
322+
expect(response.status).toBe(500)
323+
expect(body.error.code).toBe('CRON_SUPABASE_PING_FAILED')
324+
})
325+
})
186326
})
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
/**
2+
* Cron API Route to ping third-party integrations to keep them awake.
3+
* Currently pings:
4+
* - Upstash (key-value store)
5+
* - Supabase (database)
6+
*/
7+
import type { APIRoute } from 'astro'
8+
import {
9+
getCronSecret,
10+
getUpstashApiToken,
11+
getUpstashApiUrl,
12+
} from '@pages/api/_environment/environmentApi'
13+
import { supabaseAdmin } from '@pages/api/_utils'
14+
import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError'
15+
import { buildApiErrorResponse, handleApiFunctionError } from '@pages/api/_errors/apiFunctionHandler'
16+
import { createApiFunctionContext } from '@pages/api/_utils/requestContext'
17+
18+
export const prerender = false
19+
20+
const ROUTE = '/api/cron/ping-integrations'
21+
const UPSTASH_KEY = '__cron_keepalive__'
22+
23+
const buildErrorResponse = (
24+
error: unknown,
25+
context: ReturnType<typeof createApiFunctionContext>['context'],
26+
fallbackMessage: string,
27+
) => buildApiErrorResponse(handleApiFunctionError(error, context), { fallbackMessage })
28+
29+
const pingUpstash = async () => {
30+
const start = Date.now()
31+
const endpoint = new URL(`/get/${encodeURIComponent(UPSTASH_KEY)}`, getUpstashApiUrl())
32+
const response = await fetch(endpoint.toString(), {
33+
headers: {
34+
Authorization: `Bearer ${getUpstashApiToken()}`,
35+
},
36+
})
37+
38+
if (!response.ok) {
39+
throw new ApiFunctionError({
40+
message: `Upstash ping failed with status ${response.status}`,
41+
status: response.status,
42+
code: 'CRON_UPSTASH_PING_FAILED',
43+
route: ROUTE,
44+
operation: 'pingUpstash',
45+
})
46+
}
47+
48+
let payload: unknown
49+
50+
try {
51+
payload = await response.json()
52+
} catch {
53+
payload = await response.text()
54+
}
55+
56+
return {
57+
payload,
58+
durationMs: Date.now() - start,
59+
}
60+
}
61+
62+
const pingSupabase = async () => {
63+
const start = Date.now()
64+
const { data, error, count } = await supabaseAdmin
65+
.from('newsletter_confirmations')
66+
.select('id', { count: 'exact' })
67+
.limit(1)
68+
69+
if (error) {
70+
throw new ApiFunctionError({
71+
message: `Supabase ping failed: ${error.message ?? 'Unknown error'}`,
72+
cause: error,
73+
status: 500,
74+
code: 'CRON_SUPABASE_PING_FAILED',
75+
route: ROUTE,
76+
operation: 'pingSupabase',
77+
})
78+
}
79+
80+
return {
81+
rowsChecked: typeof count === 'number' ? count : data?.length ?? 0,
82+
durationMs: Date.now() - start,
83+
}
84+
}
85+
86+
export const GET: APIRoute = async ({ request, clientAddress, cookies }) => {
87+
const { context: apiContext } = createApiFunctionContext({
88+
route: ROUTE,
89+
operation: 'GET',
90+
request,
91+
clientAddress,
92+
cookies,
93+
})
94+
95+
const authHeader = request.headers.get('authorization')
96+
97+
if (authHeader !== `Bearer ${getCronSecret()}`) {
98+
console.warn('Unauthorized cron attempt - invalid CRON_SECRET')
99+
apiContext.extra = {
100+
...(apiContext.extra || {}),
101+
authHeader: authHeader ? 'PRESENT' : 'MISSING',
102+
clientAddress,
103+
}
104+
105+
return buildErrorResponse(
106+
new ApiFunctionError({
107+
message: 'Unauthorized',
108+
status: 401,
109+
code: 'UNAUTHORIZED',
110+
}),
111+
apiContext,
112+
'Unauthorized cron access',
113+
)
114+
}
115+
116+
try {
117+
const [upstashResult, supabaseResult] = await Promise.all([
118+
pingUpstash(),
119+
pingSupabase(),
120+
])
121+
122+
return new Response(
123+
JSON.stringify({
124+
success: true,
125+
upstash: upstashResult,
126+
supabase: supabaseResult,
127+
timestamp: new Date().toISOString(),
128+
}),
129+
{
130+
status: 200,
131+
headers: {
132+
'Content-Type': 'application/json',
133+
},
134+
},
135+
)
136+
} catch (error) {
137+
apiContext.extra = {
138+
...(apiContext.extra || {}),
139+
authHeader: 'REDACTED',
140+
}
141+
return buildErrorResponse(error, apiContext, 'Failed to ping backing services')
142+
}
143+
}

vercel.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@
1010
{
1111
"path": "/api/cron/cleanup-dsar-requests",
1212
"schedule": "0 3 * * *"
13+
},
14+
{
15+
"path": "/api/cron/ping-integrations",
16+
"schedule": "0 4 * * *"
1317
}
1418
]
1519
}

0 commit comments

Comments
 (0)