package usersignup import ( "testing" "time" "github.com/stretchr/testify/require" "gorm.io/gorm" "github.com/pocket-id/pocket-id/backend/internal/utils" testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" ) // versionBeforeMoveTokens is the migration version right before the "actor tokens" migration. const versionBeforeMoveTokens = 20260722120000 // seedSignupTokensForMigration seeds two signup tokens (one with a user group, one without) into the pre-migration schema. func seedSignupTokensForMigration(t *testing.T, db *gorm.DB, createdAt, expiresAt time.Time) { t.Helper() // An unrelated, non-JSON kv entry, to ensure the freeze/restore queries don't choke on other kv keys err := db.Exec( `INSERT INTO kv ("key", "value") VALUES ('instance_id', ?)`, "not-json-instance-id", ).Error require.NoError(t, err) // A user group referenced by one of the tokens err = db.Exec( `INSERT INTO user_groups (id, created_at, friendly_name, name) VALUES (?, ?, ?, ?)`, "grp-1", createdAt.Unix(), "Group One", "group-one", ).Error require.NoError(t, err) // A token with a user group err = db.Exec( `INSERT INTO signup_tokens (id, created_at, token, expires_at, usage_limit, usage_count) VALUES (?, ?, ?, ?, ?, ?)`, "tok-1", createdAt.Unix(), "TOKENWITHGROUP01", expiresAt.Unix(), 3, 1, ).Error require.NoError(t, err) err = db.Exec( `INSERT INTO signup_tokens_user_groups (signup_token_id, user_group_id) VALUES (?, ?)`, "tok-1", "grp-1", ).Error require.NoError(t, err) // A token without user groups err = db.Exec( `INSERT INTO signup_tokens (id, created_at, token, expires_at, usage_limit, usage_count) VALUES (?, ?, ?, ?, ?, ?)`, "tok-2", createdAt.Unix(), "TOKENNOGROUP0002", expiresAt.Unix(), 1, 0, ).Error require.NoError(t, err) } func TestLoadMigratedSignupTokens(t *testing.T) { createdAt := time.Now().Add(-time.Hour).Truncate(time.Second) expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second) db := testutils.NewDatabaseForTestWithMigrationSeed(t, versionBeforeMoveTokens, func(t *testing.T, db *gorm.DB) { seedSignupTokensForMigration(t, db, createdAt, expiresAt) }) // The migration must have dropped the signup_tokens tables ok := db.Migrator().HasTable("signup_tokens") require.False(t, ok, "signup_tokens table should have been dropped") ok = db.Migrator().HasTable("signup_tokens_user_groups") require.False(t, ok, "signup_tokens_user_groups table should have been dropped") tokens, err := loadMigratedSignupTokens(t.Context(), db) require.NoError(t, err) require.Len(t, tokens, 2) byID := make(map[string]migratedSignupToken, len(tokens)) for _, tok := range tokens { byID[tok.ID] = tok } tok1 := byID["tok-1"] require.Equal(t, "TOKENWITHGROUP01", tok1.Token) require.Equal(t, 3, tok1.UsageLimit) require.Equal(t, 1, tok1.UsageCount) require.Equal(t, []string{"grp-1"}, tok1.UserGroupIDs) require.Equal(t, expiresAt.Unix(), tok1.ExpiresAt) require.Equal(t, createdAt.Unix(), tok1.CreatedAt) tok2 := byID["tok-2"] require.Equal(t, "TOKENNOGROUP0002", tok2.Token) require.Equal(t, 1, tok2.UsageLimit) require.Equal(t, 0, tok2.UsageCount) require.Empty(t, tok2.UserGroupIDs) } // TestMigrateSignupTokens verifies that the frozen signup tokens are turned into per-token actors, and that already-expired ones are skipped func TestMigrateSignupTokens(t *testing.T) { createdAt := time.Now().Add(-time.Hour).Truncate(time.Second) expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second) db := testutils.NewDatabaseForTestWithMigrationSeed(t, versionBeforeMoveTokens, func(t *testing.T, db *gorm.DB) { seedSignupTokensForMigration(t, db, createdAt, expiresAt) // A token that has already expired: it must not be migrated err := db.Exec( `INSERT INTO signup_tokens (id, created_at, token, expires_at, usage_limit, usage_count) VALUES (?, ?, ?, ?, ?, ?)`, "tok-expired", createdAt.Unix(), "EXPIREDTOKEN0003", time.Now().Add(-time.Hour).Unix(), 1, 0, ).Error require.NoError(t, err) }) svc := newSignupServiceForTest(t, db, fakeUserCreator{}) err := svc.migrateSignupTokens(t.Context()) require.NoError(t, err) entries, err := svc.listSignupTokenStates(t.Context()) require.NoError(t, err) require.Len(t, entries, 2) byToken := make(map[string]SignupTokenState, len(entries)) for _, e := range entries { byToken[e.Token] = e.State } tok1 := byToken["TOKENWITHGROUP01"] require.Equal(t, "tok-1", tok1.ID) require.Equal(t, 3, tok1.UsageLimit) require.Equal(t, 1, tok1.UsageCount) require.Equal(t, []string{"grp-1"}, tok1.UserGroupIDs) require.Equal(t, expiresAt.Unix(), tok1.ExpiresAt.Unix()) require.Equal(t, createdAt.Unix(), tok1.CreatedAt.Unix()) tok2 := byToken["TOKENNOGROUP0002"] require.Equal(t, "tok-2", tok2.ID) require.Empty(t, tok2.UserGroupIDs) // The expired token must not have been migrated require.NotContains(t, byToken, "EXPIREDTOKEN0003") // The migration is idempotent: re-running it doesn't reset a token that has been used since require.Equal(t, signupTokenConsumeOK, consumeSignupTokenForTest(t, svc.actorService, "TOKENNOGROUP0002").Status) err = svc.migrateSignupTokens(t.Context()) require.NoError(t, err) require.Equal(t, signupTokenConsumeLimitReached, consumeSignupTokenForTest(t, svc.actorService, "TOKENNOGROUP0002").Status) } // TestLoadMigratedSignupTokensEmpty verifies that when there were no signup tokens, nothing is frozen and nothing is loaded. func TestLoadMigratedSignupTokensEmpty(t *testing.T) { db := testutils.NewDatabaseForTest(t) tokens, err := loadMigratedSignupTokens(t.Context(), db) require.NoError(t, err) require.Empty(t, tokens) } // TestMoveTokensToActorStateDown verifies that rolling the migration back recreates the signup token tables and restores their contents from the frozen kv document. func TestMoveTokensToActorStateDown(t *testing.T) { createdAt := time.Now().Add(-time.Hour).Truncate(time.Second) expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second) db := testutils.NewDatabaseForTestWithMigrationSeed(t, versionBeforeMoveTokens, func(t *testing.T, db *gorm.DB) { seedSignupTokensForMigration(t, db, createdAt, expiresAt) }) // The tables were frozen and dropped by the up migration ok := db.Migrator().HasTable("signup_tokens") require.False(t, ok) // Roll the migration back sqlDB, err := db.DB() require.NoError(t, err) m, cleanup, err := utils.GetEmbeddedMigrateInstance(t.Context(), sqlDB) require.NoError(t, err) defer cleanup() err = m.Migrate(versionBeforeMoveTokens) require.NoError(t, err) // The tables must have been recreated and repopulated from the frozen document ok = db.Migrator().HasTable("signup_tokens") require.True(t, ok) ok = db.Migrator().HasTable("signup_tokens_user_groups") require.True(t, ok) type row struct { ID string Token string UsageLimit int UsageCount int } var rows []row err = db.Raw(`SELECT id, token, usage_limit, usage_count FROM signup_tokens ORDER BY id`).Scan(&rows).Error require.NoError(t, err) require.Equal(t, []row{ {ID: "tok-1", Token: "TOKENWITHGROUP01", UsageLimit: 3, UsageCount: 1}, {ID: "tok-2", Token: "TOKENNOGROUP0002", UsageLimit: 1, UsageCount: 0}, }, rows) var groupID string err = db.Raw(`SELECT user_group_id FROM signup_tokens_user_groups WHERE signup_token_id = ?`, "tok-1").Scan(&groupID).Error require.NoError(t, err) require.Equal(t, "grp-1", groupID) // The frozen document must have been removed from the kv table var kvCount int64 err = db.Raw(`SELECT count(*) FROM kv WHERE "key" = ?`, signupTokensMigratedKey).Scan(&kvCount).Error require.NoError(t, err) require.Zero(t, kvCount) }