package api import ( "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "testing" authmock "geniusrun/backend/internal/auth/mock" "geniusrun/backend/internal/garmin" "geniusrun/backend/internal/store" ) // 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, *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 := &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 } func TestSetupGarminLogin_AuthenticatesWithoutCreatingAccount(t *testing.T) { s, db, _ := 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("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) } if _, found, err := db.GetUserBySub(newCtx(), "test-user"); err != nil || found { t.Fatalf("expected no user created yet, found=%v err=%v", found, err) } } func TestSetupGarminLogin_MFARequiredThenCompleteMFA(t *testing.T) { s, db, m := newUnprovisionedServer(t) m.AuthResults = []garmin.AuthResult{{Status: garmin.AuthMFARequired, Message: "MFA required"}} router := s.Router() rec := doJSON(t, 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_CreatesAccountWithGarminCredentialsAndClosesEphemeralClient(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 := &garmin.MockClient{} tokenStoreRoot := t.TempDir() var factoryConfigs []garmin.ClientConfig garminFactory := func(cfg garmin.ClientConfig) garmin.Client { factoryConfigs = append(factoryConfigs, cfg) return m } 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{ "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 (only the original login, no redundant re-authentication)", m.AuthenticateCalls) } if !m.ClosedCalled { t.Error("expected the ephemeral client to be Close()d at setup completion, not promoted as-is -- its cfg.TokenStorePath still points at the ephemeral setup/{hash} dir, which would go stale the moment anything (e.g. a Profile save) later respawns it") } // 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.clientFor(newCtx(), u.ID); err != nil { t.Fatalf("garminFor: %v", err) } wantTokenStorePath := filepath.Join(tokenStoreRoot, itoa(u.ID)) var gotTokenStorePath string for _, cfg := range factoryConfigs { if cfg.GarminEmail == "runner@example.com" { gotTokenStorePath = cfg.TokenStorePath } } if gotTokenStorePath != wantTokenStorePath { t.Errorf("garminFor built client with TokenStorePath = %q, want %q", gotTokenStorePath, wantTokenStorePath) } } func TestSetupComplete_RejectsWithoutSuccessfulGarminConnection(t *testing.T) { s, db, _ := newUnprovisionedServer(t) rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup/complete", map[string]any{"display_name": "Lucie"}) if rec.Code != http.StatusConflict { t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String()) } if _, found, err := db.GetUserBySub(newCtx(), "test-user"); err != nil || found { t.Fatalf("expected no user created, found=%v err=%v", found, err) } } func TestSetupComplete_RejectsEmptyDisplayName(t *testing.T) { s, _, _ := newUnprovisionedServer(t) router := s.Router() rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{ "garmin_email": "runner@example.com", "garmin_password": "hunter2", }) if rec.Code != http.StatusOK { t.Fatalf("login status = %d, body = %s", rec.Code, rec.Body.String()) } rec = doJSON(t, router, http.MethodPost, "/api/setup/complete", map[string]any{"display_name": ""}) if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400", rec.Code) } } func TestSetupGarminLogin_RejectsWhenAlreadyProvisioned(t *testing.T) { s, _, _ := newTestServer(t) // pre-provisioned "test-user" rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup/garmin/login", map[string]any{ "garmin_email": "runner@example.com", "garmin_password": "hunter2", }) if rec.Code != http.StatusConflict { t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String()) } } func TestSetupComplete_RejectsWhenAlreadyProvisioned(t *testing.T) { s, _, _ := newTestServer(t) // pre-provisioned "test-user" rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup/complete", map[string]any{"display_name": "Someone Else"}) if rec.Code != http.StatusConflict { t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String()) } } func TestSetupComplete_RenamesTokenStoreDirectory(t *testing.T) { db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db")) if err != nil { t.Fatalf("store.Open: %v", err) } t.Cleanup(func() { db.Close() }) m := &garmin.MockClient{} tokenStoreRoot := t.TempDir() 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{ "garmin_email": "runner@example.com", "garmin_password": "hunter2", }) if rec.Code != http.StatusOK { t.Fatalf("login status = %d, body = %s", rec.Code, rec.Body.String()) } // Simulate what a real subprocess would have written under the // ephemeral (subject-keyed) directory during that login call. oldDir := setupTokenStoreDir(tokenStoreRoot, "test-user") if err := os.MkdirAll(oldDir, 0o755); err != nil { t.Fatalf("MkdirAll: %v", err) } if err := os.WriteFile(filepath.Join(oldDir, "session.json"), []byte("{}"), 0o644); err != nil { t.Fatalf("WriteFile: %v", err) } rec = doJSON(t, router, http.MethodPost, "/api/setup/complete", map[string]any{"display_name": "Lucie"}) if rec.Code != http.StatusOK { t.Fatalf("complete status = %d, body = %s", rec.Code, rec.Body.String()) } u, found, err := db.GetUserBySub(newCtx(), "test-user") if err != nil || !found { t.Fatalf("GetUserBySub: found=%v err=%v", found, err) } if _, err := os.Stat(oldDir); !os.IsNotExist(err) { t.Fatalf("expected old setup token store dir %q to be gone, stat err = %v", oldDir, err) } newDir := filepath.Join(tokenStoreRoot, itoa(u.ID)) if _, err := os.Stat(filepath.Join(newDir, "session.json")); err != nil { t.Fatalf("expected renamed token store dir %q to contain session.json: %v", newDir, err) } } func unmarshalBody(t *testing.T, rec *httptest.ResponseRecorder, v any) { t.Helper() if err := json.Unmarshal(rec.Body.Bytes(), v); err != nil { t.Fatalf("unmarshal response body %q: %v", rec.Body.String(), err) } } // Regression: os.Rename refuses to replace an existing directory, so a // stale {userID} token-store dir (left behind when the DB file was // recreated -- ids restart at 1 -- while the token-store root survived) // used to make setup completion silently keep the OLD tokens in place. // The fresh, just-validated session's directory must win. func TestSetupComplete_ReplacesStaleTokenStoreDir(t *testing.T) { db, err := store.Open(filepath.Join(t.TempDir(), "setup_stale_dir_test.db")) if err != nil { t.Fatalf("store.Open: %v", err) } t.Cleanup(func() { db.Close() }) m := &garmin.MockClient{} tokenStoreRoot := t.TempDir() s := NewServer(db, func(garmin.ClientConfig) garmin.Client { return m }, garmin.ClientConfig{TokenStorePath: tokenStoreRoot}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig) router := s.Router() // The ephemeral setup dir the wrapper would have written tokens into. setupDir := setupTokenStoreDir(tokenStoreRoot, "test-user") if err := os.MkdirAll(setupDir, 0o755); err != nil { t.Fatalf("mkdir setup dir: %v", err) } if err := os.WriteFile(filepath.Join(setupDir, "oauth_token"), []byte("fresh"), 0o600); err != nil { t.Fatalf("write fresh token: %v", err) } // A stale dir already occupying the permanent {userID} path (fresh DB // starts ids at 1). staleDir := filepath.Join(tokenStoreRoot, "1") if err := os.MkdirAll(staleDir, 0o755); err != nil { t.Fatalf("mkdir stale dir: %v", err) } if err := os.WriteFile(filepath.Join(staleDir, "oauth_token"), []byte("stale"), 0o600); err != nil { t.Fatalf("write stale token: %v", err) } 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) } token, err := os.ReadFile(filepath.Join(tokenStoreRoot, itoa(u.ID), "oauth_token")) if err != nil { t.Fatalf("read token after complete: %v", err) } if string(token) != "fresh" { t.Fatalf("token content = %q, want the fresh setup session to replace the stale dir", token) } if _, err := os.Stat(setupDir); !os.IsNotExist(err) { t.Errorf("ephemeral setup dir still present after rename: %v", err) } }