feat(02-01): create internal/auth package skeleton, test DB harness, env docs
- 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)
This commit is contained in:
parent
799c26099e
commit
2c84f4275b
6 changed files with 302 additions and 6 deletions
|
|
@ -1,6 +1,16 @@
|
||||||
# Postgres connection string used by the web + worker binaries (and `just migrate`).
|
# Postgres connection string used by the web + worker binaries (and `just migrate`).
|
||||||
DATABASE_URL=postgres://xtablo:xtablo@localhost:5432/xtablo?sslmode=disable
|
DATABASE_URL=postgres://xtablo:xtablo@localhost:5432/xtablo?sslmode=disable
|
||||||
|
|
||||||
|
# Postgres connection string used by integration tests (auth, session, etc.).
|
||||||
|
# Falls back to DATABASE_URL if unset; tests skip if neither is set.
|
||||||
|
# The test harness creates an isolated schema per test run and drops it on cleanup.
|
||||||
|
TEST_DATABASE_URL=postgres://xtablo:xtablo@localhost:5432/xtablo?sslmode=disable
|
||||||
|
|
||||||
|
# Session secret — 32 random bytes hex-encoded. Used as the CSRF authentication key.
|
||||||
|
# Generate a new value with: openssl rand -hex 32
|
||||||
|
# MUST be persistent across restarts (changing it invalidates all active CSRF tokens).
|
||||||
|
SESSION_SECRET=
|
||||||
|
|
||||||
# HTTP port for cmd/web.
|
# HTTP port for cmd/web.
|
||||||
PORT=8080
|
PORT=8080
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,16 @@ require (
|
||||||
github.com/go-chi/chi/v5 v5.2.5
|
github.com/go-chi/chi/v5 v5.2.5
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/jackc/pgx/v5 v5.9.2
|
github.com/jackc/pgx/v5 v5.9.2
|
||||||
|
github.com/pressly/goose/v3 v3.27.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
golang.org/x/sync v0.17.0 // indirect
|
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||||
golang.org/x/text v0.29.0 // indirect
|
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||||
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
|
golang.org/x/text v0.36.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBd
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
|
@ -17,18 +19,42 @@ github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
|
||||||
|
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||||
|
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||||
|
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
|
||||||
|
github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
||||||
|
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||||
|
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||||
|
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0=
|
||||||
|
modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U=
|
||||||
|
modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
|
||||||
|
|
|
||||||
17
backend/internal/auth/doc.go
Normal file
17
backend/internal/auth/doc.go
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
// Package auth implements the authentication and session-management layer for the Xtablo
|
||||||
|
// Go+HTMX rewrite. It consolidates all security-sensitive primitives in one place:
|
||||||
|
//
|
||||||
|
// - Password hashing and verification (argon2id, PHC format) — password.go
|
||||||
|
// - Session token generation, storage, lookup, rotation, and extension — session.go
|
||||||
|
// - Per-key in-memory rate limiting for login attempts — ratelimit.go
|
||||||
|
// - HTTP cookie helpers (set, clear) — cookie.go
|
||||||
|
// - CSRF field rendering via gorilla/csrf — csrf.go
|
||||||
|
//
|
||||||
|
// Package layout decision (RESEARCH Open Question 3, resolved): all capabilities
|
||||||
|
// are consolidated here rather than split across internal/auth + internal/session.
|
||||||
|
// The Phase 1 internal/session placeholder (internal/session/doc.go) is kept as a
|
||||||
|
// one-line comment pointing here, preserving the file for git history.
|
||||||
|
//
|
||||||
|
// Constants, types, and sentinel errors exported from this package are the
|
||||||
|
// contracts consumed by Plans 02–07 in Phase 2.
|
||||||
|
package auth
|
||||||
188
backend/internal/auth/testdb_test.go
Normal file
188
backend/internal/auth/testdb_test.go
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
51
backend/internal/auth/types.go
Normal file
51
backend/internal/auth/types.go
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// User is the domain representation of a registered user.
|
||||||
|
// Mirrors the sqlc-generated User row shape with Go standard types.
|
||||||
|
type User struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
Email string
|
||||||
|
PasswordHash string
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session is the domain representation of an active session row.
|
||||||
|
// The ID field holds the hex-encoded SHA-256 hash of the opaque token (D-05).
|
||||||
|
type Session struct {
|
||||||
|
ID string
|
||||||
|
UserID uuid.UUID
|
||||||
|
CreatedAt time.Time
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionCookieName is the HTTP cookie name used across the package.
|
||||||
|
// Decision D-12 fixes this value.
|
||||||
|
const SessionCookieName = "xtablo_session"
|
||||||
|
|
||||||
|
// SessionTTL is the sliding session lifetime (D-09).
|
||||||
|
const SessionTTL = 30 * 24 * time.Hour
|
||||||
|
|
||||||
|
// SessionExtendThreshold triggers an extension when the remaining session
|
||||||
|
// lifetime drops below this threshold (D-09 — extension runs ~once per 23 days).
|
||||||
|
const SessionExtendThreshold = 7 * 24 * time.Hour
|
||||||
|
|
||||||
|
// Sentinel errors returned by auth operations.
|
||||||
|
var (
|
||||||
|
// ErrSessionNotFound is returned when a session lookup fails (expired, tampered, or deleted).
|
||||||
|
ErrSessionNotFound = errors.New("auth: session not found")
|
||||||
|
|
||||||
|
// ErrInvalidHash is returned when a stored PHC string cannot be parsed.
|
||||||
|
ErrInvalidHash = errors.New("auth: hash is not in PHC format")
|
||||||
|
|
||||||
|
// ErrIncompatibleVersion is returned when the argon2 version in the PHC string
|
||||||
|
// does not match the running library version.
|
||||||
|
ErrIncompatibleVersion = errors.New("auth: incompatible argon2 version")
|
||||||
|
)
|
||||||
Loading…
Reference in a new issue