- TestDeviceCodes (classes, short): tokens, short-code alphabet and distribution, normalization - TestDeviceAuthURL, TestDeviceDisplayName (short): auth_url template, TTL, PHP's user-agent order - TestDeviceInitiate, TestDeviceStatus, TestDeviceAuthorize: PHP bodies for every state and refusal, bounded cleanup, short-code collision retry, concurrent authorizes binding one user - TestDeviceHandOverConcurrent, TestDeviceListAndRevoke, TestDeviceRevokeAfterRefresh, TestDeviceTokensAtRest - TestDeviceAuthSessionsMigration (updates): columns, constraints, indexes, cascade, rollback and re-up
193 lines
6.9 KiB
Go
193 lines
6.9 KiB
Go
package updates
|
|
|
|
import (
|
|
"errors"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
|
"github.com/go-gormigrate/gormigrate/v2"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// TestDeviceAuthSessionsMigration pins 202610260001_create_device_auth_sessions
|
|
// (D-16, D-06, D-12b) on a database of its own: the PHP table and column
|
|
// names plus jwt_jti, the column types and nullability of PHP's v2.1.0 to
|
|
// v2.7.1 migrations, the unique token and short_code, the CHECK on status,
|
|
// the indexes, the cascade on user delete, the rollback and a second up.
|
|
func TestDeviceAuthSessionsMigration(t *testing.T) {
|
|
sqlDB := dedicatedDB(t, "user_device_auth_sessions")
|
|
db, err := lagoon.Use(t.Context(), sqlDB)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m := gormigrate.New(db, &gormigrate.Options{TableName: "summer_migrations_golem15_user", UseTransaction: true}, All())
|
|
if err := m.Migrate(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
const table = "golem15_user_device_auth_sessions"
|
|
if len(deviceAuthSessionMigrations) != 1 || deviceAuthSessionMigrations[0].ID != "202610260001_create_device_auth_sessions" {
|
|
t.Fatalf("device migrations = %v", deviceAuthSessionMigrations)
|
|
}
|
|
|
|
type column struct {
|
|
Name string
|
|
Type string
|
|
Length *int
|
|
Nullable string
|
|
}
|
|
var columns []column
|
|
if err := db.Raw(`SELECT column_name AS name, data_type AS type, character_maximum_length AS length, is_nullable AS nullable
|
|
FROM information_schema.columns WHERE table_name = ? ORDER BY ordinal_position`, table).Scan(&columns).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := map[string]column{}
|
|
var names []string
|
|
for _, c := range columns {
|
|
got[c.Name] = c
|
|
names = append(names, c.Name)
|
|
}
|
|
want := []string{
|
|
// PHP's columns (v2.1.0, v2.3.1 short_code).
|
|
"id", "token", "short_code", "user_id", "status", "expires_at", "device_ip", "device_user_agent", "device_name",
|
|
"auth_ip", "confirmed_at", "used_at", "session_id", "last_activity_at",
|
|
// D-12b.
|
|
"jwt_jti",
|
|
"created_at", "updated_at",
|
|
}
|
|
if !slices.Equal(names, want) {
|
|
t.Fatalf("columns = %v, want %v", names, want)
|
|
}
|
|
for name, spec := range map[string]struct {
|
|
typ string
|
|
length int
|
|
nullable string
|
|
}{
|
|
"token": {"character varying", 64, "NO"},
|
|
"short_code": {"character varying", 9, "YES"},
|
|
"user_id": {"integer", 0, "YES"},
|
|
"status": {"character varying", 16, "NO"},
|
|
"expires_at": {"timestamp with time zone", 0, "YES"},
|
|
"device_name": {"character varying", 255, "YES"},
|
|
"device_user_agent": {"text", 0, "YES"},
|
|
"jwt_jti": {"character varying", 64, "YES"},
|
|
"session_id": {"character varying", 255, "YES"},
|
|
} {
|
|
c := got[name]
|
|
length := 0
|
|
if c.Length != nil {
|
|
length = *c.Length
|
|
}
|
|
if c.Type != spec.typ || length != spec.length || c.Nullable != spec.nullable {
|
|
t.Fatalf("%s = %s(%d) nullable %s, want %s(%d) nullable %s", name, c.Type, length, c.Nullable, spec.typ, spec.length, spec.nullable)
|
|
}
|
|
}
|
|
|
|
var indexes []string
|
|
if err := db.Raw(`SELECT indexdef FROM pg_indexes WHERE tablename = ?`, table).Scan(&indexes).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
joined := strings.Join(indexes, "\n")
|
|
for _, def := range []string{
|
|
"UNIQUE INDEX " + table + "_token_key ON public." + table + " USING btree (token)",
|
|
"UNIQUE INDEX " + table + "_short_code_key ON public." + table + " USING btree (short_code)",
|
|
"INDEX " + table + "_user_id_index ON public." + table + " USING btree (user_id)",
|
|
"INDEX " + table + "_status_index ON public." + table + " USING btree (status)",
|
|
"INDEX " + table + "_expires_at_index ON public." + table + " USING btree (expires_at)",
|
|
"INDEX " + table + "_jwt_jti_index ON public." + table + " USING btree (jwt_jti)",
|
|
} {
|
|
if !strings.Contains(joined, def) {
|
|
t.Fatalf("index %q missing from:\n%s", def, joined)
|
|
}
|
|
}
|
|
|
|
pgCode := func(err error) string {
|
|
var pgErr *pgconn.PgError
|
|
if errors.As(err, &pgErr) {
|
|
return pgErr.Code
|
|
}
|
|
return ""
|
|
}
|
|
insert := func(token string, shortCode any, status string, userID any) error {
|
|
return db.Exec(`INSERT INTO `+table+` (token, short_code, status, user_id, expires_at) VALUES (?, ?, ?, ?, NOW())`,
|
|
token, shortCode, status, userID).Error
|
|
}
|
|
if err := insert("t1", "AAAA-AAAA", "pending", nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if code := pgCode(insert("t1", "BBBB-BBBB", "pending", nil)); code != "23505" {
|
|
t.Fatalf("duplicate token: SQLSTATE %q, want 23505", code)
|
|
}
|
|
if code := pgCode(insert("t2", "AAAA-AAAA", "pending", nil)); code != "23505" {
|
|
t.Fatalf("duplicate short_code: SQLSTATE %q, want 23505", code)
|
|
}
|
|
if err := insert("t3", nil, "used", nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := insert("t4", nil, "revoked", nil); err != nil {
|
|
t.Fatalf("a second NULL short_code: %v", err)
|
|
}
|
|
for _, status := range []string{"confirmed", "expired"} {
|
|
if err := insert("t-"+status, nil, status, nil); err != nil {
|
|
t.Fatalf("status %s refused: %v", status, err)
|
|
}
|
|
}
|
|
for _, status := range []string{"unknown", "PENDING", ""} {
|
|
if code := pgCode(insert("t-bad-"+status, nil, status, nil)); code != "23514" {
|
|
t.Fatalf("status %q: SQLSTATE %q, want 23514 (check)", status, code)
|
|
}
|
|
}
|
|
var status string
|
|
if err := db.Raw(`INSERT INTO ` + table + ` (token) VALUES ('t-default') RETURNING status`).Scan(&status).Error; err != nil || status != "pending" {
|
|
t.Fatalf("default status = %q (%v)", status, err)
|
|
}
|
|
|
|
user := &models.User{Email: "device-migration@example.test", Password: "x"}
|
|
if err := db.Omit("Groups").Create(user).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if code := pgCode(insert("t-fk", nil, "pending", 987654321)); code != "23503" {
|
|
t.Fatalf("unknown user_id: SQLSTATE %q, want 23503", code)
|
|
}
|
|
if err := insert("t-owned", nil, "used", user.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Exec(`DELETE FROM users WHERE id = ?`, user.ID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var owned int64
|
|
if err := db.Raw(`SELECT COUNT(*) FROM ` + table + ` WHERE token = 't-owned'`).Scan(&owned).Error; err != nil || owned != 0 {
|
|
t.Fatalf("rows of a deleted user = %d (%v), want 0 (ON DELETE CASCADE)", owned, err)
|
|
}
|
|
|
|
history := func() int64 {
|
|
var n int64
|
|
if err := db.Raw(`SELECT COUNT(*) FROM summer_migrations_golem15_user WHERE id = ?`, deviceAuthSessionMigrations[0].ID).Scan(&n).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return n
|
|
}
|
|
if history() != 1 {
|
|
t.Fatal("the migration is not recorded")
|
|
}
|
|
if err := m.RollbackMigration(deviceAuthSessionMigrations[0]); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if db.Migrator().HasTable(table) || history() != 0 {
|
|
t.Fatal("rollback left the table or its history row")
|
|
}
|
|
if err := m.Migrate(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !db.Migrator().HasTable(table) || history() != 1 {
|
|
t.Fatal("a second up did not recreate the table")
|
|
}
|
|
var rows int64
|
|
if err := db.Session(&gorm.Session{}).Raw(`SELECT COUNT(*) FROM ` + table).Scan(&rows).Error; err != nil || rows != 0 {
|
|
t.Fatalf("the recreated table has %d rows (%v)", rows, err)
|
|
}
|
|
}
|