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) } }