veza/veza-backend-api/internal/services/hyperswitch/webhook_log_test.go
senke 3c4d0148be feat(webhooks): persist raw hyperswitch payloads to audit log — v1.0.7 item E
Every POST /webhooks/hyperswitch delivery now writes a row to
`hyperswitch_webhook_log` regardless of signature-valid or
processing outcome. Captures both legitimate deliveries and attack
probes — a forensics query now has the actual bytes to read, not
just a "webhook rejected" log line. Disputes (axis-1 P1.6) ride
along: the log captures dispute.* events alongside payment and
refund events, ready for when disputes get a handler.

Table shape (migration 984):
  * payload TEXT — readable in psql, invalid UTF-8 replaced with
    empty (forensics value is in headers + ip + timing for those
    attacks, not the binary body).
  * signature_valid BOOLEAN + partial index for "show me attack
    attempts" being instantaneous.
  * processing_result TEXT — 'ok' / 'error: <msg>' /
    'signature_invalid' / 'skipped'. Matches the P1.5 action
    semantic exactly.
  * source_ip, user_agent, request_id — forensics essentials.
    request_id is captured from Hyperswitch's X-Request-Id header
    when present, else a server-side UUID so every row correlates
    to VEZA's structured logs.
  * event_type — best-effort extract from the JSON payload, NULL
    on malformed input.

Hardening:
  * 64KB body cap via io.LimitReader rejects oversize with 413
    before any INSERT — prevents log-spam DoS.
  * Single INSERT per delivery with final state; no two-phase
    update race on signature-failure path. signature_invalid and
    processing-error rows both land.
  * DB persistence failures are logged but swallowed — the
    endpoint's contract is to ack Hyperswitch, not perfect audit.

Retention sweep:
  * CleanupHyperswitchWebhookLog in internal/jobs, daily tick,
    batched DELETE (10k rows + 100ms pause) so a large backlog
    doesn't lock the table.
  * HYPERSWITCH_WEBHOOK_LOG_RETENTION_DAYS (default 90).
  * Same goroutine-ticker pattern as ScheduleOrphanTracksCleanup.
  * Wired in cmd/api/main.go alongside the existing cleanup jobs.

Tests: 5 in webhook_log_test.go (persistence, request_id auto-gen,
invalid-JSON leaves event_type empty, invalid-signature capture,
extractEventType 5 sub-cases) + 4 in cleanup_hyperswitch_webhook_
log_test.go (deletes-older-than, noop, default-on-zero,
context-cancel). Migration 984 applied cleanly to local Postgres;
all indexes present.

Also (v107-plan.md):
  * Item G acceptance gains an explicit Idempotency-Key threading
    requirement with an empty-key loud-fail test — "literally
    copy-paste D's 4-line test skeleton". Closes the risk that
    item G silently reopens the HTTP-retry duplicate-charge
    exposure D closed.

Out of scope for E (noted in CHANGELOG):
  * Rate limit on the endpoint — pre-existing middleware covers
    it at the router level; adding a per-endpoint limit is
    separate scope.
  * Readable-payload SQL view — deferred, the TEXT column is
    already human-readable; a convenience view is a nice-to-have
    not a ship-blocker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 02:44:58 +02:00

124 lines
3.9 KiB
Go

package hyperswitch
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func setupWebhookLogDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&WebhookLog{}))
return db
}
func TestLogWebhook_PersistsMinimalFields(t *testing.T) {
db := setupWebhookLogDB(t)
row := &WebhookLog{
Payload: `{"event_type":"payment.succeeded","payment_id":"pay_1"}`,
SignatureValid: true,
SignatureHeader: "deadbeef",
ProcessingResult: "ok",
SourceIP: "203.0.113.7",
UserAgent: "Hyperswitch/1.0",
RequestID: "req_abc",
}
require.NoError(t, LogWebhook(context.Background(), db, row))
var persisted WebhookLog
require.NoError(t, db.First(&persisted, row.ID).Error)
assert.Equal(t, row.Payload, persisted.Payload)
assert.True(t, persisted.SignatureValid)
assert.Equal(t, "ok", persisted.ProcessingResult)
assert.Equal(t, "203.0.113.7", persisted.SourceIP)
assert.Equal(t, "Hyperswitch/1.0", persisted.UserAgent)
assert.Equal(t, "req_abc", persisted.RequestID)
// event_type is extracted from the payload on insert — the caller
// didn't populate it, LogWebhook did.
assert.Equal(t, "payment.succeeded", persisted.EventType)
// received_at auto-populated
assert.False(t, persisted.ReceivedAt.IsZero())
// Explicit non-nil ID
assert.NotEqual(t, uuid.Nil, persisted.ID)
}
func TestLogWebhook_FillsMissingRequestID(t *testing.T) {
db := setupWebhookLogDB(t)
row := &WebhookLog{
Payload: `{}`,
SignatureValid: false,
ProcessingResult: "signature_invalid",
// RequestID left empty — LogWebhook must generate one.
}
require.NoError(t, LogWebhook(context.Background(), db, row))
assert.NotEmpty(t, row.RequestID)
_, err := uuid.Parse(row.RequestID)
assert.NoError(t, err, "generated request_id must be a valid UUID")
}
func TestLogWebhook_InvalidJSONLeavesEventTypeEmpty(t *testing.T) {
db := setupWebhookLogDB(t)
row := &WebhookLog{
Payload: `not json at all`,
SignatureValid: false,
ProcessingResult: "signature_invalid",
RequestID: "req_probe",
}
require.NoError(t, LogWebhook(context.Background(), db, row))
var persisted WebhookLog
require.NoError(t, db.First(&persisted, row.ID).Error)
// Attack probes / malformed payloads: event_type stays empty, no
// insert failure — the row exists for forensics regardless.
assert.Empty(t, persisted.EventType)
assert.Equal(t, "not json at all", persisted.Payload)
}
func TestLogWebhook_CapturesInvalidSignatureRows(t *testing.T) {
db := setupWebhookLogDB(t)
// The point of the log: even rejected deliveries persist. Drive
// the insert the way the handler would on a signature failure.
row := &WebhookLog{
Payload: `{"fake":"payload"}`,
SignatureValid: false,
SignatureHeader: "invalid-sig",
ProcessingResult: "signature_invalid",
SourceIP: "198.51.100.42",
RequestID: "req_attack",
}
require.NoError(t, LogWebhook(context.Background(), db, row))
var count int64
require.NoError(t, db.Model(&WebhookLog{}).
Where("signature_valid = ? AND source_ip = ?", false, "198.51.100.42").
Count(&count).Error)
assert.Equal(t, int64(1), count,
"forensics query on signature_invalid rows must find the attack attempt")
}
func TestExtractEventType_Variants(t *testing.T) {
cases := []struct {
name string
payload string
want string
}{
{"valid event", `{"event_type":"refund.succeeded"}`, "refund.succeeded"},
{"extra fields", `{"payment_id":"x","event_type":"payment.processing","amount":500}`, "payment.processing"},
{"missing field", `{"payment_id":"x"}`, ""},
{"empty payload", "", ""},
{"not json", `<xml><event>foo</event></xml>`, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, extractEventType(tc.payload))
})
}
}