2026-07-25 17:55:13 +02:00
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
2026-07-26 13:30:51 +02:00
"os"
2026-07-25 18:10:09 +02:00
"path/filepath"
2026-07-25 17:55:13 +02:00
"testing"
2026-07-25 18:10:09 +02:00
authmock "geniusrun/backend/internal/auth/mock"
"geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/garmin/mock"
"geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
2026-07-25 17:55:13 +02:00
)
2026-07-26 13:30:51 +02:00
// 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 ( )
2026-07-25 18:10:09 +02:00
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 { }
2026-07-26 13:30:51 +02:00
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" } }
2026-07-25 17:55:13 +02:00
router := s . Router ( )
2026-07-26 13:30:51 +02:00
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 )
2026-07-25 17:55:13 +02:00
}
2026-07-26 13:30:51 +02:00
}
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 ( ) )
}
}
2026-07-26 21:23:24 +02:00
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 )
2026-07-26 13:30:51 +02:00
router := s . Router ( )
2026-07-25 17:55:13 +02:00
2026-07-26 13:30:51 +02:00
rec := doJSON ( t , router , http . MethodPost , "/api/setup/garmin/login" , map [ string ] any {
"garmin_email" : "runner@example.com" , "garmin_password" : "hunter2" ,
} )
2026-07-25 17:55:13 +02:00
if rec . Code != http . StatusOK {
2026-07-26 13:30:51 +02:00
t . Fatalf ( "login status = %d, body = %s" , rec . Code , rec . Body . String ( ) )
2026-07-25 17:55:13 +02:00
}
2026-07-26 13:30:51 +02:00
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 ( ) )
2026-07-25 17:55:13 +02:00
}
2026-07-26 13:30:51 +02:00
u , found , err := db . GetUserBySub ( newCtx ( ) , "test-user" )
2026-07-25 17:55:13 +02:00
if err != nil || ! found {
t . Fatalf ( "GetUserBySub: found=%v err=%v" , found , err )
}
2026-07-26 13:30:51 +02:00
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" )
2026-07-25 17:55:13 +02:00
}
2026-07-26 13:30:51 +02:00
if m . AuthenticateCalls != 1 {
2026-07-26 21:23:24 +02:00
t . Errorf ( "AuthenticateCalls = %d, want 1 (only the original login, no redundant re-authentication)" , m . AuthenticateCalls )
2026-07-26 13:30:51 +02:00
}
2026-07-26 21:23:24 +02:00
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" )
2026-07-26 13:30:51 +02:00
}
2026-07-26 21:23:24 +02:00
// 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 {
2026-07-26 13:30:51 +02:00
t . Fatalf ( "garminFor: %v" , err )
2026-07-25 18:10:09 +02:00
}
2026-07-26 21:23:24 +02:00
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 )
2026-07-26 13:30:51 +02:00
}
}
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 )
}
}
2026-07-25 18:10:09 +02:00
2026-07-26 13:30:51 +02:00
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" : "" } )
2026-07-25 17:55:13 +02:00
if rec . Code != http . StatusBadRequest {
t . Fatalf ( "status = %d, want 400" , rec . Code )
}
}
2026-07-26 13:30:51 +02:00
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" } )
2026-07-25 17:55:13 +02:00
if rec . Code != http . StatusConflict {
t . Fatalf ( "status = %d, want 409, body = %s" , rec . Code , rec . Body . String ( ) )
}
}
2026-07-26 13:30:51 +02:00
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 , 0 o755 ) ; err != nil {
t . Fatalf ( "MkdirAll: %v" , err )
}
if err := os . WriteFile ( filepath . Join ( oldDir , "session.json" ) , [ ] byte ( "{}" ) , 0 o644 ) ; 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 )
}
}
2026-07-25 17:55:13 +02:00
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 )
}
}