Backend (Go): - Project structure with chi router, pgxpool, goose migrations - JWT auth (access/refresh tokens) with Firebase token verification - NoopTokenVerifier for local dev without Firebase credentials - PostgreSQL user repository with atomic profile updates (transactions) - Mifflin-St Jeor calorie calculation based on profile data - REST API: POST /auth/login, /auth/refresh, /auth/logout, GET/PUT /profile, GET /health - Middleware: auth, CORS (localhost wildcard), logging, recovery, request_id - Unit tests (51 passing) and integration tests (testcontainers) - Docker Compose setup with postgres healthcheck and graceful shutdown Flutter client: - Riverpod state management with GoRouter navigation - Firebase Auth (email/password + Google sign-in with web popup support) - Platform-aware API URLs (web/Android/iOS) - Dio HTTP client with JWT auth interceptor and concurrent refresh handling - Secure token storage - Screens: Login, Register, Home (tabs: Menu, Recipes, Products, Profile) - Unit tests (17 passing) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
273 lines
8.2 KiB
Go
273 lines
8.2 KiB
Go
//go:build integration
|
|
|
|
package auth
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/food-ai/backend/internal/auth/mocks"
|
|
"github.com/food-ai/backend/internal/middleware"
|
|
"github.com/food-ai/backend/internal/testutil"
|
|
"github.com/food-ai/backend/internal/user"
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// testValidator adapts JWTManager to middleware.AccessTokenValidator for tests.
|
|
type testValidator struct {
|
|
jm *JWTManager
|
|
}
|
|
|
|
func (v *testValidator) ValidateAccessToken(tokenStr string) (*middleware.TokenClaims, error) {
|
|
claims, err := v.jm.ValidateAccessToken(tokenStr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &middleware.TokenClaims{UserID: claims.UserID, Plan: claims.Plan}, nil
|
|
}
|
|
|
|
func setupIntegrationTest(t *testing.T) (*chi.Mux, *JWTManager) {
|
|
t.Helper()
|
|
pool := testutil.SetupTestDB(t)
|
|
|
|
verifier := &mocks.MockTokenVerifier{
|
|
VerifyTokenFn: func(ctx context.Context, idToken string) (string, string, string, string, error) {
|
|
return "fb-" + idToken, idToken + "@test.com", "Test User", "", nil
|
|
},
|
|
}
|
|
|
|
jm := NewJWTManager("test-secret", 15*time.Minute, 720*time.Hour)
|
|
repo := user.NewRepository(pool)
|
|
svc := NewService(verifier, repo, jm)
|
|
handler := NewHandler(svc)
|
|
|
|
r := chi.NewRouter()
|
|
r.Post("/auth/login", handler.Login)
|
|
r.Post("/auth/refresh", handler.Refresh)
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(middleware.Auth(&testValidator{jm: jm}))
|
|
r.Post("/auth/logout", handler.Logout)
|
|
})
|
|
|
|
return r, jm
|
|
}
|
|
|
|
func TestIntegration_Login(t *testing.T) {
|
|
router, _ := setupIntegrationTest(t)
|
|
|
|
body := `{"firebase_token":"user1"}`
|
|
req := httptest.NewRequest("POST", "/auth/login", bytes.NewBufferString(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rr := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var resp LoginResponse
|
|
json.NewDecoder(rr.Body).Decode(&resp)
|
|
if resp.AccessToken == "" {
|
|
t.Error("expected non-empty access token")
|
|
}
|
|
if resp.RefreshToken == "" {
|
|
t.Error("expected non-empty refresh token")
|
|
}
|
|
if resp.User == nil {
|
|
t.Fatal("expected user in response")
|
|
}
|
|
}
|
|
|
|
func TestIntegration_Login_EmptyToken(t *testing.T) {
|
|
router, _ := setupIntegrationTest(t)
|
|
|
|
body := `{"firebase_token":""}`
|
|
req := httptest.NewRequest("POST", "/auth/login", bytes.NewBufferString(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rr := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestIntegration_Login_InvalidBody(t *testing.T) {
|
|
router, _ := setupIntegrationTest(t)
|
|
|
|
req := httptest.NewRequest("POST", "/auth/login", bytes.NewBufferString("invalid"))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rr := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestIntegration_Refresh(t *testing.T) {
|
|
router, _ := setupIntegrationTest(t)
|
|
|
|
// First login
|
|
loginBody := `{"firebase_token":"user2"}`
|
|
loginReq := httptest.NewRequest("POST", "/auth/login", bytes.NewBufferString(loginBody))
|
|
loginReq.Header.Set("Content-Type", "application/json")
|
|
loginRR := httptest.NewRecorder()
|
|
router.ServeHTTP(loginRR, loginReq)
|
|
|
|
var loginResp LoginResponse
|
|
json.NewDecoder(loginRR.Body).Decode(&loginResp)
|
|
|
|
// Then refresh
|
|
refreshBody, _ := json.Marshal(refreshRequest{RefreshToken: loginResp.RefreshToken})
|
|
refreshReq := httptest.NewRequest("POST", "/auth/refresh", bytes.NewBuffer(refreshBody))
|
|
refreshReq.Header.Set("Content-Type", "application/json")
|
|
refreshRR := httptest.NewRecorder()
|
|
router.ServeHTTP(refreshRR, refreshReq)
|
|
|
|
if refreshRR.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", refreshRR.Code, refreshRR.Body.String())
|
|
}
|
|
|
|
var resp RefreshResponse
|
|
json.NewDecoder(refreshRR.Body).Decode(&resp)
|
|
if resp.AccessToken == "" {
|
|
t.Error("expected non-empty access token")
|
|
}
|
|
if resp.RefreshToken == loginResp.RefreshToken {
|
|
t.Error("expected rotated refresh token")
|
|
}
|
|
}
|
|
|
|
func TestIntegration_Refresh_InvalidToken(t *testing.T) {
|
|
router, _ := setupIntegrationTest(t)
|
|
|
|
body := `{"refresh_token":"nonexistent"}`
|
|
req := httptest.NewRequest("POST", "/auth/refresh", bytes.NewBufferString(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rr := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Errorf("expected 401, got %d", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestIntegration_Refresh_EmptyToken(t *testing.T) {
|
|
router, _ := setupIntegrationTest(t)
|
|
|
|
body := `{"refresh_token":""}`
|
|
req := httptest.NewRequest("POST", "/auth/refresh", bytes.NewBufferString(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rr := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestIntegration_Logout(t *testing.T) {
|
|
router, _ := setupIntegrationTest(t)
|
|
|
|
// Login first
|
|
loginBody := `{"firebase_token":"user3"}`
|
|
loginReq := httptest.NewRequest("POST", "/auth/login", bytes.NewBufferString(loginBody))
|
|
loginReq.Header.Set("Content-Type", "application/json")
|
|
loginRR := httptest.NewRecorder()
|
|
router.ServeHTTP(loginRR, loginReq)
|
|
|
|
var loginResp LoginResponse
|
|
json.NewDecoder(loginRR.Body).Decode(&loginResp)
|
|
|
|
// Logout
|
|
logoutReq := httptest.NewRequest("POST", "/auth/logout", nil)
|
|
logoutReq.Header.Set("Authorization", "Bearer "+loginResp.AccessToken)
|
|
logoutRR := httptest.NewRecorder()
|
|
router.ServeHTTP(logoutRR, logoutReq)
|
|
|
|
if logoutRR.Code != http.StatusOK {
|
|
t.Errorf("expected 200, got %d: %s", logoutRR.Code, logoutRR.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestIntegration_Logout_NoAuth(t *testing.T) {
|
|
router, _ := setupIntegrationTest(t)
|
|
|
|
req := httptest.NewRequest("POST", "/auth/logout", nil)
|
|
rr := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Errorf("expected 401, got %d", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestIntegration_RefreshAfterLogout(t *testing.T) {
|
|
router, _ := setupIntegrationTest(t)
|
|
|
|
// Login
|
|
loginBody := `{"firebase_token":"user4"}`
|
|
loginReq := httptest.NewRequest("POST", "/auth/login", bytes.NewBufferString(loginBody))
|
|
loginReq.Header.Set("Content-Type", "application/json")
|
|
loginRR := httptest.NewRecorder()
|
|
router.ServeHTTP(loginRR, loginReq)
|
|
|
|
var loginResp LoginResponse
|
|
json.NewDecoder(loginRR.Body).Decode(&loginResp)
|
|
|
|
// Logout
|
|
logoutReq := httptest.NewRequest("POST", "/auth/logout", nil)
|
|
logoutReq.Header.Set("Authorization", "Bearer "+loginResp.AccessToken)
|
|
logoutRR := httptest.NewRecorder()
|
|
router.ServeHTTP(logoutRR, logoutReq)
|
|
|
|
// Try to refresh with old token
|
|
refreshBody, _ := json.Marshal(refreshRequest{RefreshToken: loginResp.RefreshToken})
|
|
refreshReq := httptest.NewRequest("POST", "/auth/refresh", bytes.NewBuffer(refreshBody))
|
|
refreshReq.Header.Set("Content-Type", "application/json")
|
|
refreshRR := httptest.NewRecorder()
|
|
router.ServeHTTP(refreshRR, refreshReq)
|
|
|
|
if refreshRR.Code != http.StatusUnauthorized {
|
|
t.Errorf("expected 401 after logout, got %d", refreshRR.Code)
|
|
}
|
|
}
|
|
|
|
func TestIntegration_OldRefreshTokenInvalid(t *testing.T) {
|
|
router, _ := setupIntegrationTest(t)
|
|
|
|
// Login
|
|
loginBody := `{"firebase_token":"user5"}`
|
|
loginReq := httptest.NewRequest("POST", "/auth/login", bytes.NewBufferString(loginBody))
|
|
loginReq.Header.Set("Content-Type", "application/json")
|
|
loginRR := httptest.NewRecorder()
|
|
router.ServeHTTP(loginRR, loginReq)
|
|
|
|
var loginResp LoginResponse
|
|
json.NewDecoder(loginRR.Body).Decode(&loginResp)
|
|
oldRefreshToken := loginResp.RefreshToken
|
|
|
|
// Refresh (rotates token)
|
|
refreshBody, _ := json.Marshal(refreshRequest{RefreshToken: oldRefreshToken})
|
|
refreshReq := httptest.NewRequest("POST", "/auth/refresh", bytes.NewBuffer(refreshBody))
|
|
refreshReq.Header.Set("Content-Type", "application/json")
|
|
refreshRR := httptest.NewRecorder()
|
|
router.ServeHTTP(refreshRR, refreshReq)
|
|
|
|
// Try old refresh token again
|
|
oldRefreshReq := httptest.NewRequest("POST", "/auth/refresh", bytes.NewBuffer(refreshBody))
|
|
oldRefreshReq.Header.Set("Content-Type", "application/json")
|
|
oldRefreshRR := httptest.NewRecorder()
|
|
router.ServeHTTP(oldRefreshRR, oldRefreshReq)
|
|
|
|
if oldRefreshRR.Code != http.StatusUnauthorized {
|
|
t.Errorf("expected 401 for old refresh token, got %d", oldRefreshRR.Code)
|
|
}
|
|
}
|