feat(api): defer account creation until Garmin actually connects
This commit is contained in:
@@ -203,3 +203,24 @@ func TestIsolation_GarminConnectedNeverLeaksAcrossUsers(t *testing.T) {
|
|||||||
t.Fatal("userA's successful Garmin auth leaked into userB's garmin_connected flag")
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
@@ -12,6 +14,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
@@ -47,6 +50,7 @@ type Server struct {
|
|||||||
userAuthStatus map[int64]garmin.AuthStatus
|
userAuthStatus map[int64]garmin.AuthStatus
|
||||||
userAuthMessage map[int64]string
|
userAuthMessage map[int64]string
|
||||||
userSyncRunning map[int64]bool
|
userSyncRunning map[int64]bool
|
||||||
|
setupGarmin map[string]*setupSession
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer builds a Server.
|
// 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{},
|
userAuthStatus: map[int64]garmin.AuthStatus{},
|
||||||
userAuthMessage: map[int64]string{},
|
userAuthMessage: map[int64]string{},
|
||||||
userSyncRunning: map[int64]bool{},
|
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.Get("/session/me", s.handleSessionMe)
|
||||||
r.Post("/session/logout", s.handleSessionLogout)
|
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.Group(func(r chi.Router) {
|
||||||
r.Use(requireProvisionedUser)
|
r.Use(requireProvisionedUser)
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ func (s *Server) handleSessionCallback(w http.ResponseWriter, r *http.Request) {
|
|||||||
// (already deleted, in the profile-deletion case this exists for).
|
// (already deleted, in the profile-deletion case this exists for).
|
||||||
func (s *Server) handleSessionLogout(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleSessionLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
claims, _ := auth.ClaimsFromContext(r.Context())
|
claims, _ := auth.ClaimsFromContext(r.Context())
|
||||||
|
s.removeSetupSession(claims.Sub)
|
||||||
http.SetCookie(w, auth.ClearCookie(auth.SessionCookieName, s.Session.Secure))
|
http.SetCookie(w, auth.ClearCookie(auth.SessionCookieName, s.Session.Secure))
|
||||||
http.Redirect(w, r, s.Auth.EndSessionURL(s.Session.FrontendURL+"/", claims.IDToken), http.StatusFound)
|
http.Redirect(w, r, s.Auth.EndSessionURL(s.Session.FrontendURL+"/", claims.IDToken), http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,97 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"geniusrun/backend/internal/auth"
|
"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())
|
claims, ok := auth.ClaimsFromContext(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||||
@@ -30,10 +115,47 @@ func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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)
|
userID, err := s.DB.ProvisionUser(r.Context(), claims.Sub, body.DisplayName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
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})
|
writeJSON(w, http.StatusOK, map[string]any{"user_id": userID, "display_name": body.DisplayName})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -14,69 +15,220 @@ import (
|
|||||||
appsync "geniusrun/backend/internal/sync"
|
appsync "geniusrun/backend/internal/sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSetup_ProvisionsNewUserWithDisplayName(t *testing.T) {
|
// newUnprovisionedServer builds a Server whose "test-user" OIDC subject
|
||||||
// This test specifically needs an *unprovisioned* session, unlike every
|
// (the identity doJSON's cookie always mints) has no users/profile row yet
|
||||||
// other test in this package -- build the server without the
|
// -- every test in this file needs that starting state, unlike
|
||||||
// newTestServer helper's automatic ProvisionUser call.
|
// 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"))
|
db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("store.Open: %v", err)
|
t.Fatalf("store.Open: %v", err)
|
||||||
}
|
}
|
||||||
t.Cleanup(func() { db.Close() })
|
t.Cleanup(func() { db.Close() })
|
||||||
m := &mock.Client{}
|
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()
|
router := s.Router()
|
||||||
|
|
||||||
rec := doJSON(t, router, http.MethodGet, "/api/session/me", nil)
|
rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{
|
||||||
var me sessionMeResponse
|
"garmin_email": "runner@example.com", "garmin_password": "hunter2",
|
||||||
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"})
|
|
||||||
if rec.Code != http.StatusOK {
|
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)
|
if _, found, err := db.GetUserBySub(newCtx(), "test-user"); err != nil || found {
|
||||||
unmarshalBody(t, rec, &me)
|
t.Fatalf("expected no user created yet, found=%v err=%v", found, err)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSetup_RejectsEmptyDisplayName(t *testing.T) {
|
func TestSetupGarminLogin_MFARequiredThenCompleteMFA(t *testing.T) {
|
||||||
db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
|
s, db, m := newUnprovisionedServer(t)
|
||||||
if err != nil {
|
m.AuthResults = []garmin.AuthResult{{Status: garmin.AuthMFARequired, Message: "MFA required"}}
|
||||||
t.Fatalf("store.Open: %v", err)
|
router := s.Router()
|
||||||
}
|
|
||||||
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)
|
|
||||||
|
|
||||||
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 {
|
if rec.Code != http.StatusBadRequest {
|
||||||
t.Fatalf("status = %d, want 400", rec.Code)
|
t.Fatalf("status = %d, want 400", rec.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSetup_RejectsWhenAlreadyProvisioned(t *testing.T) {
|
func TestSetupGarminLogin_RejectsWhenAlreadyProvisioned(t *testing.T) {
|
||||||
s, _, _ := newTestServer(t)
|
s, _, _ := newTestServer(t) // pre-provisioned "test-user"
|
||||||
rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup", map[string]any{"display_name": "Someone Else"})
|
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 {
|
if rec.Code != http.StatusConflict {
|
||||||
t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String())
|
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) {
|
func unmarshalBody(t *testing.T, rec *httptest.ResponseRecorder, v any) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), v); err != nil {
|
if err := json.Unmarshal(rec.Body.Bytes(), v); err != nil {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ type Client struct {
|
|||||||
authResultCursor int
|
authResultCursor int
|
||||||
ClosedCalled bool
|
ClosedCalled bool
|
||||||
GetActivitiesCalls int
|
GetActivitiesCalls int
|
||||||
|
AuthenticateCalls int
|
||||||
LastEmail string
|
LastEmail string
|
||||||
LastPassword string
|
LastPassword string
|
||||||
}
|
}
|
||||||
@@ -35,6 +36,7 @@ func (c *Client) nextAuthResult() garmin.AuthResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Authenticate(ctx context.Context) (garmin.AuthResult, error) {
|
func (c *Client) Authenticate(ctx context.Context) (garmin.AuthResult, error) {
|
||||||
|
c.AuthenticateCalls++
|
||||||
if c.Err != nil {
|
if c.Err != nil {
|
||||||
return garmin.AuthResult{}, c.Err
|
return garmin.AuthResult{}, c.Err
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user