Files
pansy/internal/store/store_test.go
T
steveandClaude Opus 4.8 02c928ac6d
Build image / build-and-push (push) Successful in 5s
Address Gadfly review on #4: TOCTOU, sliding cookie, CSRF, argon2
Fixes from the PR #23 adversarial review (graded 35 real / 1 false positive):

Security / correctness
- Race-free registration: is_admin and the registration gate are now
  computed atomically inside a single INSERT...SELECT, so concurrent
  first registrations can't both become admin or bypass closed
  registration (fixed the whole TOCTOU cluster).
- Sliding session now reaches the browser: ResolveSession returns the
  current expiry and requireAuth re-sets the cookie, so active users
  aren't logged out 30 days after login regardless of activity.
- Login CSRF: csrfGuard rejects state-changing requests whose Origin
  doesn't match PANSY_BASE_URL (no-op when unset, so the dev proxy is
  unaffected). SameSite=Lax alone didn't cover this.
- argon2id tuned to RFC 9106's second recommended profile (t=3).
- Timing equalizer can't fail open: the dummy hash is derived
  deterministically (fixed salt, no RNG) so it's always present.
- Password length (<=1024) enforced in the service for both register
  and login, not just HTTP binding tags; login rejects over-long input
  before spending argon2 work.

Error handling / robustness
- Login logs a malformed stored hash instead of silently treating it as
  a wrong password.
- Best-effort session writes (Touch/Delete during renewal, expiry, and
  corrupt-expiry cleanup) now log on failure.
- index sessions.expires_at via new migration 0002 (0001 is immutable).

Maintainability
- Extract startSessionAndRespond and abortUnauthenticated; make
  writeServiceError a free function; consistent error handling in
  decodeHash; doc/comment fixes.

Tests: over-long password, CSRF guard (cross-origin/same-origin/dev
no-op), and cookie refresh on authenticated requests; migration-version
assertions bumped to 2.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
2026-07-18 17:04:35 -04:00

181 lines
5.3 KiB
Go

package store
import (
"context"
"path/filepath"
"strings"
"testing"
)
// openTestDB opens a fresh file-backed DB in a temp dir and migrates it.
func openTestDB(t *testing.T) *DB {
t.Helper()
db, err := Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { db.Close() })
if err := db.Migrate(context.Background()); err != nil {
t.Fatalf("Migrate: %v", err)
}
return db
}
func TestMigrateCreatesSchema(t *testing.T) {
db := openTestDB(t)
want := []string{
"users", "sessions", "gardens", "garden_shares",
"garden_objects", "plants", "plantings", "schema_migrations",
}
for _, table := range want {
var name string
err := db.SQL().QueryRow(
`SELECT name FROM sqlite_master WHERE type='table' AND name=?`, table,
).Scan(&name)
if err != nil {
t.Errorf("expected table %q to exist: %v", table, err)
}
}
var version int
if err := db.SQL().QueryRow(`SELECT max(version) FROM schema_migrations`).Scan(&version); err != nil {
t.Fatalf("read schema_migrations: %v", err)
}
if version != 2 {
t.Errorf("schema version = %d, want 2", version)
}
}
func TestMigrateIsIdempotent(t *testing.T) {
db := openTestDB(t)
// A second Migrate must be a no-op and leave exactly one row per migration.
if err := db.Migrate(context.Background()); err != nil {
t.Fatalf("second Migrate: %v", err)
}
var count int
if err := db.SQL().QueryRow(`SELECT count(*) FROM schema_migrations`).Scan(&count); err != nil {
t.Fatalf("count schema_migrations: %v", err)
}
if count != 2 {
t.Errorf("schema_migrations rows = %d, want 2", count)
}
}
func TestForeignKeysEnforced(t *testing.T) {
db := openTestDB(t)
// The DSN sets foreign_keys(1) per connection; a planting referencing a
// nonexistent object must be rejected. This guards the pragma actually
// reaching the app's pooled connections, not just the sqlite3 CLI.
_, err := db.SQL().Exec(
`INSERT INTO plantings (object_id, plant_id, x_cm, y_cm, radius_cm) VALUES (999, 999, 0, 0, 10)`,
)
if err == nil {
t.Fatal("expected foreign-key violation, got nil")
}
}
func TestCheckConstraintRejectsBadEnum(t *testing.T) {
db := openTestDB(t)
// Seed a user + garden so the FK is satisfied and only the CHECK can fail.
res, err := db.SQL().Exec(
`INSERT INTO users (email, display_name) VALUES ('[email protected]', 'A')`)
if err != nil {
t.Fatalf("insert user: %v", err)
}
uid, _ := res.LastInsertId()
res, err = db.SQL().Exec(
`INSERT INTO gardens (owner_id, name, width_cm, height_cm) VALUES (?, 'G', 100, 100)`, uid)
if err != nil {
t.Fatalf("insert garden: %v", err)
}
gid, _ := res.LastInsertId()
// kind='invalid' violates the CHECK constraint.
_, err = db.SQL().Exec(
`INSERT INTO garden_objects (garden_id, kind, x_cm, y_cm, width_cm, height_cm)
VALUES (?, 'invalid', 0, 0, 10, 10)`, gid)
if err == nil {
t.Fatal("expected CHECK constraint violation for bad kind, got nil")
}
}
func TestForeignKeysEnforcedForFileURIDSN(t *testing.T) {
// A file: URI (or any path with query params) must still get foreign_keys(1):
// the earlier passthrough silently dropped the pragmas for these DSNs.
p := filepath.Join(t.TempDir(), "uri.db")
db, err := Open("file:" + p + "?_pragma=synchronous(1)")
if err != nil {
t.Fatalf("Open file: URI: %v", err)
}
t.Cleanup(func() { db.Close() })
if err := db.Migrate(context.Background()); err != nil {
t.Fatalf("Migrate: %v", err)
}
_, err = db.SQL().Exec(
`INSERT INTO plantings (object_id, plant_id, x_cm, y_cm, radius_cm) VALUES (999, 999, 0, 0, 10)`,
)
if err == nil {
t.Fatal("expected foreign-key violation for file: URI DSN, got nil")
}
}
func TestBuildDSNAlwaysIncludesPragmas(t *testing.T) {
for _, in := range []string{
"./pansy.db",
"/data/pansy.db",
"file:/data/pansy.db",
"file:/data/pansy.db?cache=shared",
"/tmp/weird?name.db",
} {
dsn, _ := buildDSN(in)
for _, want := range []string{"busy_timeout", "journal_mode", "foreign_keys"} {
if !strings.Contains(dsn, want) {
t.Errorf("buildDSN(%q) = %q, missing %s pragma", in, dsn, want)
}
}
}
}
func TestBuildDSNDoesNotDuplicateUserPragma(t *testing.T) {
// A user-supplied busy_timeout must not be duplicated by our default.
dsn, _ := buildDSN("file:/data/x.db?_pragma=busy_timeout(9000)")
if strings.Count(dsn, "busy_timeout") != 1 {
t.Errorf("buildDSN kept both user and default busy_timeout: %q", dsn)
}
}
func TestBuildDSNMemoryDetection(t *testing.T) {
if _, mem := buildDSN(":memory:"); !mem {
t.Error(":memory: not detected as in-memory")
}
if _, mem := buildDSN("file:x.db?mode=memory"); !mem {
t.Error("mode=memory not detected as in-memory")
}
if _, mem := buildDSN("/var/lib/pansy/pansy.db"); mem {
t.Error("file path wrongly detected as in-memory")
}
}
func TestInMemoryDBIsCoherentAcrossQueries(t *testing.T) {
// A bare ":memory:" DSN gives each connection its own DB; Open must pin the
// pool so the migrated schema is visible to subsequent queries.
db, err := Open(":memory:")
if err != nil {
t.Fatalf("Open(:memory:): %v", err)
}
defer db.Close()
if err := db.Migrate(context.Background()); err != nil {
t.Fatalf("Migrate: %v", err)
}
var name string
if err := db.SQL().QueryRow(
`SELECT name FROM sqlite_master WHERE type='table' AND name='gardens'`,
).Scan(&name); err != nil {
t.Fatalf("in-memory schema not visible: %v", err)
}
}