193 lines
5.8 KiB
Go
193 lines
5.8 KiB
Go
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)
|
||
|
|
}
|
||
|
|
}
|