garth more done

This commit is contained in:
2025-08-28 19:44:35 -07:00
parent 6b9150c541
commit 237e17fbb3
11 changed files with 150 additions and 100 deletions

View File

@@ -20,7 +20,7 @@ func main() {
if err := godotenv.Load(); err != nil {
fmt.Println("Failed to load .env file:", err)
}
// Re-check after loading .env
if os.Getenv("GARMIN_USERNAME") == "" || os.Getenv("GARMIN_PASSWORD") == "" {
fmt.Println("GARMIN_USERNAME and GARMIN_PASSWORD must be set in environment or .env file")

View File

@@ -329,15 +329,15 @@ func (m *MockGarminAPI) Do(req *http.Request) (*http.Response, error) {
## 6. Implementation Timeline
### Week 1: Project Setup + Authentication Tests
- [ ] Initialize Go module and project structure
- [ ] Write authentication test cases
- [x] Initialize Go module and project structure
- [x] Write authentication test cases
- [ ] Set up CI/CD pipeline
- [ ] Implement basic session management
- [x] Implement basic session management
### Week 2: Complete Authentication Module
- [ ] Implement OAuth1 flow
- [ ] Implement OAuth1 flow (in progress)
- [ ] Implement OAuth2 token refresh
- [ ] Add MFA support
- [x] Add MFA support (core implementation)
- [ ] Comprehensive authentication testing
### Week 3: HTTP Client Module
@@ -352,8 +352,8 @@ func (m *MockGarminAPI) Do(req *http.Request) (*http.Response, error) {
- [ ] JSON marshaling/unmarshaling tests
### Week 5: Data Models - Health Metrics
- [x] Stress data models
- [x] Steps, HRV, weight models
- [x] Stress data models (implemented)
- [x] Steps, HRV, weight models (implemented)
- [x] Validation and business logic
### Week 6: Main Interface + Integration
@@ -384,10 +384,10 @@ func (m *MockGarminAPI) Do(req *http.Request) (*http.Response, error) {
## 8. Success Metrics
### Functional Requirements:
- [ ] Authentication flow matches Python library
- [ ] All data models supported
- [ ] Authentication flow matches Python library (in progress)
- [x] All data models supported
- [ ] API requests work identically
- [ ] Session persistence compatible
- [x] Session persistence compatible
### Quality Requirements:
- [ ] >90% test coverage

13
go.mod
View File

@@ -4,8 +4,10 @@ go 1.24.2
require (
github.com/dghubble/oauth1 v0.7.3
github.com/go-playground/validator/v10 v10.27.0
github.com/go-resty/resty/v2 v2.11.0
github.com/joho/godotenv v1.5.1
github.com/stretchr/testify v1.8.4
golang.org/x/time v0.12.0
)
require (
@@ -13,19 +15,12 @@ require (
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/go-resty/resty/v2 v2.11.0 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/objx v0.5.0 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/net v0.34.0 // indirect
golang.org/x/oauth2 v0.15.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/protobuf v1.31.0 // indirect
golang.org/x/time v0.12.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

28
go.sum
View File

@@ -1,10 +1,11 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dghubble/oauth1 v0.7.3 h1:EkEM/zMDMp3zOsX2DC/ZQ2vnEX3ELK0/l9kb+vs4ptE=
github.com/dghubble/oauth1 v0.7.3/go.mod h1:oxTe+az9NSMIucDPDCCtzJGsPhciJV33xocHfcR2sVY=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
@@ -13,23 +14,12 @@ github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHO
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-resty/resty/v2 v2.11.0 h1:i7jMfNOJYMp69lq7qozJP+bjgzfAzeOhuGlyDrqxT/8=
github.com/go-resty/resty/v2 v2.11.0/go.mod h1:iiP/OpA0CkcL3IGt1O0+/SIItFUbkkyw5BGXiVdTu+A=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
@@ -40,19 +30,14 @@ golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/oauth2 v0.15.0 h1:s8pnnxNVzjWyrvYdFUQq5llS1PX2zhPXmccZv99h7uQ=
golang.org/x/oauth2 v0.15.0/go.mod h1:q48ptWNTY5XWf+JNten23lcvHpLJ0ZSxF5ttTHKVCAM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -72,7 +57,6 @@ golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
@@ -88,15 +72,7 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -293,4 +293,4 @@ func (c *Client) DownloadActivity(ctx context.Context, activityID int64) ([]byte
}
return resp.Body(), nil
}
}

View File

@@ -18,9 +18,9 @@ func TestActivitiesEndpoints(t *testing.T) {
client := NewClientWithBaseURL(mockServer.URL())
tests := []struct {
name string
setup func()
testFunc func(t *testing.T)
name string
setup func()
testFunc func(t *testing.T)
}{
{
name: "GetActivitiesSuccess",
@@ -28,17 +28,17 @@ func TestActivitiesEndpoints(t *testing.T) {
mockServer.SetActivitiesHandler(func(w http.ResponseWriter, r *http.Request) {
// Create a properly formatted time string for Garmin format
timeStr := time.Now().Add(-24 * time.Hour).Format("2006-01-02T15:04:05")
// Create response with raw JSON to avoid time marshaling issues
response := map[string]interface{}{
"activities": []map[string]interface{}{
{
"activityId": 1,
"activityName": "Morning Run",
"activityType": "RUNNING",
"startTimeLocal": timeStr,
"duration": 3600.0,
"distance": 10.0,
"activityId": 1,
"activityName": "Morning Run",
"activityType": "RUNNING",
"startTimeLocal": timeStr,
"duration": 3600.0,
"distance": 10.0,
},
},
"pagination": map[string]interface{}{
@@ -47,7 +47,7 @@ func TestActivitiesEndpoints(t *testing.T) {
"totalCount": 1,
},
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(response)
@@ -81,18 +81,18 @@ func TestActivitiesEndpoints(t *testing.T) {
// Use proper time format for Garmin API
timeStr := time.Now().Add(-24 * time.Hour).Format("2006-01-02T15:04:05")
response := map[string]interface{}{
"activityId": activityID,
"activityName": "Mock Activity",
"activityType": "RUNNING",
"startTimeLocal": timeStr,
"duration": 3600.0,
"distance": 10.0,
"calories": 500.0,
"averageHR": 150,
"maxHR": 170,
"elevationGain": 100.0,
"activityId": activityID,
"activityName": "Mock Activity",
"activityType": "RUNNING",
"startTimeLocal": timeStr,
"duration": 3600.0,
"distance": 10.0,
"calories": 500.0,
"averageHR": 150,
"maxHR": 170,
"elevationGain": 100.0,
}
w.Header().Set("Content-Type", "application/json")
@@ -156,7 +156,7 @@ func TestActivitiesEndpoints(t *testing.T) {
fitData := make([]byte, 20)
fitData[0] = 14 // header size
copy(fitData[8:12], []byte(".FIT"))
id, err := client.UploadActivity(context.Background(), fitData)
assert.NoError(t, err)
assert.Equal(t, int64(12345), id)
@@ -187,4 +187,4 @@ func TestActivitiesEndpoints(t *testing.T) {
tt.testFunc(t)
})
}
}
}

View File

@@ -17,7 +17,7 @@ func TestGetBodyComposition(t *testing.T) {
// Check for required parameters without enforcing order
startDate := r.URL.Query().Get("startDate")
endDate := r.URL.Query().Get("endDate")
assert.Equal(t, "2023-01-01", startDate, "startDate should match")
assert.Equal(t, "2023-01-31", endDate, "endDate should match")
@@ -115,4 +115,4 @@ func TestGetBodyComposition(t *testing.T) {
}
})
}
}
}

View File

@@ -18,7 +18,7 @@ func TestGearService(t *testing.T) {
// Create test server
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/gear-service/stats/valid-uuid":
w.WriteHeader(http.StatusOK)
@@ -129,4 +129,4 @@ func TestGearService(t *testing.T) {
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to get gear activities")
})
}
}

View File

@@ -18,9 +18,11 @@ func (e *APIError) Error() string {
// Error types for API responses
type ErrNotFound struct{}
func (e ErrNotFound) Error() string { return "resource not found" }
type ErrBadRequest struct{}
func (e ErrBadRequest) Error() string { return "bad request" }
// Time represents a Garmin Connect time value
@@ -47,7 +49,7 @@ func (t *Time) UnmarshalJSON(data []byte) error {
if err := json.Unmarshal(data, &s); err != nil {
return err
}
// Try multiple time formats that Garmin might use
formats := []string{
"2006-01-02T15:04:05.000Z",
@@ -57,14 +59,14 @@ func (t *Time) UnmarshalJSON(data []byte) error {
time.RFC3339,
time.RFC3339Nano,
}
for _, format := range formats {
if parsedTime, err := time.Parse(format, s); err == nil {
*t = Time(parsedTime)
return nil
}
}
// If none of the formats work, try parsing as RFC3339
parsedTime, err := time.Parse(time.RFC3339, s)
if err != nil {

View File

@@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"regexp"
@@ -96,16 +97,25 @@ func (g *GarthAuthenticator) Login(username, password string) (*Session, error)
// getRequestToken obtains OAuth1 request token
func (g *GarthAuthenticator) getRequestToken() (token, secret string, err error) {
_, err = g.HTTPClient.R().
resp, err := g.HTTPClient.R().
SetHeader("Accept", "text/html").
SetResult(&struct{}{}).
Post(g.BaseURL + "/oauth-service/oauth/request_token")
if err != nil {
return "", "", err
return "", "", fmt.Errorf("request token request failed: %w", err)
}
// Parse token and secret from response body
return "temp_token", "temp_secret", nil
values, err := url.ParseQuery(resp.String())
if err != nil {
return "", "", fmt.Errorf("failed to parse request token response: %w", err)
}
token = values.Get("oauth_token")
secret = values.Get("oauth_token_secret")
if token == "" || secret == "" {
return "", "", errors.New("request token response missing oauth_token or oauth_token_secret")
}
return token, secret, nil
}
// authenticate handles username/password authentication and MFA
@@ -199,12 +209,44 @@ func (d DefaultConsolePrompter) GetMFACode(ctx context.Context) (string, error)
// getAccessToken exchanges request token for access token
func (g *GarthAuthenticator) getAccessToken(token, secret, verifier string) (accessToken, accessSecret string, err error) {
return "access_token", "access_secret", nil
resp, err := g.HTTPClient.R().
SetQueryParam("oauth_token", token).
SetQueryParam("oauth_verifier", verifier).
Post(g.BaseURL + "/oauth-service/oauth/access_token")
if err != nil {
return "", "", fmt.Errorf("access token request failed: %w", err)
}
values, err := url.ParseQuery(resp.String())
if err != nil {
return "", "", fmt.Errorf("failed to parse access token response: %w", err)
}
accessToken = values.Get("oauth_token")
accessSecret = values.Get("oauth_token_secret")
if accessToken == "" || accessSecret == "" {
return "", "", errors.New("access token response missing oauth_token or oauth_token_secret")
}
return accessToken, accessSecret, nil
}
// getOAuth2Token exchanges OAuth1 token for OAuth2 token
func (g *GarthAuthenticator) getOAuth2Token(token, secret string) (oauth2Token string, err error) {
return "oauth2_access_token", nil
resp, err := g.HTTPClient.R().
SetFormData(map[string]string{
"token": token,
"token_secret": secret,
}).
Post(g.BaseURL + "/oauth-service/oauth/exchange/user/2.0")
if err != nil {
return "", fmt.Errorf("OAuth2 token exchange failed: %w", err)
}
if resp.StatusCode() != 200 {
return "", fmt.Errorf("OAuth2 token exchange failed with status %d", resp.StatusCode())
}
return strings.TrimSpace(resp.String()), nil
}
// Save persists the session to the specified path

View File

@@ -9,20 +9,27 @@ import (
)
func TestOAuth1LoginFlow(t *testing.T) {
// Setup mock server to simulate Garmin SSO flow
// Setup mock server to simulate complete Garmin SSO flow
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// The request token step uses text/html Accept header
if r.URL.Path == "/oauth-service/oauth/request_token" {
assert.Equal(t, "text/html", r.Header.Get("Accept"))
} else {
// Other requests use application/json
assert.Equal(t, "application/json", r.Header.Get("Accept"))
}
assert.Equal(t, "garmin-connect-client", r.Header.Get("User-Agent"))
switch r.URL.Path {
case "/oauth-service/oauth/request_token":
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte("oauth_token=test_token&oauth_token_secret=test_secret"))
// Simulate successful SSO response
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(`<input type="hidden" name="oauth_verifier" value="test_verifier" />`))
case "/sso/signin":
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(`<input type="hidden" name="oauth_verifier" value="test_verifier" />`))
case "/oauth-service/oauth/access_token":
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte("oauth_token=access_token&oauth_token_secret=access_secret"))
case "/oauth-service/oauth/exchange/user/2.0":
w.Write([]byte("oauth2_token"))
default:
t.Errorf("Unexpected request to path: %s", r.URL.Path)
}
}))
defer server.Close()
@@ -34,21 +41,43 @@ func TestOAuth1LoginFlow(t *testing.T) {
session, err := auth.Login("test_user", "test_pass")
assert.NoError(t, err, "Login should succeed")
assert.NotNil(t, session, "Session should be created")
// Verify session values
assert.Equal(t, "access_token", session.OAuth1Token)
assert.Equal(t, "access_secret", session.OAuth1Secret)
assert.Equal(t, "oauth2_token", session.OAuth2Token)
assert.False(t, session.IsExpired(), "Session should not be expired")
}
func TestMFAFlow(t *testing.T) {
mfaTriggered := false
// Setup mock server to simulate MFA requirement
// Setup mock server to simulate MFA requirement and complete flow
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !mfaTriggered {
switch {
case r.URL.Path == "/oauth-service/oauth/request_token":
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte("oauth_token=test_token&oauth_token_secret=test_secret"))
case r.URL.Path == "/sso/signin" && !mfaTriggered:
// First response requires MFA
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(`<div class="mfa-required"><input type="hidden" name="mfaContext" value="context123" /></div>`))
mfaTriggered = true
} else {
// Second response after MFA
case r.URL.Path == "/sso/verifyMFA":
// MFA verification
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(`<input type="hidden" name="oauth_verifier" value="mfa_verifier" />`))
case r.URL.Path == "/oauth-service/oauth/access_token":
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte("oauth_token=access_token&oauth_token_secret=access_secret"))
case r.URL.Path == "/oauth-service/oauth/exchange/user/2.0":
w.Write([]byte("oauth2_token"))
default:
t.Errorf("Unexpected request to path: %s", r.URL.Path)
}
}))
defer server.Close()
@@ -61,6 +90,12 @@ func TestMFAFlow(t *testing.T) {
session, err := auth.Login("mfa_user", "mfa_pass")
assert.NoError(t, err, "MFA login should succeed")
assert.NotNil(t, session, "Session should be created")
// Verify session values
assert.Equal(t, "access_token", session.OAuth1Token)
assert.Equal(t, "access_secret", session.OAuth1Secret)
assert.Equal(t, "oauth2_token", session.OAuth2Token)
assert.False(t, session.IsExpired(), "Session should not be expired")
}
func TestLoginFailure(t *testing.T) {