165 lines
5.4 KiB
Go
165 lines
5.4 KiB
Go
package webhook
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"telesrv/internal/otpdelivery"
|
|
)
|
|
|
|
func TestDeliverSendsVersionedSignedRequest(t *testing.T) {
|
|
secret := "webhook-secret"
|
|
var got requestBody
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
t.Errorf("read request: %v", err)
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if r.Method != http.MethodPost || r.Header.Get("Idempotency-Key") != "otp_test_delivery" {
|
|
t.Errorf("method/idempotency = %s/%q", r.Method, r.Header.Get("Idempotency-Key"))
|
|
}
|
|
timestamp := r.Header.Get("X-Telesrv-Timestamp")
|
|
mac := hmac.New(sha256.New, []byte(secret))
|
|
_, _ = mac.Write([]byte(timestamp + "."))
|
|
_, _ = mac.Write(body)
|
|
wantSignature := "sha256=" + hex.EncodeToString(mac.Sum(nil))
|
|
if r.Header.Get("X-Telesrv-Signature") != wantSignature {
|
|
t.Errorf("signature = %q, want %q", r.Header.Get("X-Telesrv-Signature"), wantSignature)
|
|
}
|
|
if err := json.Unmarshal(body, &got); err != nil {
|
|
t.Errorf("decode request: %v", err)
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = io.WriteString(w, `{"accepted":true,"message_id":"provider-42"}`)
|
|
}))
|
|
defer server.Close()
|
|
|
|
sender, err := New(Config{URL: server.URL, Secret: secret, Timeout: time.Second})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
expiresAt := time.Now().Add(5 * time.Minute).UTC()
|
|
result, err := sender.Deliver(context.Background(), otpdelivery.Request{
|
|
DeliveryID: "otp_test_delivery",
|
|
Purpose: otpdelivery.PurposeLoginEmail,
|
|
Channel: otpdelivery.ChannelEmail,
|
|
Recipient: "alice@example.test",
|
|
Code: "482913",
|
|
ExpiresAt: expiresAt,
|
|
Locale: "zh-CN",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Deliver: %v", err)
|
|
}
|
|
if result.ProviderMessageID != "provider-42" {
|
|
t.Fatalf("message id = %q", result.ProviderMessageID)
|
|
}
|
|
if got.Version != protocolVersion || got.DeliveryID != "otp_test_delivery" ||
|
|
got.Purpose != otpdelivery.PurposeLoginEmail || got.Channel != otpdelivery.ChannelEmail ||
|
|
got.Recipient != "alice@example.test" || got.Code != "482913" || got.Locale != "zh-CN" || got.ExpiresIn < 298 {
|
|
t.Fatalf("request = %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestDeliverExplicitRejectionIsDefinite(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
_, _ = io.WriteString(w, `{"accepted":false,"error_code":"RATE_LIMITED","retryable":true}`)
|
|
}))
|
|
defer server.Close()
|
|
sender, err := New(Config{URL: server.URL})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
_, err = sender.Deliver(context.Background(), validRequest())
|
|
var rejected *otpdelivery.RejectedError
|
|
if !errors.As(err, &rejected) || rejected.StatusCode != http.StatusTooManyRequests || rejected.Code != "RATE_LIMITED" || !rejected.Retryable {
|
|
t.Fatalf("rejection = %#v err=%v", rejected, err)
|
|
}
|
|
if errors.Is(err, otpdelivery.ErrOutcomeUnknown) {
|
|
t.Fatalf("explicit rejection marked unknown: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDeliverMalformedSuccessIsOutcomeUnknown(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = io.WriteString(w, `{}`)
|
|
}))
|
|
defer server.Close()
|
|
sender, err := New(Config{URL: server.URL})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
_, err = sender.Deliver(context.Background(), validRequest())
|
|
if !errors.Is(err, otpdelivery.ErrOutcomeUnknown) {
|
|
t.Fatalf("err = %v, want unknown outcome", err)
|
|
}
|
|
}
|
|
|
|
func TestDeliverTransportFailureIsOutcomeUnknown(t *testing.T) {
|
|
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
|
return nil, errors.New("connection reset after write")
|
|
})}
|
|
sender, err := New(Config{URL: "https://otp.example.test/send", Client: client})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
_, err = sender.Deliver(context.Background(), validRequest())
|
|
if !errors.Is(err, otpdelivery.ErrOutcomeUnknown) || !strings.Contains(err.Error(), "connection reset") {
|
|
t.Fatalf("err = %v, want unknown transport outcome", err)
|
|
}
|
|
}
|
|
|
|
func TestDeliverDoesNotFollowRedirect(t *testing.T) {
|
|
targetCalled := false
|
|
target := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
|
targetCalled = true
|
|
}))
|
|
defer target.Close()
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Location", target.URL)
|
|
w.WriteHeader(http.StatusTemporaryRedirect)
|
|
}))
|
|
defer server.Close()
|
|
sender, err := New(Config{URL: server.URL})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
_, err = sender.Deliver(context.Background(), validRequest())
|
|
var rejected *otpdelivery.RejectedError
|
|
if !errors.As(err, &rejected) || rejected.StatusCode != http.StatusTemporaryRedirect {
|
|
t.Fatalf("err = %v, want redirect rejection", err)
|
|
}
|
|
if targetCalled {
|
|
t.Fatal("redirect target received OTP")
|
|
}
|
|
}
|
|
|
|
func validRequest() otpdelivery.Request {
|
|
return otpdelivery.Request{
|
|
DeliveryID: "otp_valid",
|
|
Purpose: otpdelivery.PurposeLoginSMS,
|
|
Channel: otpdelivery.ChannelSMS,
|
|
Recipient: "15550001001",
|
|
Code: "12345",
|
|
ExpiresAt: time.Now().Add(time.Minute),
|
|
}
|
|
}
|
|
|
|
type roundTripFunc func(*http.Request) (*http.Response, error)
|
|
|
|
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
return f(req)
|
|
}
|