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) getProjectOwnedFn func(ctx context.Context, projectID, 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) } func (m *mockAuthStore) GetProjectOwned(ctx context.Context, projectID, userID uuid.UUID) (store.Project, error) { return m.getProjectOwnedFn(ctx, projectID, userID) } type mockSessionManager struct { createFn func(ctx context.Context, userID uuid.UUID) (string, time.Time, error) validateFn func(ctx context.Context, token string) (uuid.UUID, 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(ctx context.Context, token string) (uuid.UUID, error) { if m.validateFn != nil { return m.validateFn(ctx, token) } 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, sessions := 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 } // /me is now behind RequireAuth: the session cookie must resolve to // userID via Sessions.Validate rather than being injected directly. sessions.validateFn = func(context.Context, string) (uuid.UUID, error) { return userID, nil } router := NewRouter(a) req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil) req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "some-valid-token"}) 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" }