- auth/doc.go: package comment explaining consolidated layout (Open Question 3 resolved) - auth/types.go: User + Session structs, SessionCookieName (D-12), SessionTTL (D-09), SessionExtendThreshold (D-09), ErrSessionNotFound, ErrInvalidHash, ErrIncompatibleVersion - auth/testdb_test.go: setupTestDB creates isolated per-test schema (test_<uuid>), runs goose Up with unique version table, drops schema on cleanup (D-26) TestSetupTestDB_Roundtrip smoke test verifies users table visible - go.mod: added github.com/pressly/goose/v3 v3.27.1 as direct dependency - .env.example: added TEST_DATABASE_URL and SESSION_SECRET with comments (D-14, D-26)
188 lines
5.5 KiB
Go
188 lines
5.5 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
_ "github.com/jackc/pgx/v5/stdlib"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"github.com/pressly/goose/v3"
|
|
)
|
|
|
|
// migrationsDir resolves the backend/migrations directory relative to this test file.
|
|
func migrationsDir() string {
|
|
_, filename, _, _ := runtime.Caller(0)
|
|
// filename: .../backend/internal/auth/testdb_test.go
|
|
// migrations: .../backend/migrations
|
|
return filepath.Join(filepath.Dir(filename), "..", "..", "migrations")
|
|
}
|
|
|
|
// gooseMu guards the global goose.SetTableName / goose.SetDialect state.
|
|
var gooseMu sync.Mutex
|
|
|
|
// setupTestDB creates an isolated Postgres schema, runs all goose migrations
|
|
// against it (including goose's own version table inside the test schema), and
|
|
// returns a pgxpool.Pool scoped to that schema plus a cleanup function.
|
|
//
|
|
// The test is skipped when TEST_DATABASE_URL is unset. Falls back to
|
|
// DATABASE_URL if set (for local dev convenience with the compose Postgres).
|
|
//
|
|
// Each invocation creates a unique schema (test_<uuid12>) ensuring isolation
|
|
// across concurrent test packages.
|
|
func setupTestDB(t *testing.T) (*pgxpool.Pool, func()) {
|
|
t.Helper()
|
|
|
|
dsn := os.Getenv("TEST_DATABASE_URL")
|
|
if dsn == "" {
|
|
dsn = os.Getenv("DATABASE_URL")
|
|
}
|
|
if dsn == "" {
|
|
t.Skip("TEST_DATABASE_URL (or DATABASE_URL) not set — integration test skipped")
|
|
return nil, nil
|
|
}
|
|
|
|
// Build a schema name from the UUID, replacing hyphens with underscores
|
|
// so it is a valid SQL identifier (no quoting required in all contexts).
|
|
rawID := uuid.New().String()[:12]
|
|
cleanID := make([]byte, len(rawID))
|
|
for i := 0; i < len(rawID); i++ {
|
|
if rawID[i] == '-' {
|
|
cleanID[i] = '_'
|
|
} else {
|
|
cleanID[i] = rawID[i]
|
|
}
|
|
}
|
|
schemaName := "test_" + string(cleanID)
|
|
|
|
// Bootstrap connection: create the schema.
|
|
bootstrapDB, err := sql.Open("pgx", dsn)
|
|
if err != nil {
|
|
t.Fatalf("setupTestDB: sql.Open bootstrap: %v", err)
|
|
}
|
|
if err := bootstrapDB.Ping(); err != nil {
|
|
bootstrapDB.Close()
|
|
t.Fatalf("setupTestDB: ping: %v", err)
|
|
}
|
|
if _, err := bootstrapDB.Exec(fmt.Sprintf("CREATE SCHEMA %q", schemaName)); err != nil {
|
|
bootstrapDB.Close()
|
|
t.Fatalf("setupTestDB: CREATE SCHEMA: %v", err)
|
|
}
|
|
bootstrapDB.Close()
|
|
|
|
// Schema-scoped DSN: connect into the test schema so all DDL (including
|
|
// goose's version table) lands in the test schema, not in public.
|
|
schemaDSN := schemaScopedDSN(dsn, schemaName)
|
|
|
|
// Run goose migrations with a schema-specific version table name so we
|
|
// don't collide with the public-schema goose_db_version (which tracks
|
|
// the production migration state). schemaName is already hyphen-free so
|
|
// this is a valid SQL identifier.
|
|
versionTable := schemaName + "_goose_version"
|
|
{
|
|
gooseMu.Lock()
|
|
defer gooseMu.Unlock()
|
|
|
|
prevTable := goose.TableName()
|
|
goose.SetTableName(versionTable)
|
|
defer goose.SetTableName(prevTable)
|
|
|
|
goose.SetBaseFS(nil)
|
|
if err := goose.SetDialect("postgres"); err != nil {
|
|
dropSchema(dsn, schemaName)
|
|
t.Fatalf("setupTestDB: goose.SetDialect: %v", err)
|
|
}
|
|
|
|
schemaDB, err := sql.Open("pgx", schemaDSN)
|
|
if err != nil {
|
|
dropSchema(dsn, schemaName)
|
|
t.Fatalf("setupTestDB: sql.Open schema-scoped: %v", err)
|
|
}
|
|
|
|
if err := goose.Up(schemaDB, migrationsDir()); err != nil {
|
|
schemaDB.Close()
|
|
dropSchema(dsn, schemaName)
|
|
t.Fatalf("setupTestDB: goose.Up: %v", err)
|
|
}
|
|
schemaDB.Close()
|
|
}
|
|
|
|
// Build a pgxpool with the same schema-scoped DSN for use by tests.
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
cfg, err := pgxpool.ParseConfig(schemaDSN)
|
|
if err != nil {
|
|
dropSchema(dsn, schemaName)
|
|
t.Fatalf("setupTestDB: pgxpool.ParseConfig: %v", err)
|
|
}
|
|
cfg.MaxConns = 5
|
|
|
|
pool, err := pgxpool.NewWithConfig(ctx, cfg)
|
|
if err != nil {
|
|
dropSchema(dsn, schemaName)
|
|
t.Fatalf("setupTestDB: pgxpool.NewWithConfig: %v", err)
|
|
}
|
|
|
|
cleanup := func() {
|
|
pool.Close()
|
|
dropSchema(dsn, schemaName)
|
|
}
|
|
|
|
return pool, cleanup
|
|
}
|
|
|
|
// schemaScopedDSN appends a search_path parameter to dsn so all connections
|
|
// default to the given schema (with public as fallback for extensions).
|
|
func schemaScopedDSN(dsn, schemaName string) string {
|
|
sep := "?"
|
|
for i := 0; i < len(dsn); i++ {
|
|
if dsn[i] == '?' {
|
|
sep = "&"
|
|
break
|
|
}
|
|
}
|
|
return fmt.Sprintf("%s%ssearch_path=%s,public", dsn, sep, schemaName)
|
|
}
|
|
|
|
// dropSchema drops the named schema (CASCADE) using a fresh connection.
|
|
// Errors are intentionally ignored — this runs in cleanup.
|
|
func dropSchema(dsn, schemaName string) {
|
|
db, err := sql.Open("pgx", dsn)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer db.Close()
|
|
db.Exec(fmt.Sprintf("DROP SCHEMA IF EXISTS %q CASCADE", schemaName)) //nolint:errcheck
|
|
}
|
|
|
|
// TestSetupTestDB_Roundtrip verifies that setupTestDB creates an isolated
|
|
// schema, applies migrations, and returns a usable pool against that schema.
|
|
func TestSetupTestDB_Roundtrip(t *testing.T) {
|
|
pool, cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
if err := pool.Ping(ctx); err != nil {
|
|
t.Fatalf("pool.Ping: %v", err)
|
|
}
|
|
|
|
// The users table created by 0002_auth.sql must be visible in the test schema.
|
|
// pgx returns pgx.ErrNoRows for LIMIT 0 queries — that is expected and means
|
|
// the table exists (just empty). Any other error indicates a missing table.
|
|
row := pool.QueryRow(ctx, "SELECT 1 FROM users LIMIT 0")
|
|
if err := row.Scan(new(int)); err != nil {
|
|
if err.Error() != "no rows in result set" {
|
|
t.Fatalf("users table not visible in test schema: %v", err)
|
|
}
|
|
}
|
|
}
|