diff --git a/backend/internal/api/api_test.go b/backend/internal/api/api_test.go index 9edfb48..a6db7f3 100644 --- a/backend/internal/api/api_test.go +++ b/backend/internal/api/api_test.go @@ -765,6 +765,56 @@ func TestProfile_RejectsInvalidHRZones(t *testing.T) { } } +func TestSessionMe_ReportsGarminConnectedAfterSuccessfulAuth(t *testing.T) { + s, _, _ := newTestServer(t) + router := s.Router() + + rec := doJSON(t, router, http.MethodGet, "/api/session/me", nil) + var before sessionMeResponse + unmarshalBody(t, rec, &before) + if before.GarminConnected { + t.Fatal("expected a fresh account to report garmin_connected=false") + } + + rec = doJSON(t, router, http.MethodPost, "/api/auth/login", nil) // mock.Client defaults to AuthSuccess + if rec.Code != http.StatusOK { + t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String()) + } + + rec = doJSON(t, router, http.MethodGet, "/api/session/me", nil) + var after sessionMeResponse + unmarshalBody(t, rec, &after) + if !after.GarminConnected { + t.Fatal("expected garmin_connected=true after a successful auth") + } +} + +func TestSessionMe_GarminConnectedStaysFalseOnMFARequiredOrFailed(t *testing.T) { + s, _, userID := newTestServer(t) + client, err := s.garminFor(context.Background(), userID) + if err != nil { + t.Fatalf("garminFor: %v", err) + } + mockClient, ok := client.(*mock.Client) + if !ok { + t.Fatalf("expected *mock.Client, got %T", client) + } + mockClient.AuthResults = []garmin.AuthResult{{Status: garmin.AuthMFARequired, Message: "MFA required"}} + + router := s.Router() + rec := doJSON(t, router, http.MethodPost, "/api/auth/login", nil) + if rec.Code != http.StatusOK { + t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String()) + } + + rec = doJSON(t, router, http.MethodGet, "/api/session/me", nil) + var me sessionMeResponse + unmarshalBody(t, rec, &me) + if me.GarminConnected { + t.Fatal("expected garmin_connected=false after mfa_required") + } +} + func TestDeleteProfile_DeletesUserAndTerminatesGarminClient(t *testing.T) { s, db, userID := newTestServer(t) router := s.Router() diff --git a/backend/internal/api/auth.go b/backend/internal/api/auth.go index 295e2ff..dea11d7 100644 --- a/backend/internal/api/auth.go +++ b/backend/internal/api/auth.go @@ -1,7 +1,9 @@ package api import ( + "context" "encoding/json" + "log" "net/http" "geniusrun/backend/internal/garmin" @@ -25,11 +27,22 @@ func authStatusString(s garmin.AuthStatus) string { } } -func (s *Server) recordAuthResult(userID int64, res garmin.AuthResult) { +// recordAuthResult updates the in-memory auth status/message for userID, +// and -- on a successful authentication -- persists that this account has +// connected to Garmin at least once (store.MarkGarminConnected), which is +// what the login gate actually checks (the in-memory auth status resets on +// every backend restart; this doesn't). +func (s *Server) recordAuthResult(ctx context.Context, userID int64, res garmin.AuthResult) { s.mu.Lock() s.userAuthStatus[userID] = res.Status s.userAuthMessage[userID] = res.Message s.mu.Unlock() + + if res.Status == garmin.AuthSuccess { + if err := s.DB.MarkGarminConnected(ctx, userID); err != nil { + log.Printf("api: mark garmin connected for user %d: %v", userID, err) + } + } } func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) { @@ -44,7 +57,7 @@ func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadGateway, err.Error()) return } - s.recordAuthResult(userID, res) + s.recordAuthResult(r.Context(), userID, res) writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message}) } @@ -72,7 +85,7 @@ func (s *Server) handleAuthMFA(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadGateway, err.Error()) return } - s.recordAuthResult(userID, res) + s.recordAuthResult(r.Context(), userID, res) writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message}) } diff --git a/backend/internal/api/isolation_test.go b/backend/internal/api/isolation_test.go index f25e4e6..0572354 100644 --- a/backend/internal/api/isolation_test.go +++ b/backend/internal/api/isolation_test.go @@ -181,3 +181,25 @@ func TestIsolation_DeleteProfileOnlyDeletesOwnAccount(t *testing.T) { t.Fatalf("userA profile status after userB deleted themselves = %d, body = %s", rec.Code, rec.Body.String()) } } + +// TestIsolation_GarminConnectedNeverLeaksAcrossUsers confirms one user's +// successful Garmin auth never flips garmin_connected for another user. +func TestIsolation_GarminConnectedNeverLeaksAcrossUsers(t *testing.T) { + s, db, _ := newTestServer(t) // provisions "test-user" (userA) + if _, err := db.ProvisionUser(newCtx(), "user-b", "B"); err != nil { + t.Fatalf("ProvisionUser(b): %v", err) + } + + router := s.Router() + rec := doJSON(t, router, http.MethodPost, "/api/auth/login", nil) // as userA + if rec.Code != http.StatusOK { + t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String()) + } + + rec = doJSONAs(t, router, "user-b", http.MethodGet, "/api/session/me", nil) + var meB sessionMeResponse + unmarshalBody(t, rec, &meB) + if meB.GarminConnected { + t.Fatal("userA's successful Garmin auth leaked into userB's garmin_connected flag") + } +} diff --git a/backend/internal/api/session.go b/backend/internal/api/session.go index 3376e36..d35c666 100644 --- a/backend/internal/api/session.go +++ b/backend/internal/api/session.go @@ -32,10 +32,11 @@ type SessionConfig struct { } type sessionMeResponse struct { - Name string `json:"name"` - Email string `json:"email"` - HasProfile bool `json:"has_profile"` - DisplayName string `json:"display_name,omitempty"` + Name string `json:"name"` + Email string `json:"email"` + HasProfile bool `json:"has_profile"` + DisplayName string `json:"display_name,omitempty"` + GarminConnected bool `json:"garmin_connected"` } func (s *Server) handleSessionLogin(w http.ResponseWriter, r *http.Request) { @@ -113,6 +114,12 @@ func (s *Server) handleSessionMe(w http.ResponseWriter, r *http.Request) { if u, found := userFromContext(r.Context()); found { resp.HasProfile = true resp.DisplayName = u.DisplayName + profile, err := s.DB.GetProfile(r.Context(), u.ID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + resp.GarminConnected = profile.GarminConnectedAt != nil } writeJSON(w, http.StatusOK, resp) }