From c02e1d5d789f7c273f409d13fb9801785f0ff549 Mon Sep 17 00:00:00 2001 From: Karolis Date: Sun, 25 Oct 2026 20:37:27 +0400 Subject: [PATCH 1/2] Partial updates over full-replace PUTs, outbound access, live outbound E2E test - buckets.update: GET + merge editable fields + PUT (backend PUT is a full replace and now 400s without name); workspace_id only when passed - outputs.update, functions.update (payload), serviceConnections.update (name) and updateInput/updateOutput: same merge so omitted fields are kept - outbound.access() / requestAccess() - RequestStatus enum now uses the string values the API returns - test/integration/outbound.integration.test.ts: live end-to-end outbound flow against Webhook Bin with Standard Webhooks signature verification --- README.md | 16 +- src/params.ts | 31 ++ src/resources/buckets.ts | 76 ++- src/resources/functions.ts | 24 +- src/resources/outbound.ts | 31 ++ src/resources/outputs.ts | 38 +- src/resources/serviceConnections.ts | 58 ++- src/types.ts | 60 ++- test/integration/api.integration.test.ts | 18 + test/integration/outbound.integration.test.ts | 461 ++++++++++++++++++ test/outbound.test.ts | 15 + test/resources.test.ts | 25 +- test/updates.test.ts | 229 +++++++++ 13 files changed, 1042 insertions(+), 40 deletions(-) create mode 100644 test/integration/outbound.integration.test.ts create mode 100644 test/updates.test.ts diff --git a/README.md b/README.md index 869b106..30db421 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,15 @@ await relay.buckets.findByName("orders"); await relay.buckets.delete(bucket.id); // delete its inputs/outputs first ``` +`update` is a partial update: fields you leave out keep their current values. +The API's `PUT /v1/buckets/{id}` replaces the whole bucket, so the SDK fetches +the bucket, merges your changes over its editable fields (`name`, +`description`, `stream`, `ephemeral`, `static_ip`, `large_webhooks`, and +`auth` field by field) and sends the result. `workspace_id` is only sent when +you pass it (`null` ungroups the bucket). `outputs.update`, +`functions.update` and the service-connection `update*` methods work the same +way. + ## Inputs Inputs are the public HTTPS endpoints that receive webhooks. @@ -138,6 +147,7 @@ await relay.outputs.setRules(bucket.id, output.id, { }); await relay.outputs.deleteRules(bucket.id, output.id); // forward everything +await relay.outputs.update(bucket.id, output.id, { disabled: true }); // other fields are kept await relay.outputs.delete(bucket.id, output.id); ``` @@ -215,10 +225,12 @@ await relay.serviceConnections.list(); Register each customer as a consumer with the HTTPS endpoint they give you, then publish events. Webhook Relay signs every delivery ([Standard Webhooks](https://www.standardwebhooks.com/)), retries failures durably -for up to 48 hours and keeps the delivery history. Outbound webhooks are in pilot: -the account needs the `outbound` feature. +for up to 48 hours and keeps the delivery history. Check that outbound webhooks +are enabled for your account (and its limits) with `relay.outbound.access()`. ```ts +const { enabled, limits } = await relay.outbound.access(); + // Once per customer. await relay.outbound.eventTypes.upsert("invoice.paid", { description: "An invoice was paid" }); await relay.outbound.consumers.upsert("customer_42", { name: "Acme" }); diff --git a/src/params.ts b/src/params.ts index 90e965e..7e48b91 100644 --- a/src/params.ts +++ b/src/params.ts @@ -1,5 +1,11 @@ type Params = Record; +const bucketAliases: Record = { + staticIp: "static_ip", + largeWebhooks: "large_webhooks", + workspaceId: "workspace_id", +}; + const inputAliases: Record = { functionId: "function_id", statusCode: "status_code", @@ -57,6 +63,10 @@ const outboundMessageAliases: Record = { eventId: "event_id", }; +export function bucketParams(params: T): T { + return applyAliases(params, bucketAliases) as T; +} + export function inputParams(params: T): T { return applyAliases(params, inputAliases) as T; } @@ -85,6 +95,27 @@ export function outboundMessageParams(params: T): T { return applyAliases(params, outboundMessageAliases) as T; } +/** + * Overlay update params on the current state of a resource whose `PUT` + * replaces it whole, so callers can send only the fields they change. + * `undefined` values in `params` keep the current value; keys in `omit` + * (read-only or server-owned fields) are dropped from `current`. + */ +export function mergeForReplace( + current: object, + params: object, + omit: readonly string[] = [], +): Params { + const out: Params = {}; + for (const [key, value] of Object.entries(current)) { + if (value !== undefined && !omit.includes(key)) out[key] = value; + } + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) out[key] = value; + } + return out; +} + function applyAliases(params: object, aliases: Record): Params { const out: Params = {}; for (const [key, value] of Object.entries(params)) { diff --git a/src/resources/buckets.ts b/src/resources/buckets.ts index 09359bd..f0373ef 100644 --- a/src/resources/buckets.ts +++ b/src/resources/buckets.ts @@ -1,10 +1,59 @@ import type { HttpClient } from "../http.js"; +import { bucketParams } from "../params.js"; import type { Bucket, + BucketAuth, CreateBucketParams, UpdateBucketParams, } from "../types.js"; +/** Top-level bucket fields `PUT /v1/buckets/{id}` reads (besides `auth`). */ +const BUCKET_UPDATE_FIELDS = [ + "name", + "description", + "stream", + "ephemeral", + "static_ip", + "large_webhooks", +] as const; + +/** Auth fields `PUT /v1/buckets/{id}` reads. */ +const BUCKET_AUTH_FIELDS = ["type", "username", "password", "token"] as const; + +function pick(source: object | undefined, keys: readonly string[]): Record { + const out: Record = {}; + if (!source) return out; + for (const key of keys) { + const value = (source as Record)[key]; + if (value !== undefined) out[key] = value; + } + return out; +} + +/** + * Build the full-replace body for `PUT /v1/buckets/{id}`: the current bucket's + * editable fields with `params` merged over them. `workspace_id` is only sent + * when the caller gave it, because the API treats a present `workspace_id` + * (even `null`) as a request to move the bucket. + */ +function bucketUpdateBody( + current: Bucket, + params: UpdateBucketParams, +): Record { + const changes = bucketParams(params) as Record; + const body: Record = { + ...pick(current, BUCKET_UPDATE_FIELDS), + ...pick(changes, BUCKET_UPDATE_FIELDS), + }; + const auth = { + ...pick(current.auth, BUCKET_AUTH_FIELDS), + ...pick(changes.auth as BucketAuth | undefined, BUCKET_AUTH_FIELDS), + }; + if (Object.keys(auth).length > 0) body.auth = auth; + if (changes.workspace_id !== undefined) body.workspace_id = changes.workspace_id; + return body; +} + /** * Manage buckets — the top-level containers that receive webhooks and route * them to outputs. @@ -29,7 +78,7 @@ export class BucketsResource { /** Create a bucket. */ create(params: CreateBucketParams): Promise { return this.http.unwrap( - this.http.api.v1.bucketsCreate(params as never), + this.http.api.v1.bucketsCreate(bucketParams(params) as never), "POST", "/v1/buckets", ); @@ -44,10 +93,29 @@ export class BucketsResource { ); } - /** Update a bucket. */ - update(bucketId: string, params: UpdateBucketParams): Promise { + /** + * Update a bucket. Only the fields you pass change; the rest keep their + * current values. + * + * The API's `PUT /v1/buckets/{id}` replaces the whole bucket (and rejects a + * body without `name`), so this fetches the bucket first, merges `params` + * over its editable fields (`name`, `description`, `stream`, `ephemeral`, + * `static_ip`, `large_webhooks`, and `auth` field by field) and sends the + * result. That costs one extra `GET`, and a change made by someone else + * between the two requests is overwritten. `workspace_id` is sent only when + * you pass it. To clear a text field, pass `""`. + * + * ```ts + * await relay.buckets.update(bucket.id, { description: "Production orders" }); + * ``` + */ + async update(bucketId: string, params: UpdateBucketParams): Promise { + const current = await this.get(bucketId); return this.http.unwrap( - this.http.api.v1.bucketsUpdate(encodeURIComponent(bucketId), params as never), + this.http.api.v1.bucketsUpdate( + encodeURIComponent(bucketId), + bucketUpdateBody(current, params) as never, + ), "PUT", "/v1/buckets/{id}", ); diff --git a/src/resources/functions.ts b/src/resources/functions.ts index 68d6b3f..720df91 100644 --- a/src/resources/functions.ts +++ b/src/resources/functions.ts @@ -1,5 +1,5 @@ import type { HttpClient } from "../http.js"; -import { functionParams } from "../params.js"; +import { functionParams, mergeForReplace } from "../params.js"; import type { CreateFunctionParams, FunctionConfigVariable, @@ -60,19 +60,27 @@ export class FunctionsResource { ); } - /** Update a function's name or source. */ + /** + * Update a function's name or source. Omitted fields keep their current + * values: the API's `PUT /v1/functions/{id}` replaces the name, driver and + * source together (a missing `payload` would empty the code), so unless all + * three are given the function is fetched first and merged. + */ async update( functionId: string, params: UpdateFunctionParams, ): Promise { let body = params; - if (params.name === undefined || params.driver === undefined) { + if ( + params.name === undefined || + params.driver === undefined || + params.payload === undefined + ) { const current = await this.get(functionId); - body = { - name: current.name, - driver: current.driver, - ...params, - }; + body = mergeForReplace( + { name: current.name, driver: current.driver, payload: current.payload }, + params, + ) as UpdateFunctionParams; } await this.http.unwrap( diff --git a/src/resources/outbound.ts b/src/resources/outbound.ts index c031a44..71c8444 100644 --- a/src/resources/outbound.ts +++ b/src/resources/outbound.ts @@ -4,6 +4,7 @@ import type { ListAllOutboundEndpointsParams, ListOutboundDeliveriesParams, ListOutboundMessagesParams, + OutboundAccess, OutboundConsumer, OutboundDelivery, OutboundEndpoint, @@ -346,6 +347,36 @@ export class OutboundResource { this.recoveryTasks = new OutboundRecoveryTasksResource(http); } + /** + * Report whether outbound webhooks are enabled for the account, with its + * limits (consumers, endpoints per consumer, event types). Works even when + * they are not enabled, unlike the other outbound methods. + * + * ```ts + * const { enabled, limits } = await relay.outbound.access(); + * ``` + */ + access(): Promise { + return this.http.unwrap( + this.http.api.v1.outboundAccessList(), + "GET", + "/v1/outbound/access", + ); + } + + /** + * Ask support to enable outbound webhooks by opening a support ticket. + * Asking again while that ticket is open returns it; nothing is opened when + * outbound webhooks are already enabled. + */ + requestAccess(): Promise { + return this.http.unwrap( + this.http.api.v1.outboundAccessRequestCreate(), + "POST", + "/v1/outbound/access/request", + ); + } + /** * Count your endpoints by state and their delivery attempts and failures * over the last 24 hours, to the hour. Attempts are counted within about diff --git a/src/resources/outputs.ts b/src/resources/outputs.ts index a8deb8e..7867ee1 100644 --- a/src/resources/outputs.ts +++ b/src/resources/outputs.ts @@ -1,5 +1,6 @@ import type { HttpClient } from "../http.js"; -import { outputParams } from "../params.js"; +import { WebhookRelayAPIError } from "../errors.js"; +import { mergeForReplace, outputParams } from "../params.js"; import type { CreateOutputParams, Output, @@ -7,6 +8,17 @@ import type { UpdateOutputParams, } from "../types.js"; +/** Server-owned output fields left out of the replace body (the API keeps them). */ +const OUTPUT_READ_ONLY = [ + "id", + "bucket_id", + "rules", + "cron_id", + "created_at", + "updated_at", + "created_by", +] as const; + /** * Manage a bucket's outputs — the destinations webhooks are forwarded to. * Control forwarding with {@link OutputsResource.setRules | forward rules} and @@ -37,17 +49,35 @@ export class OutputsResource { ); } - /** Update an output. */ - update( + /** + * Update an output. Only the fields you pass change; the rest keep their + * current values. + * + * The API's `PUT /v1/buckets/{id}/outputs/{outputId}` replaces the whole + * output (a missing `destination` or `function_id` would be cleared), so + * this reads the output from its bucket first and merges `params` over it. + * Forward rules are untouched; manage them with {@link setRules}. + */ + async update( bucketId: string, outputId: string, params: UpdateOutputParams, ): Promise { + const current = (await this.list(bucketId)).find((o) => o.id === outputId); + if (!current) { + throw new WebhookRelayAPIError({ + status: 404, + message: `output '${outputId}' not found in bucket '${bucketId}'`, + body: undefined, + method: "PUT", + path: "/v1/buckets/{id}/outputs/{outputId}", + }); + } return this.http.unwrap( this.http.api.v1.bucketsOutputsUpdate( encodeURIComponent(bucketId), encodeURIComponent(outputId), - outputParams(params), + mergeForReplace(current, outputParams(params), OUTPUT_READ_ONLY) as never, ), "PUT", "/v1/buckets/{id}/outputs/{outputId}", diff --git a/src/resources/serviceConnections.ts b/src/resources/serviceConnections.ts index c3eae89..634fd9c 100644 --- a/src/resources/serviceConnections.ts +++ b/src/resources/serviceConnections.ts @@ -1,6 +1,8 @@ import type { HttpClient } from "../http.js"; +import { WebhookRelayAPIError } from "../errors.js"; import { managedServiceParams, + mergeForReplace, serviceConnectionParams, } from "../params.js"; import type { @@ -11,6 +13,26 @@ import type { UpdateServiceConnectionParams, } from "../types.js"; +/** Server-owned fields left out of managed input/output replace bodies. */ +const MANAGED_READ_ONLY = [ + "id", + "bucket_id", + "status", + "error", + "created_at", + "updated_at", +] as const; + +function notFound(kind: string, id: string, bucketId: string): WebhookRelayAPIError { + return new WebhookRelayAPIError({ + status: 404, + method: "PUT", + path: `/v1/buckets/{id}/${kind.replace(/ /g, "-")}s/{detailed}`, + body: undefined, + message: `${kind} '${id}' not found in bucket '${bucketId}'`, + }); +} + /** * Manage service connections — credentials for managed cloud integrations * (AWS, GCP, Azure) plus the per-bucket managed inputs/outputs that use them @@ -62,15 +84,21 @@ export class ServiceConnectionsResource { ); } - /** Update a service connection. */ - update( + /** + * Update a service connection. Credential fields you omit are kept. The API + * always replaces the name (and rejects an empty one), so when `name` is + * omitted the current name is fetched and sent. + */ + async update( id: string, params: UpdateServiceConnectionParams, ): Promise { + const body = serviceConnectionParams(params); + if (body.name === undefined) body.name = (await this.get(id)).name; return this.http.unwrap( this.http.api.v1.serviceConnectionsUpdate( encodeURIComponent(id), - serviceConnectionParams(params) as never, + body as never, ), "PUT", "/v1/service-connections/{id}", @@ -114,17 +142,23 @@ export class ServiceConnectionsResource { ); } - /** Update a service-connection input. */ - updateInput( + /** + * Update a service-connection input. Only the fields you pass change: the + * API's `PUT` replaces the whole input, so the current one is read from + * {@link listInputs} first and `params` are merged over it. + */ + async updateInput( bucketId: string, inputId: string, params: Partial, ): Promise { + const current = (await this.listInputs(bucketId)).find((i) => i.id === inputId); + if (!current) throw notFound("service-connection input", inputId, bucketId); return this.http.unwrap( this.http.api.v1.bucketsServiceConnectionInputsUpdate( encodeURIComponent(bucketId), encodeURIComponent(inputId), - managedServiceParams(params) as never, + mergeForReplace(current, managedServiceParams(params), MANAGED_READ_ONLY) as never, ), "PUT", "/v1/buckets/{id}/service-connection-inputs/{inputId}", @@ -173,17 +207,23 @@ export class ServiceConnectionsResource { ); } - /** Update a service-connection output. */ - updateOutput( + /** + * Update a service-connection output. Only the fields you pass change: the + * API's `PUT` replaces the whole output, so the current one is read from + * {@link listOutputs} first and `params` are merged over it. + */ + async updateOutput( bucketId: string, outputId: string, params: Partial, ): Promise { + const current = (await this.listOutputs(bucketId)).find((o) => o.id === outputId); + if (!current) throw notFound("service-connection output", outputId, bucketId); return this.http.unwrap( this.http.api.v1.bucketsServiceConnectionOutputsUpdate( encodeURIComponent(bucketId), encodeURIComponent(outputId), - managedServiceParams(params) as never, + mergeForReplace(current, managedServiceParams(params), MANAGED_READ_ONLY) as never, ), "PUT", "/v1/buckets/{id}/service-connection-outputs/{outputId}", diff --git a/src/types.ts b/src/types.ts index b66ed6b..eddb908 100644 --- a/src/types.ts +++ b/src/types.ts @@ -40,6 +40,8 @@ export interface Bucket { static_ip?: boolean; large_webhooks?: boolean; cron_id?: string; + /** Dashboard workspace the bucket is grouped under; null when ungrouped. */ + workspace_id?: string | null; auth?: BucketAuth; inputs?: Input[]; outputs?: Output[]; @@ -54,12 +56,28 @@ export interface CreateBucketParams { description?: string; ephemeral?: boolean; stream?: boolean; + /** CamelCase alias for `static_ip`. */ + staticIp?: boolean; static_ip?: boolean; + /** CamelCase alias for `large_webhooks`. */ + largeWebhooks?: boolean; large_webhooks?: boolean; auth?: BucketAuth; } -export type UpdateBucketParams = Partial; +/** + * Fields to change on a bucket. Omitted fields keep their current value + * (see {@link import("./resources/buckets.js").BucketsResource.update}). + */ +export interface UpdateBucketParams extends Partial { + /** + * Move the bucket to a dashboard workspace; `null` or `""` ungroups it. + * Left unchanged unless given. + */ + workspace_id?: string | null; + /** CamelCase alias for `workspace_id`. */ + workspaceId?: string | null; +} // --------------------------------------------------------------------------- // Inputs (public endpoints that receive webhooks) @@ -384,16 +402,23 @@ export interface ServiceConnectionOutput { // Webhook logs // --------------------------------------------------------------------------- -/** Delivery status of a webhook log entry. */ +/** + * Delivery status of a webhook log entry. The API sends it as a lowercase + * string (e.g. `"sent"`), so compare with either `RequestStatus.Sent` or + * `"sent"`. + */ export enum RequestStatus { - Preparing = 0, - Sent = 1, - Failed = 2, - Stalled = 3, - Received = 4, - Rejected = 5, + Preparing = "preparing", + Sent = "sent", + Failed = "failed", + Stalled = "stalled", + Received = "received", + Rejected = "rejected", } +/** {@link RequestStatus} as the plain string the API returns. */ +export type RequestStatusName = `${RequestStatus}`; + export interface WebhookLog { id: string; account_id?: string; @@ -401,7 +426,7 @@ export interface WebhookLog { input_id?: string; output_id?: string; method?: string; - status?: RequestStatus; + status?: RequestStatus | RequestStatusName; status_code?: number; body?: string; headers?: Headers; @@ -449,6 +474,23 @@ export interface WebhookLogsPage { // Outbound webhooks (pilot): send signed webhooks to your own customers // --------------------------------------------------------------------------- +/** Whether outbound webhooks are enabled for the account, and its limits. */ +export interface OutboundAccess { + enabled: boolean; + /** The open request to enable outbound webhooks; absent when none was made. */ + request?: { + /** The support ticket; replies arrive there. */ + ticket_id: string; + requested_at?: string; + }; + /** The account's effective outbound limits; absent when they could not be read. */ + limits?: { + consumers: number; + endpoints_per_consumer: number; + event_types: number; + }; +} + /** One of your customers. You choose its ID, for example your own customer ID. */ export interface OutboundConsumer { id: string; diff --git a/test/integration/api.integration.test.ts b/test/integration/api.integration.test.ts index e1e01a2..a01cd82 100644 --- a/test/integration/api.integration.test.ts +++ b/test/integration/api.integration.test.ts @@ -154,6 +154,11 @@ describeLive("Webhook Relay live API", () => { description: "Updated by webhookrelay-js live integration tests", }); expect(updated.id).toBe(bucket.id); + // Partial update: the PUT is a full replace, so name/stream must survive. + const refetched = await relay.buckets.get(bucket.id); + expect(refetched.description).toBe("Updated by webhookrelay-js live integration tests"); + expect(refetched.name).toBe(bucket.name); + expect(refetched.stream).toBe(true); const input = await relay.inputs.create(bucket.id, { name: "public", @@ -183,6 +188,13 @@ describeLive("Webhook Relay live API", () => { expect(ruled.id).toBe(output.id); await relay.outputs.deleteRules(bucket.id, output.id); + // Partial output update keeps the destination and name. + await relay.outputs.update(bucket.id, output.id, { description: "partial" }); + const outputAfter = (await relay.outputs.list(bucket.id)).find((o) => o.id === output.id); + expect(outputAfter?.description).toBe("partial"); + expect(outputAfter?.name).toBe("example"); + expect(outputAfter?.destination).toContain("example.com/webhookrelay-js"); + expect(await relay.outputs.list(bucket.id)).toEqual( expect.arrayContaining([expect.objectContaining({ id: output.id })]), ); @@ -216,6 +228,12 @@ describeLive("Webhook Relay live API", () => { }); expect(updated.id).toBe(fn.id); + // Renaming alone must not blank the source (the PUT replaces it). + const renamedTo = bucketName("fn-renamed"); + const renamed = await relay.functions.update(fn.id, { name: renamedTo }); + expect(renamed.name).toBe(renamedTo); + expect(renamed.payload).toContain("r.Headers = r.Headers || {}"); + const variable = await relay.functions.setConfig(fn.id, "SDK_TEST", "1"); expect(variable.key).toBe("SDK_TEST"); expect(await relay.functions.listConfig(fn.id)).toEqual( diff --git a/test/integration/outbound.integration.test.ts b/test/integration/outbound.integration.test.ts new file mode 100644 index 0000000..ff6e97a --- /dev/null +++ b/test/integration/outbound.integration.test.ts @@ -0,0 +1,461 @@ +import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + WebhookRelay, + WebhookRelayAPIError, + type OutboundDelivery, + type OutboundEndpointWithSecret, + type OutboundMessage, + type OutboundRecoveryTask, +} from "../../src/index.js"; + +// Live end-to-end test of outbound webhooks against my.webhookrelay.com. +// Deliveries go to free Webhook Bins (https://webhookrelay.com/webhook-bin.md), +// whose public API lets the test read back exactly what was delivered. +// +// Secrets and the API key are never printed: assertions on secrets compare +// booleans so a failure message cannot leak them. + +const hasCredentials = + Boolean(process.env.RELAY_API_KEY) || + (Boolean(process.env.RELAY_KEY) && Boolean(process.env.RELAY_SECRET)); + +const describeLive = hasCredentials ? describe : describe.skip; + +const relay = hasCredentials + ? new WebhookRelay({ timeoutMs: 15_000 }) + : (undefined as unknown as WebhookRelay); + +const BIN_API = "https://bin.webhookrelay.com"; + +const random = () => Math.random().toString(36).slice(2, 10); +const runId = `${Date.now()}-${random()}`; +const consumerId = `whr-js-sdk-${runId}`; +const eventType = `sdk.test.${random()}`; + +// Resources to clean up, even when a test fails half-way. +const createdEndpointIds = new Set(); +let consumerCreated = false; +let eventTypeCreated = false; + +// --- Webhook Bin ------------------------------------------------------------- + +interface Bin { + id: string; + /** Public HTTPS capture URL. */ + url: string; +} + +interface BinRequest { + id: string; + receivedAt: number; + method: string; + query?: string; + header?: Record; + body?: string; +} + +async function createBin(): Promise { + const response = await fetch(`${BIN_API}/v1/bins`, { method: "POST" }); + expect(response.status, "Webhook Bin create").toBe(201); + const { id } = (await response.json()) as { id: string }; + expect(id).toBeTruthy(); + return { id, url: `${BIN_API}/v1/webhooks/${id}` }; +} + +async function binRequests(bin: Bin): Promise { + const response = await fetch(`${BIN_API}/v1/bins/${bin.id}`); + expect(response.status, "Webhook Bin read").toBe(200); + const data = (await response.json()) as { requests?: BinRequest[] | null }; + return data.requests ?? []; +} + +function binHeader(request: BinRequest, name: string): string | undefined { + const lower = name.toLowerCase(); + for (const [key, value] of Object.entries(request.header ?? {})) { + if (key.toLowerCase() === lower || value.key?.toLowerCase() === lower) { + return value.values?.[0]; + } + } + return undefined; +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** Poll `fn` until it returns a value (not undefined) or `timeoutMs` passes. */ +async function waitFor( + what: string, + fn: () => Promise, + { timeoutMs = 60_000, intervalMs = 2_000 } = {}, +): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + const value = await fn(); + if (value !== undefined) return value; + if (Date.now() > deadline) throw new Error(`Timed out after ${timeoutMs / 1000}s waiting for ${what}`); + await sleep(intervalMs); + } +} + +/** Wait until the bin has at least `count` deliveries of `messageId`. */ +function waitForDeliveries(bin: Bin, messageId: string, count = 1): Promise { + return waitFor(`${count} delivery(ies) of message ${messageId} at the bin`, async () => { + const matching = (await binRequests(bin)).filter( + (r) => binHeader(r, "webhook-id") === messageId, + ); + return matching.length >= count ? matching : undefined; + }); +} + +// --- Standard Webhooks -------------------------------------------------------- + +/** + * Verify a Standard Webhooks signature: HMAC-SHA256 over + * `${id}.${timestamp}.${body}` keyed with the base64-decoded secret (after the + * `whsec_` prefix), compared against any `v1,` entry of the + * space-separated signature header. + */ +function verifySignature( + secret: string, + id: string, + timestamp: string, + body: string, + signatureHeader: string, +): boolean { + const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64"); + const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${body}`).digest(); + return signatureHeader.split(" ").some((entry) => { + const [version, signature] = entry.split(",", 2); + if (version !== "v1" || !signature) return false; + const actual = Buffer.from(signature, "base64"); + return actual.length === expected.length && timingSafeEqual(actual, expected); + }); +} + +function expectSignedDelivery( + request: BinRequest, + messageId: string, + secret: string, +): { id: string; timestamp: string; signature: string; body: string } { + const id = binHeader(request, "webhook-id"); + const timestamp = binHeader(request, "webhook-timestamp"); + const signature = binHeader(request, "webhook-signature"); + const body = request.body ?? ""; + + expect(request.method).toBe("POST"); + expect(id).toBe(messageId); + expect(timestamp).toMatch(/^\d+$/); + // The timestamp is the attempt's, so it must be recent. + expect(Math.abs(Date.now() / 1000 - Number(timestamp))).toBeLessThan(300); + expect(signature).toMatch(/^v1,[A-Za-z0-9+/=]+( v1,[A-Za-z0-9+/=]+)*$/); + expect( + verifySignature(secret, id!, timestamp!, body, signature!), + "webhook-signature must verify with the endpoint's signing secret", + ).toBe(true); + return { id: id!, timestamp: timestamp!, signature: signature!, body }; +} + +function isWhsec(secret: unknown): boolean { + if (typeof secret !== "string" || !secret.startsWith("whsec_")) return false; + return Buffer.from(secret.slice("whsec_".length), "base64").length >= 24; +} + +async function ignoreNotFound(fn: () => Promise): Promise { + try { + await fn(); + } catch (err) { + if (!(err instanceof WebhookRelayAPIError && err.status === 404)) throw err; + } +} + +// --- State shared by the ordered tests below ----------------------------------- + +let bin1: Bin; +let bin2: Bin; +let endpoint1: OutboundEndpointWithSecret | undefined; +let endpoint2: OutboundEndpointWithSecret | undefined; +let importedSecret: string | undefined; +let message1: OutboundMessage | undefined; +const payload1 = { + marker: `whr-js-sdk-${runId}`, + invoice: { id: "inv_123", amount: 4900, currency: "usd", paid: true }, + lines: [{ sku: "a", qty: 1 }, { sku: "b", qty: 2 }], + note: null, +}; + +afterAll(async () => { + if (!hasCredentials) return; + // Endpoints first, then the consumer, then the event type (which cannot be + // deleted while an endpoint subscribes to it). Keep going on failure. + const errors: string[] = []; + const attempt = async (what: string, fn: () => Promise) => { + try { + await ignoreNotFound(fn); + } catch (err) { + errors.push(`${what}: ${String(err)}`); + } + }; + for (const id of createdEndpointIds) { + await attempt(`delete endpoint ${id}`, () => relay.outbound.endpoints.delete(id)); + } + if (consumerCreated) { + await attempt(`delete consumer ${consumerId}`, () => relay.outbound.consumers.delete(consumerId)); + } + if (eventTypeCreated) { + await attempt(`delete event type ${eventType}`, () => relay.outbound.eventTypes.delete(eventType)); + } + if (errors.length > 0) throw new Error(`outbound cleanup failed: ${errors.join("; ")}`); +}, 60_000); + +describeLive("Webhook Relay live API: outbound webhooks", () => { + beforeAll(async () => { + const access = await relay.outbound.access(); + if (!access.enabled) { + throw new Error("outbound not enabled for the CI account (needs Basic+ plan)"); + } + expect(access.limits?.consumers ?? 1).toBeGreaterThan(0); + + [bin1, bin2] = await Promise.all([createBin(), createBin()]); + + const et = await relay.outbound.eventTypes.upsert(eventType, { + description: "Created by webhookrelay-js live integration tests", + example: payload1, + }); + eventTypeCreated = true; + expect(et.name).toBe(eventType); + expect(et.deprecated ?? false).toBe(false); + + const consumer = await relay.outbound.consumers.upsert(consumerId, { + name: "webhookrelay-js SDK live test", + }); + consumerCreated = true; + expect(consumer.id).toBe(consumerId); + expect(consumer.name).toBe("webhookrelay-js SDK live test"); + }, 60_000); + + it("creates an endpoint with a generated whsec_ signing secret", async () => { + endpoint1 = await relay.outbound.endpoints.create(consumerId, { + url: bin1.url, + eventTypes: [eventType], + description: "webhookrelay-js live test endpoint 1", + headers: { "X-WHR-JS-SDK-Test": "1" }, + }); + createdEndpointIds.add(endpoint1.id); + + expect(isWhsec(endpoint1.secret), "endpoint secret must be whsec_ + base64 (>= 24 bytes)").toBe(true); + expect(endpoint1.consumer).toBe(consumerId); + expect(endpoint1.url).toBe(bin1.url); + expect(endpoint1.event_types).toEqual([eventType]); + expect(endpoint1.state).toBe("active"); + + const fetched = await relay.outbound.endpoints.get(endpoint1.id); + expect(fetched.id).toBe(endpoint1.id); + expect(fetched).not.toHaveProperty("secret"); + + const listed = await relay.outbound.endpoints.list(consumerId); + expect(listed.map((e) => e.id)).toContain(endpoint1.id); + + const revealed = await relay.outbound.endpoints.revealSecret(endpoint1.id); + expect(revealed === endpoint1.secret, "revealSecret must return the creation secret").toBe(true); + }); + + it("publishes idempotently and delivers a signed request to the endpoint", async () => { + expect(endpoint1, "endpoint 1 was not created").toBeDefined(); + const idempotencyKey = `whr-js-sdk-${runId}-1`; + const params = { + consumer: consumerId, + eventType, + eventId: `evt-${runId}`, + payload: payload1, + }; + + message1 = await relay.outbound.messages.publish(params, { idempotencyKey }); + expect(message1.id).toBeTruthy(); + expect(message1.consumer).toBe(consumerId); + expect(message1.event_type).toBe(eventType); + expect(message1.event_id).toBe(`evt-${runId}`); + expect(message1.endpoint_ids).toEqual([endpoint1!.id]); + + // Same key + same message → the original receipt, not a second message. + const repeat = await relay.outbound.messages.publish(params, { idempotencyKey }); + expect(repeat.id).toBe(message1.id); + + // Same key + different message → conflict. + const conflict = await relay.outbound.messages + .publish({ ...params, payload: { different: true } }, { idempotencyKey }) + .catch((err: unknown) => err); + expect(conflict).toBeInstanceOf(WebhookRelayAPIError); + expect((conflict as WebhookRelayAPIError).status).toBe(409); + + const [delivery] = await waitForDeliveries(bin1, message1.id); + const { body } = expectSignedDelivery(delivery!, message1.id, endpoint1!.secret); + expect(JSON.parse(body)).toEqual(payload1); + expect(binHeader(delivery!, "content-type")).toContain("application/json"); + expect(binHeader(delivery!, "webhook-event-type")).toBe(eventType); + expect(binHeader(delivery!, "webhook-consumer")).toBe(consumerId); + expect(binHeader(delivery!, "x-whr-js-sdk-test")).toBe("1"); + + // Exactly one delivery: the idempotent repeat did not publish again. + await sleep(3_000); + const all = (await binRequests(bin1)).filter((r) => binHeader(r, "webhook-id") === message1!.id); + expect(all).toHaveLength(1); + }, 120_000); + + it("reports the delivery as sent on the message and in the endpoint's deliveries", async () => { + expect(message1, "message 1 was not published").toBeDefined(); + const endpointId = endpoint1!.id; + + const sent = await waitFor("message 1 delivery status 'sent'", async () => { + const message = await relay.outbound.messages.get(message1!.id); + expect(message.payload).toEqual(payload1); + const delivery = message.deliveries?.find((d) => d.output_id === endpointId); + return delivery?.status === "sent" ? delivery : undefined; + }); + expect(sent.status_code).toBe(200); + expect(sent.message_id ?? message1!.id).toBe(message1!.id); + + const filtered = await waitFor("filtered deliveries list", async () => { + const list = await relay.outbound.endpoints.deliveries(endpointId, { + status: "sent", + event_type: eventType, + message_id: message1!.id, + limit: 10, + }); + return list.length > 0 ? list : undefined; + }, { timeoutMs: 30_000 }); + expect(filtered).toHaveLength(1); + expect(filtered[0]!.status).toBe("sent"); + expect(filtered[0]!.event_type ?? eventType).toBe(eventType); + expect(filtered[0]!.message_id ?? message1!.id).toBe(message1!.id); + + // Filters exclude: no failed delivery for this message, nothing for another event type. + expect( + await relay.outbound.endpoints.deliveries(endpointId, { status: "failed", message_id: message1!.id }), + ).toEqual([]); + expect( + await relay.outbound.endpoints.deliveries(endpointId, { event_type: `${eventType}.other` }), + ).toEqual([]); + + const messages = await relay.outbound.messages.list({ consumer: consumerId, eventType, limit: 10 }); + expect(messages.map((m) => m.id)).toContain(message1!.id); + }, 120_000); + + it("imports an existing whsec_ secret and signs deliveries with it", async () => { + expect(endpoint1, "endpoint 1 was not created").toBeDefined(); + importedSecret = `whsec_${randomBytes(32).toString("base64")}`; + + endpoint2 = await relay.outbound.endpoints.create(consumerId, { + url: bin2.url, + eventTypes: [eventType], + description: "webhookrelay-js live test endpoint 2 (imported secret)", + secret: importedSecret, + }); + createdEndpointIds.add(endpoint2.id); + expect(endpoint2.secret === importedSecret, "create must return the imported secret").toBe(true); + expect( + (await relay.outbound.endpoints.revealSecret(endpoint2.id)) === importedSecret, + "revealSecret must return the imported secret", + ).toBe(true); + + const message2 = await relay.outbound.messages.publish( + { consumer: consumerId, eventType, payload: { marker: `${payload1.marker}-2` } }, + { idempotencyKey: `whr-js-sdk-${runId}-2` }, + ); + expect([...message2.endpoint_ids].sort()).toEqual([endpoint1!.id, endpoint2.id].sort()); + + const [delivery] = await waitForDeliveries(bin2, message2.id); + const signed = expectSignedDelivery(delivery!, message2.id, importedSecret); + expect(JSON.parse(signed.body)).toEqual({ marker: `${payload1.marker}-2` }); + expect( + verifySignature(endpoint1!.secret, signed.id, signed.timestamp, signed.body, signed.signature), + "endpoint 2's delivery must not verify with endpoint 1's secret", + ).toBe(false); + }, 120_000); + + it("rejects an invalid imported secret with 400 without echoing it", async () => { + const invalid = "whsec_short"; + const err = await relay.outbound.endpoints + .create(consumerId, { + url: `${bin2.url}?invalid-secret=1`, + eventTypes: [eventType], + secret: invalid, + }) + .then((created) => { + createdEndpointIds.add(created.id); + return created; + }) + .catch((e: unknown) => e); + + expect(err).toBeInstanceOf(WebhookRelayAPIError); + const apiErr = err as WebhookRelayAPIError; + expect(apiErr.status).toBe(400); + expect(JSON.stringify(apiErr.body ?? "")).not.toContain(invalid); + expect(apiErr.message).not.toContain(invalid); + }); + + it("reports outbound health and lists endpoints by state", async () => { + const health = await relay.outbound.health(); + for (const state of ["active", "failing", "paused", "disabled"] as const) { + expect(typeof health.endpoints[state], `health.endpoints.${state}`).toBe("number"); + } + expect(health.endpoints.active).toBeGreaterThanOrEqual(endpoint2 ? 2 : 1); + expect(typeof health.stats.attempts).toBe("number"); + expect(typeof health.stats.failures).toBe("number"); + expect(health.stats.failures).toBeLessThanOrEqual(health.stats.attempts); + expect(Number.isNaN(Date.parse(health.since))).toBe(false); + expect(Date.now() - Date.parse(health.since)).toBeLessThanOrEqual(25 * 3600 * 1000); + + const failing = await relay.outbound.endpoints.listAll({ state: "failing", limit: 20 }); + expect(Array.isArray(failing)).toBe(true); + for (const e of failing) { + expect(e.state).toBe("failing"); + expect(e).not.toHaveProperty("secret"); + } + + const ours = await relay.outbound.endpoints.listAll({ consumer: consumerId }); + const ids = ours.map((e) => e.id); + expect(ids).toContain(endpoint1!.id); + if (endpoint2) expect(ids).toContain(endpoint2.id); + for (const e of ours) { + expect(e.consumer).toBe(consumerId); + expect(typeof e.stats?.attempts).toBe("number"); + expect(typeof e.stats?.failures).toBe("number"); + } + }); + + it("retries a delivery through a recovery task", async () => { + expect(message1, "message 1 was not published").toBeDefined(); + const task = await relay.outbound.endpoints.retry(endpoint1!.id, message1!.id); + expect(task.id).toBeTruthy(); + expect(task.endpoint_id).toBe(endpoint1!.id); + expect(task.message_id).toBe(message1!.id); + expect(task.kind).toBe("retry"); + expect(["pending", "running", "completed"]).toContain(task.status); + expect(typeof task.processed).toBe("number"); + + const done = await waitFor("retry recovery task to finish", async () => { + const current = await relay.outbound.recoveryTasks.get(task.id); + expect(current.id).toBe(task.id); + return current.status === "completed" || current.status === "failed" ? current : undefined; + }); + expect(done.status, done.error ?? "").toBe("completed"); + + // The retry re-sent message 1: a second signed delivery with the same webhook-id. + const deliveries = await waitForDeliveries(bin1, message1!.id, 2); + for (const d of deliveries) expectSignedDelivery(d, message1!.id, endpoint1!.secret); + }, 120_000); + + it("rotates the signing secret", async () => { + expect(endpoint1, "endpoint 1 was not created").toBeDefined(); + const rotated = await relay.outbound.endpoints.rotateSecret(endpoint1!.id); + expect(isWhsec(rotated), "rotated secret must be whsec_ + base64").toBe(true); + expect(rotated !== endpoint1!.secret, "rotation must produce a new secret").toBe(true); + + const revealed = await relay.outbound.endpoints.revealSecret(endpoint1!.id); + expect(revealed === rotated, "revealSecret must return the rotated secret").toBe(true); + + const after = await relay.outbound.endpoints.get(endpoint1!.id); + expect(after.previous_secret_expires_at).toBeTruthy(); + expect(Date.parse(after.previous_secret_expires_at!)).toBeGreaterThan(Date.now()); + }); +}); diff --git a/test/outbound.test.ts b/test/outbound.test.ts index e62fd11..96236d5 100644 --- a/test/outbound.test.ts +++ b/test/outbound.test.ts @@ -169,6 +169,21 @@ describe("outbound", () => { expect(listed[0]?.stats?.failures).toBe(7); }); + it("reads and requests outbound access", async () => { + const body = { + enabled: true, + limits: { consumers: 100, endpoints_per_consumer: 10, event_types: 300 }, + }; + const { relay, calls } = createClient(() => ({ json: body })); + + const access = await relay.outbound.access(); + await relay.outbound.requestAccess(); + + expect(requests(calls)).toEqual(["GET /v1/outbound/access", "POST /v1/outbound/access/request"]); + expect(access.enabled).toBe(true); + expect(access.limits?.endpoints_per_consumer).toBe(10); + }); + it("reads outbound health", async () => { const body = { endpoints: { active: 3, failing: 1, paused: 0, disabled: 2 }, diff --git a/test/resources.test.ts b/test/resources.test.ts index c486de6..0ba53a7 100644 --- a/test/resources.test.ts +++ b/test/resources.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { RequestStatus } from "../src/index.js"; import { createClient } from "./helpers.js"; const base = "https://my.webhookrelay.com"; @@ -23,6 +24,7 @@ describe("buckets", () => { "GET /v1/buckets", "POST /v1/buckets", "GET /v1/buckets/b1", + "GET /v1/buckets/b1", "PUT /v1/buckets/b1", "DELETE /v1/buckets/b1", ]); @@ -151,6 +153,12 @@ describe("webhooks (logs)", () => { expect(calls[0].url).toContain("status=sent"); }); + it("RequestStatus matches the string statuses the API returns", () => { + expect(RequestStatus.Sent).toBe("sent"); + expect(RequestStatus.Preparing).toBe("preparing"); + expect(RequestStatus.Rejected).toBe("rejected"); + }); + it("gets and updates a single log", async () => { const { relay, calls } = createClient(() => ({ json: { id: "l1" } })); await relay.webhooks.get("l1"); @@ -205,13 +213,20 @@ describe("remaining resource methods", () => { ); await relay.outputs.update("b1", "o1", { disabled: true }); expect(await relay.outputs.list("b1")).toHaveLength(1); - expect(calls[0].method).toBe("PUT"); - expect(calls[0].url).toContain("/v1/buckets/b1/outputs/o1"); - expect(calls[0].body).toEqual({ disabled: true }); + expect(calls[0].method).toBe("GET"); + expect(calls[1].method).toBe("PUT"); + expect(calls[1].url).toContain("/v1/buckets/b1/outputs/o1"); + expect(calls[1].body).toEqual({ disabled: true }); }); it("service connections: full CRUD + managed inputs/outputs", async () => { - const { relay, calls } = createClient(() => ({ json: { id: "sc1" } })); + const { relay, calls } = createClient((url, method) => { + if (method === "GET" && url.endsWith("/service-connection-inputs")) + return { json: [{ id: "i1" }] }; + if (method === "GET" && url.endsWith("/service-connection-outputs")) + return { json: [{ id: "o1" }] }; + return { json: { id: "sc1" } }; + }); await relay.serviceConnections.get("sc1"); await relay.serviceConnections.update("sc1", { name: "renamed" }); await relay.serviceConnections.delete("sc1"); @@ -228,8 +243,10 @@ describe("remaining resource methods", () => { "DELETE /v1/service-connections/sc1", "GET /v1/buckets/b1/service-connection-inputs", "POST /v1/buckets/b1/service-connection-inputs", + "GET /v1/buckets/b1/service-connection-inputs", "PUT /v1/buckets/b1/service-connection-inputs/i1", "DELETE /v1/buckets/b1/service-connection-inputs/i1", + "GET /v1/buckets/b1/service-connection-outputs", "PUT /v1/buckets/b1/service-connection-outputs/o1", "DELETE /v1/buckets/b1/service-connection-outputs/o1", ]); diff --git a/test/updates.test.ts b/test/updates.test.ts new file mode 100644 index 0000000..0623fb5 --- /dev/null +++ b/test/updates.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, it } from "vitest"; +import { WebhookRelayAPIError } from "../src/index.js"; +import { createClient } from "./helpers.js"; + +const base = "https://my.webhookrelay.com"; + +const storedBucket = { + id: "b1", + name: "orders", + description: "old", + account_id: "acc", + stream: true, + ephemeral: false, + static_ip: false, + large_webhooks: false, + suspended: false, + workspace_id: "ws1", + cron_id: null, + auth: { id: "a1", type: "token", token: "t0k3n", created_at: 1, updated_at: 1 }, + inputs: [{ id: "in1" }], + outputs: [{ id: "out1" }], + service_connection_inputs: [], + service_connection_outputs: [], + created_at: 1, + updated_at: 2, +}; + +function bucketClient(stored: object = storedBucket) { + return createClient((url, method, call) => { + if (method === "PUT") return { json: { ...stored, ...(call.body as object) } }; + return { json: stored }; + }); +} + +describe("buckets.update (partial over a full-replace PUT)", () => { + it("fetches the bucket and sends its editable fields with the change", async () => { + const { relay, calls } = bucketClient(); + const updated = await relay.buckets.update("b1", { description: "new" }); + + expect(calls.map((c) => `${c.method} ${c.url.replace(base, "")}`)).toEqual([ + "GET /v1/buckets/b1", + "PUT /v1/buckets/b1", + ]); + expect(calls[1]!.body).toEqual({ + name: "orders", + description: "new", + stream: true, + ephemeral: false, + static_ip: false, + large_webhooks: false, + auth: { type: "token", token: "t0k3n" }, + }); + expect(updated.description).toBe("new"); + }); + + it("never sends workspace_id unless the caller passed it", async () => { + const { relay, calls } = bucketClient(); + await relay.buckets.update("b1", { name: "renamed" }); + const body = calls[1]!.body as Record; + expect(body).not.toHaveProperty("workspace_id"); + expect(body.name).toBe("renamed"); + // Read-only and nested fields from the GET are not echoed back. + for (const key of ["id", "account_id", "suspended", "cron_id", "inputs", "outputs", "created_at"]) { + expect(body).not.toHaveProperty(key); + } + }); + + it("sends workspace_id when given, including null to ungroup", async () => { + const { relay, calls } = bucketClient(); + await relay.buckets.update("b1", { workspace_id: "ws2" }); + await relay.buckets.update("b1", { workspaceId: null }); + expect((calls[1]!.body as Record).workspace_id).toBe("ws2"); + expect(calls[3]!.body as Record).toHaveProperty("workspace_id", null); + }); + + it("merges auth field by field and accepts camelCase aliases", async () => { + const { relay, calls } = bucketClient(); + await relay.buckets.update("b1", { + auth: { token: "rotated" }, + staticIp: true, + largeWebhooks: true, + }); + expect(calls[1]!.body).toMatchObject({ + name: "orders", + static_ip: true, + large_webhooks: true, + auth: { type: "token", token: "rotated" }, + }); + expect(calls[1]!.body).not.toHaveProperty("staticIp"); + }); + + it("ignores undefined values and lets an empty string clear a field", async () => { + const { relay, calls } = bucketClient(); + await relay.buckets.update("b1", { description: undefined, stream: false }); + expect(calls[1]!.body).toMatchObject({ description: "old", stream: false }); + await relay.buckets.update("b1", { description: "" }); + expect(calls[3]!.body).toMatchObject({ description: "" }); + }); + + it("omits auth when the bucket has none and none is given", async () => { + const { relay, calls } = bucketClient({ id: "b1", name: "bare" }); + await relay.buckets.update("b1", { description: "d" }); + expect(calls[1]!.body).toEqual({ name: "bare", description: "d" }); + }); + + it("propagates a failing GET without sending the PUT", async () => { + const { relay, calls } = createClient(() => ({ status: 404, text: "Not Found" })); + await expect(relay.buckets.update("nope", { description: "x" })).rejects.toBeInstanceOf( + WebhookRelayAPIError, + ); + expect(calls).toHaveLength(1); + }); + + it("create normalizes camelCase bucket params", async () => { + const { relay, calls } = createClient(() => ({ json: { id: "b2", name: "n" } })); + await relay.buckets.create({ name: "n", staticIp: true }); + expect(calls[0]!.body).toEqual({ name: "n", static_ip: true }); + }); +}); + +describe("outputs.update (partial over a full-replace PUT)", () => { + const output = { + id: "o1", + bucket_id: "b1", + name: "prod", + destination: "https://example.com/hook", + function_id: "fn1", + headers: { "X-Key": ["v"] }, + internal: false, + tls_verification: true, + rules: { match: { type: "value", value: "x" } }, + cron_id: null, + created_by: "u1", + created_at: 1, + updated_at: 2, + }; + + it("keeps the destination and other fields, and leaves rules alone", async () => { + const { relay, calls } = createClient((url, method) => + method === "GET" ? { json: { id: "b1", outputs: [output] } } : { json: output }, + ); + await relay.outputs.update("b1", "o1", { description: "d", tlsVerification: false }); + expect(calls[0]!.url).toBe(`${base}/v1/buckets/b1`); + expect(calls[1]!.body).toEqual({ + name: "prod", + destination: "https://example.com/hook", + function_id: "fn1", + headers: { "X-Key": ["v"] }, + internal: false, + tls_verification: false, + description: "d", + }); + }); + + it("throws a 404 API error when the output is not on the bucket", async () => { + const { relay, calls } = createClient(() => ({ json: { id: "b1", outputs: [] } })); + const err = await relay.outputs.update("b1", "missing", { disabled: true }).catch((e) => e); + expect(err).toBeInstanceOf(WebhookRelayAPIError); + expect((err as WebhookRelayAPIError).status).toBe(404); + expect(calls).toHaveLength(1); + }); +}); + +describe("functions.update (partial over a full-replace PUT)", () => { + it("keeps the source when only the name changes", async () => { + const fn = { id: "fn1", name: "old", driver: "js", payload: "function t(r){return r}" }; + const { relay, calls } = createClient(() => ({ json: fn })); + await relay.functions.update("fn1", { name: "new" }); + expect(calls[1]!.method).toBe("PUT"); + expect(calls[1]!.body).toEqual({ name: "new", driver: "js", payload: fn.payload }); + }); + + it("skips the GET when name, driver and payload are all given", async () => { + const { relay, calls } = createClient(() => ({ json: { id: "fn1" } })); + await relay.functions.update("fn1", { name: "n", driver: "lua", payload: "x" }); + expect(calls[0]!.method).toBe("PUT"); + }); +}); + +describe("serviceConnections update helpers", () => { + it("update fills in the current name when omitted", async () => { + const { relay, calls } = createClient(() => ({ json: { id: "sc1", name: "prod-aws" } })); + await relay.serviceConnections.update("sc1", { + awsServiceConnection: { access_key_id: "AK", secret_access_key: "SK" }, + }); + expect(calls[0]!.method).toBe("GET"); + expect(calls[1]!.body).toEqual({ + name: "prod-aws", + aws_service_connection: { access_key_id: "AK", secret_access_key: "SK" }, + }); + }); + + it("update skips the GET when a name is given", async () => { + const { relay, calls } = createClient(() => ({ json: { id: "sc1" } })); + await relay.serviceConnections.update("sc1", { name: "renamed" }); + expect(calls.map((c) => c.method)).toEqual(["PUT"]); + }); + + it("updateInput / updateOutput merge over the current managed resource", async () => { + const input = { + id: "i1", + bucket_id: "b1", + name: "queue", + service_connection_id: "sc1", + service_connection_input_type: "aws_sqs", + aws_sqs_input: { queue_url: "https://sqs/q" }, + status: "running", + error: "", + created_at: 1, + }; + const { relay, calls } = createClient((url, method) => { + if (method === "GET") return { json: [input] }; + return { json: input }; + }); + await relay.serviceConnections.updateInput("b1", "i1", { functionId: "fn1" } as never); + expect(calls[1]!.body).toEqual({ + name: "queue", + service_connection_id: "sc1", + service_connection_input_type: "aws_sqs", + aws_sqs_input: { queue_url: "https://sqs/q" }, + function_id: "fn1", + }); + + const err = await relay.serviceConnections + .updateOutput("b1", "missing", { name: "x" }) + .catch((e) => e); + expect((err as WebhookRelayAPIError).status).toBe(404); + }); +}); From 34931e6b1f25075c47c6798fd100e8c67c43665f Mon Sep 17 00:00:00 2001 From: Karolis Date: Sun, 25 Oct 2026 20:42:49 +0400 Subject: [PATCH 2/2] Outbound live test: include access limits in the not-enabled failure --- test/integration/outbound.integration.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/integration/outbound.integration.test.ts b/test/integration/outbound.integration.test.ts index ff6e97a..85ee7d0 100644 --- a/test/integration/outbound.integration.test.ts +++ b/test/integration/outbound.integration.test.ts @@ -211,7 +211,12 @@ describeLive("Webhook Relay live API: outbound webhooks", () => { beforeAll(async () => { const access = await relay.outbound.access(); if (!access.enabled) { - throw new Error("outbound not enabled for the CI account (needs Basic+ plan)"); + // Limits hint at the plan family; an open request means access was asked for. + const detail = JSON.stringify({ + limits: access.limits ?? null, + open_request: Boolean(access.request), + }); + throw new Error(`outbound not enabled for the CI account (needs Basic+ plan); GET /v1/outbound/access → ${detail}`); } expect(access.limits?.consumers ?? 1).toBeGreaterThan(0);