feat(auth): add user profile, phone login, and account deletion

This commit is contained in:
iu
2026-10-12 14:26:32 +03:00
parent 1cb9aaf198
commit 86aff695df
46 changed files with 2893 additions and 134 deletions
+239
View File
@@ -0,0 +1,239 @@
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)
}
}