92633f935e
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 1m7s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m42s
Record the execution platform (kind vk|telegram|direct + device subtype ios|android|web) on each session, captured at creation and carried gateway->backend as a trusted X-Platform header, so the upcoming store-compliance gate has an unforgeable execution context. - backend.sessions gains nullable platform_kind/platform_subtype columns (migration 00011, CHECK-constrained, jet regenerated); session.Platform captures them at mint, resolve returns them, middleware exposes platform(c). kind is derived from the establish endpoint, never a client field; the account-merge session mint inherits the caller's platform. - gateway derives the platform (VK subtype from the signed vk_platform via vkauth, Telegram/direct best-effort from the client) and injects X-Platform on every authenticated backend call through the request context. - ui submits a best-effort device subtype on the telegram/guest/email login requests (new FBS subtype field); VK is server-derived from the signed params. - an unattributed session is untrusted (view-only); VK/TG self-heal on the next cold-start re-mint, direct/email on re-login. Signal plumbing only, no user-visible change; X-Platform is inert until the gate consumes it.
179 lines
5.9 KiB
Go
179 lines
5.9 KiB
Go
//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
|
|
}
|