You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
memos/internal/webhook/webhook_test.go

147 lines
4.6 KiB
Go

package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestPostAsyncNilPayloadDoesNotPanic(t *testing.T) {
require.NotPanics(t, func() {
PostAsync(nil)
})
}
func TestResolveSigningKey(t *testing.T) {
rawKey := []byte("0123456789abcdef")
whsec := "whsec_" + base64.StdEncoding.EncodeToString(rawKey)
t.Run("plain secret used as-is", func(t *testing.T) {
key, err := resolveSigningKey("my-plain-secret")
require.NoError(t, err)
require.Equal(t, []byte("my-plain-secret"), key)
})
t.Run("whsec_ prefix is base64-decoded", func(t *testing.T) {
key, err := resolveSigningKey(whsec)
require.NoError(t, err)
require.Equal(t, rawKey, key)
})
t.Run("whsec_ with invalid base64 fails loudly", func(t *testing.T) {
_, err := resolveSigningKey("whsec_not!valid!base64!")
require.Error(t, err)
})
}
func TestValidateSigningSecret(t *testing.T) {
tests := []struct {
name string
secret string
wantErr bool
}{
{name: "empty is allowed", secret: "", wantErr: false},
{name: "printable ascii", secret: "abcDEF123!@#", wantErr: false},
{name: "valid whsec_", secret: "whsec_" + base64.StdEncoding.EncodeToString([]byte("key")), wantErr: false},
{name: "newline rejected", secret: "abc\ndef", wantErr: true},
{name: "carriage return rejected", secret: "abc\rdef", wantErr: true},
{name: "tab rejected", secret: "abc\tdef", wantErr: true},
{name: "non-ascii rejected", secret: "abc€def", wantErr: true},
{name: "whsec_ with invalid base64 rejected", secret: "whsec_not!base64", wantErr: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := ValidateSigningSecret(tc.secret)
if tc.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
})
}
}
// TestPostSignsRequest verifies the end-to-end Standard Webhooks signature so a
// receiver following the documented verification recipe will accept our requests.
func TestPostSignsRequest(t *testing.T) {
// httptest listens on 127.0.0.1, which the SSRF guard blocks by default.
prev := AllowPrivateIPs
AllowPrivateIPs = true
defer func() { AllowPrivateIPs = prev }()
rawKey := []byte("0123456789abcdef0123456789abcdef")
cases := []struct {
name string
secret string
key []byte
}{
{name: "plain secret", secret: "plain-secret-value", key: []byte("plain-secret-value")},
{name: "whsec_ secret", secret: "whsec_" + base64.StdEncoding.EncodeToString(rawKey), key: rawKey},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var gotID, gotTimestamp, gotSignature string
var gotBody []byte
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotID = r.Header.Get("webhook-id")
gotTimestamp = r.Header.Get("webhook-timestamp")
gotSignature = r.Header.Get("webhook-signature")
gotBody, _ = io.ReadAll(r.Body)
_, _ = w.Write([]byte(`{"code":0}`))
}))
defer server.Close()
err := Post(&WebhookRequestPayload{
URL: server.URL,
ActivityType: "memos.memo.created",
Creator: "users/1",
SigningSecret: tc.secret,
})
require.NoError(t, err)
require.True(t, strings.HasPrefix(gotID, "msg_"), "webhook-id should be prefixed with msg_")
require.NotEmpty(t, gotTimestamp)
require.True(t, strings.HasPrefix(gotSignature, "v1,"), "signature should carry the v1 version tag")
// Recompute the signature the way a receiver would and confirm it matches.
mac := hmac.New(sha256.New, tc.key)
mac.Write([]byte(gotID + "." + gotTimestamp + "."))
mac.Write(gotBody)
want := base64.StdEncoding.EncodeToString(mac.Sum(nil))
require.Equal(t, "v1,"+want, gotSignature)
})
}
}
// TestPostWithoutSecretSetsNoSignatureHeaders ensures unsigned webhooks stay unsigned.
func TestPostWithoutSecretSetsNoSignatureHeaders(t *testing.T) {
prev := AllowPrivateIPs
AllowPrivateIPs = true
defer func() { AllowPrivateIPs = prev }()
var hasSignatureHeaders bool
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hasSignatureHeaders = r.Header.Get("webhook-id") != "" ||
r.Header.Get("webhook-timestamp") != "" ||
r.Header.Get("webhook-signature") != ""
_, _ = w.Write([]byte(`{"code":0}`))
}))
defer server.Close()
err := Post(&WebhookRequestPayload{
URL: server.URL,
ActivityType: "memos.memo.created",
Creator: "users/1",
})
require.NoError(t, err)
require.False(t, hasSignatureHeaders, "no signature headers should be set when no secret is configured")
}