feat: implement Iteration 0 foundation (backend + Flutter client)

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>
This commit is contained in:
dbastrikin
2026-02-20 13:14:58 +02:00
commit 24219b611e
140 changed files with 13062 additions and 0 deletions

View File

@@ -0,0 +1,272 @@
//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)
}
}