refactor: merge internal/sync into internal/garmin, regroup api files and routes
Garmin auth/sync routes move under /api/garmin/*; sync.Service becomes garmin.Sync with garmin.SyncConfig/ClientConfig; applog becomes internal/log; the test mock moves into the garmin package as MockClient (breaking the test-only import cycle the merge created); stale test URLs and type names updated to match. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,13 +15,11 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"geniusrun/backend/internal/applog"
|
||||
"geniusrun/backend/internal/auth"
|
||||
authmock "geniusrun/backend/internal/auth/mock"
|
||||
"geniusrun/backend/internal/garmin"
|
||||
"geniusrun/backend/internal/garmin/mock"
|
||||
"geniusrun/backend/internal/log"
|
||||
"geniusrun/backend/internal/store"
|
||||
appsync "geniusrun/backend/internal/sync"
|
||||
)
|
||||
|
||||
func newCtx() context.Context { return context.Background() }
|
||||
@@ -47,9 +45,9 @@ func newTestServer(t *testing.T) (*Server, *store.DB, int64) {
|
||||
t.Fatalf("ProvisionUser: %v", err)
|
||||
}
|
||||
|
||||
m := &mock.Client{}
|
||||
garminFactory := func(garmin.Config) garmin.Client { return m }
|
||||
s := NewServer(db, garminFactory, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
|
||||
m := &garmin.MockClient{}
|
||||
garminFactory := func(garmin.ClientConfig) garmin.Client { return m }
|
||||
s := NewServer(db, garminFactory, garmin.ClientConfig{}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
|
||||
return s, db, userID
|
||||
}
|
||||
|
||||
@@ -586,7 +584,7 @@ func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) {
|
||||
}
|
||||
|
||||
router := s.Router()
|
||||
rec := doJSON(t, router, http.MethodPost, "/api/sync/reset", nil)
|
||||
rec := doJSON(t, router, http.MethodPost, "/api/garmin/sync/reset", nil)
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("reset status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
@@ -607,9 +605,9 @@ func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) {
|
||||
}
|
||||
|
||||
// "Full backfill" no longer exists as an endpoint -- superseded by reset.
|
||||
rec = doJSON(t, router, http.MethodPost, "/api/sync/backfill", nil)
|
||||
rec = doJSON(t, router, http.MethodPost, "/api/garmin/sync/backfill", nil)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("/api/sync/backfill status = %d, want 404 (removed)", rec.Code)
|
||||
t.Errorf("/api/garmin/sync/backfill status = %d, want 404 (removed)", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -633,7 +631,7 @@ func TestSyncStatus_ReportsPhaseAwareProgressAndWorkoutsPending(t *testing.T) {
|
||||
t.Fatalf("SetActivityDetails: %v", err)
|
||||
}
|
||||
|
||||
rec := doJSON(t, s.Router(), http.MethodGet, "/api/sync/status", nil)
|
||||
rec := doJSON(t, s.Router(), http.MethodGet, "/api/garmin/sync/status", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
@@ -777,7 +775,7 @@ func TestSessionMe_ReportsGarminConnectedAfterSuccessfulAuth(t *testing.T) {
|
||||
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
|
||||
rec = doJSON(t, router, http.MethodPost, "/api/garmin/auth/login", nil) // garmin.MockClient defaults to AuthSuccess
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
@@ -792,18 +790,18 @@ func TestSessionMe_ReportsGarminConnectedAfterSuccessfulAuth(t *testing.T) {
|
||||
|
||||
func TestSessionMe_GarminConnectedStaysFalseOnMFARequiredOrFailed(t *testing.T) {
|
||||
s, _, userID := newTestServer(t)
|
||||
client, err := s.garminFor(context.Background(), userID)
|
||||
client, err := s.clientFor(context.Background(), userID)
|
||||
if err != nil {
|
||||
t.Fatalf("garminFor: %v", err)
|
||||
}
|
||||
mockClient, ok := client.(*mock.Client)
|
||||
mockClient, ok := client.(*garmin.MockClient)
|
||||
if !ok {
|
||||
t.Fatalf("expected *mock.Client, got %T", client)
|
||||
t.Fatalf("expected *garmin.MockClient, 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)
|
||||
rec := doJSON(t, router, http.MethodPost, "/api/garmin/auth/login", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
@@ -821,17 +819,17 @@ func TestDeleteProfile_DeletesUserAndTerminatesGarminClient(t *testing.T) {
|
||||
router := s.Router()
|
||||
|
||||
// Force the per-user Garmin client to be built and cached.
|
||||
rec := doJSON(t, router, http.MethodPost, "/api/auth/login", nil)
|
||||
rec := doJSON(t, router, http.MethodPost, "/api/garmin/auth/login", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
client, err := s.garminFor(context.Background(), userID)
|
||||
client, err := s.clientFor(context.Background(), userID)
|
||||
if err != nil {
|
||||
t.Fatalf("garminFor: %v", err)
|
||||
}
|
||||
mockClient, ok := client.(*mock.Client)
|
||||
mockClient, ok := client.(*garmin.MockClient)
|
||||
if !ok {
|
||||
t.Fatalf("expected *mock.Client, got %T", client)
|
||||
t.Fatalf("expected *garmin.MockClient, got %T", client)
|
||||
}
|
||||
|
||||
rec = doJSON(t, router, http.MethodDelete, "/api/profile", nil)
|
||||
@@ -882,9 +880,9 @@ func TestDeleteProfile_RemovesTokenStoreDirectory(t *testing.T) {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
m := &mock.Client{}
|
||||
garminFactory := func(garmin.Config) garmin.Client { return m }
|
||||
s := NewServer(db, garminFactory, garmin.Config{TokenStorePath: tokenStoreRoot}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
|
||||
m := &garmin.MockClient{}
|
||||
garminFactory := func(garmin.ClientConfig) garmin.Client { return m }
|
||||
s := NewServer(db, garminFactory, garmin.ClientConfig{TokenStorePath: tokenStoreRoot}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
|
||||
|
||||
rec := doJSON(t, s.Router(), http.MethodDelete, "/api/profile", nil)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"geniusrun/backend/internal/garmin"
|
||||
)
|
||||
|
||||
type authResponse struct {
|
||||
Status string `json:"status"` // "authenticated" | "mfa_required" | "failed"
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func authStatusString(s garmin.AuthStatus) string {
|
||||
switch s {
|
||||
case garmin.AuthSuccess:
|
||||
return "authenticated"
|
||||
case garmin.AuthMFARequired:
|
||||
return "mfa_required"
|
||||
case garmin.AuthFailed:
|
||||
return "failed"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
client, err := s.garminFor(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
res, err := client.Authenticate(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
s.recordAuthResult(r.Context(), userID, res)
|
||||
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
|
||||
}
|
||||
|
||||
func (s *Server) handleAuthMFA(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
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
|
||||
}
|
||||
|
||||
client, err := s.garminFor(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
res, err := client.CompleteMFA(r.Context(), body.Code)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
s.recordAuthResult(r.Context(), userID, res)
|
||||
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
|
||||
}
|
||||
|
||||
func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
s.mu.Lock()
|
||||
status, msg := s.userAuthStatus[userID], s.userAuthMessage[userID]
|
||||
s.mu.Unlock()
|
||||
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(status), Message: msg})
|
||||
}
|
||||
@@ -14,7 +14,7 @@ type EnvVar struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type appConfigEntry struct {
|
||||
type configEntry struct {
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
Default string `json:"default"`
|
||||
@@ -30,13 +30,13 @@ func (s *Server) configView(r *http.Request) (map[string]any, error) {
|
||||
return nil, err
|
||||
}
|
||||
registry := config.AppRegistry()
|
||||
app := make([]appConfigEntry, 0, len(registry))
|
||||
app := make([]configEntry, 0, len(registry))
|
||||
for _, k := range registry {
|
||||
value, overridden := overrides[k.Key]
|
||||
if !overridden {
|
||||
value = k.Default
|
||||
}
|
||||
app = append(app, appConfigEntry{
|
||||
app = append(app, configEntry{
|
||||
Key: k.Key, Value: value, Default: k.Default,
|
||||
Overridden: overridden, Description: k.Description,
|
||||
})
|
||||
@@ -5,12 +5,9 @@ import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"geniusrun/backend/internal/garmin"
|
||||
"geniusrun/backend/internal/garmin/mock"
|
||||
"geniusrun/backend/internal/store"
|
||||
appsync "geniusrun/backend/internal/sync"
|
||||
|
||||
authmock "geniusrun/backend/internal/auth/mock"
|
||||
"geniusrun/backend/internal/garmin"
|
||||
"geniusrun/backend/internal/store"
|
||||
)
|
||||
|
||||
type configTestResponse struct {
|
||||
@@ -87,8 +84,8 @@ func TestConfig_RequiresProvisionedUser(t *testing.T) {
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
m := &mock.Client{}
|
||||
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
|
||||
m := &garmin.MockClient{}
|
||||
s := NewServer(db, func(garmin.ClientConfig) garmin.Client { return m }, garmin.ClientConfig{}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
|
||||
|
||||
if rec := doJSON(t, s.Router(), http.MethodGet, "/api/config", nil); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("GET status = %d, want 403", rec.Code)
|
||||
200
backend/internal/api/garmin.go
Normal file
200
backend/internal/api/garmin.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"geniusrun/backend/internal/garmin"
|
||||
)
|
||||
|
||||
// detailFillBatchSize bounds how many activities' details/splits are fetched
|
||||
// per sync trigger, matching the sequential rate-limited fetch in
|
||||
// internal/sync.Service.FillPendingDetails.
|
||||
const detailFillBatchSize = 50
|
||||
|
||||
type authResponse struct {
|
||||
Status string `json:"status"` // "authenticated" | "mfa_required" | "failed"
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func authStatusString(s garmin.AuthStatus) string {
|
||||
switch s {
|
||||
case garmin.AuthSuccess:
|
||||
return "authenticated"
|
||||
case garmin.AuthMFARequired:
|
||||
return "mfa_required"
|
||||
case garmin.AuthFailed:
|
||||
return "failed"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// 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) handleGarminAuthLogin(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
client, err := s.clientFor(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
res, err := client.Authenticate(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
s.recordAuthResult(r.Context(), userID, res)
|
||||
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
|
||||
}
|
||||
|
||||
func (s *Server) handleGarminAuthMFA(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
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
|
||||
}
|
||||
|
||||
client, err := s.clientFor(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
res, err := client.CompleteMFA(r.Context(), body.Code)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
s.recordAuthResult(r.Context(), userID, res)
|
||||
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
|
||||
}
|
||||
|
||||
func (s *Server) handleGarminAuthStatus(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
s.mu.Lock()
|
||||
status, msg := s.userAuthStatus[userID], s.userAuthMessage[userID]
|
||||
s.mu.Unlock()
|
||||
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(status), Message: msg})
|
||||
}
|
||||
|
||||
// handleGarminSyncRun does a full sync pass: Backfill first (resumes from the
|
||||
// watermark, so widening the configured history horizon between clicks is
|
||||
// picked up automatically), then IncrementalSync (catches anything new since
|
||||
// the latest known activity), then fills in details for whatever's still
|
||||
// missing them. Activities already fully processed are left untouched --
|
||||
// see internal/sync.Service.FillPendingDetails. Recorded as a single
|
||||
// FullSync run so "last sync" reports the combined activity count, not just
|
||||
// whichever of Backfill/IncrementalSync happened to finish last.
|
||||
func (s *Server) handleGarminSyncRun(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
svc, err := s.syncFor(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
ok := s.backgroundSync(userID, func(ctx context.Context) error {
|
||||
return svc.FullSync(ctx, detailFillBatchSize)
|
||||
})
|
||||
if !ok {
|
||||
writeError(w, http.StatusConflict, "a sync is already in progress")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"status": "started"})
|
||||
}
|
||||
|
||||
// handleGarminSyncReset wipes every synced activity (and its laps/samples/kind
|
||||
// assignments) and rewinds the backfill watermark, so the next Sync Now
|
||||
// performs a genuinely fresh pull from Garmin. Destructive -- the frontend
|
||||
// gates this behind a confirmation.
|
||||
func (s *Server) handleGarminSyncReset(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
svc, err := s.syncFor(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
ok := s.backgroundSync(userID, func(ctx context.Context) error {
|
||||
return svc.ResetAll(ctx)
|
||||
})
|
||||
if !ok {
|
||||
writeError(w, http.StatusConflict, "a sync is already in progress")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"status": "started"})
|
||||
}
|
||||
|
||||
func (s *Server) handleGarminSyncRuns(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
runs, err := s.DB.ListSyncRuns(r.Context(), userID, 20)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, runs)
|
||||
}
|
||||
|
||||
func (s *Server) handleGarminSyncStatus(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
run, ok, err := s.DB.LatestSyncRun(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
pendingDetails, err := s.DB.CountActivitiesMissingDetails(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
pendingWorkouts, err := s.DB.CountActivitiesMissingWorkout(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
inProgress := s.userSyncRunning[userID]
|
||||
s.mu.Unlock()
|
||||
|
||||
svc, err := s.syncFor(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
progress := svc.Progress()
|
||||
|
||||
resp := map[string]any{
|
||||
"in_progress": inProgress,
|
||||
"progress": progress,
|
||||
"activities_pending_details": pendingDetails,
|
||||
"workouts_pending": pendingWorkouts,
|
||||
}
|
||||
if ok {
|
||||
resp["last_run"] = run
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
@@ -10,9 +10,7 @@ import (
|
||||
"geniusrun/backend/internal/auth"
|
||||
authmock "geniusrun/backend/internal/auth/mock"
|
||||
"geniusrun/backend/internal/garmin"
|
||||
"geniusrun/backend/internal/garmin/mock"
|
||||
"geniusrun/backend/internal/store"
|
||||
appsync "geniusrun/backend/internal/sync"
|
||||
)
|
||||
|
||||
// doJSONAs is doJSON but for an explicit session Sub, for tests that need
|
||||
@@ -150,8 +148,8 @@ func TestIsolation_RequireProvisionedUser_BlocksDataRoutesUntilSetup(t *testing.
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
m := &mock.Client{}
|
||||
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
|
||||
m := &garmin.MockClient{}
|
||||
s := NewServer(db, func(garmin.ClientConfig) garmin.Client { return m }, garmin.ClientConfig{}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
|
||||
|
||||
rec := doJSON(t, s.Router(), http.MethodGet, "/api/activities/", nil) // "test-user" sub, never provisioned
|
||||
if rec.Code != http.StatusForbidden {
|
||||
@@ -196,7 +194,7 @@ func TestIsolation_GarminConnectedNeverLeaksAcrossUsers(t *testing.T) {
|
||||
}
|
||||
|
||||
router := s.Router()
|
||||
rec := doJSON(t, router, http.MethodPost, "/api/auth/login", nil) // as userA
|
||||
rec := doJSON(t, router, http.MethodPost, "/api/garmin/auth/login", nil) // as userA
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
client, err := s.garminFor(r.Context(), userID)
|
||||
client, err := s.clientFor(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
@@ -98,6 +98,6 @@ func (s *Server) handleDeleteProfile(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
s.removeGarminClient(userID)
|
||||
s.removeUserClient(userID)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
applog "geniusrun/backend/internal/log"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -18,35 +19,34 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"geniusrun/backend/internal/applog"
|
||||
"geniusrun/backend/internal/auth"
|
||||
"geniusrun/backend/internal/garmin"
|
||||
"geniusrun/backend/internal/store"
|
||||
appsync "geniusrun/backend/internal/sync"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
)
|
||||
|
||||
// Server wires the HTTP handlers to the app's dependencies. garmin.Client
|
||||
// and sync.Service are per-user (each user might have their own Garmin
|
||||
// account), built lazily on first use via GarminFactory and cached.
|
||||
type Server struct {
|
||||
DB *store.DB
|
||||
Auth auth.Verifier
|
||||
Session SessionConfig
|
||||
DB *store.DB
|
||||
Auth auth.Verifier
|
||||
SessionConfig SessionConfig
|
||||
|
||||
// GarminFactory builds a real (or fake, in tests) garmin.Client from a
|
||||
// fully-resolved per-user Config. Production wiring passes
|
||||
// garmin.NewClient; tests inject a factory returning a shared
|
||||
// *mock.Client (see newTestServer in api_test.go).
|
||||
GarminFactory func(garmin.Config) garmin.Client
|
||||
// GarminBase holds the plumbing shared by every user's garmin.Config
|
||||
GarminFactory func(garmin.ClientConfig) garmin.Client
|
||||
|
||||
// ClientConfig holds the plumbing shared by every user's garmin.Config
|
||||
// (the python interpreter path + the token-store root directory); only
|
||||
// GarminEmail/GarminPassword/TokenStorePath vary per user, filled in by
|
||||
// garminFor.
|
||||
GarminBase garmin.Config
|
||||
SyncConfig appsync.Config
|
||||
ClientConfig garmin.ClientConfig
|
||||
SyncConfig garmin.SyncConfig
|
||||
|
||||
// EnvVars is the read-only, display-safe environment-configuration
|
||||
// snapshot served by GET /api/config -- built once in main.go from
|
||||
@@ -55,260 +55,36 @@ type Server struct {
|
||||
EnvVars []EnvVar
|
||||
|
||||
mu sync.Mutex
|
||||
userGarmin map[int64]garmin.Client
|
||||
userSync map[int64]*appsync.Service
|
||||
userClient map[int64]garmin.Client
|
||||
userSync map[int64]*garmin.Sync
|
||||
userAuthStatus map[int64]garmin.AuthStatus
|
||||
userAuthMessage map[int64]string
|
||||
userSyncRunning map[int64]bool
|
||||
setupGarmin map[string]*setupSession
|
||||
setupSession map[string]*setupSession
|
||||
}
|
||||
|
||||
// NewServer builds a Server.
|
||||
func NewServer(db *store.DB, garminFactory func(garmin.Config) garmin.Client, garminBase garmin.Config, syncConfig appsync.Config, authVerifier auth.Verifier, session SessionConfig) *Server {
|
||||
func NewServer(db *store.DB, garminFactory func(garmin.ClientConfig) garmin.Client, garminBase garmin.ClientConfig, syncConfig garmin.SyncConfig, authVerifier auth.Verifier, sessionConfig SessionConfig) *Server {
|
||||
return &Server{
|
||||
DB: db, GarminFactory: garminFactory, GarminBase: garminBase, SyncConfig: syncConfig,
|
||||
Auth: authVerifier, Session: session,
|
||||
userGarmin: map[int64]garmin.Client{},
|
||||
userSync: map[int64]*appsync.Service{},
|
||||
DB: db,
|
||||
GarminFactory: garminFactory,
|
||||
ClientConfig: garminBase,
|
||||
SyncConfig: syncConfig,
|
||||
Auth: authVerifier,
|
||||
SessionConfig: sessionConfig,
|
||||
userClient: map[int64]garmin.Client{},
|
||||
userSync: map[int64]*garmin.Sync{},
|
||||
userAuthStatus: map[int64]garmin.AuthStatus{},
|
||||
userAuthMessage: map[int64]string{},
|
||||
userSyncRunning: map[int64]bool{},
|
||||
setupGarmin: map[string]*setupSession{},
|
||||
setupSession: 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. Closed (never promoted into Server.userGarmin) once
|
||||
// /api/setup/complete actually creates the account -- its Client's
|
||||
// garmin.Config.TokenStorePath is permanently pinned to the ephemeral
|
||||
// setup/{hash} directory, so reusing the object after that directory is
|
||||
// renamed to the permanent {userID} one would respawn against a stale path
|
||||
// the next time anything closes and restarts its subprocess; a later
|
||||
// garminFor(ctx, userID) call builds a fresh client with the correct path
|
||||
// instead. Otherwise 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). handleSetupComplete closes the client the
|
||||
// same way but keeps (renames) the directory instead of removing it, since
|
||||
// that's the real, now-permanent session.
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
// garminFor returns userID's garmin.Client, building and caching it (from
|
||||
// userID's own profile row) on first use.
|
||||
func (s *Server) garminFor(ctx context.Context, userID int64) (garmin.Client, error) {
|
||||
s.mu.Lock()
|
||||
if c, ok := s.userGarmin[userID]; ok {
|
||||
s.mu.Unlock()
|
||||
return c, nil
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
profile, err := s.DB.GetProfile(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load profile for garmin client (user %d): %w", userID, err)
|
||||
}
|
||||
cfg := s.GarminBase
|
||||
cfg.GarminEmail = profile.GarminEmail
|
||||
cfg.GarminPassword = profile.GarminPassword
|
||||
if cfg.TokenStorePath != "" {
|
||||
cfg.TokenStorePath = filepath.Join(cfg.TokenStorePath, strconv.FormatInt(userID, 10))
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if c, ok := s.userGarmin[userID]; ok {
|
||||
return c, nil // built concurrently by another request between our unlock and re-lock
|
||||
}
|
||||
client := s.GarminFactory(cfg)
|
||||
s.userGarmin[userID] = client
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// syncFor returns userID's sync.Service, building and caching it on first use.
|
||||
func (s *Server) syncFor(ctx context.Context, userID int64) (*appsync.Service, error) {
|
||||
s.mu.Lock()
|
||||
if svc, ok := s.userSync[userID]; ok {
|
||||
s.mu.Unlock()
|
||||
return svc, nil
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
client, err := s.garminFor(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if svc, ok := s.userSync[userID]; ok {
|
||||
return svc, nil
|
||||
}
|
||||
svc := appsync.NewService(client, s.DB, userID, s.SyncConfig, nil)
|
||||
s.userSync[userID] = svc
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
// removeGarminClient drops userID's cached garmin.Client/sync.Service (if
|
||||
// any) and every other per-user in-memory entry for userID, terminating the
|
||||
// client's subprocess and best-effort removing its on-disk token-store
|
||||
// directory. Called when a user's account has just been deleted from the
|
||||
// DB, so nothing in memory keeps referencing a userID that no longer
|
||||
// exists.
|
||||
func (s *Server) removeGarminClient(userID int64) {
|
||||
s.mu.Lock()
|
||||
client, ok := s.userGarmin[userID]
|
||||
delete(s.userGarmin, userID)
|
||||
delete(s.userSync, userID)
|
||||
delete(s.userAuthStatus, userID)
|
||||
delete(s.userAuthMessage, userID)
|
||||
delete(s.userSyncRunning, userID)
|
||||
s.mu.Unlock()
|
||||
|
||||
if ok {
|
||||
if err := client.Close(); err != nil {
|
||||
log.Printf("api: close garmin client for deleted user %d: %v", userID, err)
|
||||
}
|
||||
}
|
||||
if s.GarminBase.TokenStorePath == "" {
|
||||
return
|
||||
}
|
||||
tokenStoreDir := filepath.Join(s.GarminBase.TokenStorePath, strconv.FormatInt(userID, 10))
|
||||
if err := os.RemoveAll(tokenStoreDir); err != nil {
|
||||
log.Printf("api: remove token store dir for deleted user %d: %v", userID, err)
|
||||
}
|
||||
}
|
||||
|
||||
var requestIDCounter atomic.Int64
|
||||
|
||||
// requestLoggingMiddleware logs one JSON line per HTTP request (method,
|
||||
// path, status, duration) and attaches a per-request logger (tagged with a
|
||||
// request_id) to the request context, so any downstream call this request
|
||||
// triggers -- e.g. a Garmin wrapper round-trip -- logs with the same
|
||||
// correlating id (see internal/applog, internal/garmin's roundTrip).
|
||||
func requestLoggingMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id := fmt.Sprintf("req-%d", requestIDCounter.Add(1))
|
||||
logger := applog.FromContext(r.Context()).With("request_id", id)
|
||||
r = r.WithContext(applog.WithLogger(r.Context(), logger))
|
||||
|
||||
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||
start := time.Now()
|
||||
next.ServeHTTP(ww, r)
|
||||
|
||||
level := slog.LevelInfo
|
||||
if ww.Status() >= 500 {
|
||||
level = slog.LevelWarn
|
||||
}
|
||||
logger.LogAttrs(r.Context(), level, "http request",
|
||||
slog.String("method", r.Method),
|
||||
slog.String("path", r.URL.Path),
|
||||
slog.Int("status", ww.Status()),
|
||||
slog.Int64("duration_ms", time.Since(start).Milliseconds()),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// Router builds the HTTP routes.
|
||||
func (s *Server) Router() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(requestLoggingMiddleware)
|
||||
r.Use(loggingMiddleware)
|
||||
r.Use(corsMiddleware)
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.Get("/health", s.handleHealth)
|
||||
@@ -319,11 +95,12 @@ func (s *Server) Router() http.Handler {
|
||||
r.Get("/session/callback", s.handleSessionCallback)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(auth.RequireSession(s.Session.Secret))
|
||||
r.Use(auth.RequireSession(s.SessionConfig.Secret))
|
||||
r.Use(s.resolveUser)
|
||||
|
||||
r.Get("/session/me", s.handleSessionMe)
|
||||
r.Post("/session/logout", s.handleSessionLogout)
|
||||
|
||||
r.Route("/setup", func(r chi.Router) {
|
||||
r.Post("/complete", s.handleSetupComplete)
|
||||
r.Route("/garmin", func(r chi.Router) {
|
||||
@@ -341,17 +118,20 @@ func (s *Server) Router() http.Handler {
|
||||
r.Delete("/", s.handleDeleteProfile)
|
||||
})
|
||||
|
||||
r.Route("/auth", func(r chi.Router) {
|
||||
r.Post("/login", s.handleAuthLogin)
|
||||
r.Post("/mfa", s.handleAuthMFA)
|
||||
r.Get("/status", s.handleAuthStatus)
|
||||
})
|
||||
r.Route("/garmin", func(r chi.Router) {
|
||||
r.Route("/auth", func(r chi.Router) {
|
||||
r.Post("/login", s.handleGarminAuthLogin)
|
||||
r.Post("/mfa", s.handleGarminAuthMFA)
|
||||
r.Get("/status", s.handleGarminAuthStatus)
|
||||
})
|
||||
|
||||
r.Route("/sync", func(r chi.Router) {
|
||||
r.Post("/run", s.handleGarminSyncRun)
|
||||
r.Post("/reset", s.handleGarminSyncReset)
|
||||
r.Get("/runs", s.handleGarminSyncRuns)
|
||||
r.Get("/status", s.handleGarminSyncStatus)
|
||||
})
|
||||
|
||||
r.Route("/sync", func(r chi.Router) {
|
||||
r.Post("/run", s.handleSyncRun)
|
||||
r.Post("/reset", s.handleSyncReset)
|
||||
r.Get("/runs", s.handleSyncRuns)
|
||||
r.Get("/status", s.handleSyncStatus)
|
||||
})
|
||||
|
||||
r.Route("/activities", func(r chi.Router) {
|
||||
@@ -379,6 +159,40 @@ func (s *Server) Router() http.Handler {
|
||||
return r
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
var requestIDCounter atomic.Int64
|
||||
|
||||
// loggingMiddleware logs one JSON line per HTTP request (method,
|
||||
// path, status, duration) and attaches a per-request logger (tagged with a
|
||||
// request_id) to the request context, so any downstream call this request
|
||||
// triggers -- e.g. a Garmin wrapper round-trip -- logs with the same
|
||||
// correlating id (see internal/applog, internal/garmin's roundTrip).
|
||||
func loggingMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id := fmt.Sprintf("req-%d", requestIDCounter.Add(1))
|
||||
logger := applog.FromContext(r.Context()).With("request_id", id)
|
||||
r = r.WithContext(applog.WithLogger(r.Context(), logger))
|
||||
|
||||
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||
start := time.Now()
|
||||
next.ServeHTTP(ww, r)
|
||||
|
||||
level := slog.LevelInfo
|
||||
if ww.Status() >= 500 {
|
||||
level = slog.LevelWarn
|
||||
}
|
||||
logger.LogAttrs(r.Context(), level, "http request",
|
||||
slog.String("method", r.Method),
|
||||
slog.String("path", r.URL.Path),
|
||||
slog.Int("status", ww.Status()),
|
||||
slog.Int64("duration_ms", time.Since(start).Milliseconds()),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// corsMiddleware allows the frontend dev server (a different port) to call
|
||||
// this API. Reflecting any origin back is safe even with credentials
|
||||
// enabled: this remains a single-operator app whose real access control is
|
||||
@@ -399,20 +213,89 @@ func corsMiddleware(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
// clientFor returns userID's garmin.Client, building and caching it (from
|
||||
// userID's own profile row) on first use.
|
||||
func (s *Server) clientFor(ctx context.Context, userID int64) (garmin.Client, error) {
|
||||
s.mu.Lock()
|
||||
if c, ok := s.userClient[userID]; ok {
|
||||
s.mu.Unlock()
|
||||
return c, nil
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
profile, err := s.DB.GetProfile(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load profile for garmin client (user %d): %w", userID, err)
|
||||
}
|
||||
cfg := s.ClientConfig
|
||||
cfg.GarminEmail = profile.GarminEmail
|
||||
cfg.GarminPassword = profile.GarminPassword
|
||||
if cfg.TokenStorePath != "" {
|
||||
cfg.TokenStorePath = filepath.Join(cfg.TokenStorePath, strconv.FormatInt(userID, 10))
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if c, ok := s.userClient[userID]; ok {
|
||||
return c, nil // built concurrently by another request between our unlock and re-lock
|
||||
}
|
||||
client := s.GarminFactory(cfg)
|
||||
s.userClient[userID] = client
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
log.Printf("api: encode response: %v", err)
|
||||
// removeUserClient drops userID's cached garmin.Client/sync.Service (if
|
||||
// any) and every other per-user in-memory entry for userID, terminating the
|
||||
// client's subprocess and best-effort removing its on-disk token-store
|
||||
// directory. Called when a user's account has just been deleted from the
|
||||
// DB, so nothing in memory keeps referencing a userID that no longer
|
||||
// exists.
|
||||
func (s *Server) removeUserClient(userID int64) {
|
||||
s.mu.Lock()
|
||||
client, ok := s.userClient[userID]
|
||||
delete(s.userClient, userID)
|
||||
delete(s.userSync, userID)
|
||||
delete(s.userAuthStatus, userID)
|
||||
delete(s.userAuthMessage, userID)
|
||||
delete(s.userSyncRunning, userID)
|
||||
s.mu.Unlock()
|
||||
|
||||
if ok {
|
||||
if err := client.Close(); err != nil {
|
||||
log.Printf("api: close garmin client for deleted user %d: %v", userID, err)
|
||||
}
|
||||
}
|
||||
if s.ClientConfig.TokenStorePath == "" {
|
||||
return
|
||||
}
|
||||
tokenStoreDir := filepath.Join(s.ClientConfig.TokenStorePath, strconv.FormatInt(userID, 10))
|
||||
if err := os.RemoveAll(tokenStoreDir); err != nil {
|
||||
log.Printf("api: remove token store dir for deleted user %d: %v", userID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
// syncFor returns userID's sync.Service, building and caching it on first use.
|
||||
func (s *Server) syncFor(ctx context.Context, userID int64) (*garmin.Sync, error) {
|
||||
s.mu.Lock()
|
||||
if svc, ok := s.userSync[userID]; ok {
|
||||
s.mu.Unlock()
|
||||
return svc, nil
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
client, err := s.clientFor(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if svc, ok := s.userSync[userID]; ok {
|
||||
return svc, nil
|
||||
}
|
||||
svc := garmin.NewSync(client, s.DB, userID, s.SyncConfig, nil)
|
||||
s.userSync[userID] = svc
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
// backgroundSync runs fn in a goroutine with a fresh context, guarded so
|
||||
@@ -439,3 +322,25 @@ func (s *Server) backgroundSync(userID int64, fn func(ctx context.Context) error
|
||||
}()
|
||||
return true
|
||||
}
|
||||
|
||||
// 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[:]))
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
log.Printf("api: encode response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ func (s *Server) handleSessionLogin(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
cookie, err := auth.MintTxnCookie(txn, s.Session.Secret, s.Session.Secure)
|
||||
cookie, err := auth.MintTxnCookie(txn, s.SessionConfig.Secret, s.SessionConfig.Secure)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
@@ -58,36 +58,36 @@ func (s *Server) handleSessionCallback(w http.ResponseWriter, r *http.Request) {
|
||||
txnCookie, err := r.Cookie(auth.TxnCookieName)
|
||||
if err != nil {
|
||||
log.Printf("session callback: missing txn cookie: %v", err)
|
||||
http.Redirect(w, r, s.Session.FrontendURL+"/?auth_error=failed", http.StatusFound)
|
||||
http.Redirect(w, r, s.SessionConfig.FrontendURL+"/?auth_error=failed", http.StatusFound)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, auth.ClearCookie(auth.TxnCookieName, s.Session.Secure))
|
||||
http.SetCookie(w, auth.ClearCookie(auth.TxnCookieName, s.SessionConfig.Secure))
|
||||
|
||||
txn, err := auth.ParseTxnCookie(txnCookie, s.Session.Secret)
|
||||
txn, err := auth.ParseTxnCookie(txnCookie, s.SessionConfig.Secret)
|
||||
if err != nil {
|
||||
log.Printf("session callback: failed to parse txn cookie: %v", err)
|
||||
http.Redirect(w, r, s.Session.FrontendURL+"/?auth_error=failed", http.StatusFound)
|
||||
http.Redirect(w, r, s.SessionConfig.FrontendURL+"/?auth_error=failed", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := s.Auth.HandleCallback(r.Context(), txn, r.URL.Query())
|
||||
if err != nil {
|
||||
log.Printf("session callback: HandleCallback failed (state mismatch, code exchange, or ID-token verification): %v", err)
|
||||
http.Redirect(w, r, s.Session.FrontendURL+"/?auth_error=failed", http.StatusFound)
|
||||
http.Redirect(w, r, s.SessionConfig.FrontendURL+"/?auth_error=failed", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if !result.Authorized {
|
||||
http.Redirect(w, r, s.Session.FrontendURL+"/?auth_error=forbidden", http.StatusFound)
|
||||
http.Redirect(w, r, s.SessionConfig.FrontendURL+"/?auth_error=forbidden", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
sessionCookie, err := auth.MintSessionCookie(result.Claims, s.Session.Secret, s.Session.Duration, s.Session.Secure)
|
||||
sessionCookie, err := auth.MintSessionCookie(result.Claims, s.SessionConfig.Secret, s.SessionConfig.Duration, s.SessionConfig.Secure)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, sessionCookie)
|
||||
http.Redirect(w, r, s.Session.FrontendURL+"/", http.StatusFound)
|
||||
http.Redirect(w, r, s.SessionConfig.FrontendURL+"/", http.StatusFound)
|
||||
}
|
||||
|
||||
// handleSessionLogout clears geniusrun's own session cookie and redirects
|
||||
@@ -99,8 +99,8 @@ func (s *Server) handleSessionCallback(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
http.SetCookie(w, auth.ClearCookie(auth.SessionCookieName, s.SessionConfig.Secure))
|
||||
http.Redirect(w, r, s.Auth.EndSessionURL(s.SessionConfig.FrontendURL+"/", claims.IDToken), http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *Server) handleSessionMe(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -7,11 +7,38 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"geniusrun/backend/internal/auth"
|
||||
"geniusrun/backend/internal/garmin"
|
||||
)
|
||||
|
||||
// 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
|
||||
|
||||
// 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. Closed (never promoted into Server.userClient) once
|
||||
// /api/setup/complete actually creates the account -- its Client's
|
||||
// garmin.Config.TokenStorePath is permanently pinned to the ephemeral
|
||||
// setup/{hash} directory, so reusing the object after that directory is
|
||||
// renamed to the permanent {userID} one would respawn against a stale path
|
||||
// the next time anything closes and restarts its subprocess; a later
|
||||
// garminFor(ctx, userID) call builds a fresh client with the correct path
|
||||
// instead. Otherwise 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
|
||||
}
|
||||
|
||||
// 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).
|
||||
@@ -90,7 +117,7 @@ func (s *Server) handleSetupGarminMFA(w http.ResponseWriter, r *http.Request) {
|
||||
// garmin.AuthSuccess. Provisions the account, persists the Garmin
|
||||
// credentials, marks it connected, closes the ephemeral client, and
|
||||
// renames its token-store directory into the permanent per-user path --
|
||||
// the next real garminFor(ctx, userID) builds a fresh client from scratch
|
||||
// the next real clientFor(ctx, userID) builds a fresh client from scratch
|
||||
// against that now-permanent directory, whose subprocess's lazy
|
||||
// startup-login resumes the just-renamed, still-valid session without
|
||||
// needing to re-authenticate (a cheap local token-store resume, not a
|
||||
@@ -160,14 +187,14 @@ func (s *Server) handleSetupComplete(w http.ResponseWriter, r *http.Request) {
|
||||
sess.Client.Close()
|
||||
|
||||
s.mu.Lock()
|
||||
delete(s.setupGarmin, claims.Sub)
|
||||
delete(s.setupSession, claims.Sub)
|
||||
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 s.ClientConfig.TokenStorePath != "" {
|
||||
oldDir := setupTokenStoreDir(s.ClientConfig.TokenStorePath, claims.Sub)
|
||||
newDir := filepath.Join(s.ClientConfig.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)
|
||||
}
|
||||
@@ -175,3 +202,80 @@ func (s *Server) handleSetupComplete(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"user_id": userID, "display_name": body.DisplayName})
|
||||
}
|
||||
|
||||
// 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.setupSession[sub]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if time.Since(sess.LastUsed) > setupSessionIdleTimeout {
|
||||
sess.Client.Close()
|
||||
delete(s.setupSession, sub)
|
||||
if s.ClientConfig.TokenStorePath != "" {
|
||||
os.RemoveAll(setupTokenStoreDir(s.ClientConfig.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.setupSession[sub]
|
||||
s.mu.Unlock()
|
||||
if ok {
|
||||
old.Client.Close()
|
||||
}
|
||||
|
||||
cfg := s.ClientConfig
|
||||
cfg.GarminEmail = email
|
||||
cfg.GarminPassword = password
|
||||
if cfg.TokenStorePath != "" {
|
||||
cfg.TokenStorePath = setupTokenStoreDir(s.ClientConfig.TokenStorePath, sub)
|
||||
}
|
||||
sess := &setupSession{Client: s.GarminFactory(cfg), Email: email, Password: password, LastUsed: time.Now()}
|
||||
|
||||
s.mu.Lock()
|
||||
s.setupSession[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.setupSession[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). handleSetupComplete closes the client the
|
||||
// same way but keeps (renames) the directory instead of removing it, since
|
||||
// that's the real, now-permanent session.
|
||||
func (s *Server) removeSetupSession(sub string) {
|
||||
s.mu.Lock()
|
||||
sess, ok := s.setupSession[sub]
|
||||
delete(s.setupSession, sub)
|
||||
s.mu.Unlock()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
sess.Client.Close()
|
||||
if s.ClientConfig.TokenStorePath != "" {
|
||||
os.RemoveAll(setupTokenStoreDir(s.ClientConfig.TokenStorePath, sub))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,25 +10,23 @@ import (
|
||||
|
||||
authmock "geniusrun/backend/internal/auth/mock"
|
||||
"geniusrun/backend/internal/garmin"
|
||||
"geniusrun/backend/internal/garmin/mock"
|
||||
"geniusrun/backend/internal/store"
|
||||
appsync "geniusrun/backend/internal/sync"
|
||||
)
|
||||
|
||||
// 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) {
|
||||
func newUnprovisionedServer(t *testing.T) (*Server, *store.DB, *garmin.MockClient) {
|
||||
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{}
|
||||
garminFactory := func(garmin.Config) garmin.Client { return m }
|
||||
s := NewServer(db, garminFactory, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
|
||||
m := &garmin.MockClient{}
|
||||
garminFactory := func(garmin.ClientConfig) garmin.Client { return m }
|
||||
s := NewServer(db, garminFactory, garmin.ClientConfig{}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
|
||||
return s, db, m
|
||||
}
|
||||
|
||||
@@ -98,14 +96,14 @@ func TestSetupComplete_CreatesAccountWithGarminCredentialsAndClosesEphemeralClie
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
m := &mock.Client{}
|
||||
m := &garmin.MockClient{}
|
||||
tokenStoreRoot := t.TempDir()
|
||||
var factoryConfigs []garmin.Config
|
||||
garminFactory := func(cfg garmin.Config) garmin.Client {
|
||||
var factoryConfigs []garmin.ClientConfig
|
||||
garminFactory := func(cfg garmin.ClientConfig) garmin.Client {
|
||||
factoryConfigs = append(factoryConfigs, cfg)
|
||||
return m
|
||||
}
|
||||
s := NewServer(db, garminFactory, garmin.Config{TokenStorePath: tokenStoreRoot}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
|
||||
s := NewServer(db, garminFactory, garmin.ClientConfig{TokenStorePath: tokenStoreRoot}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
|
||||
router := s.Router()
|
||||
|
||||
rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{
|
||||
@@ -145,7 +143,7 @@ func TestSetupComplete_CreatesAccountWithGarminCredentialsAndClosesEphemeralClie
|
||||
// A later real use must build a genuinely fresh client, configured
|
||||
// against the permanent {userID} token store directory -- never the
|
||||
// stale ephemeral setup/{hash} one the closed client was carrying.
|
||||
if _, err := s.garminFor(newCtx(), u.ID); err != nil {
|
||||
if _, err := s.clientFor(newCtx(), u.ID); err != nil {
|
||||
t.Fatalf("garminFor: %v", err)
|
||||
}
|
||||
wantTokenStorePath := filepath.Join(tokenStoreRoot, itoa(u.ID))
|
||||
@@ -210,9 +208,9 @@ func TestSetupComplete_RenamesTokenStoreDirectory(t *testing.T) {
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
m := &mock.Client{}
|
||||
m := &garmin.MockClient{}
|
||||
tokenStoreRoot := t.TempDir()
|
||||
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{TokenStorePath: tokenStoreRoot}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
|
||||
s := NewServer(db, func(garmin.ClientConfig) garmin.Client { return m }, garmin.ClientConfig{TokenStorePath: tokenStoreRoot}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
|
||||
router := s.Router()
|
||||
|
||||
rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// detailFillBatchSize bounds how many activities' details/splits are fetched
|
||||
// per sync trigger, matching the sequential rate-limited fetch in
|
||||
// internal/sync.Service.FillPendingDetails.
|
||||
const detailFillBatchSize = 50
|
||||
|
||||
// handleSyncRun does a full sync pass: Backfill first (resumes from the
|
||||
// watermark, so widening the configured history horizon between clicks is
|
||||
// picked up automatically), then IncrementalSync (catches anything new since
|
||||
// the latest known activity), then fills in details for whatever's still
|
||||
// missing them. Activities already fully processed are left untouched --
|
||||
// see internal/sync.Service.FillPendingDetails. Recorded as a single
|
||||
// FullSync run so "last sync" reports the combined activity count, not just
|
||||
// whichever of Backfill/IncrementalSync happened to finish last.
|
||||
func (s *Server) handleSyncRun(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
svc, err := s.syncFor(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
ok := s.backgroundSync(userID, func(ctx context.Context) error {
|
||||
return svc.FullSync(ctx, detailFillBatchSize)
|
||||
})
|
||||
if !ok {
|
||||
writeError(w, http.StatusConflict, "a sync is already in progress")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"status": "started"})
|
||||
}
|
||||
|
||||
// handleSyncReset wipes every synced activity (and its laps/samples/kind
|
||||
// assignments) and rewinds the backfill watermark, so the next Sync Now
|
||||
// performs a genuinely fresh pull from Garmin. Destructive -- the frontend
|
||||
// gates this behind a confirmation.
|
||||
func (s *Server) handleSyncReset(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
svc, err := s.syncFor(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
ok := s.backgroundSync(userID, func(ctx context.Context) error {
|
||||
return svc.ResetAll(ctx)
|
||||
})
|
||||
if !ok {
|
||||
writeError(w, http.StatusConflict, "a sync is already in progress")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"status": "started"})
|
||||
}
|
||||
|
||||
func (s *Server) handleSyncRuns(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
runs, err := s.DB.ListSyncRuns(r.Context(), userID, 20)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, runs)
|
||||
}
|
||||
|
||||
func (s *Server) handleSyncStatus(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
run, ok, err := s.DB.LatestSyncRun(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
pendingDetails, err := s.DB.CountActivitiesMissingDetails(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
pendingWorkouts, err := s.DB.CountActivitiesMissingWorkout(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
inProgress := s.userSyncRunning[userID]
|
||||
s.mu.Unlock()
|
||||
|
||||
svc, err := s.syncFor(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
progress := svc.Progress()
|
||||
|
||||
resp := map[string]any{
|
||||
"in_progress": inProgress,
|
||||
"progress": progress,
|
||||
"activities_pending_details": pendingDetails,
|
||||
"workouts_pending": pendingWorkouts,
|
||||
}
|
||||
if ok {
|
||||
resp["last_run"] = run
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
@@ -9,9 +9,7 @@ import (
|
||||
"geniusrun/backend/internal/auth"
|
||||
authmock "geniusrun/backend/internal/auth/mock"
|
||||
"geniusrun/backend/internal/garmin"
|
||||
"geniusrun/backend/internal/garmin/mock"
|
||||
"geniusrun/backend/internal/store"
|
||||
appsync "geniusrun/backend/internal/sync"
|
||||
)
|
||||
|
||||
func TestResolveUser_AttachesResolvedUserWhenProvisioned(t *testing.T) {
|
||||
@@ -41,8 +39,8 @@ func TestResolveUser_LeavesContextEmptyWhenNotProvisioned(t *testing.T) {
|
||||
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)
|
||||
m := &garmin.MockClient{}
|
||||
s := NewServer(db, func(garmin.ClientConfig) garmin.Client { return m }, garmin.ClientConfig{}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
|
||||
|
||||
var gotOK bool
|
||||
handler := auth.RequireSession(testSessionConfig.Secret)(s.resolveUser(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
Reference in New Issue
Block a user