mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-25 14:22:57 +00:00
* fix(security): harden upstream critical surfaces Refs #30 * fix(security): close pre-landing review gaps Refs #30 * fix(security): close official release blockers
223 lines
7.1 KiB
Go
223 lines
7.1 KiB
Go
package http
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// TestExtractBodyHash_canonical verifies that extractBodyHash correctly parses
|
|
// the canonical {"body_hash":"...","meta":{...}} JSON shape produced by buildAuditPayload.
|
|
func TestExtractBodyHash_canonical(t *testing.T) {
|
|
body := []byte(`{"input":"hello"}`)
|
|
payload, err := buildAuditPayload(body, map[string]string{"key": "val"})
|
|
if err != nil {
|
|
t.Fatalf("buildAuditPayload: %v", err)
|
|
}
|
|
|
|
got := extractBodyHash(payload)
|
|
want := sha256Hex(body)
|
|
if got != want {
|
|
t.Errorf("extractBodyHash got %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
// TestExtractBodyHash_oldFormat ensures the old hex-prefix format (non-JSON bytes)
|
|
// is rejected (returns ""), preventing hash bypass via legacy records.
|
|
func TestExtractBodyHash_oldFormat(t *testing.T) {
|
|
// Old format: 64 hex bytes + JSON suffix (not valid JSON at top level).
|
|
body := []byte(`{"x":1}`)
|
|
hexHash := sha256Hex(body)
|
|
old := append([]byte(hexHash), []byte(`{"channel_name":"c"}`)...)
|
|
|
|
got := extractBodyHash(old)
|
|
if got != "" {
|
|
t.Errorf("old hex-prefix format should return \"\", got %q", got)
|
|
}
|
|
}
|
|
|
|
// TestExtractBodyHash_empty returns "" for nil/empty payload.
|
|
func TestExtractBodyHash_empty(t *testing.T) {
|
|
if got := extractBodyHash(nil); got != "" {
|
|
t.Errorf("nil payload: want \"\", got %q", got)
|
|
}
|
|
if got := extractBodyHash([]byte{}); got != "" {
|
|
t.Errorf("empty payload: want \"\", got %q", got)
|
|
}
|
|
}
|
|
|
|
// TestExtractBodyHash_missingField returns "" when body_hash field is absent.
|
|
func TestExtractBodyHash_missingField(t *testing.T) {
|
|
payload := []byte(`{"meta":{"channel_name":"c"}}`)
|
|
if got := extractBodyHash(payload); got != "" {
|
|
t.Errorf("missing body_hash: want \"\", got %q", got)
|
|
}
|
|
}
|
|
|
|
// TestExtractBodyHash_wrongLength returns "" when body_hash is not 64 chars.
|
|
func TestExtractBodyHash_wrongLength(t *testing.T) {
|
|
payload := []byte(`{"body_hash":"abc123","meta":{}}`)
|
|
if got := extractBodyHash(payload); got != "" {
|
|
t.Errorf("short hash: want \"\", got %q", got)
|
|
}
|
|
}
|
|
|
|
// TestExtractBodyHash_nonHexChars returns "" when body_hash contains non-hex chars.
|
|
func TestExtractBodyHash_nonHexChars(t *testing.T) {
|
|
// 64 chars but contains uppercase G — not valid lowercase hex.
|
|
badHash := "GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG"
|
|
payload, _ := json.Marshal(map[string]string{"body_hash": badHash})
|
|
if got := extractBodyHash(payload); got != "" {
|
|
t.Errorf("non-hex chars: want \"\", got %q", got)
|
|
}
|
|
}
|
|
|
|
// TestBuildAuditPayload_shape verifies the top-level JSON structure.
|
|
func TestBuildAuditPayload_shape(t *testing.T) {
|
|
body := []byte(`{"input":"test"}`)
|
|
meta := map[string]string{"channel": "tg"}
|
|
|
|
payload, err := buildAuditPayload(body, meta)
|
|
if err != nil {
|
|
t.Fatalf("buildAuditPayload: %v", err)
|
|
}
|
|
|
|
var p struct {
|
|
BodyHash string `json:"body_hash"`
|
|
Meta json.RawMessage `json:"meta"`
|
|
}
|
|
if err := json.Unmarshal(payload, &p); err != nil {
|
|
t.Fatalf("payload not valid JSON: %v\npayload: %s", err, payload)
|
|
}
|
|
if len(p.BodyHash) != 64 {
|
|
t.Errorf("body_hash length %d, want 64", len(p.BodyHash))
|
|
}
|
|
if p.BodyHash != sha256Hex(body) {
|
|
t.Errorf("body_hash mismatch")
|
|
}
|
|
if len(p.Meta) == 0 {
|
|
t.Error("meta must not be empty")
|
|
}
|
|
}
|
|
|
|
// TestCheckIdempotency_malformedStoredHash verifies that a stored row with
|
|
// an empty/malformed body_hash (extractBodyHash returns "") causes a 409 Conflict
|
|
// response rather than falling through to replay. This is the K3 fail-closed fix:
|
|
// storedHash != bodyHash includes the empty-string case, preventing a corrupt or
|
|
// tampered stored row from serving as a replay vehicle for arbitrary request bodies.
|
|
func TestCheckIdempotency_malformedStoredHash(t *testing.T) {
|
|
webhookID := uuid.New()
|
|
body := []byte(`{"input":"hello"}`)
|
|
|
|
// Stored row has malformed request_payload (not valid canonical JSON).
|
|
// extractBodyHash will return "" for this payload.
|
|
malformedPayload := []byte(`not-valid-json`)
|
|
existing := &store.WebhookCallData{
|
|
ID: uuid.New(),
|
|
WebhookID: webhookID,
|
|
IdempotencyKey: strPtr("idem-key-1"),
|
|
RequestPayload: malformedPayload,
|
|
Status: "completed",
|
|
}
|
|
|
|
calls := newStubCallStore(existing)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/webhooks/llm", strings.NewReader(string(body)))
|
|
req.Header.Set("Idempotency-Key", "idem-key-1")
|
|
rec := httptest.NewRecorder()
|
|
|
|
proceed, err := checkIdempotency(rec, req, body, webhookID, calls)
|
|
|
|
if proceed {
|
|
t.Error("expected proceed=false (409 written), got proceed=true")
|
|
}
|
|
if err == nil {
|
|
t.Error("expected non-nil error for idempotency conflict")
|
|
}
|
|
if rec.Code != http.StatusConflict {
|
|
t.Errorf("expected 409 Conflict, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestCheckIdempotency_StaleSyncReservationExpires(t *testing.T) {
|
|
webhookID := uuid.New()
|
|
body := []byte(`{"input":"hello"}`)
|
|
payload, err := buildAuditPayload(body, map[string]string{"input": "hello"})
|
|
if err != nil {
|
|
t.Fatalf("buildAuditPayload: %v", err)
|
|
}
|
|
|
|
key := "idem-stale-sync"
|
|
startedAt := time.Now().Add(-(webhookSyncReservationTTL + time.Second))
|
|
existing := &store.WebhookCallData{
|
|
ID: uuid.New(),
|
|
WebhookID: webhookID,
|
|
IdempotencyKey: &key,
|
|
Mode: "sync",
|
|
Status: "running",
|
|
RequestPayload: payload,
|
|
StartedAt: &startedAt,
|
|
CreatedAt: startedAt,
|
|
}
|
|
calls := newStubCallStore(existing)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/webhooks/llm", strings.NewReader(string(body)))
|
|
req.Header.Set("Idempotency-Key", key)
|
|
rec := httptest.NewRecorder()
|
|
|
|
proceed, err := checkIdempotency(rec, req, body, webhookID, calls)
|
|
|
|
if proceed {
|
|
t.Fatal("expected stale idempotency row to be handled, got proceed=true")
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("expected nil error for expired replay response, got %v", err)
|
|
}
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 replay for expired row, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
if rec.Header().Get("X-Idempotency-Replayed") != "true" {
|
|
t.Fatal("expected X-Idempotency-Replayed header")
|
|
}
|
|
if existing.Status != "failed" {
|
|
t.Fatalf("expected stale row status failed, got %q", existing.Status)
|
|
}
|
|
if len(existing.Response) == 0 || !strings.Contains(string(existing.Response), "sync idempotency reservation expired") {
|
|
t.Fatalf("expected stored expiry response, got %s", string(existing.Response))
|
|
}
|
|
}
|
|
|
|
// strPtr is a test helper returning a pointer to s.
|
|
func strPtr(s string) *string { return &s }
|
|
|
|
// TestBuildAuditPayload_validJSON ensures the output is always valid JSON
|
|
// (the property that prevented PG 22P02 errors).
|
|
func TestBuildAuditPayload_validJSON(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
body []byte
|
|
meta any
|
|
}{
|
|
{"string meta", []byte(`{}`), "just a string"},
|
|
{"nil meta", []byte(`{}`), nil},
|
|
{"nested meta", []byte(`{"a":1}`), map[string]any{"x": []int{1, 2, 3}}},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
p, err := buildAuditPayload(tc.body, tc.meta)
|
|
if err != nil {
|
|
t.Fatalf("buildAuditPayload: %v", err)
|
|
}
|
|
if !json.Valid(p) {
|
|
t.Errorf("output not valid JSON: %s", p)
|
|
}
|
|
})
|
|
}
|
|
}
|