package auth import ( "os" "strings" "testing" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) func TestResetPasswordWithTokenBumpsSessionVersion(t *testing.T) { dsn := strings.TrimSpace(os.Getenv("DATABASE_URL")) if dsn == "" { t.Skip("DATABASE_URL not set") } ctx := t.Context() pg, err := pgxpool.New(ctx, dsn) if err != nil { t.Fatalf("postgres: %v", err) } t.Cleanup(pg.Close) var ready bool if err := pg.QueryRow(ctx, ` SELECT EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'users' AND column_name = 'session_version' )`).Scan(&ready); err != nil || !ready { t.Skip("users.session_version missing — run goose up for 042_user_session_version") } if err := pg.QueryRow(ctx, ` SELECT EXISTS ( SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'password_reset_tokens' )`).Scan(&ready); err != nil || !ready { t.Skip("password_reset_tokens missing — run goose up for 041_password_reset_tokens") } svc := &Service{Pool: pg} userID := uuid.New() email := "session-ver-" + userID.String()[:8] + "@example.test" hash, err := HashPassword("OldPassword123!") if err != nil { t.Fatalf("hash: %v", err) } _, err = pg.Exec(ctx, ` INSERT INTO users (id, email, name, password_hash, must_set_password, is_platform_admin, is_active, session_version) VALUES ($1, $2, $3, $4, false, false, true, 3)`, userID, email, "Session Ver", hash) if err != nil { t.Fatalf("seed user: %v", err) } t.Cleanup(func() { cleanupCtx := t.Context() _, _ = pg.Exec(cleanupCtx, `DELETE FROM password_reset_tokens WHERE user_id = $1`, userID) _, _ = pg.Exec(cleanupCtx, `DELETE FROM users WHERE id = $1`, userID) }) issue, err := svc.IssuePasswordReset(ctx, email, time.Hour) if err != nil { t.Fatalf("IssuePasswordReset: %v", err) } if err := svc.ResetPasswordWithToken(ctx, issue.Token, "NewPassword456!"); err != nil { t.Fatalf("ResetPasswordWithToken: %v", err) } st, err := svc.UserSessionState(ctx, userID) if err != nil { t.Fatalf("UserSessionState: %v", err) } if !st.Active { t.Fatal("expected active user") } if st.Version != 4 { t.Fatalf("session_version=%d want 4 (bumped from 3)", st.Version) } } func TestChangePasswordBumpsSessionVersion(t *testing.T) { dsn := strings.TrimSpace(os.Getenv("DATABASE_URL")) if dsn == "" { t.Skip("DATABASE_URL not set") } ctx := t.Context() pg, err := pgxpool.New(ctx, dsn) if err != nil { t.Fatalf("postgres: %v", err) } t.Cleanup(pg.Close) var ready bool if err := pg.QueryRow(ctx, ` SELECT EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'users' AND column_name = 'session_version' )`).Scan(&ready); err != nil || !ready { t.Skip("users.session_version missing — run goose up for 042_user_session_version") } svc := &Service{Pool: pg} userID := uuid.New() email := "change-pw-" + userID.String()[:8] + "@example.test" const oldPassword = "OldPassword123!" hash, err := HashPassword(oldPassword) if err != nil { t.Fatalf("hash: %v", err) } _, err = pg.Exec(ctx, ` INSERT INTO users (id, email, name, password_hash, must_set_password, is_platform_admin, is_active, session_version) VALUES ($1, $2, $3, $4, false, false, true, 2)`, userID, email, "Change PW", hash) if err != nil { t.Fatalf("seed user: %v", err) } t.Cleanup(func() { _, _ = pg.Exec(t.Context(), `DELETE FROM users WHERE id = $1`, userID) }) if err := svc.ChangePassword(ctx, userID, "wrong-password", "NewPassword456!"); err != ErrInvalidCredentials { t.Fatalf("wrong current: err=%v want ErrInvalidCredentials", err) } if err := svc.ChangePassword(ctx, userID, oldPassword, "short"); err != ErrPasswordTooShort { t.Fatalf("short password: err=%v want ErrPasswordTooShort", err) } if err := svc.ChangePassword(ctx, userID, oldPassword, "NewPassword456!"); err != nil { t.Fatalf("ChangePassword: %v", err) } st, err := svc.UserSessionState(ctx, userID) if err != nil { t.Fatalf("UserSessionState: %v", err) } if st.Version != 3 { t.Fatalf("session_version=%d want 3 (bumped from 2)", st.Version) } var stored string if err := pg.QueryRow(ctx, `SELECT password_hash FROM users WHERE id = $1`, userID).Scan(&stored); err != nil { t.Fatalf("load hash: %v", err) } ok, err := VerifyPassword(stored, "NewPassword456!") if err != nil || !ok { t.Fatalf("new password verify ok=%v err=%v", ok, err) } ok, err = VerifyPassword(stored, oldPassword) if err != nil || ok { t.Fatal("old password should no longer verify") } } func TestSetPasswordRejectsWhenAlreadySet(t *testing.T) { dsn := strings.TrimSpace(os.Getenv("DATABASE_URL")) if dsn == "" { t.Skip("DATABASE_URL not set") } ctx := t.Context() pg, err := pgxpool.New(ctx, dsn) if err != nil { t.Fatalf("postgres: %v", err) } t.Cleanup(pg.Close) svc := &Service{Pool: pg} userID := uuid.New() email := "set-pw-" + userID.String()[:8] + "@example.test" hash, err := HashPassword("AlreadySet123!") if err != nil { t.Fatalf("hash: %v", err) } _, err = pg.Exec(ctx, ` INSERT INTO users (id, email, name, password_hash, must_set_password, is_platform_admin, is_active) VALUES ($1, $2, $3, $4, false, false, true)`, userID, email, "Set PW", hash) if err != nil { t.Fatalf("seed user: %v", err) } t.Cleanup(func() { _, _ = pg.Exec(t.Context(), `DELETE FROM users WHERE id = $1`, userID) }) if err := svc.SetPassword(ctx, userID, "AnotherPass123!"); err != ErrPasswordAlreadySet { t.Fatalf("SetPassword: err=%v want ErrPasswordAlreadySet", err) } if err := svc.ChangePassword(ctx, userID, "AlreadySet123!", "short"); err != ErrPasswordTooShort { // ensure ChangePassword path still works for eligible users after SetPassword rejection t.Fatalf("ChangePassword short: err=%v", err) } }