Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions __tests__/lib/autoblogReceiver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// @vitest-environment node
import { describe, it, expect } from 'vitest'
import { buildEvent, signRequest } from '@profullstack/autoblog'
import { verifyAutoblog } from '@/lib/autoblogReceiver'

// The receiver moved from @profullstack/autoblog's verifyAndParse to
// @profullstack/webhooks. These requests are built with the autoblog SENDER's
// own code, so they prove crawlproof's existing deliveries still verify.

const post = {
id: 'p1',
title: 'Hello',
slug: 'hello',
html: '<p>hi</p>',
status: 'published',
published_at: '2026-10-28T00:00:00Z',
tags: ['a'],
categories: [],
}

function request(secret: string, opts: { bearer?: string; body?: string; ts?: number } = {}) {
const event = buildEvent(post as any, { source: 'https://crawlproof.com', eventId: 'evt_1' })
const body = opts.body ?? JSON.stringify(event)
const ts = opts.ts ?? Math.floor(Date.now() / 1000)
const headers = new Headers({
'content-type': 'application/cloudevents+json',
authorization: `Bearer ${opts.bearer ?? secret}`,
...signRequest({ id: event.id, timestamp: ts, body, secret }),
})
return { headers, body }
}

describe('verifyAutoblog', () => {
// The prod secret is 64 hex characters; a whsec_ one is the trap: the
// webhooks package would base64-decode it unless given the bytes.
for (const secret of ['371f7a'.padEnd(64, '0'), 'whsec_dGhpcyBpcyBub3QgdGhlIGtleQ==']) {
it(`accepts a delivery signed by @profullstack/autoblog (${secret.slice(0, 6)}…)`, () => {
const r = verifyAutoblog({ ...request(secret), secret })
expect(r.ok).toBe(true)
if (r.ok) expect(r.post).toMatchObject({ id: 'p1', slug: 'hello' })
})
}

it('refuses a wrong bearer, a wrong signature, a stale timestamp and a bad envelope', () => {
const secret = 'a'.repeat(64)
expect(verifyAutoblog({ ...request(secret, { bearer: 'b'.repeat(64) }), secret })).toMatchObject({ ok: false, status: 401 })
const signedOther = request('c'.repeat(64))
signedOther.headers.set('authorization', `Bearer ${secret}`)
expect(verifyAutoblog({ ...signedOther, secret })).toMatchObject({ ok: false, status: 401 })
expect(verifyAutoblog({ ...request(secret, { ts: Math.floor(Date.now() / 1000) - 3600 }), secret })).toMatchObject({
ok: false,
status: 401,
})
const bad = JSON.stringify({ specversion: '1.0', id: 'x', type: 't', source: 's', data: { post: { id: 1 } } })
expect(verifyAutoblog({ ...request(secret, { body: bad }), secret })).toMatchObject({ ok: false, status: 400 })
expect(verifyAutoblog({ ...request(secret), secret: '' })).toMatchObject({ ok: false, status: 401 })
})
})
53 changes: 53 additions & 0 deletions __tests__/lib/mcpServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ vi.mock('@/lib/credits', () => ({
createInviteFor: (...args: unknown[]) => createInviteFor(...args),
}))

// Webhooks: the real hub on an in-memory store, so the tools run end to end.
const webhookHub = vi.hoisted(() => ({ current: null as any }))
vi.mock('@/lib/webhooks/hub', async (orig) => ({ ...(await orig<object>()), getHooks: () => webhookHub.current }))
const { memoryStore } = await import('@profullstack/webhooks')
const { createHooks } = await import('@/lib/webhooks/hub')

const { handleMcpMessage, TOOLS, DEFAULT_PROTOCOL_VERSION } = await import(
'@/lib/mcpServer'
)
Expand Down Expand Up @@ -158,20 +164,27 @@ describe('tools/list', () => {
expect(tools.map((t) => t.name).sort()).toEqual([
'create_invite',
'create_listing',
'create_webhook',
'delete_listing',
'delete_webhook',
'get_my_listing',
'get_product',
'get_vocabulary',
'karma_leaderboard',
'karma_rules',
'list_categories',
'list_my_listings',
'list_webhooks',
'my_invites',
'my_karma',
'redeem_credit_code',
'redeem_karma_credit',
'rotate_webhook_secret',
'search_products',
'test_webhook',
'update_listing',
'update_webhook',
'webhook_deliveries',
])
for (const tool of tools) expect(tool.inputSchema).toBeTruthy()
})
Expand Down Expand Up @@ -482,4 +495,44 @@ describe('credit codes and invites over MCP', () => {
)
expect(createInviteFor).toHaveBeenCalledWith(principal, 'for Carol', request, 'http://localhost:3000')
})

describe('webhook tools', () => {
const call = (name: string, args: Record<string, unknown> = {}) =>
handleMcpMessage({ id: 1, method: 'tools/call', params: { name, arguments: args } }, { principal })

beforeEach(() => {
webhookHub.current = createHooks(memoryStore(), { log: () => {}, guard: { lookup: async () => ['93.184.216.34'] } })
})

it('creates, lists, rotates and deletes an endpoint for the caller', async () => {
const created = parseToolText(await call('create_webhook', { url: 'https://hooks.example.com/x', events: ['product.*'] }))
expect(created.secret).toMatch(/^whsec_/)
const id = created.endpoint.id

const listed = parseToolText(await call('list_webhooks'))
expect(listed.endpoints.map((e: { id: string }) => e.id)).toEqual([id])
expect(listed.event_types.map((e: { type: string }) => e.type)).toContain('karma.changed')
expect(await webhookHub.current.listEndpoints('u1')).toHaveLength(1)

const rotated = parseToolText(await call('rotate_webhook_secret', { id }))
expect(rotated.secret).not.toBe(created.secret)

const updated = parseToolText(await call('update_webhook', { id, enabled: false }))
expect(updated.enabled).toBe(false)

expect(parseToolText(await call('delete_webhook', { id }))).toEqual({ deleted: true })
})

it("turns the hub's own errors into tool errors", async () => {
const bad = await call('create_webhook', { url: 'https://hooks.example.com/x', events: ['nope.event'] })
expect(resultOf(bad).isError).toBe(true)
const missing = await call('test_webhook', { id: 'wh_missing' })
expect(resultOf(missing).isError).toBe(true)
})

it('needs an account', async () => {
const res = await handleMcpMessage({ id: 1, method: 'tools/call', params: { name: 'list_webhooks', arguments: {} } })
expect(resultOf(res).isError).toBe(true)
})
})
})
57 changes: 57 additions & 0 deletions __tests__/lib/webhooks/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// @vitest-environment node
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { memoryStore } from '@profullstack/webhooks'
import { createHooks } from '@/lib/webhooks/hub'

const auth = vi.hoisted(() => ({ principal: null as null | { userId: string } }))
const hooks = vi.hoisted(() => ({ current: null as any }))

vi.mock('@/lib/apiAuth', async () => {
const { ApiError } = await import('@/lib/publicApi')
return {
requirePrincipal: async () => {
if (!auth.principal) throw new ApiError(401, 'Authentication required.')
return auth.principal
},
}
})
vi.mock('@/lib/webhooks/hub', async (orig) => ({ ...(await orig<object>()), getHooks: () => hooks.current }))

const { GET, POST, DELETE } = await import('../../../app/api/v1/webhooks/[[...path]]/route')

const req = (path: string, init: RequestInit = {}) => new Request(`https://saasrow.com/api/v1/webhooks${path}`, init)

describe('/api/v1/webhooks', () => {
beforeEach(() => {
auth.principal = null
hooks.current = createHooks(memoryStore(), { log: () => {}, guard: { lookup: async () => ['93.184.216.34'] } })
})

it('answers 401 without a credential', async () => {
const res = await GET(req(''))
expect(res.status).toBe(401)
expect(res.headers.get('access-control-allow-origin')).toBe('*')
})

it("scopes every call to the caller's account", async () => {
auth.principal = { userId: 'u1' }
const created = await POST(
req('', { method: 'POST', body: JSON.stringify({ url: 'https://hooks.example.com/x', events: ['product.*'] }) }),
)
expect(created.status).toBe(201)
const { endpoint, secret } = await created.json()
expect(secret).toMatch(/^whsec_/)

const list = await (await GET(req(''))).json()
expect(list.endpoints.map((e: { id: string }) => e.id)).toEqual([endpoint.id])
expect(list.event_types).toContain('product.upvoted')

auth.principal = { userId: 'u2' }
expect((await (await GET(req(''))).json()).endpoints).toEqual([])
expect((await GET(req(`/${endpoint.id}`))).status).toBe(404)
expect((await DELETE(req(`/${endpoint.id}`, { method: 'DELETE' }))).status).toBe(404)

auth.principal = { userId: 'u1' }
expect((await DELETE(req(`/${endpoint.id}`, { method: 'DELETE' }))).status).toBe(200)
})
})
92 changes: 92 additions & 0 deletions __tests__/lib/webhooks/fakeSupabase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* A tiny in-memory stand-in for the supabase-js query builder: just the calls
* supabaseStore makes (select/insert/update/delete, eq/neq/lt, order, limit,
* maybeSingle, in) plus the two claim RPCs. Enough to run the real hub end to end.
*/
type Row = Record<string, unknown>;

export function fakeSupabase() {
const tables = new Map<string, Row[]>();
const rows = (t: string) => {
if (!tables.has(t)) tables.set(t, []);
return tables.get(t)!;
};
const calls: string[] = [];

function from(table: string) {
calls.push(table);
let op: "select" | "insert" | "update" | "delete" = "select";
let payload: Row | Row[] | null = null;
const filters: Array<(r: Row) => boolean> = [];
let orderBy: { col: string; asc: boolean } | null = null;
let lim: number | null = null;
let single = false;

const run = () => {
const all = rows(table);
if (op === "insert") {
const list = Array.isArray(payload) ? payload : [payload!];
for (const r of list) {
if (all.some((x) => x.id === r.id)) return { data: null, error: { message: "duplicate key" } };
}
all.push(...list.map((r) => ({ ...r })));
return { data: null, error: null };
}
const hit = all.filter((r) => filters.every((f) => f(r)));
if (op === "update") {
for (const r of hit) Object.assign(r, payload);
return { data: null, error: null };
}
if (op === "delete") {
tables.set(table, all.filter((r) => !hit.includes(r)));
return { data: null, error: null };
}
let out = hit.map((r) => ({ ...r }));
if (orderBy) {
const { col, asc } = orderBy;
out.sort((a, b) => (Number(a[col]) - Number(b[col])) * (asc ? 1 : -1));
}
if (lim !== null) out = out.slice(0, lim);
if (single) return { data: out[0] ?? null, error: null };
return { data: out, error: null };
};

const q: any = {
select: () => q,
insert: (p: Row | Row[]) => ((op = "insert"), (payload = p), q),
update: (p: Row) => ((op = "update"), (payload = p), q),
delete: () => ((op = "delete"), q),
eq: (c: string, v: unknown) => (filters.push((r) => r[c] === v), q),
neq: (c: string, v: unknown) => (filters.push((r) => r[c] !== v), q),
in: (c: string, vs: unknown[]) => (filters.push((r) => vs.includes(r[c])), q),
lt: (c: string, v: number) => (filters.push((r) => Number(r[c]) < v), q),
order: (col: string, o: { ascending?: boolean } = {}) => ((orderBy = { col, asc: o.ascending !== false }), q),
limit: (n: number) => ((lim = n), q),
maybeSingle: () => ((single = true), q),
then: (res: (v: unknown) => unknown, rej?: (e: unknown) => unknown) => Promise.resolve(run()).then(res, rej),
};
return q;
}

async function rpc(fn: string, args: Record<string, unknown>) {
if (fn === "claim_hub_webhook_events") {
const now = Date.now();
const due = rows("hub_webhook_events")
.filter((r) => !r.claimed_until || Number(r.claimed_until) < now)
.sort((a, b) => Number(a.id) - Number(b.id))
.slice(0, Number(args.p_limit));
for (const r of due) r.claimed_until = now + 120_000;
return { data: due.map((r) => ({ ...r })), error: null };
}
if (fn !== "claim_hub_webhook_deliveries") return { data: null, error: { message: `no rpc ${fn}` } };
const { p_now, p_limit, p_lease_until } = args as { p_now: number; p_limit: number; p_lease_until: number };
const due = rows("hub_webhook_deliveries")
.filter((r) => r.status === "pending" && Number(r.next_at) <= p_now)
.sort((a, b) => Number(a.next_at) - Number(b.next_at))
.slice(0, p_limit);
for (const r of due) r.next_at = p_lease_until;
return { data: due.map((r) => ({ ...r })), error: null };
}

return { from, rpc, tables, rows, calls };
}
103 changes: 103 additions & 0 deletions __tests__/lib/webhooks/hub.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// @vitest-environment node
import { describe, it, expect } from 'vitest'
import { verifyAndParse } from '@profullstack/webhooks'
import { createHooks, pumpEvents, WEBHOOK_EVENT_TYPES, WEBHOOK_SOURCE, EVENTS_TABLE } from '@/lib/webhooks/hub'
import { supabaseStore } from '@/lib/webhooks/supabase-store'
import { fakeSupabase } from './fakeSupabase'

// The whole path a trigger's outbox row takes: claim, emit, drain, deliver,
// and a receiver verifying it with the secret it was shown.

function setup() {
const sb = fakeSupabase()
const sent: Array<{ url: string; headers: Record<string, string>; body: string }> = []
const fetchImpl = (async (url: string, init: RequestInit) => {
sent.push({ url: String(url), headers: init.headers as Record<string, string>, body: String(init.body) })
return new Response('ok', { status: 200 })
}) as unknown as typeof fetch
const hooks = createHooks(supabaseStore(sb), {
fetchImpl,
guard: { allowHttp: true, allowPrivate: true },
log: () => {},
})
return { sb, hooks, sent }
}

const outbox = (sb: ReturnType<typeof fakeSupabase>, row: Record<string, unknown>) => sb.rows(EVENTS_TABLE).push(row)

describe('saasrow webhooks hub', () => {
it('emits SaaSRow events and validates endpoint patterns against them', async () => {
const { hooks } = setup()
expect(hooks.eventTypes).toEqual([...WEBHOOK_EVENT_TYPES])
await expect(hooks.createEndpoint('u1', { url: 'https://hooks.example.com/x', events: ['gig.*'] })).rejects.toThrow()
const { endpoint, secret } = await hooks.createEndpoint('u1', { url: 'https://hooks.example.com/x', events: ['product.*'] })
expect(endpoint.events).toEqual(['product.*'])
expect(secret).toMatch(/^whsec_/)
})

it('turns an outbox row into a signed CloudEvent delivery and removes the row', async () => {
const { sb, hooks, sent } = setup()
const { secret } = await hooks.createEndpoint('owner-1', { url: 'https://hooks.example.com/saasrow', events: ['product.upvoted'] })
outbox(sb, {
id: 7,
owner: 'owner-1',
type: 'product.upvoted',
subject: 'product:abc',
data: { product: { id: 'abc', title: 'Thing', upvotes: 3 }, vote: { id: 'v1', member: true } },
claimed_until: null,
})
// Another owner's event and an event this endpoint did not subscribe to.
outbox(sb, { id: 8, owner: 'owner-2', type: 'product.upvoted', subject: null, data: {}, claimed_until: null })
outbox(sb, { id: 9, owner: 'owner-1', type: 'karma.changed', subject: null, data: { points: 1 }, claimed_until: null })

expect(await pumpEvents(hooks, sb)).toBe(3)
expect(sb.rows(EVENTS_TABLE)).toHaveLength(0)
expect(sb.rows('hub_webhook_deliveries')).toHaveLength(1)

await hooks.drain()
expect(sent).toHaveLength(1)
const [d] = sent
expect(d.url).toBe('https://hooks.example.com/saasrow')

const parsed = verifyAndParse<any>({ headers: d.headers, body: d.body, secret })
expect(parsed.ok).toBe(true)
if (!parsed.ok) return
expect(parsed.id).toBe('evt_saasrow_7')
expect(parsed.event).toMatchObject({
specversion: '1.0',
id: 'evt_saasrow_7',
type: 'product.upvoted',
source: WEBHOOK_SOURCE,
subject: 'product:abc',
data: { product: { id: 'abc', title: 'Thing', upvotes: 3 }, vote: { member: true } },
})
expect(typeof parsed.event.time).toBe('string')
expect(sb.rows('hub_webhook_deliveries')[0]).toMatchObject({ status: 'delivered', attempts: 1 })
})

it('a wrong secret does not verify', async () => {
const { sb, hooks, sent } = setup()
await hooks.createEndpoint('owner-1', { url: 'https://hooks.example.com/a' })
outbox(sb, { id: 1, owner: 'owner-1', type: 'product.approved', subject: null, data: {}, claimed_until: null })
await pumpEvents(hooks, sb)
await hooks.drain()
const other = (await hooks.createEndpoint('owner-9', { url: 'https://hooks.example.com/b' })).secret
expect(verifyAndParse({ headers: sent[0].headers, body: sent[0].body, secret: other }).ok).toBe(false)
})

it('leaves a row it could not emit for the next pump', async () => {
const { sb, hooks } = setup()
const broken = { ...hooks, emit: async () => { throw new Error('db down') } } as unknown as typeof hooks
outbox(sb, { id: 1, owner: 'o', type: 'product.approved', subject: null, data: {}, claimed_until: null })
expect(await pumpEvents(broken, sb)).toBe(1)
expect(sb.rows(EVENTS_TABLE)).toHaveLength(1)
// Claimed: the same pump does not take it again until the lease runs out.
expect(await pumpEvents(hooks, sb)).toBe(0)
})

it('throws when the claim RPC fails rather than dropping events', async () => {
const { hooks } = setup()
const sb = { from: () => ({}), rpc: async () => ({ data: null, error: { message: 'boom' } }) }
await expect(pumpEvents(hooks, sb as any)).rejects.toThrow(/claim: boom/)
})
})
Loading
Loading