feat(api): persist and expose garmin_connected on successful auth

This commit is contained in:
2026-07-26 12:23:39 +02:00
parent 6a3f0201af
commit 002858c1cb
4 changed files with 99 additions and 7 deletions

View File

@@ -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()

View File

@@ -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})
}

View File

@@ -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")
}
}

View File

@@ -36,6 +36,7 @@ type sessionMeResponse struct {
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)
}