diff --git a/backend/internal/api/isolation_test.go b/backend/internal/api/isolation_test.go index 0572354..28848ae 100644 --- a/backend/internal/api/isolation_test.go +++ b/backend/internal/api/isolation_test.go @@ -203,3 +203,24 @@ func TestIsolation_GarminConnectedNeverLeaksAcrossUsers(t *testing.T) { t.Fatal("userA's successful Garmin auth leaked into userB's garmin_connected flag") } } + +// TestIsolation_SetupSessionsNeverLeakAcrossSubjects confirms one OIDC +// subject's pending ephemeral Garmin session is invisible to another +// subject -- e.g. subject B completing MFA must not accidentally continue +// subject A's in-progress attempt. +func TestIsolation_SetupSessionsNeverLeakAcrossSubjects(t *testing.T) { + s, _, _ := newUnprovisionedServer(t) + router := s.Router() + + rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{ + "garmin_email": "a@example.com", "garmin_password": "pw-a", + }) + if rec.Code != http.StatusOK { + t.Fatalf("login(a) status = %d, body = %s", rec.Code, rec.Body.String()) + } + + rec = doJSONAs(t, router, "user-b", http.MethodPost, "/api/setup/garmin/mfa", map[string]any{"code": "000000"}) + if rec.Code != http.StatusConflict { + t.Fatalf("user-b mfa (no login attempt of their own) status = %d, want 409, body = %s", rec.Code, rec.Body.String()) + } +} diff --git a/backend/internal/api/server.go b/backend/internal/api/server.go index 4bd723d..ee18af2 100644 --- a/backend/internal/api/server.go +++ b/backend/internal/api/server.go @@ -4,6 +4,8 @@ package api import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "log" @@ -12,6 +14,7 @@ import ( "path/filepath" "strconv" "sync" + "time" "github.com/go-chi/chi/v5" @@ -47,6 +50,7 @@ type Server struct { userAuthStatus map[int64]garmin.AuthStatus userAuthMessage map[int64]string userSyncRunning map[int64]bool + setupGarmin map[string]*setupSession } // NewServer builds a Server. @@ -59,6 +63,115 @@ func NewServer(db *store.DB, garminFactory func(garmin.Config) garmin.Client, ga userAuthStatus: map[int64]garmin.AuthStatus{}, userAuthMessage: map[int64]string{}, userSyncRunning: map[int64]bool{}, + setupGarmin: map[string]*setupSession{}, + } +} + +// setupSession is a temporary, not-yet-persisted Garmin authentication +// attempt made during onboarding, before any users/profile row exists -- +// keyed by OIDC subject (the only stable identifier available pre-account) +// rather than a user id. Promoted into Server.userGarmin once +// /api/setup/complete actually creates the account; evicted lazily (the +// next setup-endpoint touch for that subject checks staleness first) once +// idle past setupSessionIdleTimeout. +type setupSession struct { + Client garmin.Client + Email, Password string + Status garmin.AuthStatus + Message string + LastUsed time.Time +} + +// setupSessionIdleTimeout bounds how long an onboarding Garmin session +// survives without being touched (login, MFA, or complete) before it's +// evicted -- long enough to check email for an MFA code, short enough that +// an abandoned attempt doesn't leave a subprocess running indefinitely. +const setupSessionIdleTimeout = 15 * time.Minute + +// setupTokenStoreDir returns the token-store directory an ephemeral setup +// session for sub should use -- hashed rather than sub itself, since sub is +// an opaque string from the identity provider and using it verbatim in a +// filesystem path would be a directory-traversal risk if it ever contained +// path separators. +func setupTokenStoreDir(root, sub string) string { + h := sha256.Sum256([]byte(sub)) + return filepath.Join(root, "setup", hex.EncodeToString(h[:])) +} + +// setupSessionFor returns sub's in-progress ephemeral Garmin session, if +// any and not stale. A stale session is closed and evicted first, so the +// caller always either gets a fresh, live session or none. +func (s *Server) setupSessionFor(sub string) (*setupSession, bool) { + s.mu.Lock() + defer s.mu.Unlock() + sess, ok := s.setupGarmin[sub] + if !ok { + return nil, false + } + if time.Since(sess.LastUsed) > setupSessionIdleTimeout { + sess.Client.Close() + delete(s.setupGarmin, sub) + if s.GarminBase.TokenStorePath != "" { + os.RemoveAll(setupTokenStoreDir(s.GarminBase.TokenStorePath, sub)) + } + return nil, false + } + return sess, true +} + +// replaceSetupSession closes and replaces sub's ephemeral Garmin session +// (if any) with a freshly built one for the given credentials -- same +// "close old, spawn new" semantics as garmin.Client.UpdateCredentials. +func (s *Server) replaceSetupSession(sub, email, password string) *setupSession { + s.mu.Lock() + old, ok := s.setupGarmin[sub] + s.mu.Unlock() + if ok { + old.Client.Close() + } + + cfg := s.GarminBase + cfg.GarminEmail = email + cfg.GarminPassword = password + if cfg.TokenStorePath != "" { + cfg.TokenStorePath = setupTokenStoreDir(s.GarminBase.TokenStorePath, sub) + } + sess := &setupSession{Client: s.GarminFactory(cfg), Email: email, Password: password, LastUsed: time.Now()} + + s.mu.Lock() + s.setupGarmin[sub] = sess + s.mu.Unlock() + return sess +} + +// recordSetupAuthResult updates sub's ephemeral session after a login or +// MFA attempt. A no-op if the session is gone (e.g. evicted concurrently). +func (s *Server) recordSetupAuthResult(sub string, res garmin.AuthResult) { + s.mu.Lock() + defer s.mu.Unlock() + if sess, ok := s.setupGarmin[sub]; ok { + sess.Status = res.Status + sess.Message = res.Message + sess.LastUsed = time.Now() + } +} + +// removeSetupSession closes and drops sub's ephemeral Garmin session, if +// any, and best-effort removes its token-store directory. Used when +// abandoning onboarding (logout) -- NOT used during promotion in +// handleSetupComplete, which transfers ownership of the client (and +// renames the directory) instead of discarding them. +func (s *Server) removeSetupSession(sub string) { + s.mu.Lock() + sess, ok := s.setupGarmin[sub] + delete(s.setupGarmin, sub) + s.mu.Unlock() + if !ok { + return + } + sess.Client.Close() + if s.GarminBase.TokenStorePath != "" { + os.RemoveAll(setupTokenStoreDir(s.GarminBase.TokenStorePath, sub)) } } @@ -190,7 +303,13 @@ func (s *Server) Router() http.Handler { r.Get("/session/me", s.handleSessionMe) r.Post("/session/logout", s.handleSessionLogout) - r.Post("/setup", s.handleSetup) + r.Route("/setup", func(r chi.Router) { + r.Post("/complete", s.handleSetupComplete) + r.Route("/garmin", func(r chi.Router) { + r.Post("/login", s.handleSetupGarminLogin) + r.Post("/mfa", s.handleSetupGarminMFA) + }) + }) r.Group(func(r chi.Router) { r.Use(requireProvisionedUser) diff --git a/backend/internal/api/session.go b/backend/internal/api/session.go index d35c666..a02579a 100644 --- a/backend/internal/api/session.go +++ b/backend/internal/api/session.go @@ -98,6 +98,7 @@ func (s *Server) handleSessionCallback(w http.ResponseWriter, r *http.Request) { // (already deleted, in the profile-deletion case this exists for). func (s *Server) handleSessionLogout(w http.ResponseWriter, r *http.Request) { claims, _ := auth.ClaimsFromContext(r.Context()) + s.removeSetupSession(claims.Sub) http.SetCookie(w, auth.ClearCookie(auth.SessionCookieName, s.Session.Secure)) http.Redirect(w, r, s.Auth.EndSessionURL(s.Session.FrontendURL+"/", claims.IDToken), http.StatusFound) } diff --git a/backend/internal/api/setup.go b/backend/internal/api/setup.go index 5143d8c..cee4d67 100644 --- a/backend/internal/api/setup.go +++ b/backend/internal/api/setup.go @@ -2,12 +2,97 @@ package api import ( "encoding/json" + "log" "net/http" + "os" + "path/filepath" + "strconv" "geniusrun/backend/internal/auth" + "geniusrun/backend/internal/garmin" ) -func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) { +// handleSetupGarminLogin authenticates against Garmin using an ephemeral, +// not-yet-persisted session keyed by the OIDC subject -- no users/profile +// row exists yet at this point (see setupSession in server.go). +func (s *Server) handleSetupGarminLogin(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.ClaimsFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "not authenticated") + return + } + if _, found := userFromContext(r.Context()); found { + writeError(w, http.StatusConflict, "profile already exists for this account") + return + } + + var body struct { + GarminEmail string `json:"garmin_email"` + GarminPassword string `json:"garmin_password"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if body.GarminEmail == "" || body.GarminPassword == "" { + writeError(w, http.StatusBadRequest, "garmin_email and garmin_password are required") + return + } + + sess := s.replaceSetupSession(claims.Sub, body.GarminEmail, body.GarminPassword) + res, err := sess.Client.Authenticate(r.Context()) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + s.recordSetupAuthResult(claims.Sub, res) + writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message}) +} + +// handleSetupGarminMFA continues an in-progress ephemeral Garmin login +// (started by handleSetupGarminLogin) with an MFA code, on the same +// session/subprocess -- never replaces it. +func (s *Server) handleSetupGarminMFA(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.ClaimsFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "not authenticated") + return + } + + var body struct { + Code string `json:"code"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if body.Code == "" { + writeError(w, http.StatusBadRequest, "code is required") + return + } + + sess, ok := s.setupSessionFor(claims.Sub) + if !ok { + writeError(w, http.StatusConflict, "no Garmin connection attempt in progress; connect again") + return + } + res, err := sess.Client.CompleteMFA(r.Context(), body.Code) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + s.recordSetupAuthResult(claims.Sub, res) + writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message}) +} + +// handleSetupComplete is the single atomic commit point: only reachable +// once the ephemeral session for this subject last reported +// garmin.AuthSuccess. Provisions the account, persists the Garmin +// credentials, marks it connected, and promotes the already-authenticated +// ephemeral client into the permanent per-user cache instead of discarding +// it (no redundant re-authentication, no repeat MFA prompt, right after +// signup). +func (s *Server) handleSetupComplete(w http.ResponseWriter, r *http.Request) { claims, ok := auth.ClaimsFromContext(r.Context()) if !ok { writeError(w, http.StatusUnauthorized, "not authenticated") @@ -30,10 +115,47 @@ func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) { return } + sess, ok := s.setupSessionFor(claims.Sub) + if !ok || sess.Status != garmin.AuthSuccess { + writeError(w, http.StatusConflict, "Garmin isn't connected yet -- connect before completing setup") + return + } + userID, err := s.DB.ProvisionUser(r.Context(), claims.Sub, body.DisplayName) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } + profile, err := s.DB.GetProfile(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + profile.GarminEmail = sess.Email + profile.GarminPassword = sess.Password + if err := s.DB.UpdateProfile(r.Context(), userID, profile); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if err := s.DB.MarkGarminConnected(r.Context(), userID); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + s.mu.Lock() + delete(s.setupGarmin, claims.Sub) + s.userGarmin[userID] = sess.Client + s.userAuthStatus[userID] = sess.Status + s.userAuthMessage[userID] = sess.Message + s.mu.Unlock() + + if s.GarminBase.TokenStorePath != "" { + oldDir := setupTokenStoreDir(s.GarminBase.TokenStorePath, claims.Sub) + newDir := filepath.Join(s.GarminBase.TokenStorePath, strconv.FormatInt(userID, 10)) + if err := os.Rename(oldDir, newDir); err != nil && !os.IsNotExist(err) { + log.Printf("api: rename setup token store dir for user %d: %v", userID, err) + } + } + writeJSON(w, http.StatusOK, map[string]any{"user_id": userID, "display_name": body.DisplayName}) } diff --git a/backend/internal/api/setup_test.go b/backend/internal/api/setup_test.go index f8ddf0d..5c22105 100644 --- a/backend/internal/api/setup_test.go +++ b/backend/internal/api/setup_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" "path/filepath" "testing" @@ -14,69 +15,220 @@ import ( appsync "geniusrun/backend/internal/sync" ) -func TestSetup_ProvisionsNewUserWithDisplayName(t *testing.T) { - // This test specifically needs an *unprovisioned* session, unlike every - // other test in this package -- build the server without the - // newTestServer helper's automatic ProvisionUser call. +// newUnprovisionedServer builds a Server whose "test-user" OIDC subject +// (the identity doJSON's cookie always mints) has no users/profile row yet +// -- every test in this file needs that starting state, unlike +// newTestServer's auto-provisioned default. +func newUnprovisionedServer(t *testing.T) (*Server, *store.DB, *mock.Client) { + t.Helper() db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db")) if err != nil { t.Fatalf("store.Open: %v", err) } t.Cleanup(func() { db.Close() }) m := &mock.Client{} - s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig) + garminFactory := func(garmin.Config) garmin.Client { return m } + s := NewServer(db, garminFactory, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig) + return s, db, m +} + +func TestSetupGarminLogin_AuthenticatesWithoutCreatingAccount(t *testing.T) { + s, db, _ := newUnprovisionedServer(t) router := s.Router() - rec := doJSON(t, router, http.MethodGet, "/api/session/me", nil) - var me sessionMeResponse - unmarshalBody(t, rec, &me) - if me.HasProfile { - t.Fatal("expected a brand-new session to have no profile yet") - } - - rec = doJSON(t, router, http.MethodPost, "/api/setup", map[string]any{"display_name": "Lucie"}) + rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{ + "garmin_email": "runner@example.com", "garmin_password": "hunter2", + }) if rec.Code != http.StatusOK { - t.Fatalf("setup status = %d, body = %s", rec.Code, rec.Body.String()) + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + var resp authResponse + unmarshalBody(t, rec, &resp) + if resp.Status != "authenticated" { + t.Fatalf("status = %q, want authenticated", resp.Status) } - rec = doJSON(t, router, http.MethodGet, "/api/session/me", nil) - unmarshalBody(t, rec, &me) - if !me.HasProfile || me.DisplayName != "Lucie" { - t.Fatalf("session/me after setup = %+v, want HasProfile=true DisplayName=Lucie", me) - } - - u, found, err := db.GetUserBySub(newCtx(), "test-user") // doJSON's test cookie's Sub - if err != nil || !found { - t.Fatalf("GetUserBySub: found=%v err=%v", found, err) - } - if u.DisplayName != "Lucie" { - t.Errorf("stored DisplayName = %q, want Lucie", u.DisplayName) + if _, found, err := db.GetUserBySub(newCtx(), "test-user"); err != nil || found { + t.Fatalf("expected no user created yet, found=%v err=%v", found, err) } } -func TestSetup_RejectsEmptyDisplayName(t *testing.T) { - db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db")) - if err != nil { - t.Fatalf("store.Open: %v", err) - } - t.Cleanup(func() { db.Close() }) - m := &mock.Client{} - s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig) +func TestSetupGarminLogin_MFARequiredThenCompleteMFA(t *testing.T) { + s, db, m := newUnprovisionedServer(t) + m.AuthResults = []garmin.AuthResult{{Status: garmin.AuthMFARequired, Message: "MFA required"}} + router := s.Router() - rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup", map[string]any{"display_name": ""}) + rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{ + "garmin_email": "runner@example.com", "garmin_password": "hunter2", + }) + if rec.Code != http.StatusOK { + t.Fatalf("login status = %d, body = %s", rec.Code, rec.Body.String()) + } + var resp authResponse + unmarshalBody(t, rec, &resp) + if resp.Status != "mfa_required" { + t.Fatalf("status = %q, want mfa_required", resp.Status) + } + + rec = doJSON(t, router, http.MethodPost, "/api/setup/garmin/mfa", map[string]any{"code": "123456"}) + if rec.Code != http.StatusOK { + t.Fatalf("mfa status = %d, body = %s", rec.Code, rec.Body.String()) + } + unmarshalBody(t, rec, &resp) + if resp.Status != "authenticated" { + t.Fatalf("status after mfa = %q, want authenticated", resp.Status) + } + + if _, found, err := db.GetUserBySub(newCtx(), "test-user"); err != nil || found { + t.Fatalf("expected no user created yet even after MFA success, found=%v err=%v", found, err) + } +} + +func TestSetupGarminMFA_RejectsWithoutPriorLoginAttempt(t *testing.T) { + s, _, _ := newUnprovisionedServer(t) + rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup/garmin/mfa", map[string]any{"code": "123456"}) + if rec.Code != http.StatusConflict { + t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String()) + } +} + +func TestSetupComplete_CreatesAccountWithGarminCredentialsAndPromotesClient(t *testing.T) { + s, db, m := newUnprovisionedServer(t) + router := s.Router() + + rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{ + "garmin_email": "runner@example.com", "garmin_password": "hunter2", + }) + if rec.Code != http.StatusOK { + t.Fatalf("login status = %d, body = %s", rec.Code, rec.Body.String()) + } + + rec = doJSON(t, router, http.MethodPost, "/api/setup/complete", map[string]any{"display_name": "Lucie"}) + if rec.Code != http.StatusOK { + t.Fatalf("complete status = %d, body = %s", rec.Code, rec.Body.String()) + } + + u, found, err := db.GetUserBySub(newCtx(), "test-user") + if err != nil || !found { + t.Fatalf("GetUserBySub: found=%v err=%v", found, err) + } + profile, err := db.GetProfile(newCtx(), u.ID) + if err != nil { + t.Fatalf("GetProfile: %v", err) + } + if profile.GarminEmail != "runner@example.com" || profile.GarminPassword != "hunter2" { + t.Errorf("profile garmin creds = %+v, want email=runner@example.com password=hunter2", profile) + } + if profile.GarminConnectedAt == nil { + t.Error("expected GarminConnectedAt to be set") + } + + if m.AuthenticateCalls != 1 { + t.Errorf("AuthenticateCalls = %d, want 1 (no redundant re-authentication after promotion)", m.AuthenticateCalls) + } + if m.ClosedCalled { + t.Error("expected the promoted client to survive (not be Close()d)") + } + + client, err := s.garminFor(newCtx(), u.ID) + if err != nil { + t.Fatalf("garminFor: %v", err) + } + if client != m { + t.Error("expected garminFor to return the promoted (already-authenticated) client") + } +} + +func TestSetupComplete_RejectsWithoutSuccessfulGarminConnection(t *testing.T) { + s, db, _ := newUnprovisionedServer(t) + rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup/complete", map[string]any{"display_name": "Lucie"}) + if rec.Code != http.StatusConflict { + t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String()) + } + if _, found, err := db.GetUserBySub(newCtx(), "test-user"); err != nil || found { + t.Fatalf("expected no user created, found=%v err=%v", found, err) + } +} + +func TestSetupComplete_RejectsEmptyDisplayName(t *testing.T) { + s, _, _ := newUnprovisionedServer(t) + router := s.Router() + rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{ + "garmin_email": "runner@example.com", "garmin_password": "hunter2", + }) + if rec.Code != http.StatusOK { + t.Fatalf("login status = %d, body = %s", rec.Code, rec.Body.String()) + } + rec = doJSON(t, router, http.MethodPost, "/api/setup/complete", map[string]any{"display_name": ""}) if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400", rec.Code) } } -func TestSetup_RejectsWhenAlreadyProvisioned(t *testing.T) { - s, _, _ := newTestServer(t) - rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup", map[string]any{"display_name": "Someone Else"}) +func TestSetupGarminLogin_RejectsWhenAlreadyProvisioned(t *testing.T) { + s, _, _ := newTestServer(t) // pre-provisioned "test-user" + rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup/garmin/login", map[string]any{ + "garmin_email": "runner@example.com", "garmin_password": "hunter2", + }) if rec.Code != http.StatusConflict { t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String()) } } +func TestSetupComplete_RejectsWhenAlreadyProvisioned(t *testing.T) { + s, _, _ := newTestServer(t) // pre-provisioned "test-user" + rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup/complete", map[string]any{"display_name": "Someone Else"}) + if rec.Code != http.StatusConflict { + t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String()) + } +} + +func TestSetupComplete_RenamesTokenStoreDirectory(t *testing.T) { + db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db")) + if err != nil { + t.Fatalf("store.Open: %v", err) + } + t.Cleanup(func() { db.Close() }) + m := &mock.Client{} + tokenStoreRoot := t.TempDir() + s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{TokenStorePath: tokenStoreRoot}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig) + router := s.Router() + + rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{ + "garmin_email": "runner@example.com", "garmin_password": "hunter2", + }) + if rec.Code != http.StatusOK { + t.Fatalf("login status = %d, body = %s", rec.Code, rec.Body.String()) + } + + // Simulate what a real subprocess would have written under the + // ephemeral (subject-keyed) directory during that login call. + oldDir := setupTokenStoreDir(tokenStoreRoot, "test-user") + if err := os.MkdirAll(oldDir, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(filepath.Join(oldDir, "session.json"), []byte("{}"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + rec = doJSON(t, router, http.MethodPost, "/api/setup/complete", map[string]any{"display_name": "Lucie"}) + if rec.Code != http.StatusOK { + t.Fatalf("complete status = %d, body = %s", rec.Code, rec.Body.String()) + } + u, found, err := db.GetUserBySub(newCtx(), "test-user") + if err != nil || !found { + t.Fatalf("GetUserBySub: found=%v err=%v", found, err) + } + + if _, err := os.Stat(oldDir); !os.IsNotExist(err) { + t.Fatalf("expected old setup token store dir %q to be gone, stat err = %v", oldDir, err) + } + newDir := filepath.Join(tokenStoreRoot, itoa(u.ID)) + if _, err := os.Stat(filepath.Join(newDir, "session.json")); err != nil { + t.Fatalf("expected renamed token store dir %q to contain session.json: %v", newDir, err) + } +} + func unmarshalBody(t *testing.T, rec *httptest.ResponseRecorder, v any) { t.Helper() if err := json.Unmarshal(rec.Body.Bytes(), v); err != nil { diff --git a/backend/internal/garmin/mock/mock.go b/backend/internal/garmin/mock/mock.go index 82231e4..26e38d3 100644 --- a/backend/internal/garmin/mock/mock.go +++ b/backend/internal/garmin/mock/mock.go @@ -19,6 +19,7 @@ type Client struct { authResultCursor int ClosedCalled bool GetActivitiesCalls int + AuthenticateCalls int LastEmail string LastPassword string } @@ -35,6 +36,7 @@ func (c *Client) nextAuthResult() garmin.AuthResult { } func (c *Client) Authenticate(ctx context.Context) (garmin.AuthResult, error) { + c.AuthenticateCalls++ if c.Err != nil { return garmin.AuthResult{}, c.Err }