package api import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/google/uuid" "github.com/vasyakrg/dns-autoresolver/internal/auth" "github.com/vasyakrg/dns-autoresolver/internal/store" ) // --- mocks --- 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) } func (m *mockAuthStore) RegisterUser(ctx context.Context, email, passwordHash string) (store.User, store.Project, error) { return m.registerUserFn(ctx, email, passwordHash) } func (m *mockAuthStore) GetUserByEmail(ctx context.Context, email string) (store.User, error) { 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) } type mockSessionManager struct { createFn func(ctx context.Context, userID uuid.UUID) (string, time.Time, error) destroyCalled bool destroyToken string destroyErr error } func (m *mockSessionManager) Create(ctx context.Context, userID uuid.UUID) (string, time.Time, error) { return m.createFn(ctx, userID) } func (m *mockSessionManager) Validate(context.Context, string) (uuid.UUID, error) { return uuid.Nil, nil } func (m *mockSessionManager) Destroy(ctx context.Context, token string) error { m.destroyCalled = true m.destroyToken = token return m.destroyErr } func newTestAuthAPI() (*API, *mockAuthStore, *mockSessionManager) { authStore := &mockAuthStore{} sessions := &mockSessionManager{ createFn: func(_ context.Context, userID uuid.UUID) (string, time.Time, error) { return "test-token", time.Now().Add(time.Hour), nil }, } return &API{Auth: authStore, Sessions: sessions}, authStore, sessions } func findCookie(resp *http.Response, name string) *http.Cookie { for _, c := range resp.Cookies() { if c.Name == name { return c } } return nil } // --- register --- func TestAuthRegister_Success(t *testing.T) { a, authStore, _ := newTestAuthAPI() userID := uuid.New() projectID := uuid.New() authStore.registerUserFn = func(_ context.Context, email, passwordHash string) (store.User, store.Project, error) { if passwordHash == "" { t.Fatal("expected non-empty password hash passed to RegisterUser") } return store.User{ID: userID, Email: email, PasswordHash: passwordHash}, store.Project{ID: projectID, UserID: userID, Name: "default"}, nil } router := NewRouter(a) body := `{"email":"alice@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.StatusOK { t.Fatalf("status %d, body %s", w.Code, w.Body.String()) } resp := w.Result() cookie := findCookie(resp, sessionCookieName) if cookie == nil { t.Fatal("expected session cookie to be set") } if cookie.Value != "test-token" { t.Fatalf("unexpected cookie value: %q", cookie.Value) } if strings.Contains(w.Body.String(), "password") { t.Fatalf("response body must not contain password/password_hash: %s", 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 != "alice@example.com" { t.Fatalf("unexpected user in response: %+v", got.User) } if got.Project.ID != projectID.String() || got.Project.Name != "default" { t.Fatalf("unexpected project in response: %+v", got.Project) } } // 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) { a, authStore, _ := newTestAuthAPI() hash, err := auth.HashPassword("correct-horse") if err != nil { t.Fatal(err) } userID := uuid.New() projectID := uuid.New() authStore.getUserByEmailFn = func(_ context.Context, email string) (store.User, error) { 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: projectID, UserID: uid, Name: "default"}, nil } router := NewRouter(a) body := `{"email":"bob@example.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 findCookie(w.Result(), sessionCookieName) == nil { t.Fatal("expected session cookie to be set") } if strings.Contains(w.Body.String(), "password") { t.Fatalf("response body must not contain password/password_hash: %s", w.Body.String()) } } // 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") if err != nil { t.Fatal(err) } authStore.getUserByEmailFn = func(_ context.Context, email string) (store.User, error) { return store.User{ID: uuid.New(), Email: email, PasswordHash: hash}, nil } router := NewRouter(a) body := `{"email":"bob@example.com","password":"wrong-password"}` req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(body)) w := httptest.NewRecorder() router.ServeHTTP(w, req) assertInvalidCredentials(t, w) } func TestAuthLogin_UnknownEmail(t *testing.T) { a, authStore, _ := newTestAuthAPI() authStore.getUserByEmailFn = func(_ context.Context, email string) (store.User, error) { return store.User{}, errNoRowsForTest } router := NewRouter(a) body := `{"email":"nobody@example.com","password":"whatever"}` req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(body)) w := httptest.NewRecorder() router.ServeHTTP(w, req) assertInvalidCredentials(t, w) } func assertInvalidCredentials(t *testing.T, w *httptest.ResponseRecorder) { t.Helper() if w.Code != http.StatusUnauthorized { 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"] != "invalid credentials" { t.Fatalf(`expected error "invalid credentials", got %q`, got["error"]) } if findCookie(w.Result(), sessionCookieName) != nil { t.Fatal("expected no session cookie on failed login") } } // --- logout --- func TestAuthLogout_ClearsSessionAndDestroys(t *testing.T) { a, _, sessions := newTestAuthAPI() router := NewRouter(a) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/logout", nil) req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "some-token"}) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status %d, body %s", w.Code, w.Body.String()) } if !sessions.destroyCalled { t.Fatal("expected Sessions.Destroy to be called") } if sessions.destroyToken != "some-token" { t.Fatalf("expected Destroy called with cookie token, got %q", sessions.destroyToken) } cookie := findCookie(w.Result(), sessionCookieName) if cookie == nil { t.Fatal("expected a session cookie in the response (clearing cookie)") } if cookie.MaxAge > 0 { t.Fatalf("expected cookie MaxAge <= 0 (cleared), got %d", cookie.MaxAge) } } // --- 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. var errNoRowsForTest = ¬FoundErr{} type notFoundErr struct{} func (*notFoundErr) Error() string { return "not found" }