fix(auth): wiring Auth/Sessions, нормализация email, GetUserByID для /me, 409 на дубль, timing-guard логина
This commit is contained in:
@@ -20,6 +20,7 @@ import (
|
||||
type mockAuthStore struct {
|
||||
registerUserFn func(ctx context.Context, email, passwordHash string) (store.User, store.Project, error)
|
||||
getUserByEmailFn func(ctx context.Context, email string) (store.User, error)
|
||||
getUserByIDFn func(ctx context.Context, userID uuid.UUID) (store.User, error)
|
||||
getUserProjectFn func(ctx context.Context, userID uuid.UUID) (store.Project, error)
|
||||
}
|
||||
|
||||
@@ -31,6 +32,10 @@ func (m *mockAuthStore) GetUserByEmail(ctx context.Context, email string) (store
|
||||
return m.getUserByEmailFn(ctx, email)
|
||||
}
|
||||
|
||||
func (m *mockAuthStore) GetUserByID(ctx context.Context, userID uuid.UUID) (store.User, error) {
|
||||
return m.getUserByIDFn(ctx, userID)
|
||||
}
|
||||
|
||||
func (m *mockAuthStore) GetUserProject(ctx context.Context, userID uuid.UUID) (store.Project, error) {
|
||||
return m.getUserProjectFn(ctx, userID)
|
||||
}
|
||||
@@ -125,6 +130,59 @@ func TestAuthRegister_Success(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthRegister_NormalizesEmail verifies the fix for the email-consistency
|
||||
// gap: a padded/mixed-case email is trimmed+lowercased before it reaches the
|
||||
// store, so storage and later lookups are always consistent.
|
||||
func TestAuthRegister_NormalizesEmail(t *testing.T) {
|
||||
a, authStore, _ := newTestAuthAPI()
|
||||
userID := uuid.New()
|
||||
var gotEmail string
|
||||
authStore.registerUserFn = func(_ context.Context, email, passwordHash string) (store.User, store.Project, error) {
|
||||
gotEmail = email
|
||||
return store.User{ID: userID, Email: email}, store.Project{ID: uuid.New(), UserID: userID, Name: "default"}, nil
|
||||
}
|
||||
|
||||
router := NewRouter(a)
|
||||
body := `{"email":" Alice@X.com ","password":"correct-horse"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", strings.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
if gotEmail != "alice@x.com" {
|
||||
t.Fatalf("expected normalized email passed to RegisterUser, got %q", gotEmail)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthRegister_DuplicateEmailReturns409 verifies the fix for the
|
||||
// duplicate-registration gap: RegisterUser reporting store.ErrEmailTaken
|
||||
// must surface as 409, not a generic 500.
|
||||
func TestAuthRegister_DuplicateEmailReturns409(t *testing.T) {
|
||||
a, authStore, _ := newTestAuthAPI()
|
||||
authStore.registerUserFn = func(context.Context, string, string) (store.User, store.Project, error) {
|
||||
return store.User{}, store.Project{}, store.ErrEmailTaken
|
||||
}
|
||||
|
||||
router := NewRouter(a)
|
||||
body := `{"email":"dup@example.com","password":"correct-horse"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", strings.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
var got map[string]string
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got["error"] != "email already registered" {
|
||||
t.Fatalf(`expected error "email already registered", got %q`, got["error"])
|
||||
}
|
||||
}
|
||||
|
||||
// --- login ---
|
||||
|
||||
func TestAuthLogin_CorrectPassword(t *testing.T) {
|
||||
@@ -159,6 +217,40 @@ func TestAuthLogin_CorrectPassword(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthLogin_NormalizesEmail verifies that a login for a padded/mixed-case
|
||||
// email reaches GetUserByEmail already trimmed+lowercased — the same
|
||||
// normalization applied on register, so "Alice@X.com" at registration and
|
||||
// "alice@x.com" at login resolve to the same account.
|
||||
func TestAuthLogin_NormalizesEmail(t *testing.T) {
|
||||
a, authStore, _ := newTestAuthAPI()
|
||||
hash, err := auth.HashPassword("correct-horse")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
userID := uuid.New()
|
||||
var gotEmail string
|
||||
authStore.getUserByEmailFn = func(_ context.Context, email string) (store.User, error) {
|
||||
gotEmail = email
|
||||
return store.User{ID: userID, Email: email, PasswordHash: hash}, nil
|
||||
}
|
||||
authStore.getUserProjectFn = func(_ context.Context, uid uuid.UUID) (store.Project, error) {
|
||||
return store.Project{ID: uuid.New(), UserID: uid, Name: "default"}, nil
|
||||
}
|
||||
|
||||
router := NewRouter(a)
|
||||
body := `{"email":" Alice@X.com ","password":"correct-horse"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
if gotEmail != "alice@x.com" {
|
||||
t.Fatalf("expected normalized email passed to GetUserByEmail, got %q", gotEmail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogin_WrongPassword(t *testing.T) {
|
||||
a, authStore, _ := newTestAuthAPI()
|
||||
hash, err := auth.HashPassword("correct-horse")
|
||||
@@ -240,6 +332,46 @@ func TestAuthLogout_ClearsSessionAndDestroys(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- me ---
|
||||
|
||||
// TestAuthMe_ReturnsRealEmail verifies the fix for the /me gap: the handler
|
||||
// now resolves the authenticated user via GetUserByID and returns their real
|
||||
// email, instead of leaving it blank.
|
||||
func TestAuthMe_ReturnsRealEmail(t *testing.T) {
|
||||
a, authStore, _ := newTestAuthAPI()
|
||||
userID := uuid.New()
|
||||
projectID := uuid.New()
|
||||
authStore.getUserByIDFn = func(_ context.Context, id uuid.UUID) (store.User, error) {
|
||||
if id != userID {
|
||||
t.Fatalf("unexpected user id: %s", id)
|
||||
}
|
||||
return store.User{ID: userID, Email: "me@example.com"}, nil
|
||||
}
|
||||
authStore.getUserProjectFn = func(_ context.Context, uid uuid.UUID) (store.Project, error) {
|
||||
return store.Project{ID: projectID, UserID: uid, Name: "default"}, nil
|
||||
}
|
||||
|
||||
router := NewRouter(a)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
|
||||
req = req.WithContext(context.WithValue(req.Context(), ctxKeyUserID{}, userID))
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
var got authResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.User.ID != userID.String() || got.User.Email != "me@example.com" {
|
||||
t.Fatalf("unexpected user in /me response: %+v", got.User)
|
||||
}
|
||||
if got.Project.ID != projectID.String() {
|
||||
t.Fatalf("unexpected project in /me response: %+v", got.Project)
|
||||
}
|
||||
}
|
||||
|
||||
// errNoRowsForTest stands in for a "not found" error a real store would
|
||||
// return (e.g. pgx.ErrNoRows) — handlers must not distinguish it from any
|
||||
// other GetUserByEmail failure in the response they send.
|
||||
|
||||
Reference in New Issue
Block a user