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>
293 lines
10 KiB
Go
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")
|
|
}
|
|
}
|