mirror of
https://github.com/sstent/go-garminconnect.git
synced 2025-12-05 23:52:03 +00:00
garth more done
This commit is contained in:
@@ -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")
|
||||
|
||||
20
garth.md
20
garth.md
@@ -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
13
go.mod
@@ -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
28
go.sum
@@ -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=
|
||||
|
||||
@@ -293,4 +293,4 @@ func (c *Client) DownloadActivity(ctx context.Context, activityID int64) ([]byte
|
||||
}
|
||||
|
||||
return resp.Body(), nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user