handleSetupComplete reused the onboarding client object as-is in the
permanent per-user cache, but its garmin.Config.TokenStorePath was fixed
at construction to the ephemeral setup/{hash} directory and never
corrected after that directory was renamed to the permanent {userID}
path. The next respawn of that same client (e.g. any Profile-page save,
which unconditionally calls UpdateCredentials) wrote a fresh token file
back under setup/{hash}, forcing a real re-login/MFA on the next Garmin
connect even though a valid session already existed under {userID}.
Close the ephemeral client instead and let the next garminFor call build
a fresh one against the correct, already-renamed directory.
259 lines
9.8 KiB
Go
259 lines
9.8 KiB
Go
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/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) {
|
|
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)
|
|
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 := &mock.Client{}
|
|
tokenStoreRoot := t.TempDir()
|
|
var factoryConfigs []garmin.Config
|
|
garminFactory := func(cfg garmin.Config) garmin.Client {
|
|
factoryConfigs = append(factoryConfigs, cfg)
|
|
return m
|
|
}
|
|
s := NewServer(db, garminFactory, 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())
|
|
}
|
|
|
|
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.garminFor(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 := &mock.Client{}
|
|
tokenStoreRoot := t.TempDir()
|
|
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{TokenStorePath: tokenStoreRoot}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
|
|
router := s.Router()
|
|
|
|
rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{
|
|
"garmin_email": "runner@example.com", "garmin_password": "hunter2",
|
|
})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("login status = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
// Simulate what a real subprocess would have written under the
|
|
// ephemeral (subject-keyed) directory during that login call.
|
|
oldDir := setupTokenStoreDir(tokenStoreRoot, "test-user")
|
|
if err := os.MkdirAll(oldDir, 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(oldDir, "session.json"), []byte("{}"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
|
|
rec = doJSON(t, router, http.MethodPost, "/api/setup/complete", map[string]any{"display_name": "Lucie"})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("complete status = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
u, found, err := db.GetUserBySub(newCtx(), "test-user")
|
|
if err != nil || !found {
|
|
t.Fatalf("GetUserBySub: found=%v err=%v", found, err)
|
|
}
|
|
|
|
if _, err := os.Stat(oldDir); !os.IsNotExist(err) {
|
|
t.Fatalf("expected old setup token store dir %q to be gone, stat err = %v", oldDir, err)
|
|
}
|
|
newDir := filepath.Join(tokenStoreRoot, itoa(u.ID))
|
|
if _, err := os.Stat(filepath.Join(newDir, "session.json")); err != nil {
|
|
t.Fatalf("expected renamed token store dir %q to contain session.json: %v", newDir, err)
|
|
}
|
|
}
|
|
|
|
func unmarshalBody(t *testing.T, rec *httptest.ResponseRecorder, v any) {
|
|
t.Helper()
|
|
if err := json.Unmarshal(rec.Body.Bytes(), v); err != nil {
|
|
t.Fatalf("unmarshal response body %q: %v", rec.Body.String(), err)
|
|
}
|
|
}
|