package api_test import ( "io" "net/http" "net/http/httptest" "testing" "atlas9.dev/c/core/assert" "atlas9.dev/c/demo/api" "atlas9.dev/c/demo/lib/tasks" "atlas9.dev/c/demo/lib/todos" "atlas9.dev/c/demo/lib/webhooks" ) func createEndpoint(t *testing.T, s *testServer, name, url string, events []string) api.Webhooks_CreateEndpointRes { t.Helper() var res api.Webhooks_CreateEndpointRes httpRes := s.call(t, "/Webhooks_CreateEndpoint", api.Webhooks_CreateEndpointReq{ Endpoint: webhooks.Endpoint{ Tenant: defaultTenant, Name: name, URL: url, EventTypes: events, }, }, &res) assert.Eq(t, httpRes.StatusCode, http.StatusOK) return res } func countDeliveryTasks(t *testing.T, s *testServer) int { t.Helper() var n int err := s.DB.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM webhook_delivery_tasks`).Scan(&n) assert.Ok(t, err) return n } func TestWebhooksApi_CreateEndpoint(t *testing.T) { s := startServer(t) res := createEndpoint(t, s, "ci", "https://example.test/hook", []string{todos.EventItemCreated}) assert.Eq(t, res.Endpoint.ID.IsEmpty(), false) assert.Eq(t, res.Endpoint.Active, true) // The plaintext secret is returned exactly once, at creation. assert.Eq(t, res.Secret != "", true) var got api.Webhooks_GetEndpointRes httpRes := s.call(t, "/Webhooks_GetEndpoint", api.Webhooks_GetEndpointReq{ Tenant: defaultTenant, ID: res.Endpoint.ID, }, &got) assert.Eq(t, httpRes.StatusCode, http.StatusOK) assert.Eq(t, got.Endpoint.Name, "ci") assert.Eq(t, len(got.Endpoint.EventTypes), 1) } func TestWebhooksApi_CreateEndpoint_URLRequired(t *testing.T) { s := startServer(t) httpRes := s.call(t, "/Webhooks_CreateEndpoint", api.Webhooks_CreateEndpointReq{ Endpoint: webhooks.Endpoint{Tenant: defaultTenant, Name: "ci"}, }, nil) assert.Eq(t, httpRes.StatusCode, http.StatusBadRequest) } func TestWebhooksApi_UpdateEndpoint(t *testing.T) { s := startServer(t) created := createEndpoint(t, s, "old", "https://example.test/hook", []string{todos.EventItemCreated}) ep := created.Endpoint ep.Name = "new" ep.Active = false var res api.Webhooks_UpdateEndpointRes httpRes := s.call(t, "/Webhooks_UpdateEndpoint", api.Webhooks_UpdateEndpointReq{Endpoint: ep}, &res) assert.Eq(t, httpRes.StatusCode, http.StatusOK) var got api.Webhooks_GetEndpointRes s.call(t, "/Webhooks_GetEndpoint", api.Webhooks_GetEndpointReq{Tenant: defaultTenant, ID: ep.ID}, &got) assert.Eq(t, got.Endpoint.Name, "new") assert.Eq(t, got.Endpoint.Active, false) } func TestWebhooksApi_DeleteEndpoint(t *testing.T) { s := startServer(t) created := createEndpoint(t, s, "ci", "https://example.test/hook", []string{todos.EventItemCreated}) httpRes := s.call(t, "/Webhooks_DeleteEndpoint", api.Webhooks_DeleteEndpointReq{ Tenant: defaultTenant, ID: created.Endpoint.ID, }, nil) assert.Eq(t, httpRes.StatusCode, http.StatusOK) var got api.Webhooks_GetEndpointRes httpRes = s.call(t, "/Webhooks_GetEndpoint", api.Webhooks_GetEndpointReq{ Tenant: defaultTenant, ID: created.Endpoint.ID, }, &got) assert.Eq(t, httpRes.StatusCode, http.StatusNotFound) } // Creating a todo item fans out one delivery per subscribed endpoint, enqueued // in the same transaction as the mutation. func TestWebhooksApi_ItemCreate_FansOutToSubscribers(t *testing.T) { s := startServer(t) // One endpoint subscribed to the event, one to a different event. createEndpoint(t, s, "subscribed", "https://example.test/hook", []string{todos.EventItemCreated}) createEndpoint(t, s, "other", "https://example.test/hook", []string{todos.EventItemUpdated}) assert.Eq(t, countDeliveryTasks(t, s), 0) createItem(t, s, todos.Item{Title: "buy milk"}) // Only the subscribed endpoint gets a delivery task. assert.Eq(t, countDeliveryTasks(t, s), 1) } // The internal Deliver endpoint (which the worker replays) signs the body with // the endpoint's secret and POSTs it to the endpoint's URL. func TestWebhooksApi_Deliver_SignsAndSends(t *testing.T) { s := startServer(t) var gotSig, gotEvent string var gotBody []byte recv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotSig = r.Header.Get(webhooks.SignatureHeader) gotEvent = r.Header.Get(webhooks.EventHeader) gotBody, _ = io.ReadAll(r.Body) w.WriteHeader(http.StatusOK) })) defer recv.Close() created := createEndpoint(t, s, "ci", recv.URL, []string{todos.EventItemCreated}) body := []byte(`{"hello":"world"}`) var res api.Webhooks_DeliverRes httpRes := s.call(t, "/Webhooks_Deliver", api.Webhooks_DeliverReq{ Tenant: defaultTenant, Endpoint: created.Endpoint.ID, Type: todos.EventItemCreated, Body: body, }, &res) assert.Eq(t, httpRes.StatusCode, http.StatusOK) assert.Eq(t, res.Task.Outcome, tasks.OutcomeCompleted) assert.Eq(t, string(gotBody), string(body)) assert.Eq(t, gotEvent, todos.EventItemCreated) // The signature is verifiable with the secret returned at creation. assert.Eq(t, gotSig, webhooks.Sign([]byte(created.Secret), body)) }