mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 14:30:13 +00:00
3c93472613
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 since6f8b6eb9a 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%, since6f8b6eb9) -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
340 lines
12 KiB
Go
340 lines
12 KiB
Go
package store
|
|
|
|
import (
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
// Connected-apps store tests (PLAN-943 TASK-954).
|
|
//
|
|
// What's covered:
|
|
//
|
|
// - ListUserOAuthConnections deduplicates a chain across multiple
|
|
// access-token rows (refresh-token rotation produces siblings).
|
|
// - The list filters on subject — Bob can't see Alice's connections.
|
|
// - Active=FALSE chains drop off the list immediately.
|
|
// - Connected_at is the earliest timestamp in the chain.
|
|
// - GrantedScopes + AllowedWorkspaces hydrate from the newest row's
|
|
// session_data + granted_scopes (revoked rows ignored on read).
|
|
// - classifyCapabilityTier maps scope vocabulary to read_only /
|
|
// read_write / full_access / unknown.
|
|
// - parseAllowedWorkspacesFromSession round-trips both []string
|
|
// and []interface{} (post-JSON) shapes.
|
|
// - RevokeUserOAuthConnection: ownership check returns NotFound
|
|
// for stranger's chains; idempotent for already-revoked.
|
|
|
|
func newConnectedTestStore(t *testing.T) *Store {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
s, err := New(filepath.Join(dir, "conn.db"))
|
|
if err != nil {
|
|
t.Fatalf("New store: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = s.Close() })
|
|
return s
|
|
}
|
|
|
|
// seedClient creates an oauth_clients row so chains have something
|
|
// to FK against. Returns the client ID.
|
|
func seedClient(t *testing.T, s *Store, name string) string {
|
|
t.Helper()
|
|
c, err := s.CreateOAuthClient(models.OAuthClientCreate{
|
|
Name: name,
|
|
RedirectURIs: []string{"https://example.test/cb"},
|
|
GrantTypes: []string{"authorization_code", "refresh_token"},
|
|
ResponseTypes: []string{"code"},
|
|
TokenEndpointAuthMethod: "none",
|
|
Scopes: []string{"pad:read", "pad:write"},
|
|
Public: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateOAuthClient: %v", err)
|
|
}
|
|
return c.ID
|
|
}
|
|
|
|
// seedUser creates a user that subsequent oauth tokens can use as
|
|
// their `subject`.
|
|
func seedUser(t *testing.T, s *Store, email string) string {
|
|
t.Helper()
|
|
u, err := s.CreateUser(models.UserCreate{
|
|
Email: email,
|
|
Name: "Conn Tester",
|
|
Password: "pw-conn-12345",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateUser: %v", err)
|
|
}
|
|
return u.ID
|
|
}
|
|
|
|
// seedAccess inserts an access-token row in the chain identified by
|
|
// requestID. ts is the requested_at timestamp; sessionData + scopes
|
|
// land on the row. Returns the signature it minted.
|
|
func seedAccess(t *testing.T, s *Store, requestID, clientID, subject string, ts time.Time, sessionData, scopes string, active bool) string {
|
|
t.Helper()
|
|
sig := newID()
|
|
err := s.CreateAccessToken(models.OAuthRequest{
|
|
Signature: sig,
|
|
RequestID: requestID,
|
|
RequestedAt: ts,
|
|
ClientID: clientID,
|
|
GrantedScopes: scopes,
|
|
SessionData: sessionData,
|
|
Subject: subject,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateAccessToken: %v", err)
|
|
}
|
|
if !active {
|
|
// CreateAccessToken always inserts active=TRUE; flip if needed.
|
|
if err := s.RevokeAccessTokenFamily(requestID); err != nil {
|
|
t.Fatalf("RevokeAccessTokenFamily: %v", err)
|
|
}
|
|
}
|
|
return sig
|
|
}
|
|
|
|
func TestListUserOAuthConnections_DeduplicatesChain(t *testing.T) {
|
|
t.Parallel()
|
|
s := newConnectedTestStore(t)
|
|
uid := seedUser(t, s, "list-dedup@example.com")
|
|
clientID := seedClient(t, s, "Claude Desktop")
|
|
|
|
now := time.Now().UTC().Truncate(time.Second)
|
|
// Three access rows in the SAME chain (refresh-token rotation
|
|
// scenario). Different requested_at; same request_id.
|
|
sessionData := `{"extra":{"allowed_workspaces":["docapp"]}}`
|
|
seedAccess(t, s, "chain-1", clientID, uid, now.Add(-3*time.Hour), sessionData, "pad:read pad:write", true)
|
|
seedAccess(t, s, "chain-1", clientID, uid, now.Add(-2*time.Hour), sessionData, "pad:read pad:write", true)
|
|
seedAccess(t, s, "chain-1", clientID, uid, now.Add(-1*time.Hour), sessionData, "pad:read pad:write", true)
|
|
|
|
// Workspace + backfill so the rewritten read path (TASK-1522)
|
|
// projects AllowedWorkspaces from oauth_connection_workspaces
|
|
// instead of the retired session.Extra parse. Production wires
|
|
// the same call at startup; tests do it explicitly.
|
|
ownerForWS := seedUser(t, s, "chain-1-ws-owner@example.com")
|
|
if _, wsErr := s.CreateWorkspace(models.WorkspaceCreate{Name: "docapp", OwnerID: ownerForWS}); wsErr != nil {
|
|
t.Fatalf("CreateWorkspace docapp: %v", wsErr)
|
|
}
|
|
if _, bfErr := s.BackfillOAuthConnections(); bfErr != nil {
|
|
t.Fatalf("BackfillOAuthConnections: %v", bfErr)
|
|
}
|
|
|
|
conns, err := s.ListUserOAuthConnections(uid)
|
|
if err != nil {
|
|
t.Fatalf("ListUserOAuthConnections: %v", err)
|
|
}
|
|
if len(conns) != 1 {
|
|
t.Fatalf("got %d connections, want 1 (deduplicated chain)", len(conns))
|
|
}
|
|
c := conns[0]
|
|
if c.RequestID != "chain-1" {
|
|
t.Errorf("RequestID = %q, want chain-1", c.RequestID)
|
|
}
|
|
if c.ClientName != "Claude Desktop" {
|
|
t.Errorf("ClientName = %q, want Claude Desktop", c.ClientName)
|
|
}
|
|
if c.CapabilityTier != models.CapabilityTierReadWrite {
|
|
t.Errorf("CapabilityTier = %q, want read_write", c.CapabilityTier)
|
|
}
|
|
if !c.ConnectedAt.Equal(now.Add(-3 * time.Hour)) {
|
|
t.Errorf("ConnectedAt = %v, want earliest chain timestamp %v", c.ConnectedAt, now.Add(-3*time.Hour))
|
|
}
|
|
if len(c.AllowedWorkspaces) != 1 || c.AllowedWorkspaces[0] != "docapp" {
|
|
t.Errorf("AllowedWorkspaces = %v, want [docapp]", c.AllowedWorkspaces)
|
|
}
|
|
}
|
|
|
|
func TestListUserOAuthConnections_FiltersBySubject(t *testing.T) {
|
|
t.Parallel()
|
|
s := newConnectedTestStore(t)
|
|
alice := seedUser(t, s, "alice-conn@example.com")
|
|
bob := seedUser(t, s, "bob-conn@example.com")
|
|
clientID := seedClient(t, s, "Cursor")
|
|
|
|
now := time.Now().UTC()
|
|
seedAccess(t, s, "alice-chain", clientID, alice, now, "", "pad:read", true)
|
|
seedAccess(t, s, "bob-chain", clientID, bob, now, "", "pad:read", true)
|
|
|
|
aliceConns, err := s.ListUserOAuthConnections(alice)
|
|
if err != nil {
|
|
t.Fatalf("ListUserOAuthConnections(alice): %v", err)
|
|
}
|
|
if len(aliceConns) != 1 || aliceConns[0].RequestID != "alice-chain" {
|
|
t.Errorf("Alice should see only alice-chain, got %+v", aliceConns)
|
|
}
|
|
|
|
bobConns, err := s.ListUserOAuthConnections(bob)
|
|
if err != nil {
|
|
t.Fatalf("ListUserOAuthConnections(bob): %v", err)
|
|
}
|
|
if len(bobConns) != 1 || bobConns[0].RequestID != "bob-chain" {
|
|
t.Errorf("Bob should see only bob-chain, got %+v", bobConns)
|
|
}
|
|
}
|
|
|
|
func TestListUserOAuthConnections_ExcludesInactiveChains(t *testing.T) {
|
|
t.Parallel()
|
|
s := newConnectedTestStore(t)
|
|
uid := seedUser(t, s, "inactive@example.com")
|
|
clientID := seedClient(t, s, "Test Client")
|
|
now := time.Now().UTC()
|
|
|
|
seedAccess(t, s, "active-chain", clientID, uid, now, "", "pad:read", true)
|
|
seedAccess(t, s, "revoked-chain", clientID, uid, now, "", "pad:read", false)
|
|
|
|
conns, err := s.ListUserOAuthConnections(uid)
|
|
if err != nil {
|
|
t.Fatalf("ListUserOAuthConnections: %v", err)
|
|
}
|
|
if len(conns) != 1 {
|
|
t.Fatalf("got %d connections, want 1 (revoked chain excluded)", len(conns))
|
|
}
|
|
if conns[0].RequestID != "active-chain" {
|
|
t.Errorf("got RequestID %q, want active-chain", conns[0].RequestID)
|
|
}
|
|
}
|
|
|
|
func TestRevokeUserOAuthConnection_OwnershipCheck(t *testing.T) {
|
|
t.Parallel()
|
|
s := newConnectedTestStore(t)
|
|
alice := seedUser(t, s, "revoke-alice@example.com")
|
|
bob := seedUser(t, s, "revoke-bob@example.com")
|
|
clientID := seedClient(t, s, "Revoke Tester")
|
|
now := time.Now().UTC()
|
|
|
|
seedAccess(t, s, "alice-only", clientID, alice, now, "", "pad:read", true)
|
|
|
|
// Bob can't revoke Alice's chain — should get NotFound (not 403,
|
|
// to prevent enumeration via 403-vs-404 distinction).
|
|
if err := s.RevokeUserOAuthConnection(bob, "alice-only"); err != ErrConnectionNotFound {
|
|
t.Errorf("Bob revoking Alice's chain: got err=%v, want ErrConnectionNotFound", err)
|
|
}
|
|
|
|
// Alice's chain is still active after Bob's failed attempt.
|
|
conns, err := s.ListUserOAuthConnections(alice)
|
|
if err != nil {
|
|
t.Fatalf("ListUserOAuthConnections after failed revoke: %v", err)
|
|
}
|
|
if len(conns) != 1 {
|
|
t.Errorf("after Bob's failed revoke, Alice's chain count = %d, want 1", len(conns))
|
|
}
|
|
|
|
// Alice CAN revoke her own.
|
|
if err := s.RevokeUserOAuthConnection(alice, "alice-only"); err != nil {
|
|
t.Errorf("Alice revoking her own chain: %v", err)
|
|
}
|
|
conns, err = s.ListUserOAuthConnections(alice)
|
|
if err != nil {
|
|
t.Fatalf("ListUserOAuthConnections after revoke: %v", err)
|
|
}
|
|
if len(conns) != 0 {
|
|
t.Errorf("after Alice's revoke, chain count = %d, want 0", len(conns))
|
|
}
|
|
}
|
|
|
|
func TestRevokeUserOAuthConnection_Idempotent(t *testing.T) {
|
|
t.Parallel()
|
|
s := newConnectedTestStore(t)
|
|
uid := seedUser(t, s, "idem@example.com")
|
|
clientID := seedClient(t, s, "Idem Tester")
|
|
seedAccess(t, s, "idem-chain", clientID, uid, time.Now().UTC(), "", "pad:read", true)
|
|
|
|
if err := s.RevokeUserOAuthConnection(uid, "idem-chain"); err != nil {
|
|
t.Errorf("first revoke: %v", err)
|
|
}
|
|
if err := s.RevokeUserOAuthConnection(uid, "idem-chain"); err != nil {
|
|
t.Errorf("second revoke (idempotent): %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRevokeUserOAuthConnection_UnknownChain(t *testing.T) {
|
|
t.Parallel()
|
|
s := newConnectedTestStore(t)
|
|
uid := seedUser(t, s, "unknown@example.com")
|
|
if err := s.RevokeUserOAuthConnection(uid, "no-such-chain"); err != ErrConnectionNotFound {
|
|
t.Errorf("unknown chain: got err=%v, want ErrConnectionNotFound", err)
|
|
}
|
|
}
|
|
|
|
func TestClassifyCapabilityTier(t *testing.T) {
|
|
t.Parallel()
|
|
cases := []struct {
|
|
scopes string
|
|
want models.CapabilityTier
|
|
}{
|
|
{"", models.CapabilityTierUnknown},
|
|
{"pad:read", models.CapabilityTierReadOnly},
|
|
{"pad:read pad:write", models.CapabilityTierReadWrite},
|
|
{"pad:write", models.CapabilityTierReadWrite},
|
|
{"pad:read pad:write pad:admin", models.CapabilityTierFullAccess},
|
|
{"pad:admin", models.CapabilityTierFullAccess},
|
|
{"openid email", models.CapabilityTierUnknown},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := classifyCapabilityTier(tc.scopes); got != tc.want {
|
|
t.Errorf("classifyCapabilityTier(%q) = %q, want %q", tc.scopes, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestExtractAllowedWorkspacesFromSessionExtra covers the parse helper
|
|
// that drives the TASK-1522 backfill from session.Extra into the new
|
|
// oauth_connections + oauth_connection_workspaces tables. Replaces the
|
|
// retired parseAllowedWorkspacesFromSession test (which was the
|
|
// previous read-path's parse helper, removed when the read path
|
|
// switched to the new tables).
|
|
//
|
|
// Shape mapping (per IDEA-1517 §2 backfill spec):
|
|
//
|
|
// - missing / malformed / non-array → (hasKey=false, isStar=false, slugs=nil)
|
|
// → seeder writes all_current_workspaces=1, no join rows.
|
|
// - ["*"] singleton → (hasKey=true, isStar=true, slugs=nil)
|
|
// → seeder writes all_current_workspaces=1, no join rows.
|
|
// - explicit slug list → (hasKey=true, isStar=false, slugs=<list>)
|
|
// → seeder writes all_current_workspaces=0, one join row per slug.
|
|
func TestExtractAllowedWorkspacesFromSessionExtra(t *testing.T) {
|
|
t.Parallel()
|
|
cases := []struct {
|
|
name string
|
|
in string
|
|
wantKey bool
|
|
wantStar bool
|
|
wantSlugs []string
|
|
}{
|
|
{"empty session", "", false, false, nil},
|
|
{"missing extra key", `{"extra":{}}`, false, false, nil},
|
|
{"explicit slug list", `{"extra":{"allowed_workspaces":["alpha","beta"]}}`, true, false, []string{"alpha", "beta"}},
|
|
{"wildcard singleton", `{"extra":{"allowed_workspaces":["*"]}}`, true, true, nil},
|
|
{"non-string elements dropped", `{"extra":{"allowed_workspaces":["alpha",42,"beta"]}}`, true, false, []string{"alpha", "beta"}},
|
|
{"explicit empty list", `{"extra":{"allowed_workspaces":[]}}`, true, false, nil},
|
|
{"wildcard mixed with slug", `{"extra":{"allowed_workspaces":["*","alpha"]}}`, true, false, []string{"*", "alpha"}},
|
|
{"malformed JSON", `not json`, false, false, nil},
|
|
{"non-array value", `{"extra":{"allowed_workspaces":"alpha"}}`, false, false, nil},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := extractAllowedWorkspacesFromSessionExtra(tc.in)
|
|
if got.hasKey != tc.wantKey {
|
|
t.Errorf("hasKey = %v, want %v", got.hasKey, tc.wantKey)
|
|
}
|
|
if got.isStar != tc.wantStar {
|
|
t.Errorf("isStar = %v, want %v", got.isStar, tc.wantStar)
|
|
}
|
|
if len(got.slugs) != len(tc.wantSlugs) {
|
|
t.Errorf("slugs len = %d (%v), want %d (%v)", len(got.slugs), got.slugs, len(tc.wantSlugs), tc.wantSlugs)
|
|
return
|
|
}
|
|
for i := range got.slugs {
|
|
if got.slugs[i] != tc.wantSlugs[i] {
|
|
t.Errorf("slugs[%d] = %q, want %q", i, got.slugs[i], tc.wantSlugs[i])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|