Skip to content

Live outbound E2E test + partial updates for full-replace PUTs (fixes buckets.update) - #4

Merged
rusenask merged 2 commits into
mainfrom
test/live-outbound
Oct 25, 2026
Merged

rusenask merged 2 commits into
mainfrom
test/live-outbound

Conversation

@rusenask

@rusenask rusenask commented Oct 25, 2026 •

Copy link
Copy Markdown
Member

Summary

Checks the public SDK end to end against the live API (https://my.webhookrelay.com) now that outbound webhooks are GA, and fixes what the checks turned up.

Fix: buckets.update is now a partial update

Since 2026-10-04, PUT /v1/buckets/{id} replaces the whole bucket and returns 400 invalid bucket: bucket name cannot be empty when name is missing. That broke relay.buckets.update(id, { description }) and the live test manages buckets, ....

buckets.update now:

  1. GETs the bucket.
  2. Merges your params over the fields the handler reads: name, description, stream, ephemeral, static_ip, large_webhooks, and auth (type/username/password/token, merged field by field).
  3. PUTs the result.

workspace_id is sent only when you pass it, because the backend moves the bucket whenever the key is present, even as null. Read-only fields (id, inputs, outputs, cron_id, ...) are never echoed back. Also added: camelCase aliases staticIp, largeWebhooks and workspaceId, plus workspace_id on Bucket. The JSDoc and README explain the behaviour.

Other full-replace update methods (checked against the backend handlers)

Method Backend behaviour Action
inputs.update handler already carries omitted fields over no change
outputs.update full replace: omitting destination gives a 400, other omitted fields get cleared fixed: reads the output from its bucket, then merges (rules/created_by/cron_id stay server-owned)
functions.update replaces name, driver and payload together, so a missing payload would empty the code fixed: also fills payload from the current function
serviceConnections.update always overwrites name and rejects an empty one fixed: fills the current name when you don't pass one
serviceConnections.updateInput/updateOutput full replace (keeps only immutable fields) fixed: list, then merge
outbound.endpoints.update documented "replace" (url required) unchanged, documented
outbound.consumers.upsert / eventTypes.upsert upsert overwrites name/rate and description/example unchanged, documented as upsert

Other SDK changes

  • relay.outbound.access() (GET /v1/outbound/access) and relay.outbound.requestAccess(), plus the OutboundAccess type with limits.
  • Bug: RequestStatus was a numeric enum (Sent = 1), but the API (and the swagger) send lowercase strings ("sent"), so comparing against the enum never matched. It now uses the string values, and WebhookLog.status is typed RequestStatus | "sent" | ....

New live test: test/integration/outbound.integration.test.ts

It uses the same credential gating as api.integration.test.ts, and vitest.integration.config.ts picks it up. Steps:

  • outbound.access() must be enabled. If not, it fails with outbound not enabled for the CI account (needs Basic+ plan).
  • Two free Webhook Bins serve as destinations. A unique event type and consumer are created, then an endpoint with a custom header. Checks: the secret is whsec_ + base64 (≥ 24 bytes), and revealSecret returns the same secret.
  • Publish with an idempotency key. Checks: the receipt (consumer, event type, event id, endpoint_ids); same key + same body gives the same message id; same key + different body gives 409. A sleep then confirms the bin got exactly one delivery.
  • Poll the bin (≤ 60s). Checks: webhook-id/webhook-timestamp/webhook-signature, with the Standard Webhooks signature verified locally (HMAC-SHA256 over id.timestamp.body); body equals the payload; the webhook-event-type, webhook-consumer and custom headers are present.
  • messages.get is polled until the delivery is sent. endpoints.deliveries is checked with the status/event_type/message_id filters, including filters that should match nothing. messages.list is filtered by consumer and event type.
  • Secret import: a second endpoint gets whsec_ + base64(32 random bytes). Checks: create and reveal both return it; the fan-out reaches both endpoints; the delivery verifies with the imported secret and not with endpoint 1's.
  • whsec_short is rejected with 400, and neither the error body nor the message contains it.
  • Checks the shape of outbound.health(), endpoints.listAll({ state: "failing" }) and listAll({ consumer }) (stats present).
  • endpoints.retry creates a recovery task, which is polled until completed; the bin then has a second signed delivery of the same message.
  • rotateSecret: reveal returns the new secret, and previous_secret_expires_at is in the future.
  • afterAll cleans up endpoints, then the consumer, then the event type, even when a test fails.

Secret assertions compare booleans, so a failure can't print a secret. Nothing logs secrets or the API key.

api.integration.test.ts now also checks that partial bucket, output and function updates keep the fields you didn't pass.

Test plan

  • npm run typecheck && npm test && npm run build pass locally (84 unit tests)
  • CI integration job (live API, RELAY_API_KEY) is green

🤖 Generated with Claude Code


Note

Medium Risk
Update methods now perform extra GETs and merge-then-PUT, which can overwrite concurrent edits and changes default RequestStatus enum values (breaking for numeric comparisons). Bucket workspace moves only when workspace_id is passed, reducing accidental moves.

Overview
Fixes partial update behavior for resources whose API uses full-replace PUTs: the SDK now GETs current state, merges only the fields you pass (via shared mergeForReplace / bucket-specific merge logic), and sends a complete body so omitted fields are not cleared. buckets.update is the main fix (partial description updates no longer fail without name); the same pattern applies to outputs.update, functions.update (including preserving payload on rename), and service connection update / updateInput / updateOutput, with workspace_id sent only when explicitly set and new bucket camelCase aliases.

Adds relay.outbound.access() and relay.outbound.requestAccess() with OutboundAccess typing; README now points at access() instead of a pilot feature flag.

RequestStatus enum values change from numbers to lowercase API strings ("sent", etc.) so comparisons against webhook log status work.

Adds a live outbound E2E integration test (Webhook Bins, Standard Webhooks verification, idempotency, secret import/rotation, recovery) plus focused unit tests in test/updates.test.ts and stronger live checks for partial bucket/output/function updates.

Reviewed by Cursor Bugbot for commit c02e1d5. Configure here.

…d 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
@rusenask
rusenask merged commit e0ac29b into main Oct 25, 2026
3 of 4 checks passed
@rusenask
rusenask deleted the test/live-outbound branch October 25, 2026 18:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant