mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-27 06:47:07 +00:00
7ee0bddc45
* 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>
121 lines
4.5 KiB
Go
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)
|
|
}
|
|
}
|