clavitor/clavitor.ai/admin/main_test.go

897 lines
24 KiB
Go

// Admin service tests - automated verification of all endpoints
package main
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"database/sql"
"encoding/hex"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
_ "github.com/mattn/go-sqlite3"
)
// TestSuite holds test state
type TestSuite struct {
Router *chi.Mux
DB *sql.DB
}
// setup creates a test environment with fresh database
func setup() *TestSuite {
// Create in-memory database for tests
testDB, err := sql.Open("sqlite3", ":memory:")
if err != nil {
panic(err)
}
// Initialize schema
initDBWithConn(testDB)
// Set global db for handlers
db = testDB
// Create router
r := chi.NewRouter()
// Mount all routes
r.Get("/", handleDynamicHome)
r.Get("/table/{table}", handleDynamicList)
r.Get("/table/{table}/new", handleDynamicForm)
r.Post("/table/{table}", handleDynamicCreate)
r.Get("/table/{table}/{id}", handleDynamicView)
r.Get("/table/{table}/{id}/edit", handleDynamicEdit)
r.Post("/table/{table}/{id}", handleDynamicUpdate)
// API routes
r.Post("/api/checkout", HandleCheckout)
r.Post("/webhooks/paddle", HandlePaddleWebhook)
return &TestSuite{Router: r, DB: testDB}
}
func (s *TestSuite) teardown() {
s.DB.Close()
}
// Helper to make requests
func (s *TestSuite) request(method, path string, body io.Reader) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, body)
if method == "POST" {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
rr := httptest.NewRecorder()
s.Router.ServeHTTP(rr, req)
return rr
}
// Test 1: Home page loads and shows all tables
func TestHomePage(t *testing.T) {
s := setup()
defer s.teardown()
rr := s.request("GET", "/", nil)
if rr.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", rr.Code)
}
body := rr.Body.String()
// Check that all expected tables are listed
expectedTables := []string{"products", "customers", "subscriptions", "vaults", "events"}
for _, table := range expectedTables {
if !strings.Contains(body, table) {
t.Errorf("Home page missing table: %s", table)
}
}
// Check products are pre-populated
if !strings.Contains(body, "pro_personal") {
t.Log("Note: products may not be pre-populated in test DB")
}
}
// Test 2: All tables have list pages
func TestTableListPages(t *testing.T) {
s := setup()
defer s.teardown()
tables := []string{
"products", "prices", "discounts",
"customers", "addresses", "businesses",
"subscriptions", "subscription_items", "transactions",
"adjustments", "vaults", "credentials",
"roles", "humans", "role_assignments",
"pop_sync_state", "events", "wl3_storage",
}
for _, table := range tables {
rr := s.request("GET", "/table/"+table, nil)
if rr.Code != http.StatusOK {
t.Errorf("Table %s list page failed: status %d", table, rr.Code)
continue
}
body := rr.Body.String()
// Check table header exists
if !strings.Contains(body, "<table>") {
t.Errorf("Table %s missing HTML table", table)
}
// Check navigation
if !strings.Contains(body, table) {
t.Errorf("Table %s page missing title reference", table)
}
}
}
// Test 3: Form pages exist for all tables
func TestFormPages(t *testing.T) {
s := setup()
defer s.teardown()
tables := []string{"customers", "products", "discounts", "subscriptions"}
for _, table := range tables {
rr := s.request("GET", "/table/"+table+"/new", nil)
if rr.Code != http.StatusOK {
t.Errorf("Form page for %s failed: status %d", table, rr.Code)
continue
}
body := rr.Body.String()
// Check form exists
if !strings.Contains(body, "<form") {
t.Errorf("Form page for %s missing form element", table)
}
// Check submit button
if !strings.Contains(body, "type=\"submit\"") {
t.Errorf("Form page for %s missing submit button", table)
}
}
}
// Test 4: Create customer via form POST (Paddle sync may fail in tests)
func TestCreateCustomer(t *testing.T) {
s := setup()
defer s.teardown()
form := url.Values{}
form.Set("email", "test@example.com")
form.Set("name", "Test Customer")
form.Set("locale", "en")
s.request("POST", "/table/customers", strings.NewReader(form.Encode()))
// In test mode, Paddle sync may fail, but local DB should still work
// Check directly in DB since the TLW may return an error but still create locally
var count int
s.DB.QueryRow("SELECT COUNT(*) FROM customers WHERE email = ?", "test@example.com").Scan(&count)
if count != 1 {
t.Errorf("Customer not created in local DB. Count: %d", count)
}
}
// Test 5: Create product via form POST
func TestCreateProduct(t *testing.T) {
s := setup()
defer s.teardown()
form := url.Values{}
form.Set("id", "pro_test_new")
form.Set("name", "Test Product New")
form.Set("description", "For testing")
form.Set("status", "active")
rr := s.request("POST", "/table/products", strings.NewReader(form.Encode()))
// Check directly in DB (products don't use Paddle sync)
var count int
s.DB.QueryRow("SELECT COUNT(*) FROM products WHERE id = ?", "pro_test_new").Scan(&count)
if count != 1 {
t.Errorf("Product not created. Count: %d, status: %d, body: %s", count, rr.Code, rr.Body.String())
}
}
// Test 6: View individual record
func TestViewRecord(t *testing.T) {
s := setup()
defer s.teardown()
// First create a product
form := url.Values{}
form.Set("id", "pro_viewtest")
form.Set("name", "View Test Product")
form.Set("status", "active")
s.request("POST", "/table/products", strings.NewReader(form.Encode()))
// Now view it
rr := s.request("GET", "/table/products/pro_viewtest", nil)
if rr.Code != http.StatusOK {
t.Errorf("View record failed: status %d", rr.Code)
}
body := rr.Body.String()
if !strings.Contains(body, "View Test Product") {
t.Errorf("View page doesn't show product name")
}
}
// Test 7: Edit record
func TestEditRecord(t *testing.T) {
s := setup()
defer s.teardown()
// Create a customer
form := url.Values{}
form.Set("email", "edit@test.com")
form.Set("name", "Before Edit")
s.request("POST", "/table/customers", strings.NewReader(form.Encode()))
// Get customer ID (we need to find it since it's auto-generated)
var customerID string
err := s.DB.QueryRow("SELECT id FROM customers WHERE email = ?", "edit@test.com").Scan(&customerID)
if err != nil {
t.Fatalf("Could not find created customer: %v", err)
}
// Edit form page
rr := s.request("GET", "/table/customers/"+customerID+"/edit", nil)
if rr.Code != http.StatusOK {
t.Errorf("Edit form failed: status %d", rr.Code)
}
// Submit edit
editForm := url.Values{}
editForm.Set("name", "After Edit")
editForm.Set("email", "edit@test.com") // Keep same email
rr = s.request("POST", "/table/customers/"+customerID, strings.NewReader(editForm.Encode()))
if rr.Code != http.StatusFound && rr.Code != http.StatusOK {
t.Errorf("Update failed: status %d, body: %s", rr.Code, rr.Body.String())
}
}
// Test 8: Schema introspection
func TestSchemaIntrospection(t *testing.T) {
s := setup()
defer s.teardown()
// Get list of tables
tables := getTablesWithConn(s.DB)
expectedTables := 18
if len(tables) < expectedTables {
t.Errorf("Expected at least %d tables, got %d: %v", expectedTables, len(tables), tables)
}
// Check each table has columns
for _, table := range []string{"customers", "products", "subscriptions"} {
cols := getTableInfoWithConn(s.DB, table)
if len(cols) == 0 {
t.Errorf("Table %s has no columns", table)
}
}
}
// Test 9: Database schema completeness
func TestSchemaCompleteness(t *testing.T) {
s := setup()
defer s.teardown()
// Check all expected tables exist
requiredTables := map[string][]string{
"customers": {"id", "email", "name", "status", "created_at"},
"products": {"id", "name", "description", "status"},
"prices": {"id", "product_id", "unit_price"},
"subscriptions": {"id", "customer_id", "status", "items"},
"vaults": {"l0", "customer_id", "status"},
"credentials": {"id", "l0", "p0"},
"events": {"id", "occurred_at", "event_type"},
"roles": {"id", "name", "permissions"},
}
for table, requiredCols := range requiredTables {
cols := getTableInfoWithConn(s.DB, table)
colMap := make(map[string]bool)
for _, c := range cols {
colMap[c.Name] = true
}
for _, col := range requiredCols {
if !colMap[col] {
t.Errorf("Table %s missing required column: %s", table, col)
}
}
}
}
// Test 10: Sync layer integration (customers, subscriptions, discounts)
func TestSyncLayerEntities(t *testing.T) {
// These entities should be in EntityRegistry for Paddle sync
paddleEntities := []string{"customers", "addresses", "businesses", "subscriptions", "transactions", "discounts"}
for _, entity := range paddleEntities {
if _, ok := EntityRegistry[entity]; !ok {
t.Errorf("Entity %s not found in EntityRegistry for Paddle sync", entity)
}
}
}
// Test 11: Non-Paddle entities (no sync needed)
func TestNonSyncEntities(t *testing.T) {
// These entities should NOT be in EntityRegistry
localOnlyEntities := []string{"vaults", "credentials", "events", "roles", "humans", "pop_sync_state", "wl3_storage"}
for _, entity := range localOnlyEntities {
if _, ok := EntityRegistry[entity]; ok {
t.Logf("Note: %s is in EntityRegistry but probably shouldn't sync to Paddle", entity)
}
}
}
// Test 12: Pre-populated data
func TestPrepopulatedData(t *testing.T) {
s := setup()
defer s.teardown()
// Check roles are pre-populated
var roleCount int
err := s.DB.QueryRow("SELECT COUNT(*) FROM roles").Scan(&roleCount)
if err != nil {
t.Fatalf("Could not query roles: %v", err)
}
if roleCount != 6 {
t.Errorf("Expected 6 pre-populated roles, got %d", roleCount)
}
// Check specific roles exist
var superAdminCount int
s.DB.QueryRow("SELECT COUNT(*) FROM roles WHERE name = 'SuperAdmin'").Scan(&superAdminCount)
if superAdminCount != 1 {
t.Errorf("SuperAdmin role not found")
}
}
// Test 13: API endpoints
func TestAPIEndpoints(t *testing.T) {
s := setup()
defer s.teardown()
// Test checkout endpoint exists
rr := s.request("POST", "/api/checkout", bytes.NewReader([]byte(`{}`)))
// Should return error (no valid data) but not 404
if rr.Code == http.StatusNotFound {
t.Errorf("Checkout API endpoint not found")
}
// Test webhook endpoint — without a signature it must REFUSE.
// Signature verification is enforced; the previous "accept all" behavior
// was a SECURITY.md violation.
rr = s.request("POST", "/webhooks/paddle", bytes.NewReader([]byte(`{}`)))
if rr.Code != http.StatusUnauthorized {
t.Errorf("Webhook without signature should return 401, got %d", rr.Code)
}
}
// Test 14: Error handling - invalid table
func TestInvalidTable(t *testing.T) {
s := setup()
defer s.teardown()
rr := s.request("GET", "/table/nonexistent", nil)
if rr.Code != http.StatusNotFound {
t.Errorf("Invalid table should return 404, got %d", rr.Code)
}
}
// Test 15: Navigation is consistent
func TestNavigationConsistency(t *testing.T) {
s := setup()
defer s.teardown()
// Get home page
rr := s.request("GET", "/", nil)
homeBody := rr.Body.String()
// Get a table page
rr = s.request("GET", "/table/customers", nil)
tableBody := rr.Body.String()
// Both should have nav with Dashboard link
if !strings.Contains(homeBody, "Dashboard") || !strings.Contains(tableBody, "Dashboard") {
t.Errorf("Navigation inconsistent - missing Dashboard link")
}
}
// Benchmark: List page performance
func BenchmarkListPage(b *testing.B) {
s := setup()
defer s.teardown()
// Pre-populate with 100 customers
for i := 0; i < 100; i++ {
_, err := s.DB.Exec("INSERT INTO customers (id, email, name, status, created_at) VALUES (?, ?, ?, 'active', ?)",
fmt.Sprintf("ctm_%d", i), fmt.Sprintf("test%d@example.com", i), fmt.Sprintf("User %d", i), time.Now().Unix())
if err != nil {
b.Fatal(err)
}
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
s.request("GET", "/table/customers", nil)
}
}
// Helper functions that work with specific DB connection
func initDBWithConn(db *sql.DB) {
schemaSQL := `-- Core tables
CREATE TABLE IF NOT EXISTS products (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
tax_category TEXT,
image_url TEXT,
custom_data TEXT,
status TEXT DEFAULT 'active',
created_at INTEGER,
updated_at INTEGER
);
CREATE TABLE IF NOT EXISTS prices (
id TEXT PRIMARY KEY,
product_id TEXT REFERENCES products(id),
description TEXT,
name TEXT,
billing_cycle TEXT,
trial_period TEXT,
tax_mode TEXT DEFAULT 'account_setting',
unit_price TEXT,
unit_price_overrides TEXT,
quantity TEXT,
status TEXT DEFAULT 'active',
custom_data TEXT,
created_at INTEGER,
updated_at INTEGER
);
CREATE TABLE IF NOT EXISTS discounts (
id TEXT PRIMARY KEY,
status TEXT DEFAULT 'active',
description TEXT,
enabled_for_checkout INTEGER DEFAULT 0,
code TEXT,
type TEXT,
mode TEXT DEFAULT 'standard',
amount TEXT,
currency_code TEXT,
recur INTEGER DEFAULT 1,
maximum_recurring_intervals INTEGER,
usage_limit INTEGER,
restrict_to TEXT,
expires_at INTEGER,
times_used INTEGER DEFAULT 0,
discount_group_id TEXT,
custom_data TEXT,
created_at INTEGER,
updated_at INTEGER,
paddle_id TEXT,
sync_status TEXT DEFAULT 'pending',
sync_error TEXT,
last_paddle_sync_at INTEGER,
pending_since INTEGER
);
CREATE TABLE IF NOT EXISTS customers (
id TEXT PRIMARY KEY,
email TEXT NOT NULL,
name TEXT,
locale TEXT DEFAULT 'en',
status TEXT DEFAULT 'active',
marketing_consent INTEGER DEFAULT 0,
import_meta TEXT,
parent_id TEXT REFERENCES customers(id),
level INTEGER DEFAULT 1,
internal_notes TEXT,
custom_data TEXT,
created_at INTEGER,
updated_at INTEGER,
paddle_id TEXT,
billing_email TEXT,
sync_status TEXT DEFAULT 'pending',
sync_error TEXT,
last_paddle_sync_at INTEGER,
pending_since INTEGER
);
CREATE TABLE IF NOT EXISTS addresses (
id TEXT PRIMARY KEY,
customer_id TEXT NOT NULL REFERENCES customers(id),
description TEXT,
city TEXT,
region TEXT,
country_code TEXT NOT NULL,
zip_code TEXT,
status TEXT DEFAULT 'active',
created_at INTEGER,
updated_at INTEGER
);
CREATE TABLE IF NOT EXISTS businesses (
id TEXT PRIMARY KEY,
customer_id TEXT NOT NULL REFERENCES customers(id),
name TEXT NOT NULL,
company_number TEXT,
tax_identifier TEXT,
contacts TEXT,
status TEXT DEFAULT 'active',
custom_data TEXT,
created_at INTEGER,
updated_at INTEGER
);
CREATE TABLE IF NOT EXISTS subscriptions (
id TEXT PRIMARY KEY,
paddle_id TEXT,
customer_id TEXT NOT NULL REFERENCES customers(id),
address_id TEXT NOT NULL REFERENCES addresses(id),
business_id TEXT REFERENCES businesses(id),
currency_code TEXT NOT NULL,
status TEXT NOT NULL,
collection_mode TEXT DEFAULT 'automatic',
billing_details TEXT,
discount TEXT,
started_at INTEGER,
first_billed_at INTEGER,
next_billed_at INTEGER,
paused_at INTEGER,
canceled_at INTEGER,
vault_l0 TEXT,
billing_cycle TEXT,
current_billing_period TEXT,
scheduled_change TEXT,
items TEXT,
management_urls TEXT,
custom_data TEXT,
import_meta TEXT,
created_at INTEGER,
updated_at INTEGER,
consent_requirements TEXT,
sync_status TEXT DEFAULT 'pending',
sync_error TEXT,
last_paddle_sync_at INTEGER,
pending_since INTEGER
);
CREATE TABLE IF NOT EXISTS subscription_items (
id TEXT PRIMARY KEY,
subscription_id TEXT NOT NULL REFERENCES subscriptions(id),
price_id TEXT NOT NULL,
product_id TEXT NOT NULL,
quantity INTEGER DEFAULT 1,
status TEXT DEFAULT 'active',
recurring INTEGER DEFAULT 1,
created_at INTEGER,
updated_at INTEGER,
previously_billed_at INTEGER,
next_billed_at INTEGER,
trial_dates TEXT,
price_snapshot TEXT,
product_snapshot TEXT
);
CREATE TABLE IF NOT EXISTS transactions (
id TEXT PRIMARY KEY,
paddle_id TEXT,
subscription_id TEXT REFERENCES subscriptions(id),
customer_id TEXT NOT NULL REFERENCES customers(id),
address_id TEXT REFERENCES addresses(id),
business_id TEXT REFERENCES businesses(id),
invoice_id TEXT,
invoice_number TEXT,
discount_id TEXT REFERENCES discounts(id),
status TEXT NOT NULL,
origin TEXT,
currency_code TEXT NOT NULL,
details TEXT,
payments TEXT,
adjusted_totals TEXT,
payout_totals TEXT,
adjusted_payout_totals TEXT,
billing_details TEXT,
additional_information TEXT,
purchase_order_number TEXT,
checkout_url TEXT,
billed_at INTEGER,
completed_at INTEGER,
revised_at INTEGER,
created_at INTEGER,
updated_at INTEGER,
processed_at INTEGER,
processing_error TEXT
);
CREATE TABLE IF NOT EXISTS adjustments (
id TEXT PRIMARY KEY,
transaction_id TEXT NOT NULL REFERENCES transactions(id),
subscription_id TEXT REFERENCES subscriptions(id),
customer_id TEXT NOT NULL REFERENCES customers(id),
action TEXT NOT NULL,
type TEXT,
amount TEXT NOT NULL,
currency_code TEXT NOT NULL,
totals TEXT,
payout_totals TEXT,
reason TEXT,
credit_applied_to TEXT,
created_at INTEGER
);
CREATE TABLE IF NOT EXISTS vaults (
l0 TEXT PRIMARY KEY,
customer_id TEXT NOT NULL REFERENCES customers(id),
subscription_id TEXT REFERENCES subscriptions(id),
status TEXT DEFAULT 'active',
created_at INTEGER,
last_seen_at INTEGER,
created_by_human INTEGER
);
CREATE TABLE IF NOT EXISTS credentials (
id INTEGER PRIMARY KEY,
customer_id TEXT NOT NULL REFERENCES customers(id),
p0 TEXT NOT NULL,
l0 TEXT NOT NULL REFERENCES vaults(l0),
credential_id BLOB,
wl3_path TEXT,
enrolled_by INTEGER,
created_at INTEGER,
last_used_at INTEGER
);
CREATE TABLE IF NOT EXISTS roles (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
permissions TEXT,
description TEXT,
created_at INTEGER
);
CREATE TABLE IF NOT EXISTS humans (
id INTEGER PRIMARY KEY,
customer_id TEXT REFERENCES customers(id),
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
status TEXT DEFAULT 'active',
created_at INTEGER,
updated_at INTEGER
);
CREATE TABLE IF NOT EXISTS role_assignments (
id INTEGER PRIMARY KEY,
human_id INTEGER NOT NULL REFERENCES humans(id),
customer_id TEXT NOT NULL REFERENCES customers(id),
role_id INTEGER NOT NULL REFERENCES roles(id),
granted_by INTEGER REFERENCES humans(id),
created_at INTEGER
);
CREATE TABLE IF NOT EXISTS pop_sync_state (
pop_id TEXT PRIMARY KEY,
last_sync_at INTEGER,
last_sync_cursor INTEGER,
status TEXT DEFAULT 'active',
error_message TEXT,
updated_at INTEGER
);
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY,
occurred_at INTEGER,
event_type TEXT,
entity_type TEXT,
entity_id TEXT,
customer_id TEXT REFERENCES customers(id),
human_id INTEGER REFERENCES humans(id),
vault_l0 TEXT,
amount_cents INTEGER,
currency_code TEXT,
details TEXT,
source TEXT DEFAULT 'api',
ip_address TEXT,
user_agent TEXT
);
CREATE TABLE IF NOT EXISTS wl3_storage (
path TEXT PRIMARY KEY,
content BLOB,
p0 TEXT,
customer_id TEXT,
created_at INTEGER,
sync_count INTEGER DEFAULT 0,
last_sync_at INTEGER
);
`
_, err := db.Exec(schemaSQL)
if err != nil {
panic(err)
}
// Pre-populate roles
now := time.Now().Unix()
db.Exec(`INSERT INTO roles (id, name, permissions, description, created_at) VALUES
(1, 'SuperAdmin', '{"all": true}', 'Full system access', ?),
(2, 'MSPAdmin', '{"customer_admin": true, "billing_view": true}', 'MSP-level admin', ?),
(3, 'TenantAdmin', '{"vault_create": true, "agent_create": true, "device_enroll": true}', 'Customer admin', ?),
(4, 'VaultUser', '{"vault_read": true, "vault_write": true}', 'Regular user', ?),
(5, 'BillingAdmin', '{"billing_view": true, "invoices": true}', 'Can view/pay invoices', ?),
(6, 'ReadOnly', '{"vault_read": true}', 'View only', ?)`,
now, now, now, now, now, now)
// Pre-populate products
products := []struct {
ID, Name, Description string
}{
{"pro_personal", "Clavitor Personal", "1 vault, 5 agents, 2 devices"},
{"pro_family", "Clavitor Family", "1 vault, 15 agents, 6 devices"},
{"pro_pro", "Clavitor Pro", "1 vault, 50 agents, unlimited devices"},
{"pro_team10", "Clavitor Team 10", "Up to 10 employees"},
{"pro_team25", "Clavitor Team 25", "Up to 25 employees"},
{"pro_team100", "Clavitor Team 100", "Up to 100 employees"},
{"pro_team250", "Clavitor Team 250", "Up to 250 employees"},
{"pro_team500", "Clavitor Team 500", "Up to 500 employees"},
}
for _, p := range products {
db.Exec("INSERT INTO products (id, name, description, status, created_at) VALUES (?, ?, ?, 'active', ?)",
p.ID, p.Name, p.Description, now)
}
}
func getTablesWithConn(db *sql.DB) []string {
rows, err := db.Query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
if err != nil {
return []string{}
}
defer rows.Close()
var tables []string
for rows.Next() {
var name string
rows.Scan(&name)
tables = append(tables, name)
}
return tables
}
func getTableInfoWithConn(db *sql.DB, table string) []ColumnInfo {
rows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
if err != nil {
return nil
}
defer rows.Close()
var cols []ColumnInfo
for rows.Next() {
var cid int
var col ColumnInfo
rows.Scan(&cid, &col.Name, &col.Type, &col.NotNull, &col.Default, &col.PK)
cols = append(cols, col)
}
return cols
}
// ============================================================================
// Paddle webhook signature verification
// ============================================================================
func makePaddleSig(t *testing.T, secret, body string, ts int64) string {
t.Helper()
mac := hmac.New(sha256.New, []byte(secret))
tsStr := strconv.FormatInt(ts, 10)
mac.Write([]byte(tsStr))
mac.Write([]byte{':'})
mac.Write([]byte(body))
return "ts=" + tsStr + ";h1=" + hex.EncodeToString(mac.Sum(nil))
}
func TestVerifyWebhookSignature_validSignaturePasses(t *testing.T) {
const secret = "test-secret-do-not-use-in-prod"
t.Setenv("PADDLE_WEBHOOK_SECRET", secret)
body := `{"event_type":"transaction.completed","data":{"id":"txn_x"}}`
sig := makePaddleSig(t, secret, body, time.Now().Unix())
req := httptest.NewRequest("POST", "/webhooks/paddle", strings.NewReader(body))
req.Header.Set("Paddle-Signature", sig)
bodyBytes, _ := io.ReadAll(req.Body)
if !verifyWebhookSignature(req, bodyBytes) {
t.Error("valid signature was rejected")
}
}
func TestVerifyWebhookSignature_missingSecretRefuses(t *testing.T) {
t.Setenv("PADDLE_WEBHOOK_SECRET", "")
req := httptest.NewRequest("POST", "/webhooks/paddle", strings.NewReader(`{}`))
req.Header.Set("Paddle-Signature", "ts=0;h1=00")
if verifyWebhookSignature(req, []byte(`{}`)) {
t.Error("must refuse when PADDLE_WEBHOOK_SECRET is unset")
}
}
func TestVerifyWebhookSignature_missingHeaderRefuses(t *testing.T) {
t.Setenv("PADDLE_WEBHOOK_SECRET", "secret")
req := httptest.NewRequest("POST", "/webhooks/paddle", strings.NewReader(`{}`))
if verifyWebhookSignature(req, []byte(`{}`)) {
t.Error("must refuse when Paddle-Signature header is absent")
}
}
func TestVerifyWebhookSignature_tamperedBodyRefuses(t *testing.T) {
const secret = "secret"
t.Setenv("PADDLE_WEBHOOK_SECRET", secret)
body := `{"a":1}`
sig := makePaddleSig(t, secret, body, time.Now().Unix())
req := httptest.NewRequest("POST", "/webhooks/paddle", strings.NewReader(body))
req.Header.Set("Paddle-Signature", sig)
tampered := []byte(`{"a":2}`) // body the verifier sees has been changed
if verifyWebhookSignature(req, tampered) {
t.Error("tampered body must be refused")
}
}
func TestVerifyWebhookSignature_oldTimestampRefuses(t *testing.T) {
const secret = "secret"
t.Setenv("PADDLE_WEBHOOK_SECRET", secret)
body := `{}`
oldTs := time.Now().Unix() - 600 // 10 min ago, outside ±5 min window
sig := makePaddleSig(t, secret, body, oldTs)
req := httptest.NewRequest("POST", "/webhooks/paddle", strings.NewReader(body))
req.Header.Set("Paddle-Signature", sig)
if verifyWebhookSignature(req, []byte(body)) {
t.Error("timestamp older than 5 minutes must be refused (anti-replay)")
}
}
func TestVerifyWebhookSignature_wrongSecretRefuses(t *testing.T) {
t.Setenv("PADDLE_WEBHOOK_SECRET", "the-real-secret")
body := `{"a":1}`
// Sign with a DIFFERENT secret
sig := makePaddleSig(t, "wrong-secret", body, time.Now().Unix())
req := httptest.NewRequest("POST", "/webhooks/paddle", strings.NewReader(body))
req.Header.Set("Paddle-Signature", sig)
if verifyWebhookSignature(req, []byte(body)) {
t.Error("signature from wrong secret must be refused")
}
}