Files
pad/internal/store/webhooks_test.go
T
xarmian 3c93472613 perf(store): run the store suite in parallel (TASK-2900 deliverable 3) (#1263)
The SQLite leg was CPU-bound and serial: 154.9s of CPU inside 185.6s of wall on
an eight-core box, which is 0.84 cores. Unlike the Postgres leg there was no
setup cost to reclaim — the unattributed gap between package wall-clock and the
sum of its tests measured MINUS 1.4s, so per-binary overhead was already nil —
and no fat test to find beyond one 48s outlier. The only lever left was the
seven idle cores.

t.Parallel() on 844 of 906 tests. Every test in this package builds its own
isolated store (a SQLite file copy, or since 6f8b6eb9 a Postgres clone), so it
shares no database with any other.

Medians on a quiet box, three SQLite repeats and two Postgres:

  SQLite wall     185.6s -> 111.0s   -40%
  Postgres wall   224.4s -> 121.2s   -46%   (495.8s -> 121.2s, -76%, since 6f8b6eb9)
  -race wall     2006.3s -> 1365.0s  -32%

THE COST, stated rather than folded into the win: SQLite CPU rose 154.9s ->
159.9s, +3.2%. That is contention overhead, it is real, and it is paid by
whoever else is on the box — this change moves WALL, it does not remove work.
A pre-registered prediction that CPU would stay within ~1% is falsified by that
number, though only just.

THAT FIGURE IS ITSELF A CORRECTION. An earlier draft of this message reported
the CPU cost as +10.4%, from ONE run taken while another seat was working the
box. Three repeats on a quiet box put the median at +3.2% — the published
number overstated the cost by more than three times. The direction survived the
correction and the magnitude did not, which is the case for repeating a figure
you intend to publish as a cost just as carefully as one you intend to publish
as a win.

Postgres binary CPU is flat to slightly down (63.9s -> 63.1s), which fits the
mechanism rather than contradicting it: that leg was 82% waiting, so parallelism
fills idle time instead of competing for cycles.

VARIANCE, since it changed shape: the parallel SQLite leg's repeat spread is
5.9% (106.7 / 111.0 / 113.2) against 0.7% for the serial leg it replaces.
Parallel runs are genuinely less reproducible than serial ones — scheduling
decides which tests share a core — so a future before/after on this package
needs more than one run per side to say anything, which the serial suite did
not.

The -race figure was measured because it was a RISK, not for completeness. CI's
Go job runs `go test -race -timeout=45m ./...` and already takes ~42 minutes;
the race detector serialises heavily, so parallelism could plausibly have made
that leg slower and pushed CI toward its own timeout. It does not: 2006s ->
1365s on the same box, measured against the template-only tree.

EXCLUSIONS — 62 tests, each for a mechanism rather than caution:

  19  files measuring ALLOCATIONS (runtime.ReadMemStats). Those counters are
      process-global, so a concurrent test's allocations land in the
      measurement. Excluded by FILE, since the hazard is the process.
   7  migration_guard_test.go, every test of which mutates the EXPORTED
      globals store.AllowSchemaAhead or store.BinaryVersion and restores in a
      t.Cleanup. AllowSchemaAhead=true under parallelism disarms the
      schema-ahead guard for every concurrently-running test — which is the
      property two of those very tests exist to assert.
  23  tests measuring ELAPSED TIME. Wall-clock under contention is not the
      wall-clock the assertion was written against.
   1  test mutating a PACKAGE-LEVEL GLOBAL (see below).
   3  the template mechanism tests, which assert on package-level counters.
  16  already had t.Parallel().

Excluding a test is a real remedy here rather than a hope, and the reason is a
Go semantic worth naming: a top-level test that calls t.Parallel() is HELD and
resumed only after the sequential pass finishes. Every excluded test therefore
still runs alone in a quiet process, which is exactly the property its timing or
allocation assertion depends on.

TWO EXCLUSIONS WERE MISSED BY MY OWN SWEEP, and the shape is the same both
times — an instrument whose question was narrower than the claim resting on it.

FIRST (found before shipping, by grepping the package's own `var` lines):


I recorded that this package has "no per-test global setters", having grepped
t.Setenv, os.Setenv and Set*ForTesting — all genuinely zero. The counts were
true and the conclusion was false. That sweep asked whether a test changes the
ENVIRONMENT; the claim it was carrying was whether a test changes anything
GLOBAL, which is wider.

TestUpdateDocument_CascadeExhaustionRollsBackTheWholeRename assigns the
package-level cascadeRewriteAttempts and restores it in a defer. Under
t.Parallel that window stays open while every other cascade test runs. The
mutating test would not flake; the tests AROUND it would, intermittently, which
is the worst shape to debug. Found by a different question — grep the package's
own non-test `var` declarations, then grep the tests for each name.

SECOND (found by codex, P1, NOT by me): that grep was `^var [a-z]`. It finds
UNEXPORTED package vars and silently skips exported ones, so it never saw
store.AllowSchemaAhead or store.BinaryVersion — both mutated by every test in
migration_guard_test.go. The instrument asked "which unexported globals exist"
while the claim it carried was "which globals exist". The fix that caught the
first miss was itself too narrow to catch the second, which is the argument for
enforcing the invariant in code rather than trusting the sweep that produced it.

Both exclusions are now enforced by guards rather than by the exclusion list:
TestCascadeExhaustionStaysSerial (function-scoped) and
TestMigrationGuardTestsStaySerial (file-scoped). Both fail closed on a missing
file or renamed function, strip line comments so an explanatory comment naming
t.Parallel() can neither satisfy nor trip them, carry a positive control that
the scan can SEE a real t.Parallel() elsewhere, and are mutation-controlled in
both directions.

VERIFICATION — 10 runs, zero failures, zero data races:

  SQLite parallel    x4   exit 0
  Postgres parallel  x3   exit 0
  -race -timeout=45m x1   exit 0, zero DATA RACE reports
  baseline -race     x1   exit 0 (the counterfactual above)

A false alarm not filed as a finding: the first -race run exited 1 with zero
`--- FAIL` lines and zero data races — the shape that reads as a pass to
anything counting FAIL lines. The log said `panic: test timed out after 10m0s`.
That was the invocation, not the code: `-timeout` was omitted so Go's default
applied, and ci.yml already carries a comment explaining that the explicit
-timeout exists because the default is too low.

KNOWN BOUND, not a solved problem: the test container's max_connections is 100,
each store sets MaxOpenConns(25), and -parallel defaults to GOMAXPROCS. On an
eight-core box the worst case permits 200. Three Postgres runs showed no
53300 "too many clients", but the arithmetic still allows it on a larger box or
a heavier test, and that belongs here as a bound rather than as an absence of
observed failures.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR
2026-09-06 16:59:31 -04:00

283 lines
9.0 KiB
Go

package store
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"strings"
"testing"
"github.com/PerpetualSoftware/pad/internal/models"
)
// newWebhookTestWorkspace creates a user + workspace so webhook FK constraints
// are satisfied, and returns the workspace ID.
func newWebhookTestWorkspace(t *testing.T, s *Store) string {
t.Helper()
u, err := s.CreateUser(models.UserCreate{
Email: "wh@test.com",
Name: "Webhook Tester",
Password: "password123",
})
if err != nil {
t.Fatalf("create user: %v", err)
}
ws, err := s.CreateWorkspace(models.WorkspaceCreate{Name: "Hooks", Slug: "hooks", OwnerID: u.ID})
if err != nil {
t.Fatalf("create workspace: %v", err)
}
return ws.ID
}
// TestWebhookSecret_EncryptedAtRest is the BUG-2057 regression: the HMAC secret
// must be encrypted in the DB, round-trip to plaintext on read (so the
// dispatcher can sign), and that plaintext must produce a valid HMAC.
func TestWebhookSecret_EncryptedAtRest(t *testing.T) {
t.Parallel()
s := testStore(t)
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
t.Fatal(err)
}
s.SetEncryptionKey(key)
wsID := newWebhookTestWorkspace(t, s)
const secret = "super-secret-signing-key"
hook, err := s.CreateWebhook(wsID, models.WebhookCreate{
URL: "https://example.com/hook",
Secret: secret,
})
if err != nil {
t.Fatalf("create webhook: %v", err)
}
// Read back through the store — secret must be decrypted plaintext.
if hook.Secret != secret {
t.Errorf("expected decrypted secret %q, got %q", secret, hook.Secret)
}
if !hook.HasSecret {
t.Error("HasSecret should be true when a secret is configured")
}
// Raw DB value must be encrypted, not plaintext.
var rawSecret string
if err := s.db.QueryRow(s.q("SELECT secret FROM webhooks WHERE id = ?"), hook.ID).Scan(&rawSecret); err != nil {
t.Fatalf("read raw secret: %v", err)
}
if rawSecret == secret {
t.Fatal("raw DB value should be encrypted, not plaintext")
}
if !strings.HasPrefix(rawSecret, "enc:") {
t.Errorf("raw DB value should start with 'enc:', got %q", rawSecret)
}
// The decrypted secret must sign identically to the known plaintext (proves
// the dispatcher gets a usable secret).
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte("payload"))
want := hex.EncodeToString(mac.Sum(nil))
got := hmac.New(sha256.New, []byte(hook.Secret))
got.Write([]byte("payload"))
if hex.EncodeToString(got.Sum(nil)) != want {
t.Error("decrypted secret produced a different HMAC than the original")
}
}
// TestWebhookSecret_ListRoundTrips confirms ListWebhooks also decrypts the
// secret for internal/dispatch use.
func TestWebhookSecret_ListRoundTrips(t *testing.T) {
t.Parallel()
s := testStore(t)
key := make([]byte, 32)
rand.Read(key)
s.SetEncryptionKey(key)
wsID := newWebhookTestWorkspace(t, s)
const secret = "list-secret"
if _, err := s.CreateWebhook(wsID, models.WebhookCreate{URL: "https://example.com/h", Secret: secret}); err != nil {
t.Fatalf("create webhook: %v", err)
}
hooks, err := s.ListWebhooks(wsID)
if err != nil {
t.Fatalf("list webhooks: %v", err)
}
if len(hooks) != 1 {
t.Fatalf("expected 1 webhook, got %d", len(hooks))
}
if hooks[0].Secret != secret {
t.Errorf("expected decrypted secret %q, got %q", secret, hooks[0].Secret)
}
if !hooks[0].HasSecret {
t.Error("HasSecret should be true")
}
}
// TestWebhookSecret_BackfillEncryptsPlaintext covers the back-compat path: a
// pre-encryption plaintext row (inserted before a key was configured) still
// signs correctly, and BackfillEncryptWebhookSecrets encrypts it in place.
func TestWebhookSecret_BackfillEncryptsPlaintext(t *testing.T) {
t.Parallel()
s := testStore(t)
wsID := newWebhookTestWorkspace(t, s)
// Insert with NO encryption key configured — stored plaintext (legacy row).
const secret = "legacy-plaintext-secret"
hook, err := s.CreateWebhook(wsID, models.WebhookCreate{URL: "https://example.com/legacy", Secret: secret})
if err != nil {
t.Fatalf("create webhook: %v", err)
}
var rawSecret string
s.db.QueryRow(s.q("SELECT secret FROM webhooks WHERE id = ?"), hook.ID).Scan(&rawSecret)
if rawSecret != secret {
t.Fatalf("expected plaintext storage without key, got %q", rawSecret)
}
// Now configure a key and run the backfill.
key := make([]byte, 32)
rand.Read(key)
s.SetEncryptionKey(key)
n, err := s.EncryptWebhookSecretsAtRest()
if err != nil {
t.Fatalf("backfill: %v", err)
}
if n != 1 {
t.Fatalf("expected 1 row encrypted, got %d", n)
}
// Raw value now encrypted...
s.db.QueryRow(s.q("SELECT secret FROM webhooks WHERE id = ?"), hook.ID).Scan(&rawSecret)
if !strings.HasPrefix(rawSecret, "enc:") {
t.Errorf("expected encrypted value after backfill, got %q", rawSecret)
}
// ...but still decrypts to the original plaintext.
fetched, err := s.GetWebhook(hook.ID)
if err != nil {
t.Fatalf("get webhook: %v", err)
}
if fetched.Secret != secret {
t.Errorf("expected %q after backfill decrypt, got %q", secret, fetched.Secret)
}
// Backfill is idempotent — a second run touches nothing.
if n, err := s.EncryptWebhookSecretsAtRest(); err != nil || n != 0 {
t.Errorf("expected idempotent backfill (0 rows), got n=%d err=%v", n, err)
}
}
// insertRawWebhook writes a webhook row directly, bypassing CreateWebhook's
// encryption, to simulate a legacy pre-encryption row.
func insertRawWebhook(t *testing.T, s *Store, wsID, secret string) string {
t.Helper()
id := newID()
ts := now()
if _, err := s.db.Exec(s.q(`
INSERT INTO webhooks (id, workspace_id, url, secret, events, active, created_at, updated_at, failure_count)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
`), id, wsID, "https://example.com/x", secret, `["*"]`, s.dialect.BoolToInt(true), ts, ts); err != nil {
t.Fatalf("insert legacy webhook: %v", err)
}
return id
}
// TestWebhookSecret_MigratesEncPrefixedLegacyPlaintext is the review follow-up:
// a legacy plaintext secret that literally starts with the reserved "enc:"
// marker must be encrypted by the one-time migration (first run encrypts every
// pre-encryption value) and round-trip on read.
func TestWebhookSecret_MigratesEncPrefixedLegacyPlaintext(t *testing.T) {
t.Parallel()
s := testStore(t)
wsID := newWebhookTestWorkspace(t, s)
const secret = "enc:not-actually-encrypted"
id := insertRawWebhook(t, s, wsID, secret)
key := make([]byte, 32)
rand.Read(key)
s.SetEncryptionKey(key)
// First run: flag unset → every existing secret is treated as plaintext,
// including the "enc:"-prefixed one.
n, err := s.EncryptWebhookSecretsAtRest()
if err != nil {
t.Fatalf("migrate: %v", err)
}
if n != 1 {
t.Fatalf("expected 1 row encrypted, got %d", n)
}
var raw string
s.db.QueryRow(s.q("SELECT secret FROM webhooks WHERE id = ?"), id).Scan(&raw)
if raw == secret {
t.Fatal("legacy enc:-prefixed plaintext should have been encrypted")
}
fetched, err := s.GetWebhook(id)
if err != nil {
t.Fatalf("get webhook after migrate: %v", err)
}
if fetched.Secret != secret {
t.Errorf("expected %q after migrate, got %q", secret, fetched.Secret)
}
// Second run is steady-state (flag set): the now-genuine ciphertext is left
// alone.
if n, err := s.EncryptWebhookSecretsAtRest(); err != nil || n != 0 {
t.Errorf("expected idempotent run (0 rows), got n=%d err=%v", n, err)
}
}
// TestWebhookSecret_DoesNotRewrapCiphertextOnKeyChange guards the other horn:
// once migrated, genuine ciphertext must NOT be re-encrypted under a rotated /
// wrong key (which would corrupt the secret). Steady-state skips "enc:" rows and
// a wrong key surfaces as a loud decrypt error instead.
func TestWebhookSecret_DoesNotRewrapCiphertextOnKeyChange(t *testing.T) {
t.Parallel()
s := testStore(t)
wsID := newWebhookTestWorkspace(t, s)
key1 := make([]byte, 32)
rand.Read(key1)
s.SetEncryptionKey(key1)
const secret = "genuine-secret"
hook, err := s.CreateWebhook(wsID, models.WebhookCreate{URL: "https://example.com/g", Secret: secret})
if err != nil {
t.Fatalf("create webhook: %v", err)
}
// Run the migration so the flag is set (steady state hereafter).
if _, err := s.EncryptWebhookSecretsAtRest(); err != nil {
t.Fatalf("migrate: %v", err)
}
var ciphertext string
s.db.QueryRow(s.q("SELECT secret FROM webhooks WHERE id = ?"), hook.ID).Scan(&ciphertext)
if !strings.HasPrefix(ciphertext, "enc:") {
t.Fatalf("expected ciphertext, got %q", ciphertext)
}
// Rotate to a different key. A steady-state run must NOT touch the enc: row.
key2 := make([]byte, 32)
rand.Read(key2)
s.SetEncryptionKey(key2)
n, err := s.EncryptWebhookSecretsAtRest()
if err != nil {
t.Fatalf("run after key change: %v", err)
}
if n != 0 {
t.Fatalf("expected 0 rows touched after key change, got %d", n)
}
var after string
s.db.QueryRow(s.q("SELECT secret FROM webhooks WHERE id = ?"), hook.ID).Scan(&after)
if after != ciphertext {
t.Fatal("ciphertext must not be re-wrapped under a rotated key")
}
// And the wrong key fails loud rather than returning corrupt data.
if _, err := s.GetWebhook(hook.ID); err == nil {
t.Error("expected a decrypt error under the wrong key, got nil")
}
}