Files
pad/internal/server/bug3340_webhook_scope_test.go
T
xarmian 7ee0bddc45 fix(webhooks): whole-workspace access to manage; no delivery for a deleted workspace (#1727)
* fix(webhooks): whole-workspace access to manage them; none for a deleted workspace (BUG-3340)

A webhook receives item snapshots from every collection, but creating or
test-firing one required only the owner role, so an owner restricted to
specific collections could route every collection's content to a URL of
their choosing. Create and test now require unrestricted workspace
access, through the same check full export uses (BUG-1922), factored
into requireUnrestrictedAccess. Delete stays open: it is revocation.

ListWebhooks now returns nothing for a soft-deleted workspace. Both
dispatcher delivery paths (Dispatch and DeliverEvent) list through it,
so a deleted workspace's events are dropped instead of delivered during
the restore window; a restore brings the webhooks back.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(webhooks): re-check the workspace before every send; hide URLs from restricted owners (BUG-3340)

Codex r1 (no P0):
- P1: ListWebhooks checked deletion once, but a delivery reuses the hook
  list and payload across inline retries, later hooks in the fan-out,
  and redirect hops; codex reproduced POSTs after the delete committed.
  The dispatcher store gains WorkspaceLive, checked before every attempt
  (a new deliverySuppressed result: nothing sent, not the endpoint's
  failure, not counted as matched) and before every redirect hop (the
  request carries the hook's workspace). A lookup error is transient, so
  a blip delays rather than drops or sends unchecked.
- P2: the list still showed restricted owners every destination URL, and
  a URL can be the credential to a receiver's stored request bodies. A
  restricted owner now sees each webhook without its URL (the id is
  kept, so they can still delete it).

Tests: retry, later hook and redirect after a mid-delivery deletion each
send nothing (all three red with the checks disabled); the list hides
URLs from the restricted owner only.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(webhooks): check the workspace on every connection; defer on lookup errors (BUG-3340)

Codex r2 (no P0):
- P1: net/http can send more than once inside one Do (it re-dials and
  replays the body after a reused connection fails with nothing
  written), and a deletion can land during dial or TLS setup, both after
  the per-attempt check. An httptrace GotConn hook now re-checks the
  workspace on every connection the request is about to be written on,
  after TLS and before writing, and cancels the request if it is gone.
- P2 (two): a liveness lookup ERROR was treated as a deletion at the
  redirect check (the outbox then acked an undelivered event) and as an
  endpoint failure before a send (charged to the hook, which could
  auto-deactivate it). A new deliveryDeferred result sends nothing, keeps
  the event owed (Transient, so the outbox does not ack), and charges
  nothing to the hook. Suppression is reserved for a confirmed deletion.

Tests: deletion before the connection is ready sends nothing; a
pre-send lookup error and a redirect lookup error each defer without
sending or charging. Each goes red when its own fix is reverted.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(webhooks): close the connection in GotConn, a real write barrier (BUG-3340)

Codex r3 (no P0/P2): cancelling the request context in GotConn is not a
write barrier for HTTP/1: the transport queues the request before it
checks the context, so its writer could still send the whole request
(codex measured 10/10,000 in the zero-send test). GotConn now also closes
the connection it was handed when the workspace is gone or its liveness
unknown: nothing can be written to a closed socket, and the cancel stops
any transport retry on a fresh one.

Measured here: the cancel-only version fails the zero-send test 2/10,000;
with the close, 0/10,000 for both the deletion and lookup-error tests,
and the webhooks package passes under -race.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(webhooks): the connection check decides the outcome even if Do returns a response (BUG-3340)

Codex r4 (no P0/P1) P2: after GotConn closes and cancels, net/http can
still return an already-buffered response, and the suppressed/deferred
flags were only consulted when Do returned an error, so such a response
counted as a delivery. The flags are now checked right after Do,
whatever it returned, closing any response body first.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-02 13:20:09 -04:00

121 lines
4.5 KiB
Go

package server
import (
"net/http"
"testing"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/PerpetualSoftware/pad/internal/webhooks"
)
// BUG-3340: a webhook receives full item snapshots from every collection, so
// configuring one is a whole-workspace capability. A workspace owner whose
// access is restricted to specific collections must not be able to create or
// fire one (the same rule as full export, BUG-1922); deleting one stays open.
// And a soft-deleted workspace's webhooks must receive nothing.
func bug3340Setup(t *testing.T) (*Server, *models.Workspace, string, string) {
t.Helper()
srv := testServer(t)
ownerCookie := bootstrapFirstUser(t, srv, "owner@test.com", "Owner")
rr := doRequestWithCookie(srv, "POST", "/api/v1/workspaces", map[string]string{"name": "Hooks"}, ownerCookie)
if rr.Code != http.StatusCreated {
t.Fatalf("create workspace: %d %s", rr.Code, rr.Body.String())
}
var ws models.Workspace
parseJSON(t, rr, &ws)
tasks, _ := srv.store.GetCollectionBySlug(ws.ID, "tasks")
co, err := srv.store.CreateUser(models.UserCreate{Email: "coowner@test.com", Name: "CoOwner", Password: "correct-horse-battery-staple"})
if err != nil {
t.Fatal(err)
}
if err := srv.store.AddWorkspaceMember(ws.ID, co.ID, "owner"); err != nil {
t.Fatal(err)
}
if err := srv.store.SetMemberCollectionAccess(ws.ID, co.ID, "specific", []string{tasks.ID}); err != nil {
t.Fatal(err)
}
coCookie, err := srv.store.CreateSession(co.ID, "web-test", "192.0.2.1", "", webSessionTTL)
if err != nil {
t.Fatal(err)
}
return srv, &ws, ownerCookie, coCookie
}
func TestBUG3340_RestrictedOwnerCannotManageWebhooks(t *testing.T) {
srv, ws, ownerCookie, coCookie := bug3340Setup(t)
base := "/api/v1/workspaces/" + ws.Slug + "/webhooks"
body := map[string]string{"url": "https://8.8.8.8/hook"}
if rr := doRequestWithCookie(srv, "POST", base, body, coCookie); rr.Code != http.StatusForbidden {
t.Errorf("restricted owner create: %d %s, want 403", rr.Code, rr.Body.String())
}
rr := doRequestWithCookie(srv, "POST", base, body, ownerCookie)
if rr.Code != http.StatusCreated {
t.Fatalf("control: unrestricted owner create: %d %s, want 201", rr.Code, rr.Body.String())
}
var hook models.Webhook
parseJSON(t, rr, &hook)
// The list shows a restricted owner each webhook's id (to delete it) but
// not its URL; the unrestricted owner sees the URL.
urls := func(cookie string) map[string]string {
t.Helper()
rr := doRequestWithCookie(srv, "GET", base, nil, cookie)
if rr.Code != http.StatusOK {
t.Fatalf("list: %d %s", rr.Code, rr.Body.String())
}
var hooks []models.Webhook
parseJSON(t, rr, &hooks)
out := map[string]string{}
for _, h := range hooks {
out[h.ID] = h.URL
}
return out
}
if got, ok := urls(coCookie)[hook.ID]; !ok || got != "" {
t.Errorf("restricted owner list: present=%v url=%q, want the hook listed with no URL", ok, got)
}
if got := urls(ownerCookie)[hook.ID]; got != "https://8.8.8.8/hook" {
t.Errorf("control: unrestricted owner list url = %q", got)
}
if rr := doRequestWithCookie(srv, "POST", base+"/"+hook.ID+"/test", nil, coCookie); rr.Code != http.StatusForbidden {
t.Errorf("restricted owner test-fire: %d %s, want 403", rr.Code, rr.Body.String())
}
// Removing a webhook is revocation and stays open to any owner.
if rr := doRequestWithCookie(srv, "DELETE", base+"/"+hook.ID, nil, coCookie); rr.Code != http.StatusNoContent && rr.Code != http.StatusOK {
t.Errorf("restricted owner delete: %d %s, want success", rr.Code, rr.Body.String())
}
}
func TestBUG3340_DeletedWorkspaceWebhooksReceiveNothing(t *testing.T) {
srv, ws, _, _ := bug3340Setup(t)
if _, err := srv.store.CreateWebhook(ws.ID, models.WebhookCreate{URL: "https://8.8.8.8/hook"}); err != nil {
t.Fatal(err)
}
d := webhooks.NewDispatcher(srv.store)
matched := func() int {
t.Helper()
out, err := d.DeliverEvent(webhooks.Delivery{WorkspaceID: ws.ID, EventID: "e1", Event: "item.created", Payload: []byte(`{}`)})
if err != nil {
t.Fatal(err)
}
return out.Matched
}
if n := matched(); n != 1 {
t.Fatalf("control: a live workspace's webhook should match, got %d", n)
}
if err := srv.store.DeleteWorkspace(ws.Slug); err != nil {
t.Fatal(err)
}
if n := matched(); n != 0 {
t.Errorf("after the workspace was deleted, %d webhook(s) still matched an event", n)
}
if err := srv.store.RestoreWorkspace(ws.Slug); err != nil {
t.Fatal(err)
}
if n := matched(); n != 1 {
t.Errorf("after the workspace was restored, %d webhook(s) matched, want 1", n)
}
}