Files
pad/internal/store/testing_helpers.go
T
xarmian d76f06d199 feat(server): push device registration and notification settings API (TASK-3938) (#2269)
* feat(store): push device register/delete, SessionInfo.ID, one-tx session rotation (TASK-3938)

RegisterPushDevice is one transaction: delete rows on either unique key,
evict the oldest at the 20-device cap, insert. RotateUserSessions mints the
replacement, rebinds the caller's own devices, deletes the other sessions,
and fails closed to revoke-all.

Claude-Session: https://claude.ai/code/session_019KLyQQPbv9eCe1d6PT2wHP

* feat(server): push device and notification-settings routes, push_available (TASK-3938)

Session-only PUT/DELETE /me/push-devices/{connection_id}, GET/PUT
/me/notification-settings, push_available on /auth/session,
PAD_PUSH_GATEWAY_URL, and all credential-change rotations through
RotateUserSessions. docs/api.md updated.

Claude-Session: https://claude.ai/code/session_019KLyQQPbv9eCe1d6PT2wHP

* test(server): classify GET /me/notification-settings in the compress census (TASK-3938)

Claude-Session: https://claude.ai/code/session_019KLyQQPbv9eCe1d6PT2wHP

* fix(server): the pinned-token refusal on notification settings takes BUG-3982's shape (TASK-3938 r1)

* fix(server): notification settings leave the pinned-token refusal to the BUG-3982 gate (TASK-3938 r1)

* test(store): concurrent registers at 19 devices hold the push-device cap (TASK-3938)

Claude-Session: https://claude.ai/code/session_019KLyQQPbv9eCe1d6PT2wHP

* test(store): make the concurrent-register cap test able to fail on Postgres (TASK-3938)

Reuse sessions across rounds and widen the count-to-insert window with a
test-owned slow insert trigger, so removing the user-row lock overshoots
the cap on every run.

Claude-Session: https://claude.ai/code/session_019KLyQQPbv9eCe1d6PT2wHP

* docs(server): a workspace-pinned token on /me/push-devices gets the BUG-3982 refusal, not session_required (TASK-3938 r1)

* fix(server): push_available goes to a signed-in caller only (TASK-3938 r2)
2026-10-21 06:49:54 -04:00

351 lines
16 KiB
Go

package store
import (
"database/sql"
"fmt"
"github.com/PerpetualSoftware/pad/internal/models"
"strings"
)
// SetBcryptCostForTesting overrides the bcrypt cost used by CreateUser
// and UpdateUser for the lifetime of a test binary. It returns a
// restore function — call it from TestMain (or defer it) to leave
// the package state clean, though process exit also suffices since
// the override is local to the test binary.
//
// Why this exists: under the race detector, bcrypt.GenerateFromPassword
// at the production cost (12) takes ~3s per call. The internal/server
// and internal/store test suites bootstrap dozens of users each; the
// cumulative cost exceeded the 30m CI -race timeout (BUG-1371). Tests
// that don't care about hash strength call this once per binary in
// TestMain to drop the cost to bcrypt.MinCost (= 4), which restores
// the race step to well under the timeout.
//
// Production code MUST NOT call this. The "ForTesting" suffix is the
// grep signal — any non-test caller is a bug.
func SetBcryptCostForTesting(cost int) func() {
prev := bcryptCost
bcryptCost = cost
return func() { bcryptCost = prev }
}
// SuspendTraitUniquenessForTesting drops TASK-2710's partial unique indexes
// and returns a restore function that recreates them. Defer the restore, so a
// suspended constraint cannot outlive the test that suspended it.
//
// Why this exists: those indexes make "two collections in one workspace
// declaring the same trait" unrepresentable, and several tests need exactly
// that state — not because it is supported, but because it exists in LEGACY
// databases and the code's handling of it is what they verify. The round-2
// shadowing tests in internal/server are the clearest case: they prove
// resolution returns the collection the caller can SEE rather than one hidden
// collection that happens to sort first, and that guarantee still stands
// between a legacy database and a wrong answer in the window before
// dedupeTraitDeclarations repairs it at startup — or on a deployment where an
// operator has dropped the index. Deleting those tests to satisfy the
// constraint would retire a live guarantee; building their fixture the way the
// state actually occurs keeps them testing what they always tested.
//
// THE RESTORE CAN FAIL, and that is information rather than a nuisance:
// recreating a unique index while a duplicate is still live is exactly the
// failure the migration would hit, so a test that de-duplicates first will see
// it succeed and a test that deliberately leaves the duplicate in place will
// see it fail. The restore therefore reports its error instead of swallowing
// it, and callers that intend to leave a duplicate behind say so by ignoring
// it explicitly.
//
// Production code MUST NOT call this. The "ForTesting" suffix is the grep
// signal — any non-test caller is a bug.
func (s *Store) SuspendTraitUniquenessForTesting() (restore func() error) {
indexes := []string{
"idx_collections_artifact_kind_per_workspace",
"idx_collections_invocation_field_per_workspace",
}
for _, name := range indexes {
if _, err := s.db.Exec(`DROP INDEX IF EXISTS ` + name); err != nil {
return func() error { return fmt.Errorf("suspend %s: %w", name, err) }
}
}
return func() error {
body, rerr := traitUniquenessIndexDDL(s.dialect.Driver())
if rerr != nil {
return rerr
}
for _, stmt := range body {
if _, err := s.db.Exec(stmt); err != nil {
return fmt.Errorf("restore trait uniqueness: %w", err)
}
}
return nil
}
}
// traitUniquenessIndexDDL returns the CREATE statements for TASK-2710's
// indexes, read from the SHIPPED migration rather than retyped — a restore
// that recreated a hand-copied approximation would let the two drift and would
// then be attesting to an index the product does not have.
func traitUniquenessIndexDDL(driver DriverType) ([]string, error) {
var path string
if driver == DriverPostgres {
path = "pgmigrations/064_collection_trait_uniqueness.sql"
} else {
path = "migrations/087_collection_trait_uniqueness.sql"
}
var raw []byte
var err error
if driver == DriverPostgres {
raw, err = pgMigrationsFS.ReadFile(path)
} else {
raw, err = migrationsFS.ReadFile(path)
}
if err != nil {
return nil, fmt.Errorf("read %s: %w", path, err)
}
var out []string
for _, stmt := range strings.Split(string(raw), ";") {
trimmed := strings.TrimSpace(stmt)
if strings.Contains(strings.ToUpper(trimmed), "CREATE UNIQUE INDEX") {
out = append(out, trimmed)
}
}
if len(out) != 2 {
return nil, fmt.Errorf("expected 2 CREATE UNIQUE INDEX statements in %s, found %d", path, len(out))
}
return out, nil
}
// SetItemUpdateCommitHookForTesting routes updateItemWithParentLinkOnce's final
// COMMIT through hook for the lifetime of the returned restore function.
//
// It exists for BUG-2994. The defect is that an item update whose transaction
// COMMITTED but whose tx.Commit() reported an error anyway — the Postgres
// ack-loss shape — was written a second time by the handler above. Nothing else
// in the tree can produce that outcome: every other failure injection makes the
// commit actually fail, which is the case the code already handles.
//
// The honest hook calls tx.Commit() itself and returns a non-nil error on top,
// so the transaction's effects really are durable and the caller really is told
// they are not. A hook that returns an error WITHOUT committing is a different
// (already-covered) case and will not exercise the double write.
//
// Production code MUST NOT call this. The "ForTesting" suffix is the grep
// signal — any non-test caller is a bug. Set it only while no other request is
// in flight against this Store.
func (s *Store) SetItemUpdateCommitHookForTesting(hook func(tx *sql.Tx) error) (restore func()) {
prev := s.commitItemUpdate
s.commitItemUpdate = hook
return func() { s.commitItemUpdate = prev }
}
// SetAfterItemPreLockReadHookForTesting sets the afterItemPreLockRead seam
// (see its field comment) for the lifetime of the returned restore function,
// so a test outside this package can commit a concurrent write inside the
// window between an update's pre-lock read and its transaction.
//
// It exists for BUG-3163: the full-fields reserved-metadata check must compare
// against the row re-read UNDER the write lock, and only a write landing in
// this window can tell that apart from a comparison against the handler's
// earlier read. The same constraints as the field apply: it fires once per
// attempt, and a hook that issues its own update must clear the seam around
// that nested call.
//
// Production code MUST NOT call this. The "ForTesting" suffix is the grep
// signal. Set it only while no other request is in flight against this Store.
func (s *Store) SetAfterItemPreLockReadHookForTesting(hook func(itemID string)) (restore func()) {
prev := s.afterItemPreLockRead
s.afterItemPreLockRead = hook
return func() { s.afterItemPreLockRead = prev }
}
// SetBeforeRotateRefreshHookForTesting runs hook at the start of every
// RotateRefreshToken, before the presented token is spent: the window after
// fosite validated it (BUG-3678). The hook may make nested store calls.
//
// Production code MUST NOT call this. The "ForTesting" suffix is the grep
// signal. Set it only while no other request is in flight against this Store.
func (s *Store) SetBeforeRotateRefreshHookForTesting(hook func(requestID string)) (restore func()) {
prev := s.beforeRotateRefresh
s.beforeRotateRefresh = hook
return func() { s.beforeRotateRefresh = prev }
}
// SetAddWorkspaceMemberCommitHookForTesting routes the COMMIT of
// AddWorkspaceMember and AcceptWorkspaceInvitation (commitWorkspaceMemberTx,
// BUG-3281) through hook for the lifetime of the returned restore function.
//
// It exists for BUG-3026, and the honest hook is the same as its BUG-2994 twin:
// call tx.Commit() and return a non-nil error on top, so the membership row really
// is durable and the caller really is told it is not. A hook that returns an error
// WITHOUT committing exercises the ordinary insert-failed case, which the existing
// ghost-user FK test already covers and which was never broken.
//
// Production code MUST NOT call this. The "ForTesting" suffix is the grep signal.
// Set it only while no other request is in flight against this Store.
func (s *Store) SetAddWorkspaceMemberCommitHookForTesting(hook func(tx *sql.Tx) error) (restore func()) {
prev := s.commitAddWorkspaceMember
s.commitAddWorkspaceMember = hook
return func() { s.commitAddWorkspaceMember = prev }
}
// SetSeedCollectionsFailureHookForTesting makes SeedCollectionsFromTemplate
// return hook's error, once the template's collections exist and before any of
// its items are seeded, for the lifetime of the returned restore function. A hook
// returning nil is a no-op and the seed proceeds.
//
// It exists for BUG-3087. The cloud auto-create door used to log a seed failure
// and report the signup a success, leaving a workspace with collections and no
// starter pack that the product then described as brand-new; it now removes the
// workspace. Nothing else reproduces a seed failure on a fresh workspace: a
// duplicate prefix is allowed, a held trait declaration is skipped, and a broken
// database breaks the removal under test too.
//
// Production code MUST NOT call this. The "ForTesting" suffix is the grep signal.
// Set it only while no other request is in flight against this Store.
func (s *Store) SetSeedCollectionsFailureHookForTesting(hook func(workspaceID, templateName string) error) (restore func()) {
prev := s.failSeedCollections
s.failSeedCollections = hook
return func() { s.failSeedCollections = prev }
}
// SetCommentInsertFailureHookForTesting makes the comment INSERT inside
// CreateComment / CreateCommentWithActivity return hook's error, for the
// lifetime of the returned restore function. A hook returning nil is a no-op.
//
// It exists for BUG-2716: the two "commented" call sites used to commit the
// activity row on its own and then write the comment, so a comment failure
// left an orphan timeline entry. Against the pre-fix code this hook leaves the
// activity row behind; against the fix it takes the activity with it.
//
// Production code MUST NOT call this. The "ForTesting" suffix is the grep
// signal. Set it only while no other request is in flight against this Store.
func (s *Store) SetCommentInsertFailureHookForTesting(hook func(input models.CommentCreate) error) (restore func()) {
prev := s.failCommentInsert
s.failCommentInsert = hook
return func() { s.failCommentInsert = prev }
}
// SetItemContentFlushedOpLogIDForTesting advances items.content_flushed_op_log_id
// directly, standing in for the collab-snapshot flush that normally moves it.
//
// It exists for BUG-3000's clearing leg. The marker must go AWAY once the row
// catches up, not merely appear when it falls behind — a predicate that latched on
// at the first op-log row would satisfy every "is it marked?" assertion and still be
// wrong for every item that has ever been edited collaboratively. Driving a real
// flush would need a browser; this writes the one column that flush advances.
//
// Production code MUST NOT call this. The "ForTesting" suffix is the grep signal.
func (s *Store) SetItemContentFlushedOpLogIDForTesting(itemID string, opLogID int64) error {
_, err := s.db.Exec(s.q(`UPDATE items SET content_flushed_op_log_id = ? WHERE id = ?`), opLogID, itemID)
return err
}
// ResetYjsClassificationForTesting returns an item's op-log rows to the state
// migration 091 leaves legacy rows in: content_hash NULL, content_bearing TRUE.
// It stands in for "rows written before the migration" so BackfillYjsContentBearing
// can be driven against real append output (BUG-3124).
//
// Production code MUST NOT call this. The "ForTesting" suffix is the grep signal.
func (s *Store) ResetYjsClassificationForTesting(itemID string) error {
_, err := s.db.Exec(s.q(`UPDATE item_yjs_updates SET content_hash = NULL, content_bearing = ? WHERE item_id = ?`),
s.dialect.BoolToInt(true), itemID)
return err
}
// SetDeadlockInjectorForTesting sets the deadlock injector (see
// injectedDeadlock) for the lifetime of the returned restore function, so a
// test outside this package can make a retried transaction fail as a
// deadlock just before its commit and check that the CALLER's effects after
// the store call run once (BUG-3395). Production code MUST NOT call this.
func (s *Store) SetDeadlockInjectorForTesting(fn func(site string, attempt int) bool) (restore func()) {
prev := s.deadlockInjector
s.deadlockInjector = fn
return func() { s.deadlockInjector = prev }
}
// readbackAfterCommitHook runs right after the commit of a write that reads
// its row back (TASK-3406), with the row's kind and id, so a test can delete
// the row at exactly that point. Nil in production.
var readbackAfterCommitHook func(kind, id string)
func afterCommitReadback(kind, id string) {
if h := readbackAfterCommitHook; h != nil {
h(kind, id)
}
}
// SetStripeEventReadFailureHookForTesting makes ClaimStripeEvent's read of an
// existing event row fail with hook's error, for the lifetime of the returned
// restore function. A hook returning nil is a no-op.
//
// Production code MUST NOT call this. The "ForTesting" suffix is the grep
// signal. Set it only while no other request is in flight against this Store.
func (s *Store) SetStripeEventReadFailureHookForTesting(hook func(eventID string) error) (restore func()) {
prev := s.failStripeEventRead
s.failStripeEventRead = hook
return func() { s.failStripeEventRead = prev }
}
// SetBeforeMoveLockForTesting installs the beforeMoveLock seam (BUG-3665) for
// the lifetime of the returned restore function: a move calls hook after its
// pre-lock read of the item and before it takes the workspace lock.
func (s *Store) SetBeforeMoveLockForTesting(hook func(itemID string)) (restore func()) {
prev := s.beforeMoveLock
s.beforeMoveLock = hook
return func() { s.beforeMoveLock = prev }
}
// SetBeforeItemVersionsReadHookForTesting runs hook at the start of every
// version-list read (ListItemVersionsPage and the snapshot read), before its
// query (BUG-3814): the window between a
// caller's read of the item's body and the version list resolved from it.
//
// Production code MUST NOT call this. The "ForTesting" suffix is the grep
// signal. Set it only while no other request is in flight against this Store.
func (s *Store) SetBeforeItemVersionsReadHookForTesting(hook func(itemID string)) (restore func()) {
prev := s.beforeItemVersionsRead
s.beforeItemVersionsRead = hook
return func() { s.beforeItemVersionsRead = prev }
}
// DisableUserLeavingCredentialsForTesting sets disabled_at without revoking
// anything and without the last-admin guard: the state a disable made before
// BUG-3349 left behind, which the credential gates must still refuse. The
// only production door is DisableUserAndRevokeAccess; Store.DisableUser,
// which did this unguarded, was removed (BUG-3826).
//
// Production code MUST NOT call this. The "ForTesting" suffix is the grep
// signal.
func (s *Store) DisableUserLeavingCredentialsForTesting(userID string) error {
_, err := s.db.Exec(s.q(`UPDATE users SET disabled_at = ?, updated_at = ?, credential_epoch = credential_epoch + 1 WHERE id = ?`),
now(), now(), userID)
if err != nil {
return fmt.Errorf("disable user: %w", err)
}
return nil
}
// SetBeforeVersionsReadHookForTesting runs hook at the start of every legacy
// document version-list read, before its query (BUG-3833): the window between
// a caller's read of the document's body and the versions resolved from it.
//
// Production code MUST NOT call this. The "ForTesting" suffix is the grep
// signal. Set it only while no other request is in flight against this Store.
func (s *Store) SetBeforeVersionsReadHookForTesting(hook func(documentID string)) (restore func()) {
prev := s.beforeVersionsRead
s.beforeVersionsRead = hook
return func() { s.beforeVersionsRead = prev }
}
// SetRotateFaultHookForTesting makes RotateUserSessions fail its transaction
// with hook's error, after the writes and before the commit, for the lifetime
// of the returned restore function. It exists to prove the fail-closed
// fallback: a failed rotation must revoke every session (TASK-3938).
//
// Production code MUST NOT call this. The "ForTesting" suffix is the grep
// signal. Set it only while no other request is in flight against this Store.
func (s *Store) SetRotateFaultHookForTesting(hook func() error) (restore func()) {
prev := s.rotateFault
s.rotateFault = hook
return func() { s.rotateFault = prev }
}