feat(api): persist and expose garmin_connected on successful auth
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user