Files
sm-user-plugin/updates/device_auth_sessions_test.go
Jakub Zych 1f3266a417 test: device authorization flow and its migration
- 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
2026-10-26 14:15:54 +01:00

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