authkit/testdb_test.go
juancwu ca5525d4bd Cap refresh chain lifetime via RefreshChainAbsoluteTTL
Sessions had an absolute cap (created_at + SessionAbsoluteTTL) but the
JWT path only had per-token TTL on the refresh row, letting a
well-behaved client refresh indefinitely. Add chain_started_at to
authkit_tokens, copy it forward on every rotation, and reject in
RefreshJWT when now > chainStartedAt + RefreshChainAbsoluteTTL.
Default 30d, mirroring SessionAbsoluteTTL.

Schema, verifier, queries, model, and integration test updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 23:41:02 +00:00

90 lines
2.5 KiB
Go

package authkit
// Integration test infrastructure. Skipped when AUTHKIT_TEST_DATABASE_URL is
// unset so the unit-test suite remains usable without a database.
import (
"context"
"database/sql"
"fmt"
"net/netip"
"os"
"testing"
"time"
"git.juancwu.dev/juancwu/authkit/hasher"
_ "github.com/jackc/pgx/v5/stdlib"
)
// noIP returns the zero-value netip.Addr — used by tests that don't care
// about the originating IP.
func noIP() netip.Addr { return netip.Addr{} }
func dbURL(t *testing.T) string {
t.Helper()
url := os.Getenv("AUTHKIT_TEST_DATABASE_URL")
if url == "" {
t.Skip("AUTHKIT_TEST_DATABASE_URL not set; skipping integration test")
}
return url
}
// freshAuth returns a fully-initialized *Auth bound to a clean database.
// All authkit_* tables are dropped before Migrate runs, so each test sees
// an empty schema.
func freshAuth(t *testing.T) *Auth {
t.Helper()
url := dbURL(t)
db, err := sql.Open("pgx", url)
if err != nil {
t.Fatalf("sql.Open: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := db.PingContext(context.Background()); err != nil {
t.Fatalf("ping: %v", err)
}
dropAllAuthkitTables(t, db, DefaultSchema())
t.Cleanup(func() { dropAllAuthkitTables(t, db, DefaultSchema()) })
a, err := New(context.Background(), Deps{
DB: db,
Hasher: hasher.NewArgon2id(hasher.DefaultArgon2idParams(), nil),
}, Config{
JWTSecret: []byte("integration-secret-thirty-two!!!"),
JWTIssuer: "authkit-int",
AccessTokenTTL: 2 * time.Minute,
RefreshTokenTTL: 48 * time.Hour,
RefreshChainAbsoluteTTL: 24 * time.Hour,
SessionIdleTTL: time.Hour,
SessionAbsoluteTTL: 24 * time.Hour,
EmailVerifyTTL: time.Hour,
PasswordResetTTL: time.Hour,
MagicLinkTTL: time.Minute,
EmailOTPTTL: time.Minute,
EmailOTPMaxAttempts: 3,
})
if err != nil {
t.Fatalf("authkit.New: %v", err)
}
return a
}
func dropAllAuthkitTables(t *testing.T, db *sql.DB, s Schema) {
t.Helper()
tables := []string{
s.Tables.ServiceKeyAbilities, s.Tables.UserPermissions,
s.Tables.UserRoles, s.Tables.RolePermissions,
s.Tables.ServiceKeys, s.Tables.Abilities,
s.Tables.Roles, s.Tables.Permissions,
s.Tables.Tokens, s.Tables.Sessions, s.Tables.Users,
s.Tables.SchemaMigrations,
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
for _, name := range tables {
_, _ = db.ExecContext(ctx,
fmt.Sprintf("DROP TABLE IF EXISTS %s CASCADE", name))
}
}