diff --git a/auth.go b/auth.go index 4397faa..d09d3da 100644 --- a/auth.go +++ b/auth.go @@ -46,9 +46,10 @@ const UserEmailKey Key = "userEmail" const UserIdKey Key = "userId" type CookieConfig struct { - Domain string // For cross-subdomain auth - Secure bool // HTTPS only (default: true) - SameSite http.SameSite // Default: SameSiteStrictMode + Domain string // For cross-subdomain auth + Secure bool // HTTPS only (default: true) + SameSite http.SameSite // Default: SameSiteStrictMode + RefreshPath string // Cookie path for refresh token. Default: "/auth/refresh" } type CSRFConfig struct { diff --git a/auth_test.go b/auth_test.go index 27d690d..76e5438 100644 --- a/auth_test.go +++ b/auth_test.go @@ -5,6 +5,8 @@ import ( "strings" "testing" "time" + + "github.com/google/uuid" ) // Phase 1: URL Encoding Tests @@ -138,6 +140,103 @@ func TestLoginStep1_ErrorsWhenNoReturnUrlsConfigured(t *testing.T) { } } +func TestLoginStep2_RejectsWrongCode(t *testing.T) { + auth := createTestAuth() + email := "test@example.com" + err := auth.Db.StoreVerificationCode(email, "123456", time.Now().Add(5*time.Minute)) + if err != nil { + t.Fatalf("failed to store code: %v", err) + } + + confirmed, tokens, err := auth.LoginStep2ConfirmCode(context.Background(), email, "000000") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if confirmed { + t.Error("expected confirmed to be false for wrong code") + } + if tokens != nil { + t.Error("expected tokens to be nil for wrong code") + } +} + +func TestLoginStep2_RejectsExpiredCode(t *testing.T) { + auth := createTestAuth() + email := "test@example.com" + err := auth.Db.StoreVerificationCode(email, "123456", time.Now().Add(-1*time.Minute)) + if err != nil { + t.Fatalf("failed to store code: %v", err) + } + + confirmed, tokens, err := auth.LoginStep2ConfirmCode(context.Background(), email, "123456") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if confirmed { + t.Error("expected confirmed to be false for expired code") + } + if tokens != nil { + t.Error("expected tokens to be nil for expired code") + } +} + +func TestRefreshToken_RejectsExpiredJWT(t *testing.T) { + auth := createTestAuth() + + // Create an expired refresh token + expiredToken, err := createRefreshToken( + uuid.New(), + "test@example.com", + auth.Cfg.RefreshTokenSecret, + -1*time.Hour, // already expired + ) + if err != nil { + t.Fatalf("failed to create token: %v", err) + } + + _, err = auth.RefreshToken(context.Background(), expiredToken.Token) + if err == nil { + t.Error("expected error for expired JWT") + } +} + +func TestRefreshToken_RejectsInvalidJWT(t *testing.T) { + auth := createTestAuth() + + _, err := auth.RefreshToken(context.Background(), "not-a-valid-jwt") + if err == nil { + t.Error("expected error for invalid JWT") + } +} + +func TestRefreshToken_ReturnsNewTokenPair(t *testing.T) { + auth := createTestAuth() + + refreshToken, err := createRefreshToken( + uuid.New(), + "test@example.com", + auth.Cfg.RefreshTokenSecret, + auth.Cfg.RefreshTokenValidityPeriod, + ) + if err != nil { + t.Fatalf("failed to create token: %v", err) + } + + tokens, err := auth.RefreshToken(context.Background(), refreshToken.Token) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tokens == nil { + t.Fatal("expected non-nil token response") + } + if tokens.AccessToken == nil || tokens.AccessToken.Token == "" { + t.Error("expected non-empty access token") + } + if tokens.RefreshToken == nil || tokens.RefreshToken.Token == "" { + t.Error("expected non-empty refresh token") + } +} + func TestLoginStep1_AcceptsValidReturnUrl(t *testing.T) { mockSender := &MockSender{} auth := New(NewMemDb(), mockSender, &MockUserCreator{}, NewEmailValidator(), nil, nil, &Config{ diff --git a/cookies.go b/cookies.go index 526ad3a..834449f 100644 --- a/cookies.go +++ b/cookies.go @@ -11,6 +11,11 @@ func (a *Auth) SetTokenCookies(w http.ResponseWriter, tokens *TokenResponse) { cfg = &CookieConfig{Secure: true, SameSite: http.SameSiteStrictMode} } + refreshPath := cfg.RefreshPath + if refreshPath == "" { + refreshPath = "/auth/refresh" + } + http.SetCookie(w, &http.Cookie{ Name: "access_token", Value: tokens.AccessToken.Token, @@ -28,7 +33,7 @@ func (a *Auth) SetTokenCookies(w http.ResponseWriter, tokens *TokenResponse) { HttpOnly: true, Secure: cfg.Secure, SameSite: cfg.SameSite, - Path: "/auth/refresh", // Only sent to refresh endpoint + Path: refreshPath, Expires: tokens.RefreshToken.TokenExpiry, Domain: cfg.Domain, }) @@ -40,6 +45,11 @@ func (a *Auth) ClearTokenCookies(w http.ResponseWriter) { cfg = &CookieConfig{Secure: true, SameSite: http.SameSiteStrictMode} } + refreshPath := cfg.RefreshPath + if refreshPath == "" { + refreshPath = "/auth/refresh" + } + expired := time.Now().Add(-24 * time.Hour) http.SetCookie(w, &http.Cookie{ @@ -60,7 +70,7 @@ func (a *Auth) ClearTokenCookies(w http.ResponseWriter) { HttpOnly: true, Secure: cfg.Secure, SameSite: cfg.SameSite, - Path: "/auth/refresh", + Path: refreshPath, Expires: expired, MaxAge: -1, Domain: cfg.Domain, diff --git a/cookies_test.go b/cookies_test.go index 9f2e5b0..8e0b015 100644 --- a/cookies_test.go +++ b/cookies_test.go @@ -135,6 +135,169 @@ func TestSetTokenCookies_SetsDomain(t *testing.T) { } } +func TestClearTokenCookies_UsesCorrectPaths(t *testing.T) { + auth := createTestAuth() + + w := httptest.NewRecorder() + auth.ClearTokenCookies(w) + + cookies := w.Result().Cookies() + accessCookie := findCookie(cookies, "access_token") + refreshCookie := findCookie(cookies, "refresh_token") + + if accessCookie == nil { + t.Fatal("expected access_token cookie") + } + if accessCookie.Path != "/" { + t.Errorf("expected access_token path '/', got '%s'", accessCookie.Path) + } + + if refreshCookie == nil { + t.Fatal("expected refresh_token cookie") + } + if refreshCookie.Path != "/auth/refresh" { + t.Errorf("expected refresh_token path '/auth/refresh', got '%s'", refreshCookie.Path) + } +} + +func TestClearTokenCookies_UsesSecureDefaultsWhenConfigNil(t *testing.T) { + auth := createTestAuth() // No CookieConfig + + w := httptest.NewRecorder() + auth.ClearTokenCookies(w) + + cookies := w.Result().Cookies() + accessCookie := findCookie(cookies, "access_token") + refreshCookie := findCookie(cookies, "refresh_token") + + if accessCookie == nil { + t.Fatal("expected access_token cookie") + } + if !accessCookie.Secure { + t.Error("expected access_token Secure to be true by default") + } + + if refreshCookie == nil { + t.Fatal("expected refresh_token cookie") + } + if !refreshCookie.Secure { + t.Error("expected refresh_token Secure to be true by default") + } +} + +func TestSetTokenCookies_SetsRefreshTokenExpiry(t *testing.T) { + auth := createTestAuth() + + expiry := time.Now().Add(24 * time.Hour) + tokens := &TokenResponse{ + AccessToken: &Token{Token: "access123", TokenExpiry: time.Now().Add(time.Hour)}, + RefreshToken: &Token{Token: "refresh123", TokenExpiry: expiry}, + } + + w := httptest.NewRecorder() + auth.SetTokenCookies(w, tokens) + + cookies := w.Result().Cookies() + refreshCookie := findCookie(cookies, "refresh_token") + + if refreshCookie == nil { + t.Fatal("expected refresh_token cookie") + } + // Allow 1 second tolerance for test execution time + diff := refreshCookie.Expires.Sub(expiry) + if diff < -time.Second || diff > time.Second { + t.Errorf("expected refresh_token Expires close to %v, got %v", expiry, refreshCookie.Expires) + } +} + +func TestSetTokenCookies_SetsRefreshTokenSecureAndDomain(t *testing.T) { + auth := createTestAuthWithConfig(&Config{ + ReturnUrls: []string{"https://app.com/login"}, + AccessTokenSecret: "secret", + RefreshTokenSecret: "secret", + CodeValidityPeriod: 5 * time.Minute, + AccessTokenValidityPeriod: 1 * time.Hour, + RefreshTokenValidityPeriod: 24 * time.Hour, + CookieConfig: &CookieConfig{Domain: ".example.com", Secure: true}, + }) + + tokens := &TokenResponse{ + AccessToken: &Token{Token: "access123", TokenExpiry: time.Now().Add(time.Hour)}, + RefreshToken: &Token{Token: "refresh123", TokenExpiry: time.Now().Add(24 * time.Hour)}, + } + + w := httptest.NewRecorder() + auth.SetTokenCookies(w, tokens) + + cookies := w.Result().Cookies() + refreshCookie := findCookie(cookies, "refresh_token") + + if refreshCookie == nil { + t.Fatal("expected refresh_token cookie") + } + if !refreshCookie.Secure { + t.Error("expected refresh_token Secure to be true") + } + if refreshCookie.Domain != "example.com" { + t.Errorf("expected refresh_token domain 'example.com', got '%s'", refreshCookie.Domain) + } +} + +func TestSetTokenCookies_UsesCustomRefreshPath(t *testing.T) { + auth := createTestAuthWithConfig(&Config{ + ReturnUrls: []string{"https://app.com/login"}, + AccessTokenSecret: "secret", + RefreshTokenSecret: "secret", + CodeValidityPeriod: 5 * time.Minute, + AccessTokenValidityPeriod: 1 * time.Hour, + RefreshTokenValidityPeriod: 24 * time.Hour, + CookieConfig: &CookieConfig{Secure: true, RefreshPath: "/api/v1/auth/refresh"}, + }) + + tokens := &TokenResponse{ + AccessToken: &Token{Token: "access123", TokenExpiry: time.Now().Add(time.Hour)}, + RefreshToken: &Token{Token: "refresh123", TokenExpiry: time.Now().Add(24 * time.Hour)}, + } + + w := httptest.NewRecorder() + auth.SetTokenCookies(w, tokens) + + cookies := w.Result().Cookies() + refreshCookie := findCookie(cookies, "refresh_token") + + if refreshCookie == nil { + t.Fatal("expected refresh_token cookie") + } + if refreshCookie.Path != "/api/v1/auth/refresh" { + t.Errorf("expected path '/api/v1/auth/refresh', got '%s'", refreshCookie.Path) + } +} + +func TestClearTokenCookies_UsesCustomRefreshPath(t *testing.T) { + auth := createTestAuthWithConfig(&Config{ + ReturnUrls: []string{"https://app.com/login"}, + AccessTokenSecret: "secret", + RefreshTokenSecret: "secret", + CodeValidityPeriod: 5 * time.Minute, + AccessTokenValidityPeriod: 1 * time.Hour, + RefreshTokenValidityPeriod: 24 * time.Hour, + CookieConfig: &CookieConfig{Secure: true, RefreshPath: "/api/v1/auth/refresh"}, + }) + + w := httptest.NewRecorder() + auth.ClearTokenCookies(w) + + cookies := w.Result().Cookies() + refreshCookie := findCookie(cookies, "refresh_token") + + if refreshCookie == nil { + t.Fatal("expected refresh_token cookie") + } + if refreshCookie.Path != "/api/v1/auth/refresh" { + t.Errorf("expected path '/api/v1/auth/refresh', got '%s'", refreshCookie.Path) + } +} + func TestClearTokenCookies_ExpiresAllTokenCookies(t *testing.T) { auth := createTestAuth() diff --git a/http_handler.go b/http_handler.go index 7ee5f78..b90dbde 100644 --- a/http_handler.go +++ b/http_handler.go @@ -32,31 +32,41 @@ func (h *Handler) SetupRoutes(router chi.Router) { if h.auth.Cfg.RateLimitPerSecond > 0 { r.Use(tollbooth_chi.LimitHandler(limiter)) } - r.Get("/auth/login", h.Login) - r.Get("/auth/confirm", h.ConfirmCode) + r.Post("/auth/login", h.Login) + r.Post("/auth/confirm", h.ConfirmCode) r.Post("/auth/refresh", h.RefreshToken) r.Post("/auth/logout", h.Logout) }) } func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { - email := r.URL.Query().Get("email") - returnUrl := r.URL.Query().Get("returnUrl") + var req struct { + Email string `json:"email"` + ReturnUrl string `json:"returnUrl"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + WriteErr(w, fmt.Errorf("invalid request body"), http.StatusBadRequest) + return + } ctx := r.Context() - if len(email) == 0 { + if len(req.Email) == 0 { WriteErr(w, fmt.Errorf("email required"), http.StatusBadRequest) return } - if len(returnUrl) == 0 { - returnUrl = h.auth.Cfg.ReturnUrls[0] + if len(req.ReturnUrl) == 0 { + if len(h.auth.Cfg.ReturnUrls) == 0 { + WriteErr(w, fmt.Errorf("returnUrl required and no defaults configured"), http.StatusBadRequest) + return + } + req.ReturnUrl = h.auth.Cfg.ReturnUrls[0] } validReturnUrl := false for _, url := range h.auth.Cfg.ReturnUrls { - if url == returnUrl { + if url == req.ReturnUrl { validReturnUrl = true break } @@ -67,7 +77,7 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { return } - err := h.auth.LoginStep1SendVerificationCode(ctx, email, returnUrl) + err := h.auth.LoginStep1SendVerificationCode(ctx, req.Email, req.ReturnUrl) if err != nil { WriteErr(w, err, http.StatusInternalServerError) return @@ -77,17 +87,23 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { } func (h *Handler) ConfirmCode(w http.ResponseWriter, r *http.Request) { - code := r.URL.Query().Get("code") - email := r.URL.Query().Get("email") + var req struct { + Code string `json:"code"` + Email string `json:"email"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + WriteErr(w, fmt.Errorf("invalid request body"), http.StatusBadRequest) + return + } ctx := r.Context() - if len(code) == 0 || len(email) == 0 { + if len(req.Code) == 0 || len(req.Email) == 0 { WriteErr(w, fmt.Errorf("code and email required"), http.StatusBadRequest) return } - confirmed, tokens, err := h.auth.LoginStep2ConfirmCode(ctx, email, code) + confirmed, tokens, err := h.auth.LoginStep2ConfirmCode(ctx, req.Email, req.Code) if err != nil { WriteErr(w, err, http.StatusInternalServerError) return @@ -104,23 +120,16 @@ func (h *Handler) ConfirmCode(w http.ResponseWriter, r *http.Request) { } func (h *Handler) RefreshToken(w http.ResponseWriter, r *http.Request) { - var req struct { - Token string `json:"token"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - WriteErr(w, fmt.Errorf("invalid request body"), http.StatusBadRequest) - return - } - - if len(req.Token) == 0 { - WriteErr(w, fmt.Errorf("token required"), http.StatusBadRequest) + cookie, err := r.Cookie("refresh_token") + if err != nil { + WriteErr(w, fmt.Errorf("refresh token required"), http.StatusUnauthorized) return } ctx := r.Context() - tokens, err := h.auth.RefreshToken(ctx, req.Token) + tokens, err := h.auth.RefreshToken(ctx, cookie.Value) if err != nil { - WriteErr(w, err, http.StatusInternalServerError) + WriteErr(w, err, http.StatusUnauthorized) return } diff --git a/http_handler_test.go b/http_handler_test.go index 941b4bc..3e90acb 100644 --- a/http_handler_test.go +++ b/http_handler_test.go @@ -25,12 +25,194 @@ func createTestHandlerWithRouter() (http.Handler, *Auth) { return r, auth } -// Phase 2: POST Refresh Tests +// Login handler tests + +func TestLogin_SendsVerificationCode(t *testing.T) { + router, _ := createTestHandlerWithRouter() + + body := `{"email":"test@example.com","returnUrl":"https://app.com/login"}` + req := httptest.NewRequest("POST", "/auth/login", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status OK, got %d: %s", w.Code, w.Body.String()) + } + + var response string + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + if response != "ok" { + t.Errorf("expected response 'ok', got '%s'", response) + } +} + +func TestLogin_RejectsMissingEmail(t *testing.T) { + router, _ := createTestHandlerWithRouter() + + body := `{"returnUrl":"https://app.com/login"}` + req := httptest.NewRequest("POST", "/auth/login", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status BadRequest, got %d", w.Code) + } +} + +func TestLogin_RejectsInvalidReturnUrl(t *testing.T) { + router, _ := createTestHandlerWithRouter() + + body := `{"email":"test@example.com","returnUrl":"https://evil.com/login"}` + req := httptest.NewRequest("POST", "/auth/login", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status BadRequest, got %d", w.Code) + } +} + +func TestLogin_DefaultsReturnUrl(t *testing.T) { + router, _ := createTestHandlerWithRouter() + + body := `{"email":"test@example.com"}` + req := httptest.NewRequest("POST", "/auth/login", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status OK, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestLogin_RejectsEmptyReturnUrlsConfig(t *testing.T) { + auth := createTestAuthWithConfig(&Config{ + AppName: "TestApp", + ReturnUrls: []string{}, + AccessTokenSecret: "test-access-secret", + RefreshTokenSecret: "test-refresh-secret", + CodeValidityPeriod: 5 * time.Minute, + AccessTokenValidityPeriod: 1 * time.Hour, + RefreshTokenValidityPeriod: 24 * time.Hour, + }) + r := chi.NewRouter() + NewHandler(r, auth) + + body := `{"email":"test@example.com"}` + req := httptest.NewRequest("POST", "/auth/login", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status BadRequest, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestLogin_RejectsGET(t *testing.T) { + router, _ := createTestHandlerWithRouter() + + req := httptest.NewRequest("GET", "/auth/login?email=test@example.com", nil) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("expected status MethodNotAllowed (405), got %d", w.Code) + } +} + +// ConfirmCode handler tests + +func TestConfirmCode_RejectsMissingParams(t *testing.T) { + router, _ := createTestHandlerWithRouter() + + body := `{}` + req := httptest.NewRequest("POST", "/auth/confirm", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status BadRequest, got %d", w.Code) + } +} + +func TestConfirmCode_RejectsWrongCode(t *testing.T) { + router, auth := createTestHandlerWithRouter() + + email := "test@example.com" + err := auth.Db.StoreVerificationCode(email, "123456", time.Now().Add(5*time.Minute)) + if err != nil { + t.Fatalf("failed to store verification code: %v", err) + } + + body := `{"code":"000000","email":"` + email + `"}` + req := httptest.NewRequest("POST", "/auth/confirm", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status BadRequest, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestConfirmCode_RejectsGET(t *testing.T) { + router, _ := createTestHandlerWithRouter() + + req := httptest.NewRequest("GET", "/auth/confirm?code=123456&email=test@example.com", nil) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("expected status MethodNotAllowed (405), got %d", w.Code) + } +} + +// RefreshToken handler tests + +func TestRefreshToken_RejectsExpiredToken(t *testing.T) { + router, auth := createTestHandlerWithRouter() + + expiredToken, err := createRefreshToken( + mustParseUUID("550e8400-e29b-41d4-a716-446655440000"), + "test@example.com", + auth.Cfg.RefreshTokenSecret, + -1*time.Hour, + ) + if err != nil { + t.Fatalf("failed to create refresh token: %v", err) + } + + req := httptest.NewRequest("POST", "/auth/refresh", nil) + req.AddCookie(&http.Cookie{Name: "refresh_token", Value: expiredToken.Token}) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected status 401, got %d: %s", w.Code, w.Body.String()) + } +} func TestRefreshToken_POSTMethod(t *testing.T) { router, auth := createTestHandlerWithRouter() - // First, create a valid refresh token refreshToken, err := createRefreshToken( mustParseUUID("550e8400-e29b-41d4-a716-446655440000"), "test@example.com", @@ -41,9 +223,8 @@ func TestRefreshToken_POSTMethod(t *testing.T) { t.Fatalf("failed to create refresh token: %v", err) } - body := `{"token": "` + refreshToken.Token + `"}` - req := httptest.NewRequest("POST", "/auth/refresh", strings.NewReader(body)) - req.Header.Set("Content-Type", "application/json") + req := httptest.NewRequest("POST", "/auth/refresh", nil) + req.AddCookie(&http.Cookie{Name: "refresh_token", Value: refreshToken.Token}) w := httptest.NewRecorder() router.ServeHTTP(w, req) @@ -52,7 +233,6 @@ func TestRefreshToken_POSTMethod(t *testing.T) { t.Errorf("expected status OK, got %d: %s", w.Code, w.Body.String()) } - // Verify cookies are set cookies := w.Result().Cookies() if len(cookies) < 2 { t.Errorf("expected at least 2 cookies, got %d", len(cookies)) @@ -69,38 +249,37 @@ func TestRefreshToken_POSTMethod(t *testing.T) { } } -func TestRefreshToken_POSTRejectsEmptyBody(t *testing.T) { +func TestRefreshToken_RejectsNoCookie(t *testing.T) { router, _ := createTestHandlerWithRouter() - req := httptest.NewRequest("POST", "/auth/refresh", strings.NewReader("{}")) - req.Header.Set("Content-Type", "application/json") + req := httptest.NewRequest("POST", "/auth/refresh", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) - if w.Code != http.StatusBadRequest { - t.Errorf("expected status BadRequest, got %d", w.Code) + if w.Code != http.StatusUnauthorized { + t.Errorf("expected status Unauthorized (401), got %d", w.Code) } } -func TestRefreshToken_POSTRejectsInvalidJSON(t *testing.T) { +func TestRefreshToken_RejectsInvalidCookie(t *testing.T) { router, _ := createTestHandlerWithRouter() - req := httptest.NewRequest("POST", "/auth/refresh", strings.NewReader("not json")) - req.Header.Set("Content-Type", "application/json") + req := httptest.NewRequest("POST", "/auth/refresh", nil) + req.AddCookie(&http.Cookie{Name: "refresh_token", Value: "not-a-valid-jwt"}) w := httptest.NewRecorder() router.ServeHTTP(w, req) - if w.Code != http.StatusBadRequest { - t.Errorf("expected status BadRequest, got %d", w.Code) + if w.Code != http.StatusUnauthorized { + t.Errorf("expected status Unauthorized (401), got %d", w.Code) } } func TestRefreshToken_GETMethodReturns405(t *testing.T) { router, _ := createTestHandlerWithRouter() - req := httptest.NewRequest("GET", "/auth/refresh?token=valid_token", nil) + req := httptest.NewRequest("GET", "/auth/refresh", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) @@ -110,12 +289,11 @@ func TestRefreshToken_GETMethodReturns405(t *testing.T) { } } -// Phase 3: Cookie Tests in Handler +// Cookie Tests in Handler func TestConfirmCode_SetsCookiesAndCSRFToken(t *testing.T) { router, auth := createTestHandlerWithRouter() - // Store a verification code email := "test@example.com" code := "123456" err := auth.Db.StoreVerificationCode(email, code, time.Now().Add(5*time.Minute)) @@ -123,7 +301,9 @@ func TestConfirmCode_SetsCookiesAndCSRFToken(t *testing.T) { t.Fatalf("failed to store verification code: %v", err) } - req := httptest.NewRequest("GET", "/auth/confirm?code="+code+"&email="+email, nil) + body := `{"code":"` + code + `","email":"` + email + `"}` + req := httptest.NewRequest("POST", "/auth/confirm", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() router.ServeHTTP(w, req) @@ -134,7 +314,6 @@ func TestConfirmCode_SetsCookiesAndCSRFToken(t *testing.T) { cookies := w.Result().Cookies() - // Should have access_token, refresh_token, and csrf_token accessCookie := findCookie(cookies, "access_token") if accessCookie == nil { t.Error("expected access_token cookie") @@ -150,7 +329,6 @@ func TestConfirmCode_SetsCookiesAndCSRFToken(t *testing.T) { t.Error("expected csrf_token cookie") } - // Response body should NOT contain tokens, just status var response map[string]string if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { t.Fatalf("failed to unmarshal response: %v", err) @@ -174,7 +352,6 @@ func TestLogout_ClearsCookies(t *testing.T) { cookies := w.Result().Cookies() - // All cookies should be expired for _, cookie := range cookies { if cookie.MaxAge != -1 { t.Errorf("expected cookie %s to have MaxAge -1, got %d", cookie.Name, cookie.MaxAge) @@ -183,7 +360,6 @@ func TestLogout_ClearsCookies(t *testing.T) { } func mustParseUUID(s string) (id [16]byte) { - // Simple UUID parsing for testing s = strings.ReplaceAll(s, "-", "") for i := 0; i < 16; i++ { var b byte