251 lines
7.6 KiB
Go
251 lines
7.6 KiB
Go
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)
|
|
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) 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)
|
|
}
|
|
}
|
|
|
|
// --- 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())
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 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" }
|