Skip to content

Commit

Permalink
feat: New webhook testing route (#40)
Browse files Browse the repository at this point in the history
  • Loading branch information
Antoine Gelloz authored Dec 1, 2022
1 parent f9e64ea commit 4b5dfe2
Show file tree
Hide file tree
Showing 11 changed files with 165 additions and 26 deletions.
2 changes: 2 additions & 0 deletions cmd/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (

"github.com/formancehq/go-libs/sharedlogging"
"github.com/formancehq/webhooks/cmd/flag"
"github.com/formancehq/webhooks/pkg/otlp"
"github.com/formancehq/webhooks/pkg/server"
"github.com/spf13/cobra"
"github.com/spf13/viper"
Expand All @@ -24,6 +25,7 @@ func RunServer(cmd *cobra.Command, _ []string) error {
syscall.Environ(), viper.AllKeys())

app := fx.New(
otlp.HttpClientModule(),
server.StartModule(
viper.GetString(flag.HttpBindAddressServer)))

Expand Down
29 changes: 17 additions & 12 deletions pkg/attempt.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,23 +34,25 @@ type Attempt struct {
NextRetryAfter time.Time `json:"nextRetryAfter,omitempty" bson:"nextRetryAfter,omitempty"`
}

func MakeAttempt(ctx context.Context, httpClient *http.Client, schedule []time.Duration, webhookID string, attemptNb int, cfg Config, data []byte) (Attempt, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.Endpoint, bytes.NewBuffer(data))
func MakeAttempt(ctx context.Context, httpClient *http.Client, schedule []time.Duration, id string, attemptNb int, cfg Config, payload []byte, isTest bool) (Attempt, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.Endpoint, bytes.NewBuffer(payload))
if err != nil {
return Attempt{}, errors.Wrap(err, "http.NewRequestWithContext")
}

date := time.Now().UTC()
signature, err := security.Sign(webhookID, date, cfg.Secret, data)
ts := time.Now().UTC()
timestamp := ts.Unix()
signature, err := security.Sign(id, timestamp, cfg.Secret, payload)
if err != nil {
return Attempt{}, errors.Wrap(err, "security.Sign")
}

req.Header.Set("content-type", "application/json")
req.Header.Set("user-agent", "formance-webhooks/v0")
req.Header.Set("formance-webhook-id", webhookID)
req.Header.Set("formance-webhook-timestamp", fmt.Sprintf("%d", date.Unix()))
req.Header.Set("formance-webhook-id", id)
req.Header.Set("formance-webhook-timestamp", fmt.Sprintf("%d", timestamp))
req.Header.Set("formance-webhook-signature", signature)
req.Header.Set("formance-webhook-test", fmt.Sprintf("%v", isTest))

resp, err := httpClient.Do(req)
if err != nil {
Expand All @@ -64,14 +66,17 @@ func MakeAttempt(ctx context.Context, httpClient *http.Client, schedule []time.D
}
}()

body, _ := io.ReadAll(resp.Body)
sharedlogging.GetLogger(ctx).Debugf("webhooks.MakeAttempt: server response body: %s\n", body)
body, err := io.ReadAll(resp.Body)
if err != nil {
return Attempt{}, errors.Wrap(err, "io.ReadAll")
}
sharedlogging.GetLogger(ctx).Debugf("webhooks.MakeAttempt: server response body: %s", string(body))

attempt := Attempt{
WebhookID: webhookID,
Date: date,
WebhookID: id,
Date: ts,
Config: cfg,
Payload: string(data),
Payload: string(payload),
StatusCode: resp.StatusCode,
RetryAttempt: attemptNb,
}
Expand All @@ -87,6 +92,6 @@ func MakeAttempt(ctx context.Context, httpClient *http.Client, schedule []time.D
}

attempt.Status = StatusAttemptToRetry
attempt.NextRetryAfter = date.Add(schedule[attemptNb])
attempt.NextRetryAfter = ts.Add(schedule[attemptNb])
return attempt, nil
}
7 changes: 3 additions & 4 deletions pkg/security/security.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,10 @@ import (
"encoding/base64"
"fmt"
"strings"
"time"
)

func Sign(id string, timestamp time.Time, secret string, payload []byte) (string, error) {
toSign := fmt.Sprintf("%s.%d.%s", id, timestamp.Unix(), payload)
func Sign(id string, timestamp int64, secret string, payload []byte) (string, error) {
toSign := fmt.Sprintf("%s.%d.%s", id, timestamp, payload)

hash := hmac.New(sha256.New, []byte(secret))
if _, err := hash.Write([]byte(toSign)); err != nil {
Expand All @@ -24,7 +23,7 @@ func Sign(id string, timestamp time.Time, secret string, payload []byte) (string
return fmt.Sprintf("v1,%s", signature), nil
}

func Verify(signatures, id string, timestamp time.Time, secret string, payload []byte) (bool, error) {
func Verify(signatures, id string, timestamp int64, secret string, payload []byte) (bool, error) {
computedSignature, err := Sign(id, timestamp, secret, payload)
if err != nil {
return false, err
Expand Down
12 changes: 8 additions & 4 deletions pkg/server/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
const (
PathHealthCheck = "/_healthcheck"
PathConfigs = "/configs"
PathTest = "/test"
PathActivate = "/activate"
PathDeactivate = "/deactivate"
PathChangeSecret = "/secret/change"
Expand All @@ -21,13 +22,15 @@ const (
type serverHandler struct {
*chi.Mux

store storage.Store
store storage.Store
httpClient *http.Client
}

func newServerHandler(store storage.Store) http.Handler {
func newServerHandler(store storage.Store, httpClient *http.Client) http.Handler {
h := &serverHandler{
Mux: chi.NewRouter(),
store: store,
Mux: chi.NewRouter(),
store: store,
httpClient: httpClient,
}

h.Mux.Use(otelchi.Middleware("webhooks"))
Expand All @@ -41,6 +44,7 @@ func newServerHandler(store storage.Store) http.Handler {
h.Mux.Get(PathConfigs, h.getManyConfigsHandle)
h.Mux.Post(PathConfigs, h.insertOneConfigHandle)
h.Mux.Delete(PathConfigs+PathId, h.deleteOneConfigHandle)
h.Mux.Get(PathConfigs+PathId+PathTest, h.testOneConfigHandle)
h.Mux.Put(PathConfigs+PathId+PathActivate, h.activateOneConfigHandle)
h.Mux.Put(PathConfigs+PathId+PathDeactivate, h.deactivateOneConfigHandle)
h.Mux.Put(PathConfigs+PathId+PathChangeSecret, h.changeSecretHandle)
Expand Down
56 changes: 56 additions & 0 deletions pkg/server/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,30 @@ paths:
description: Config successfully deleted.
content: {}

/configs/{id}/test:
get:
summary: Test one config
description: |
Test one config by sending a webhook to its endpoint.
operationId: testOneConfig
tags:
- Webhooks
parameters:
- name: id
in: path
description: Config ID
required: true
schema:
type: string
example: 4997257d-dfb6-445b-929c-cbe2ab182818
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/AttemptResponse'

/configs/{id}/activate:
put:
summary: Activate one config
Expand Down Expand Up @@ -285,6 +309,38 @@ components:
format: date-time
example: "2022-07-20T08:32:59Z"

AttemptResponse:
type: object
properties:
data:
$ref: '#/components/schemas/Attempt'

Attempt:
properties:
webhookID:
type: string
example: 4997257d-dfb6-445b-929c-cbe2ab182818
date:
type: string
format: date-time
config:
$ref: '#/components/schemas/ConfigActivated'
payload:
type: string
example: '{"data":"test"}'
statusCode:
type: integer
example: 200
retryAttempt:
type: integer
example: 1
status:
type: string
example: success
nextRetryAfter:
type: string
format: date-time

Cursor:
type: object
properties:
Expand Down
45 changes: 45 additions & 0 deletions pkg/server/test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package server

import (
"encoding/json"
"net/http"

"github.com/formancehq/go-libs/sharedapi"
"github.com/formancehq/go-libs/sharedlogging"
webhooks "github.com/formancehq/webhooks/pkg"
"github.com/formancehq/webhooks/pkg/storage"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)

func (h *serverHandler) testOneConfigHandle(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, PathParamId)
cfgs, err := h.store.FindManyConfigs(r.Context(), map[string]any{webhooks.KeyID: id})
if err == nil {
if len(cfgs) == 0 {
sharedlogging.GetLogger(r.Context()).Errorf("GET %s/%s%s: %s", PathConfigs, id, PathTest, storage.ErrConfigNotFound)
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
sharedlogging.GetLogger(r.Context()).Infof("GET %s/%s%s", PathConfigs, id, PathTest)
attempt, err := webhooks.MakeAttempt(r.Context(), h.httpClient, nil,
uuid.NewString(), 0, cfgs[0], []byte(`{"data":"test"}`), true)
if err != nil {
sharedlogging.GetLogger(r.Context()).Errorf("GET %s/%s%s: %s", PathConfigs, id, PathTest, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} else {
sharedlogging.GetLogger(r.Context()).Infof("GET %s/%s%s", PathConfigs, id, PathTest)
resp := sharedapi.BaseResponse[webhooks.Attempt]{
Data: &attempt,
}
if err := json.NewEncoder(w).Encode(resp); err != nil {
sharedlogging.GetLogger(r.Context()).Errorf("json.Encoder.Encode: %s", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
}
} else {
sharedlogging.GetLogger(r.Context()).Errorf("GET %s/%s%s: %s", PathConfigs, id, PathTest, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
}
2 changes: 1 addition & 1 deletion pkg/worker/messages/worker.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ func (w *WorkerMessages) processMessage(ctx context.Context, msgValue []byte) er
}

attempt, err := webhooks.MakeAttempt(ctx, w.httpClient, w.retriesSchedule,
uuid.NewString(), 0, cfg, data)
uuid.NewString(), 0, cfg, data, false)
if err != nil {
return errors.Wrap(err, "sending webhook")
}
Expand Down
2 changes: 1 addition & 1 deletion pkg/worker/retries/worker.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ func (w *WorkerRetries) attemptRetries(ctx context.Context, errChan chan error)

newAttemptNb := atts[0].RetryAttempt + 1
attempt, err := webhooks.MakeAttempt(ctx, w.httpClient, w.retriesSchedule,
id, newAttemptNb, atts[0].Config, []byte(atts[0].Payload))
id, newAttemptNb, atts[0].Config, []byte(atts[0].Payload), false)
if err != nil {
errChan <- errors.Wrap(err, "webhooks.MakeAttempt")
continue
Expand Down
29 changes: 29 additions & 0 deletions test/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package test_test
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
Expand All @@ -13,11 +14,20 @@ import (
"github.com/spf13/viper"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/fx"
"go.uber.org/fx/fxtest"
)

func TestServer(t *testing.T) {
// New test server with success handler
httpServerSuccess := httptest.NewServer(http.HandlerFunc(webhooksSuccessHandler))
defer func() {
httpServerSuccess.CloseClientConnections()
httpServerSuccess.Close()
}()

serverApp := fxtest.New(t,
fx.Supply(httpServerSuccess.Client()),
server.StartModule(
viper.GetString(flag.HttpBindAddressServer)))

Expand Down Expand Up @@ -186,6 +196,25 @@ func TestServer(t *testing.T) {
})
})

t.Run("GET "+server.PathConfigs+"/{id}"+server.PathTest, func(t *testing.T) {
resBody := requestServer(t, http.MethodPost, server.PathConfigs, http.StatusOK, webhooks.ConfigUser{
Endpoint: httpServerSuccess.URL,
Secret: secret,
EventTypes: []string{"TYPE1"},
})
c, ok := decodeSingleResponse[webhooks.Config](t, resBody)
assert.Equal(t, true, ok)
require.NoError(t, resBody.Close())

resBody = requestServer(t, http.MethodGet, server.PathConfigs+"/"+c.ID+server.PathTest, http.StatusOK)
attempt, ok := decodeSingleResponse[webhooks.Attempt](t, resBody)
assert.Equal(t, true, ok)
assert.Equal(t, webhooks.StatusAttemptSuccess, attempt.Status)
assert.Equal(t, `{"data":"test"}`, attempt.Payload)

requestServer(t, http.MethodDelete, server.PathConfigs+"/"+c.ID, http.StatusOK)
})

t.Run("DELETE "+server.PathConfigs, func(t *testing.T) {
for _, id := range insertedIds {
requestServer(t, http.MethodDelete, server.PathConfigs+"/"+id, http.StatusOK)
Expand Down
1 change: 1 addition & 0 deletions test/workerMessages_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ func TestWorkerMessages(t *testing.T) {
}()

serverApp := fxtest.New(t,
fx.Supply(httpServerSuccess.Client()),
server.StartModule(
viper.GetString(flag.HttpBindAddressServer)))
require.NoError(t, serverApp.Start(context.Background()))
Expand Down
6 changes: 2 additions & 4 deletions test/worker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"io"
"net/http"
"strconv"
"time"

"github.com/formancehq/webhooks/pkg/security"
)
Expand All @@ -19,21 +18,20 @@ func webhooksSuccessHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
timestamp := time.Unix(timeInt, 0)

payload, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

ok, err := security.Verify(signatures, id, timestamp, secret, payload)
ok, err := security.Verify(signatures, id, timeInt, secret, payload)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if !ok {
http.Error(w, "", http.StatusBadRequest)
http.Error(w, "security.Verify NOK", http.StatusBadRequest)
return
}

Expand Down

0 comments on commit 4b5dfe2

Please sign in to comment.