Files
geniusrun/backend/internal/api/setup_test.go
Christophe Vila 6effb79097 refactor(log): unified schema — mandatory type/class/method, request_id removed
Every log record now carries type ('http' for the request middleware
line, 'wrapper' for garmin execute() calls and forwarded wrapper.py
stderr, 'app' for everything else), class (Go type with package, or the
package/module for free functions), and method (the emitting Go/Python
function -- wrapper.py stamps it automatically, so its msg prefixes are
gone). msg is optional and omitted when empty; the redundant messages
('HTTP request', 'wrapper call', 'garmin wrapper stderr') are dropped,
the HTTP verb moves to http_method, source=garmin-wrapper is replaced by
type=wrapper, and the request_id middleware plumbing (applog
WithLogger/FromContext) is removed. applog.App(class, method) is the
tagging helper for app records.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:45:30 +02:00

318 lines
12 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/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)
}
}