897 lines
24 KiB
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")
|
|
}
|
|
}
|