package auth import ( "context" "errors" "io/fs" "path/filepath" "testing" "time" assets "galo" "galo/internal/db" "galo/internal/httpx" ) func newTestService(t *testing.T) *Service { t.Helper() migFS, err := fs.Sub(assets.Migrations, "migrations/sqlite") if err != nil { t.Fatalf("migrations fs: %v", err) } path := filepath.Join(t.TempDir(), "auth.db") if err := db.MigrateSQLite(path, migFS); err != nil { t.Fatalf("migrate: %v", err) } database, err := db.OpenSQLite(path) if err != nil { t.Fatalf("open: %v", err) } t.Cleanup(func() { database.Close() }) return NewService(NewSQLiteStore(database), NewTokenManager("secret", 15*time.Minute, time.Hour)) } func errCode(t *testing.T, err error) string { t.Helper() var appErr *httpx.AppError if !errors.As(err, &appErr) { t.Fatalf("expected AppError, got %v", err) } return appErr.Code } func TestRegisterByEmailOrPhone(t *testing.T) { ctx := context.Background() svc := newTestService(t) u, pair, err := svc.Register(ctx, "User@Test.Local", "", "password123", "Tester") if err != nil { t.Fatalf("register email: %v", err) } if u.Email != "user@test.local" || u.Phone != "" { t.Fatalf("email registration stored %+v", u) } if pair == nil || pair.AccessToken == "" || pair.RefreshToken == "" { t.Fatalf("no tokens issued") } p, _, err := svc.Register(ctx, "", "+15550000001", "password123", "Phone") if err != nil { t.Fatalf("register phone: %v", err) } if p.Email != "" || p.Phone != "+15550000001" { t.Fatalf("phone registration stored %+v", p) } both, _, err := svc.Register(ctx, "both@test.local", "+15550000002", "password123", "Both") if err != nil { t.Fatalf("register both: %v", err) } if both.Email != "both@test.local" || both.Phone != "+15550000002" { t.Fatalf("both registration stored %+v", both) } } func TestRegisterValidation(t *testing.T) { ctx := context.Background() svc := newTestService(t) if _, _, err := svc.Register(ctx, "", "", "password123", "N"); errCode(t, err) != "identifier_required" { t.Fatalf("no identifier code = %v", err) } if _, _, err := svc.Register(ctx, "a@test.local", "", "short", "N"); errCode(t, err) != "weak_password" { t.Fatalf("weak password code = %v", err) } if _, _, err := svc.Register(ctx, "a@test.local", "", "password123", ""); errCode(t, err) != "invalid_name" { t.Fatalf("empty name code = %v", err) } if _, _, err := svc.Register(ctx, "not-an-email", "", "password123", "N"); errCode(t, err) != "invalid_email" { t.Fatalf("invalid email code = %v", err) } if _, _, err := svc.Register(ctx, "", "5550000", "password123", "N"); errCode(t, err) != "invalid_phone" { t.Fatalf("invalid phone code = %v", err) } } func TestRegisterConflicts(t *testing.T) { ctx := context.Background() svc := newTestService(t) if _, _, err := svc.Register(ctx, "dup@test.local", "", "password123", "A"); err != nil { t.Fatalf("first register: %v", err) } if _, _, err := svc.Register(ctx, "Dup@Test.Local", "", "password123", "B"); errCode(t, err) != "email_taken" { t.Fatalf("duplicate email code = %v", err) } if _, _, err := svc.Register(ctx, "", "+15550000009", "password123", "C"); err != nil { t.Fatalf("phone register: %v", err) } if _, _, err := svc.Register(ctx, "", "+15550000009", "password123", "D"); errCode(t, err) != "phone_taken" { t.Fatalf("duplicate phone code = %v", err) } } func TestLoginByIdentifier(t *testing.T) { ctx := context.Background() svc := newTestService(t) if _, _, err := svc.Register(ctx, "login@test.local", "+15550000010", "password123", "Login"); err != nil { t.Fatalf("register: %v", err) } if _, pair, err := svc.Login(ctx, "Login@Test.Local", "password123"); err != nil || pair == nil { t.Fatalf("login by email: %v", err) } if _, pair, err := svc.Login(ctx, "+15550000010", "password123"); err != nil || pair == nil { t.Fatalf("login by phone: %v", err) } _, _, errMissing := svc.Login(ctx, "nobody@test.local", "password123") _, _, errWrong := svc.Login(ctx, "login@test.local", "wrong-password") if errCode(t, errMissing) != "invalid_credentials" { t.Fatalf("missing user code = %v", errMissing) } if errCode(t, errWrong) != "invalid_credentials" { t.Fatalf("wrong password code = %v", errWrong) } if errMissing.Error() != errWrong.Error() { t.Fatalf("errors distinguishable: %q vs %q", errMissing, errWrong) } } func TestUpdateName(t *testing.T) { ctx := context.Background() svc := newTestService(t) u, _, err := svc.Register(ctx, "name@test.local", "", "password123", "Old") if err != nil { t.Fatalf("register: %v", err) } if _, err := svc.UpdateName(ctx, u.ID, " "); errCode(t, err) != "invalid_name" { t.Fatalf("empty name code = %v", err) } got, err := svc.UpdateName(ctx, u.ID, " New Name ") if err != nil || got.Name != "New Name" { t.Fatalf("update name = %+v err=%v", got, err) } } func TestChangePasswordRevokesTokens(t *testing.T) { ctx := context.Background() svc := newTestService(t) u, pair, err := svc.Register(ctx, "pass@test.local", "", "password123", "Pass") if err != nil { t.Fatalf("register: %v", err) } if err := svc.ChangePassword(ctx, u.ID, "wrong", "newpassword"); errCode(t, err) != "current_password_incorrect" { t.Fatalf("wrong current password code = %v", err) } if err := svc.ChangePassword(ctx, u.ID, "password123", "short"); errCode(t, err) != "weak_password" { t.Fatalf("weak new password code = %v", err) } if err := svc.ChangePassword(ctx, u.ID, "password123", "newpassword"); err != nil { t.Fatalf("change password: %v", err) } if _, _, err := svc.Login(ctx, "pass@test.local", "newpassword"); err != nil { t.Fatalf("login with new password: %v", err) } if _, _, err := svc.Login(ctx, "pass@test.local", "password123"); errCode(t, err) != "invalid_credentials" { t.Fatalf("old password still works: %v", err) } if _, err := svc.Refresh(ctx, pair.RefreshToken); errCode(t, err) != "invalid_refresh_token" { t.Fatalf("refresh token not revoked: %v", err) } } func TestAddIdentifier(t *testing.T) { ctx := context.Background() svc := newTestService(t) u, _, err := svc.Register(ctx, "add@test.local", "", "password123", "Add") if err != nil { t.Fatalf("register: %v", err) } if _, err := svc.AddIdentifier(ctx, u.ID, "other@test.local", ""); errCode(t, err) != "email_already_set" { t.Fatalf("re-set email code = %v", err) } if _, err := svc.AddIdentifier(ctx, u.ID, "", ""); errCode(t, err) != "identifier_required" { t.Fatalf("no identifier code = %v", err) } if _, err := svc.AddIdentifier(ctx, u.ID, "x@test.local", "+15550000011"); errCode(t, err) != "invalid_request" { t.Fatalf("both at once code = %v", err) } got, err := svc.AddIdentifier(ctx, u.ID, "", "+15550000011") if err != nil || got.Phone != "+15550000011" { t.Fatalf("add phone = %+v err=%v", got, err) } if _, err := svc.AddIdentifier(ctx, u.ID, "later@test.local", ""); errCode(t, err) != "identifiers_complete" { t.Fatalf("both set code = %v", err) } other, _, _ := svc.Register(ctx, "owner@test.local", "", "password123", "Owner") if _, err := svc.AddIdentifier(ctx, other.ID, "", "+15550000011"); errCode(t, err) != "phone_taken" { t.Fatalf("phone conflict code = %v", err) } } func TestDeleteAccount(t *testing.T) { ctx := context.Background() svc := newTestService(t) u, _, err := svc.Register(ctx, "delete@test.local", "", "password123", "Delete") if err != nil { t.Fatalf("register: %v", err) } if err := svc.DeleteAccount(ctx, u.ID, "wrong"); errCode(t, err) != "current_password_incorrect" { t.Fatalf("wrong password code = %v", err) } if err := svc.DeleteAccount(ctx, u.ID, "password123"); err != nil { t.Fatalf("delete account: %v", err) } if _, _, err := svc.Login(ctx, "delete@test.local", "password123"); errCode(t, err) != "invalid_credentials" { t.Fatalf("deleted account can still log in: %v", err) } if _, err := svc.store.GetUserByID(ctx, u.ID); !errors.Is(err, ErrNotFound) { t.Fatalf("user row survived deletion: %v", err) } }