api/webhook_test.go
Snider fb498f0b88 feat(api): canonical webhook events + chat completions transport discovery
Implements gaps between RFC.md spec and code:

- Export canonical webhook event identifiers (RFC §6) as Go constants:
  WebhookEventWorkspaceCreated, WebhookEventLinkClicked, etc. Plus
  WebhookEvents() and IsKnownWebhookEvent(name) helpers for SDK consumers
  and middleware validation.

- Surface the chat completions endpoint (RFC §11.1) through TransportConfig
  (ChatCompletionsEnabled + ChatCompletionsPath) and the OpenAPI spec
  extensions (x-chat-completions-enabled, x-chat-completions-path) so
  clients can auto-discover the local OpenAI-compatible endpoint.

- Add internal test coverage for chat completions sampling defaults
  (Gemma 4 calibrated temp=1.0, top_p=0.95, top_k=64, max_tokens=2048)
  and the ThinkingExtractor channel routing (RFC §11.6).

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

293 lines
10 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_WebhookEvents_Good_ListsCanonicalIdentifiers verifies the
// exported list of canonical event names documented in RFC §6.
func TestWebhook_WebhookEvents_Good_ListsCanonicalIdentifiers(t *testing.T) {
got := WebhookEvents()
want := []string{
"workspace.created",
"workspace.deleted",
"subscription.changed",
"subscription.cancelled",
"biolink.created",
"link.clicked",
"ticket.created",
"ticket.replied",
}
if len(got) != len(want) {
t.Fatalf("expected %d events, got %d: %v", len(want), len(got), got)
}
for i, evt := range want {
if got[i] != evt {
t.Fatalf("index %d: expected %q, got %q", i, evt, got[i])
}
}
}
// TestWebhook_WebhookEvents_Good_ReturnsFreshSlice ensures the returned slice
// is safe to mutate — callers never corrupt the canonical list.
func TestWebhook_WebhookEvents_Good_ReturnsFreshSlice(t *testing.T) {
first := WebhookEvents()
first[0] = "mutated"
second := WebhookEvents()
if second[0] != "workspace.created" {
t.Fatalf("expected canonical list to be immutable, got %q", second[0])
}
}
// TestWebhook_IsKnownWebhookEvent_Good_RecognisesCanonical confirms canonical
// identifiers pass the known-event predicate.
func TestWebhook_IsKnownWebhookEvent_Good_RecognisesCanonical(t *testing.T) {
for _, evt := range WebhookEvents() {
if !IsKnownWebhookEvent(evt) {
t.Fatalf("expected %q to be recognised", evt)
}
}
}
// TestWebhook_IsKnownWebhookEvent_Good_TrimsWhitespace ensures whitespace
// around user-supplied event names does not defeat the lookup.
func TestWebhook_IsKnownWebhookEvent_Good_TrimsWhitespace(t *testing.T) {
if !IsKnownWebhookEvent(" workspace.created ") {
t.Fatal("expected whitespace-padded identifier to be recognised")
}
}
// TestWebhook_IsKnownWebhookEvent_Bad_RejectsUnknown guards against silent
// acceptance of typos or out-of-catalogue event identifiers.
func TestWebhook_IsKnownWebhookEvent_Bad_RejectsUnknown(t *testing.T) {
if IsKnownWebhookEvent("") {
t.Fatal("expected empty string to be rejected")
}
if IsKnownWebhookEvent("workspace.created.extra") {
t.Fatal("expected suffixed event to be rejected")
}
if IsKnownWebhookEvent("Workspace.Created") {
t.Fatal("expected differently-cased identifier to be rejected")
}
}
// 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")
}
}