api/webhook_test.go
Snider da1839f730 feat(api): webhooks + sunset headers + WithWebSocket + cmd/api migration
- webhook.go: HMAC-SHA256 WebhookSigner matching PHP WebhookSignature —
  sign/verify, X-Webhook-Signature / X-Webhook-Timestamp headers,
  VerifyRequest middleware helper, 5-minute default tolerance,
  secret generator (RFC §6)
- sunset.go: ApiSunsetWith(date, replacement, opts...) + WithSunsetNoticeURL;
  ApiSunset now emits API-Suggested-Replacement when replacement set;
  RouteDescription.NoticeURL surfaces API-Deprecation-Notice-URL (RFC §8)
- options.go + api.go + transport.go: WithWebSocket(gin.HandlerFunc)
  alongside existing WithWSHandler(http.Handler); gin form wins when
  both supplied (RFC §2.2)
- openapi.go: apiSuggestedReplacement + apiDeprecationNoticeURL as
  reusable header components; NoticeURL on a RouteDescription flips
  operation deprecated flag and emits response header doc
- cmd/api/*.go: migrated from Cobra (cli.NewCommand, StringFlag) to
  new path-based CLI API (c.Command + core.Options.String/Int/Bool);
  replaces the 1,422-line Cobra test suite with _Good/_Bad/_Ugly
  triads on the new surface
- webhook_test.go + sunset_test.go + websocket_test.go: full coverage

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-14 14:51:04 +01:00

226 lines
8.2 KiB
Go

// SPDX-License-Identifier: EUPL-1.2
package api
import (
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
)
// TestWebhook_NewWebhookSigner_Good_BuildsSignerWithDefaults verifies the
// constructor sets up a usable signer with the documented default tolerance.
func TestWebhook_NewWebhookSigner_Good_BuildsSignerWithDefaults(t *testing.T) {
s := NewWebhookSigner("hello")
if s == nil {
t.Fatal("expected non-nil signer")
}
if s.Tolerance() != DefaultWebhookTolerance {
t.Fatalf("expected default tolerance %s, got %s", DefaultWebhookTolerance, s.Tolerance())
}
}
// TestWebhook_NewWebhookSignerWithTolerance_Good_OverridesTolerance ensures the
// custom-tolerance constructor is honoured for positive durations.
func TestWebhook_NewWebhookSignerWithTolerance_Good_OverridesTolerance(t *testing.T) {
s := NewWebhookSignerWithTolerance("x", 30*time.Second)
if s.Tolerance() != 30*time.Second {
t.Fatalf("expected 30s tolerance, got %s", s.Tolerance())
}
}
// TestWebhook_NewWebhookSignerWithTolerance_Ugly_FallsBackOnZero verifies a
// non-positive tolerance falls back to the documented default rather than
// silently disabling replay protection.
func TestWebhook_NewWebhookSignerWithTolerance_Ugly_FallsBackOnZero(t *testing.T) {
s := NewWebhookSignerWithTolerance("x", 0)
if s.Tolerance() != DefaultWebhookTolerance {
t.Fatalf("expected default tolerance after zero override, got %s", s.Tolerance())
}
s = NewWebhookSignerWithTolerance("x", -5*time.Minute)
if s.Tolerance() != DefaultWebhookTolerance {
t.Fatalf("expected default tolerance after negative override, got %s", s.Tolerance())
}
}
// TestWebhook_GenerateWebhookSecret_Good_Returns64HexChars ensures the helper
// returns a stable-format secret of the documented length.
func TestWebhook_GenerateWebhookSecret_Good_Returns64HexChars(t *testing.T) {
secret, err := GenerateWebhookSecret()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(secret) != 64 {
t.Fatalf("expected 64-char secret, got %d", len(secret))
}
for _, r := range secret {
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f')) {
t.Fatalf("expected lowercase hex characters, got %q", secret)
}
}
}
// TestWebhook_Sign_Good_ProducesStableHexDigest ensures the sign helper is
// deterministic for the same payload, secret, and timestamp.
func TestWebhook_Sign_Good_ProducesStableHexDigest(t *testing.T) {
s := NewWebhookSigner("secret")
first := s.Sign([]byte("payload"), 1234567890)
second := s.Sign([]byte("payload"), 1234567890)
if first != second {
t.Fatalf("expected stable digest, got %s vs %s", first, second)
}
if len(first) != 64 {
t.Fatalf("expected 64-char hex digest, got %d", len(first))
}
}
// TestWebhook_Sign_Bad_ReturnsEmptyOnNilReceiver guards the nil-receiver
// behaviour required for safe defensive use in middleware.
func TestWebhook_Sign_Bad_ReturnsEmptyOnNilReceiver(t *testing.T) {
var s *WebhookSigner
if got := s.Sign([]byte("x"), 1); got != "" {
t.Fatalf("expected empty digest from nil receiver, got %q", got)
}
}
// TestWebhook_SignNow_Good_RoundTripsCurrentTimestamp verifies SignNow returns
// a fresh timestamp that the verifier accepts.
func TestWebhook_SignNow_Good_RoundTripsCurrentTimestamp(t *testing.T) {
s := NewWebhookSigner("secret")
payload := []byte(`{"event":"workspace.created"}`)
sig, ts := s.SignNow(payload)
if !s.Verify(payload, sig, ts) {
t.Fatal("expected SignNow output to verify")
}
}
// TestWebhook_Verify_Good_AcceptsMatchingSignature exercises the happy path of
// matching payload/signature/timestamp inside the tolerance window.
func TestWebhook_Verify_Good_AcceptsMatchingSignature(t *testing.T) {
s := NewWebhookSigner("secret")
payload := []byte("body")
now := time.Now().Unix()
sig := s.Sign(payload, now)
if !s.Verify(payload, sig, now) {
t.Fatal("expected valid signature to verify")
}
}
// TestWebhook_Verify_Bad_RejectsTamperedPayload ensures payload mutation
// invalidates the signature even when the secret/timestamp are valid.
func TestWebhook_Verify_Bad_RejectsTamperedPayload(t *testing.T) {
s := NewWebhookSigner("secret")
now := time.Now().Unix()
sig := s.Sign([]byte("body"), now)
if s.Verify([]byte("tampered"), sig, now) {
t.Fatal("expected verification to fail for tampered payload")
}
}
// TestWebhook_Verify_Bad_RejectsExpiredTimestamp ensures stale timestamps fail
// even when the signature itself is valid for the older timestamp.
func TestWebhook_Verify_Bad_RejectsExpiredTimestamp(t *testing.T) {
s := NewWebhookSignerWithTolerance("secret", time.Minute)
old := time.Now().Add(-2 * time.Minute).Unix()
sig := s.Sign([]byte("body"), old)
if s.Verify([]byte("body"), sig, old) {
t.Fatal("expected stale timestamp to be rejected")
}
}
// TestWebhook_VerifySignatureOnly_Good_IgnoresExpiredTimestamp lets callers
// validate signature integrity even when timestamps fall outside tolerance.
func TestWebhook_VerifySignatureOnly_Good_IgnoresExpiredTimestamp(t *testing.T) {
s := NewWebhookSignerWithTolerance("secret", time.Second)
old := time.Now().Add(-time.Hour).Unix()
sig := s.Sign([]byte("body"), old)
if !s.VerifySignatureOnly([]byte("body"), sig, old) {
t.Fatal("expected signature-only verification to pass for expired timestamp")
}
}
// TestWebhook_Headers_Good_PopulatesSignatureAndTimestamp verifies the header
// helper returns both the signature and the timestamp that produced it.
func TestWebhook_Headers_Good_PopulatesSignatureAndTimestamp(t *testing.T) {
s := NewWebhookSigner("secret")
headers := s.Headers([]byte("body"))
if headers[WebhookSignatureHeader] == "" {
t.Fatal("expected signature header to be set")
}
if headers[WebhookTimestampHeader] == "" {
t.Fatal("expected timestamp header to be set")
}
ts, err := strconv.ParseInt(headers[WebhookTimestampHeader], 10, 64)
if err != nil {
t.Fatalf("expected numeric timestamp header, got %q", headers[WebhookTimestampHeader])
}
if !s.Verify([]byte("body"), headers[WebhookSignatureHeader], ts) {
t.Fatal("expected Headers() output to verify")
}
}
// TestWebhook_VerifyRequest_Good_AcceptsValidHeaders uses the request helper
// to ensure middleware can verify webhooks straight from an http.Request.
func TestWebhook_VerifyRequest_Good_AcceptsValidHeaders(t *testing.T) {
s := NewWebhookSigner("secret")
payload := []byte(`{"event":"link.clicked"}`)
headers := s.Headers(payload)
r := httptest.NewRequest(http.MethodPost, "/incoming", strings.NewReader(string(payload)))
for k, v := range headers {
r.Header.Set(k, v)
}
if !s.VerifyRequest(r, payload) {
t.Fatal("expected VerifyRequest to accept valid signed request")
}
}
// TestWebhook_VerifyRequest_Bad_RejectsMissingHeaders rejects requests with
// missing or malformed signature/timestamp headers.
func TestWebhook_VerifyRequest_Bad_RejectsMissingHeaders(t *testing.T) {
s := NewWebhookSigner("secret")
r := httptest.NewRequest(http.MethodPost, "/incoming", strings.NewReader("body"))
if s.VerifyRequest(r, []byte("body")) {
t.Fatal("expected VerifyRequest to fail with no headers")
}
r.Header.Set(WebhookSignatureHeader, "deadbeef")
if s.VerifyRequest(r, []byte("body")) {
t.Fatal("expected VerifyRequest to fail with missing timestamp header")
}
r.Header.Set(WebhookTimestampHeader, "not-a-number")
if s.VerifyRequest(r, []byte("body")) {
t.Fatal("expected VerifyRequest to fail with malformed timestamp header")
}
}
// TestWebhook_VerifyRequest_Ugly_NilRequestReturnsFalse documents the
// defensive nil-request guard so middleware can safely call this helper.
func TestWebhook_VerifyRequest_Ugly_NilRequestReturnsFalse(t *testing.T) {
s := NewWebhookSigner("secret")
if s.VerifyRequest(nil, []byte("body")) {
t.Fatal("expected VerifyRequest(nil) to return false")
}
}
// TestWebhook_IsTimestampValid_Good_UsesConfiguredTolerance exercises the
// inclusive boundary where the timestamp falls right at the tolerance edge.
func TestWebhook_IsTimestampValid_Good_UsesConfiguredTolerance(t *testing.T) {
s := NewWebhookSignerWithTolerance("x", time.Minute)
now := time.Now().Unix()
if !s.IsTimestampValid(now) {
t.Fatal("expected current timestamp to be valid")
}
if !s.IsTimestampValid(now - 30) {
t.Fatal("expected timestamp within tolerance to be valid")
}
if s.IsTimestampValid(now - 120) {
t.Fatal("expected timestamp outside tolerance to be invalid")
}
}