feat(api): defer account creation until Garmin actually connects

This commit is contained in:
2026-07-26 13:30:51 +02:00
parent a1eaa42d42
commit 7d68af5b3c
6 changed files with 457 additions and 40 deletions

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
@@ -14,69 +15,220 @@ import (
appsync "geniusrun/backend/internal/sync"
)
func TestSetup_ProvisionsNewUserWithDisplayName(t *testing.T) {
// This test specifically needs an *unprovisioned* session, unlike every
// other test in this package -- build the server without the
// newTestServer helper's automatic ProvisionUser call.
// newUnprovisionedServer builds a Server whose "test-user" OIDC subject
// (the identity doJSON's cookie always mints) has no users/profile row yet
// -- every test in this file needs that starting state, unlike
// newTestServer's auto-provisioned default.
func newUnprovisionedServer(t *testing.T) (*Server, *store.DB, *mock.Client) {
t.Helper()
db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { db.Close() })
m := &mock.Client{}
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
garminFactory := func(garmin.Config) garmin.Client { return m }
s := NewServer(db, garminFactory, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
return s, db, m
}
func TestSetupGarminLogin_AuthenticatesWithoutCreatingAccount(t *testing.T) {
s, db, _ := newUnprovisionedServer(t)
router := s.Router()
rec := doJSON(t, router, http.MethodGet, "/api/session/me", nil)
var me sessionMeResponse
unmarshalBody(t, rec, &me)
if me.HasProfile {
t.Fatal("expected a brand-new session to have no profile yet")
}
rec = doJSON(t, router, http.MethodPost, "/api/setup", map[string]any{"display_name": "Lucie"})
rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{
"garmin_email": "runner@example.com", "garmin_password": "hunter2",
})
if rec.Code != http.StatusOK {
t.Fatalf("setup status = %d, body = %s", rec.Code, rec.Body.String())
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var resp authResponse
unmarshalBody(t, rec, &resp)
if resp.Status != "authenticated" {
t.Fatalf("status = %q, want authenticated", resp.Status)
}
rec = doJSON(t, router, http.MethodGet, "/api/session/me", nil)
unmarshalBody(t, rec, &me)
if !me.HasProfile || me.DisplayName != "Lucie" {
t.Fatalf("session/me after setup = %+v, want HasProfile=true DisplayName=Lucie", me)
}
u, found, err := db.GetUserBySub(newCtx(), "test-user") // doJSON's test cookie's Sub
if err != nil || !found {
t.Fatalf("GetUserBySub: found=%v err=%v", found, err)
}
if u.DisplayName != "Lucie" {
t.Errorf("stored DisplayName = %q, want Lucie", u.DisplayName)
if _, found, err := db.GetUserBySub(newCtx(), "test-user"); err != nil || found {
t.Fatalf("expected no user created yet, found=%v err=%v", found, err)
}
}
func TestSetup_RejectsEmptyDisplayName(t *testing.T) {
db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { db.Close() })
m := &mock.Client{}
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
func TestSetupGarminLogin_MFARequiredThenCompleteMFA(t *testing.T) {
s, db, m := newUnprovisionedServer(t)
m.AuthResults = []garmin.AuthResult{{Status: garmin.AuthMFARequired, Message: "MFA required"}}
router := s.Router()
rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup", map[string]any{"display_name": ""})
rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{
"garmin_email": "runner@example.com", "garmin_password": "hunter2",
})
if rec.Code != http.StatusOK {
t.Fatalf("login status = %d, body = %s", rec.Code, rec.Body.String())
}
var resp authResponse
unmarshalBody(t, rec, &resp)
if resp.Status != "mfa_required" {
t.Fatalf("status = %q, want mfa_required", resp.Status)
}
rec = doJSON(t, router, http.MethodPost, "/api/setup/garmin/mfa", map[string]any{"code": "123456"})
if rec.Code != http.StatusOK {
t.Fatalf("mfa status = %d, body = %s", rec.Code, rec.Body.String())
}
unmarshalBody(t, rec, &resp)
if resp.Status != "authenticated" {
t.Fatalf("status after mfa = %q, want authenticated", resp.Status)
}
if _, found, err := db.GetUserBySub(newCtx(), "test-user"); err != nil || found {
t.Fatalf("expected no user created yet even after MFA success, found=%v err=%v", found, err)
}
}
func TestSetupGarminMFA_RejectsWithoutPriorLoginAttempt(t *testing.T) {
s, _, _ := newUnprovisionedServer(t)
rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup/garmin/mfa", map[string]any{"code": "123456"})
if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String())
}
}
func TestSetupComplete_CreatesAccountWithGarminCredentialsAndPromotesClient(t *testing.T) {
s, db, m := newUnprovisionedServer(t)
router := s.Router()
rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{
"garmin_email": "runner@example.com", "garmin_password": "hunter2",
})
if rec.Code != http.StatusOK {
t.Fatalf("login status = %d, body = %s", rec.Code, rec.Body.String())
}
rec = doJSON(t, router, http.MethodPost, "/api/setup/complete", map[string]any{"display_name": "Lucie"})
if rec.Code != http.StatusOK {
t.Fatalf("complete status = %d, body = %s", rec.Code, rec.Body.String())
}
u, found, err := db.GetUserBySub(newCtx(), "test-user")
if err != nil || !found {
t.Fatalf("GetUserBySub: found=%v err=%v", found, err)
}
profile, err := db.GetProfile(newCtx(), u.ID)
if err != nil {
t.Fatalf("GetProfile: %v", err)
}
if profile.GarminEmail != "runner@example.com" || profile.GarminPassword != "hunter2" {
t.Errorf("profile garmin creds = %+v, want email=runner@example.com password=hunter2", profile)
}
if profile.GarminConnectedAt == nil {
t.Error("expected GarminConnectedAt to be set")
}
if m.AuthenticateCalls != 1 {
t.Errorf("AuthenticateCalls = %d, want 1 (no redundant re-authentication after promotion)", m.AuthenticateCalls)
}
if m.ClosedCalled {
t.Error("expected the promoted client to survive (not be Close()d)")
}
client, err := s.garminFor(newCtx(), u.ID)
if err != nil {
t.Fatalf("garminFor: %v", err)
}
if client != m {
t.Error("expected garminFor to return the promoted (already-authenticated) client")
}
}
func TestSetupComplete_RejectsWithoutSuccessfulGarminConnection(t *testing.T) {
s, db, _ := newUnprovisionedServer(t)
rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup/complete", map[string]any{"display_name": "Lucie"})
if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String())
}
if _, found, err := db.GetUserBySub(newCtx(), "test-user"); err != nil || found {
t.Fatalf("expected no user created, found=%v err=%v", found, err)
}
}
func TestSetupComplete_RejectsEmptyDisplayName(t *testing.T) {
s, _, _ := newUnprovisionedServer(t)
router := s.Router()
rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{
"garmin_email": "runner@example.com", "garmin_password": "hunter2",
})
if rec.Code != http.StatusOK {
t.Fatalf("login status = %d, body = %s", rec.Code, rec.Body.String())
}
rec = doJSON(t, router, http.MethodPost, "/api/setup/complete", map[string]any{"display_name": ""})
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
}
func TestSetup_RejectsWhenAlreadyProvisioned(t *testing.T) {
s, _, _ := newTestServer(t)
rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup", map[string]any{"display_name": "Someone Else"})
func TestSetupGarminLogin_RejectsWhenAlreadyProvisioned(t *testing.T) {
s, _, _ := newTestServer(t) // pre-provisioned "test-user"
rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup/garmin/login", map[string]any{
"garmin_email": "runner@example.com", "garmin_password": "hunter2",
})
if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String())
}
}
func TestSetupComplete_RejectsWhenAlreadyProvisioned(t *testing.T) {
s, _, _ := newTestServer(t) // pre-provisioned "test-user"
rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup/complete", map[string]any{"display_name": "Someone Else"})
if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String())
}
}
func TestSetupComplete_RenamesTokenStoreDirectory(t *testing.T) {
db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { db.Close() })
m := &mock.Client{}
tokenStoreRoot := t.TempDir()
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{TokenStorePath: tokenStoreRoot}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
router := s.Router()
rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{
"garmin_email": "runner@example.com", "garmin_password": "hunter2",
})
if rec.Code != http.StatusOK {
t.Fatalf("login status = %d, body = %s", rec.Code, rec.Body.String())
}
// Simulate what a real subprocess would have written under the
// ephemeral (subject-keyed) directory during that login call.
oldDir := setupTokenStoreDir(tokenStoreRoot, "test-user")
if err := os.MkdirAll(oldDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(filepath.Join(oldDir, "session.json"), []byte("{}"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
rec = doJSON(t, router, http.MethodPost, "/api/setup/complete", map[string]any{"display_name": "Lucie"})
if rec.Code != http.StatusOK {
t.Fatalf("complete status = %d, body = %s", rec.Code, rec.Body.String())
}
u, found, err := db.GetUserBySub(newCtx(), "test-user")
if err != nil || !found {
t.Fatalf("GetUserBySub: found=%v err=%v", found, err)
}
if _, err := os.Stat(oldDir); !os.IsNotExist(err) {
t.Fatalf("expected old setup token store dir %q to be gone, stat err = %v", oldDir, err)
}
newDir := filepath.Join(tokenStoreRoot, itoa(u.ID))
if _, err := os.Stat(filepath.Join(newDir, "session.json")); err != nil {
t.Fatalf("expected renamed token store dir %q to contain session.json: %v", newDir, err)
}
}
func unmarshalBody(t *testing.T, rec *httptest.ResponseRecorder, v any) {
t.Helper()
if err := json.Unmarshal(rec.Body.Bytes(), v); err != nil {