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
35 changes: 34 additions & 1 deletion src/commands/webhook.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const { runWebhookList, runWebhookCreate, runWebhookDelete } = await import('./w

const mockWebhook = {
id: 'we_123',
url: 'https://example.com/hook',
endpoint_url: 'https://example.com/hook',
events: ['dsync.user.created'],
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
Expand Down Expand Up @@ -59,6 +59,39 @@ describe('webhook commands', () => {
await runWebhookList('sk_test');
expect(consoleOutput.some((l) => l.includes('No webhook endpoints found'))).toBe(true);
});

it('truncates long event lists with a "+N more" suffix', async () => {
mockClient.webhooks.list.mockResolvedValue({
data: [
{
...mockWebhook,
events: [
'user.created',
'user.updated',
'user.deleted',
'session.created',
'session.revoked',
'organization.created',
'organization.updated',
],
},
],
list_metadata: { before: null, after: null },
});
await runWebhookList('sk_test');
expect(consoleOutput.some((l) => /\+\d+ more/.test(l))).toBe(true);
});

it('always shows at least one event when a single event name exceeds the budget', async () => {
const longEvent = 'a.very.long.namespace.with.many.segments.that.exceeds.sixty.chars.event';
mockClient.webhooks.list.mockResolvedValue({
data: [{ ...mockWebhook, events: [longEvent, 'user.created'] }],
list_metadata: { before: null, after: null },
});
await runWebhookList('sk_test');
expect(consoleOutput.some((l) => l.includes(longEvent))).toBe(true);
expect(consoleOutput.some((l) => l.includes('(+1 more)'))).toBe(true);
});
});

describe('runWebhookCreate', () => {
Expand Down
20 changes: 19 additions & 1 deletion src/commands/webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,25 @@ export async function runWebhookList(apiKey: string, baseUrl?: string): Promise<
return;
}

const rows = result.data.map((ep) => [ep.id, ep.url, ep.events.join(', '), ep.created_at]);
const maxEventsChars = 60;
const rows = result.data.map((ep) => {
const joined = ep.events.join(', ');
if (joined.length <= maxEventsChars) {
return [ep.id, ep.endpoint_url, joined, ep.created_at];
}
// Always include the first event so the cell isn't content-free when a single event name exceeds the budget.
const visible: string[] = [ep.events[0]];
let len = ep.events[0].length;
for (let i = 1; i < ep.events.length; i++) {
const next = len + 2 + ep.events[i].length;
if (next > maxEventsChars) break;
visible.push(ep.events[i]);
len = next;
}
const hidden = ep.events.length - visible.length;
const suffix = hidden > 0 ? `, … (+${hidden} more)` : '';
return [ep.id, ep.endpoint_url, `${visible.join(', ')}${suffix}`, ep.created_at];
});

console.log(formatTable([{ header: 'ID' }, { header: 'URL' }, { header: 'Events' }, { header: 'Created' }], rows));

Expand Down
2 changes: 1 addition & 1 deletion src/emulate/workos/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,7 @@ export interface WorkOSEvent extends Entity {

export interface WorkOSWebhookEndpoint extends Entity {
object: 'webhook_endpoint';
url: string;
endpoint_url: string;
secret: string;
enabled: boolean;
events: string[];
Expand Down
8 changes: 4 additions & 4 deletions src/emulate/workos/event-bus.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ describe('EventBus', () => {
const ws = getWorkOSStore(store);
ws.webhookEndpoints.insert({
object: 'webhook_endpoint',
url: 'http://localhost:9999/webhook',
endpoint_url: 'http://localhost:9999/webhook',
secret: 'whsec_test',
enabled: false,
events: [],
Expand All @@ -64,7 +64,7 @@ describe('EventBus', () => {
const ws = getWorkOSStore(store);
ws.webhookEndpoints.insert({
object: 'webhook_endpoint',
url: 'http://localhost:9999/webhook',
endpoint_url: 'http://localhost:9999/webhook',
secret: 'whsec_test',
enabled: true,
events: ['organization.created'],
Expand All @@ -88,7 +88,7 @@ describe('EventBus', () => {

ws.webhookEndpoints.insert({
object: 'webhook_endpoint',
url: 'http://localhost:9999/webhook',
endpoint_url: 'http://localhost:9999/webhook',
secret,
enabled: true,
events: [],
Expand Down Expand Up @@ -128,7 +128,7 @@ describe('EventBus', () => {

ws.webhookEndpoints.insert({
object: 'webhook_endpoint',
url: 'http://localhost:9999/webhook',
endpoint_url: 'http://localhost:9999/webhook',
secret: 'whsec_test',
enabled: true,
events: [],
Expand Down
2 changes: 1 addition & 1 deletion src/emulate/workos/event-bus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export class EventBus {

const signature = signWebhookPayload(body, endpoint.secret);

await fetch(endpoint.url, {
await fetch(endpoint.endpoint_url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Expand Down
2 changes: 1 addition & 1 deletion src/emulate/workos/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,7 @@ export function formatWebhookEndpoint(
return {
object: 'webhook_endpoint',
id: ep.id,
url: ep.url,
endpoint_url: ep.endpoint_url,
secret: opts?.includeSecret ? ep.secret : `${ep.secret.slice(0, 8)}****`,
enabled: ep.enabled,
events: ep.events,
Expand Down
10 changes: 8 additions & 2 deletions src/emulate/workos/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,9 @@ export interface WorkOSSeedPermission {
}

export interface WorkOSSeedWebhookEndpoint {
url: string;
endpoint_url?: string;
/** @deprecated Use endpoint_url */
url?: string;
events?: string[];
enabled?: boolean;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Expand Down Expand Up @@ -315,9 +317,13 @@ export function seedFromConfig(store: Store, _baseUrl: string, config: WorkOSSee

if (config.webhookEndpoints) {
for (const whConfig of config.webhookEndpoints) {
const endpointUrl = whConfig.endpoint_url ?? whConfig.url;
if (!endpointUrl || typeof endpointUrl !== 'string') {
throw new Error('workos seed config: webhookEndpoints[].endpoint_url is required');
}
ws.webhookEndpoints.insert({
object: 'webhook_endpoint',
url: whConfig.url,
endpoint_url: endpointUrl,
secret: randomBytes(32).toString('hex'),
enabled: whConfig.enabled !== false,
events: whConfig.events ?? [],
Expand Down
39 changes: 32 additions & 7 deletions src/emulate/workos/routes/webhook-endpoints.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,12 @@ describe('Webhook endpoint routes', () => {
it('creates a webhook endpoint with auto-generated secret', async () => {
const res = await req('/webhook_endpoints', {
method: 'POST',
body: JSON.stringify({ url: 'http://localhost:3000/webhooks' }),
body: JSON.stringify({ endpoint_url: 'http://localhost:3000/webhooks' }),
});
expect(res.status).toBe(201);
const ep = await json(res);
expect(ep.object).toBe('webhook_endpoint');
expect(ep.url).toBe('http://localhost:3000/webhooks');
expect(ep.endpoint_url).toBe('http://localhost:3000/webhooks');
expect(ep.secret).toHaveLength(64); // full hex secret on create
Comment thread
coderabbitai[bot] marked this conversation as resolved.
expect(ep.enabled).toBe(true);
expect(ep.events).toEqual([]);
Expand All @@ -38,7 +38,7 @@ describe('Webhook endpoint routes', () => {
const res = await req('/webhook_endpoints', {
method: 'POST',
body: JSON.stringify({
url: 'http://localhost:3000/webhooks',
endpoint_url: 'http://localhost:3000/webhooks',
secret: 'my_custom_secret',
events: ['user.created', 'user.deleted'],
description: 'Test endpoint',
Expand All @@ -53,7 +53,7 @@ describe('Webhook endpoint routes', () => {
it('masks secret on GET', async () => {
const createRes = await req('/webhook_endpoints', {
method: 'POST',
body: JSON.stringify({ url: 'http://localhost:3000/webhooks' }),
body: JSON.stringify({ endpoint_url: 'http://localhost:3000/webhooks' }),
});
const created = await json(createRes);

Expand All @@ -66,7 +66,7 @@ describe('Webhook endpoint routes', () => {
it('masks secret on list', async () => {
await req('/webhook_endpoints', {
method: 'POST',
body: JSON.stringify({ url: 'http://localhost:3000/webhooks' }),
body: JSON.stringify({ endpoint_url: 'http://localhost:3000/webhooks' }),
});

const listRes = await req('/webhook_endpoints');
Expand All @@ -78,7 +78,7 @@ describe('Webhook endpoint routes', () => {
it('updates a webhook endpoint', async () => {
const createRes = await req('/webhook_endpoints', {
method: 'POST',
body: JSON.stringify({ url: 'http://localhost:3000/webhooks' }),
body: JSON.stringify({ endpoint_url: 'http://localhost:3000/webhooks' }),
});
const created = await json(createRes);

Expand All @@ -94,7 +94,7 @@ describe('Webhook endpoint routes', () => {
it('deletes a webhook endpoint', async () => {
const createRes = await req('/webhook_endpoints', {
method: 'POST',
body: JSON.stringify({ url: 'http://localhost:3000/webhooks' }),
body: JSON.stringify({ endpoint_url: 'http://localhost:3000/webhooks' }),
});
const created = await json(createRes);

Expand All @@ -105,6 +105,31 @@ describe('Webhook endpoint routes', () => {
expect(getRes.status).toBe(404);
});

it('accepts legacy url on create for backward compatibility', async () => {
const res = await req('/webhook_endpoints', {
method: 'POST',
body: JSON.stringify({ url: 'http://localhost:3000/legacy' }),
});
expect(res.status).toBe(201);
const ep = await json(res);
expect(ep.endpoint_url).toBe('http://localhost:3000/legacy');
});

it('accepts legacy url on update for backward compatibility', async () => {
const createRes = await req('/webhook_endpoints', {
method: 'POST',
body: JSON.stringify({ endpoint_url: 'http://localhost:3000/webhooks' }),
});
const created = await json(createRes);
const updateRes = await req(`/webhook_endpoints/${created.id}`, {
method: 'PUT',
body: JSON.stringify({ url: 'http://localhost:3000/updated-legacy' }),
});
expect(updateRes.status).toBe(200);
const updated = await json(updateRes);
expect(updated.endpoint_url).toBe('http://localhost:3000/updated-legacy');
});

it('returns 422 for missing url', async () => {
const res = await req('/webhook_endpoints', {
method: 'POST',
Expand Down
17 changes: 9 additions & 8 deletions src/emulate/workos/routes/webhook-endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,16 @@ export function webhookEndpointRoutes(ctx: RouteContext): void {

app.post('/webhook_endpoints', async (c) => {
const body = await parseJsonBody(c);
const url = body.url as string | undefined;
if (!url || typeof url !== 'string') {
throw validationError('URL is required', [{ field: 'url', code: 'required' }]);
const endpointUrl = (body.endpoint_url ?? body.url) as string | undefined;
if (!endpointUrl || typeof endpointUrl !== 'string') {
throw validationError('endpoint_url is required', [{ field: 'endpoint_url', code: 'required' }]);
}

const secret = (body.secret as string) ?? randomBytes(32).toString('hex');

const endpoint = ws.webhookEndpoints.insert({
object: 'webhook_endpoint',
url,
endpoint_url: endpointUrl,
secret,
enabled: body.enabled !== false,
events: Array.isArray(body.events) ? (body.events as string[]) : [],
Expand Down Expand Up @@ -49,11 +49,12 @@ export function webhookEndpointRoutes(ctx: RouteContext): void {
const body = await parseJsonBody(c);
const updates: Record<string, unknown> = {};

if ('url' in body) {
if (!body.url || typeof body.url !== 'string') {
throw validationError('URL is required', [{ field: 'url', code: 'required' }]);
if ('endpoint_url' in body || 'url' in body) {
const newUrl = (body.endpoint_url ?? body.url) as string | undefined;
if (!newUrl || typeof newUrl !== 'string') {
throw validationError('endpoint_url is required', [{ field: 'endpoint_url', code: 'required' }]);
}
updates.url = body.url;
updates.endpoint_url = newUrl;
}
if ('enabled' in body) updates.enabled = !!body.enabled;
if ('events' in body) updates.events = Array.isArray(body.events) ? body.events : [];
Expand Down
2 changes: 1 addition & 1 deletion src/emulate/workos/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ export function getWorkOSStore(store: Store): WorkOSStore {
webhookEndpoints: store.collection<WorkOSWebhookEndpoint>(
'workos.webhook_endpoints',
ID_PREFIXES.webhook_endpoint,
['url'],
['endpoint_url'],
),
};

Expand Down
2 changes: 1 addition & 1 deletion src/lib/workos-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { resolveApiKey, resolveApiBaseUrl } from './api-key.js';

export interface WebhookEndpoint {
id: string;
url: string;
endpoint_url: string;
events: string[];
secret?: string;
created_at: string;
Expand Down
Loading