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) {
|
func TestDeleteProfile_DeletesUserAndTerminatesGarminClient(t *testing.T) {
|
||||||
s, db, userID := newTestServer(t)
|
s, db, userID := newTestServer(t)
|
||||||
router := s.Router()
|
router := s.Router()
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"geniusrun/backend/internal/garmin"
|
"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.mu.Lock()
|
||||||
s.userAuthStatus[userID] = res.Status
|
s.userAuthStatus[userID] = res.Status
|
||||||
s.userAuthMessage[userID] = res.Message
|
s.userAuthMessage[userID] = res.Message
|
||||||
s.mu.Unlock()
|
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) {
|
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())
|
writeError(w, http.StatusBadGateway, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.recordAuthResult(userID, res)
|
s.recordAuthResult(r.Context(), userID, res)
|
||||||
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
|
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())
|
writeError(w, http.StatusBadGateway, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.recordAuthResult(userID, res)
|
s.recordAuthResult(r.Context(), userID, res)
|
||||||
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
|
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())
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,10 +32,11 @@ type SessionConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type sessionMeResponse struct {
|
type sessionMeResponse struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
HasProfile bool `json:"has_profile"`
|
HasProfile bool `json:"has_profile"`
|
||||||
DisplayName string `json:"display_name,omitempty"`
|
DisplayName string `json:"display_name,omitempty"`
|
||||||
|
GarminConnected bool `json:"garmin_connected"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleSessionLogin(w http.ResponseWriter, r *http.Request) {
|
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 {
|
if u, found := userFromContext(r.Context()); found {
|
||||||
resp.HasProfile = true
|
resp.HasProfile = true
|
||||||
resp.DisplayName = u.DisplayName
|
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)
|
writeJSON(w, http.StatusOK, resp)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user