//go:build integration package inttest import ( "context" "database/sql" "testing" "github.com/google/uuid" "github.com/pressly/goose/v3" testcontainers "github.com/testcontainers/testcontainers-go" tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres" "github.com/testcontainers/testcontainers-go/wait" "scrabble/backend/internal/account" "scrabble/backend/internal/postgres" "scrabble/backend/internal/postgres/migrations" "scrabble/backend/internal/session" ) // TestSessionPlatformCaptureAndUntrusted proves a session round-trips its captured // platform through create and a cold-cache (DB-backed) resolve for each kind, and // that an unattributed session records no platform — the untrusted, view-only case. func TestSessionPlatformCaptureAndUntrusted(t *testing.T) { ctx := context.Background() accounts := account.NewStore(testDB) store := session.NewStore(testDB) svc := session.NewService(store, session.NewCache()) for _, tc := range []struct { name string platform session.Platform }{ {"vk-ios", session.Platform{Kind: session.PlatformKindVK, Subtype: session.SubtypeIOS}}, {"telegram-android", session.Platform{Kind: session.PlatformKindTelegram, Subtype: session.SubtypeAndroid}}, {"direct-web", session.Platform{Kind: session.PlatformKindDirect, Subtype: session.SubtypeWeb}}, {"untrusted", session.Platform{}}, } { t.Run(tc.name, func(t *testing.T) { acc, err := accounts.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString()) if err != nil { t.Fatalf("provision: %v", err) } token, sess, err := svc.Create(ctx, acc.ID, tc.platform) if err != nil { t.Fatalf("create: %v", err) } if sess.Platform != tc.platform { t.Errorf("created platform = %+v, want %+v", sess.Platform, tc.platform) } // Resolve through a cold cache so the value comes back via the DB columns. cold := session.NewService(store, session.NewCache()) if err := cold.Warm(ctx); err != nil { t.Fatalf("warm: %v", err) } got, err := cold.Resolve(ctx, token) if err != nil { t.Fatalf("resolve: %v", err) } if got.Platform != tc.platform { t.Errorf("resolved platform = %+v, want %+v", got.Platform, tc.platform) } if got.Platform.Trusted() != tc.platform.Trusted() { t.Errorf("resolved Trusted() = %v, want %v", got.Platform.Trusted(), tc.platform.Trusted()) } }) } } // TestSessionPlatformCheckConstraints proves the CHECK constraints reject an // out-of-range kind or subtype, so a corrupt value cannot be persisted. func TestSessionPlatformCheckConstraints(t *testing.T) { ctx := context.Background() acc, err := account.NewStore(testDB).ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString()) if err != nil { t.Fatalf("provision: %v", err) } const pgCheckViolation = "23514" for _, tc := range []struct { name string kind string subtype string }{ {"bad-kind", "facebook", "web"}, {"bad-subtype", "vk", "windows"}, } { t.Run(tc.name, func(t *testing.T) { _, err := testDB.ExecContext(ctx, `INSERT INTO backend.sessions (session_id, account_id, token_hash, platform_kind, platform_subtype) VALUES ($1, $2, $3, $4, $5)`, uuid.New(), acc.ID, uuid.NewString(), tc.kind, tc.subtype) if !isPgCode(err, pgCheckViolation) { t.Errorf("insert %s: err = %v, want check_violation", tc.name, err) } }) } } // TestSessionPlatformMigrationReversible proves migration 00011 is expand-contract: // it applies, rolls back (dropping the platform columns), and re-applies cleanly — // so a backend image rollback stays DB-safe. It uses its own container so the Down // does not disturb the shared suite database. func TestSessionPlatformMigrationReversible(t *testing.T) { ctx := context.Background() container, err := tcpostgres.Run(ctx, pgImage, tcpostgres.WithDatabase(pgDatabase), tcpostgres.WithUsername(pgUser), tcpostgres.WithPassword(pgPassword), testcontainers.WithWaitStrategy( wait.ForLog("database system is ready to accept connections"). WithOccurrence(2). WithStartupTimeout(containerStartup), ), ) if err != nil { t.Fatalf("start container: %v", err) } defer func() { _ = container.Terminate(context.Background()) }() baseDSN, err := container.ConnectionString(ctx, "sslmode=disable") if err != nil { t.Fatalf("connection string: %v", err) } dsn, err := withSearchPath(baseDSN, pgSchema) if err != nil { t.Fatalf("search path: %v", err) } cfg := postgres.DefaultConfig() cfg.DSN = dsn db, err := postgres.Open(ctx, cfg) if err != nil { t.Fatalf("open pool: %v", err) } defer func() { _ = db.Close() }() if err := postgres.ApplyMigrations(ctx, db); err != nil { t.Fatalf("apply up: %v", err) } if !sessionsPlatformColumnsExist(ctx, t, db) { t.Fatal("platform columns absent after up") } goose.SetBaseFS(migrations.Migrations()) defer goose.SetBaseFS(nil) if err := goose.SetDialect("postgres"); err != nil { t.Fatalf("set dialect: %v", err) } // Down past 00011 (to 00010) and assert the columns are gone. if err := goose.DownToContext(ctx, db, ".", 10); err != nil { t.Fatalf("down to 10: %v", err) } if sessionsPlatformColumnsExist(ctx, t, db) { t.Error("platform columns survived the down migration") } // Re-apply and assert they return. if err := goose.UpContext(ctx, db, "."); err != nil { t.Fatalf("re-apply up: %v", err) } if !sessionsPlatformColumnsExist(ctx, t, db) { t.Fatal("platform columns absent after re-apply") } } // sessionsPlatformColumnsExist reports whether both platform columns are present on // backend.sessions. func sessionsPlatformColumnsExist(ctx context.Context, t *testing.T, db *sql.DB) bool { t.Helper() var n int if err := db.QueryRowContext(ctx, `SELECT count(*) FROM information_schema.columns WHERE table_schema = 'backend' AND table_name = 'sessions' AND column_name IN ('platform_kind', 'platform_subtype')`).Scan(&n); err != nil { t.Fatalf("count platform columns: %v", err) } return n == 2 }