mirror of
https://github.com/sstent/go-garminconnect.git
synced 2026-04-30 23:32:47 +00:00
with garth
This commit is contained in:
@@ -4,9 +4,8 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
@@ -28,15 +27,15 @@ type Activity struct {
|
||||
// ActivityDetail represents comprehensive activity data
|
||||
type ActivityDetail struct {
|
||||
Activity
|
||||
Calories float64 `json:"calories"`
|
||||
AverageHR int `json:"averageHR"`
|
||||
MaxHR int `json:"maxHR"`
|
||||
AverageTemp float64 `json:"averageTemperature"`
|
||||
ElevationGain float64 `json:"elevationGain"`
|
||||
ElevationLoss float64 `json:"elevationLoss"`
|
||||
Weather Weather `json:"weather"`
|
||||
Gear Gear `json:"gear"`
|
||||
GPSTracks []GPSTrackPoint `json:"gpsTracks"`
|
||||
Calories float64 `json:"calories"`
|
||||
AverageHR int `json:"averageHR"`
|
||||
MaxHR int `json:"maxHR"`
|
||||
AverageTemp float64 `json:"averageTemperature"`
|
||||
ElevationGain float64 `json:"elevationGain"`
|
||||
ElevationLoss float64 `json:"elevationLoss"`
|
||||
Weather Weather `json:"weather"`
|
||||
Gear Gear `json:"gear"`
|
||||
GPSTracks []GPSTrackPoint `json:"gpsTracks"`
|
||||
}
|
||||
|
||||
// garminTime implements custom JSON unmarshaling for Garmin's time format
|
||||
@@ -72,15 +71,15 @@ type ActivityResponse struct {
|
||||
// ActivityDetailResponse is used for JSON unmarshaling with custom time handling
|
||||
type ActivityDetailResponse struct {
|
||||
ActivityResponse
|
||||
Calories float64 `json:"calories"`
|
||||
AverageHR int `json:"averageHR"`
|
||||
MaxHR int `json:"maxHR"`
|
||||
AverageTemp float64 `json:"averageTemperature"`
|
||||
ElevationGain float64 `json:"elevationGain"`
|
||||
ElevationLoss float64 `json:"elevationLoss"`
|
||||
Weather Weather `json:"weather"`
|
||||
Gear Gear `json:"gear"`
|
||||
GPSTracks []GPSTrackPoint `json:"gpsTracks"`
|
||||
Calories float64 `json:"calories"`
|
||||
AverageHR int `json:"averageHR"`
|
||||
MaxHR int `json:"maxHR"`
|
||||
AverageTemp float64 `json:"averageTemperature"`
|
||||
ElevationGain float64 `json:"elevationGain"`
|
||||
ElevationLoss float64 `json:"elevationLoss"`
|
||||
Weather Weather `json:"weather"`
|
||||
Gear Gear `json:"gear"`
|
||||
GPSTracks []GPSTrackPoint `json:"gpsTracks"`
|
||||
}
|
||||
|
||||
// Convert to ActivityDetail
|
||||
@@ -94,15 +93,15 @@ func (adr *ActivityDetailResponse) ToActivityDetail() ActivityDetail {
|
||||
Duration: adr.Duration,
|
||||
Distance: adr.Distance,
|
||||
},
|
||||
Calories: adr.Calories,
|
||||
AverageHR: adr.AverageHR,
|
||||
MaxHR: adr.MaxHR,
|
||||
AverageTemp: adr.AverageTemp,
|
||||
ElevationGain: adr.ElevationGain,
|
||||
ElevationLoss: adr.ElevationLoss,
|
||||
Weather: adr.Weather,
|
||||
Gear: adr.Gear,
|
||||
GPSTracks: adr.GPSTracks,
|
||||
Calories: adr.Calories,
|
||||
AverageHR: adr.AverageHR,
|
||||
MaxHR: adr.MaxHR,
|
||||
AverageTemp: adr.AverageTemp,
|
||||
ElevationGain: adr.ElevationGain,
|
||||
ElevationLoss: adr.ElevationLoss,
|
||||
Weather: adr.Weather,
|
||||
Gear: adr.Gear,
|
||||
GPSTracks: adr.GPSTracks,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,7 +120,7 @@ func (ar *ActivityResponse) ToActivity() Activity {
|
||||
// Weather contains weather conditions during activity
|
||||
type Weather struct {
|
||||
Condition string `json:"condition"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
Humidity float64 `json:"humidity"`
|
||||
}
|
||||
|
||||
@@ -225,47 +224,41 @@ func (c *Client) GetActivityDetails(ctx context.Context, activityID int64) (*Act
|
||||
|
||||
// UploadActivity handles FIT file uploads
|
||||
func (c *Client) UploadActivity(ctx context.Context, fitFile []byte) (int64, error) {
|
||||
path := "/upload-service/upload/.fit"
|
||||
|
||||
// Validate FIT file
|
||||
if valid := fit.Validate(fitFile); !valid {
|
||||
return 0, fmt.Errorf("invalid FIT file: signature verification failed")
|
||||
if err := fit.ValidateFIT(fitFile); err != nil {
|
||||
return 0, fmt.Errorf("invalid FIT file: %w", err)
|
||||
}
|
||||
|
||||
// Prepare multipart form
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
part, err := writer.CreateFormFile("file", "activity.fit")
|
||||
// Refresh token if needed
|
||||
if err := c.refreshTokenIfNeeded(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
path := "/upload-service/upload/.fit"
|
||||
|
||||
resp, err := c.HTTPClient.R().
|
||||
SetContext(ctx).
|
||||
SetFileReader("file", "activity.fit", bytes.NewReader(fitFile)).
|
||||
SetHeader("Content-Type", "multipart/form-data").
|
||||
Post(path)
|
||||
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if _, err = io.Copy(part, bytes.NewReader(fitFile)); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
writer.Close()
|
||||
|
||||
fullURL := c.baseURL.ResolveReference(&url.URL{Path: path}).String()
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", fullURL, body)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
if resp.StatusCode() == http.StatusUnauthorized {
|
||||
return 0, errors.New("token expired, please reauthenticate")
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
return 0, fmt.Errorf("upload failed with status %d", resp.StatusCode)
|
||||
if resp.StatusCode() >= 400 {
|
||||
return 0, handleAPIError(resp)
|
||||
}
|
||||
|
||||
// Parse response to get activity ID
|
||||
var result struct {
|
||||
ActivityID int64 `json:"activityId"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
if err := json.Unmarshal(resp.Body(), &result); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
@@ -274,35 +267,29 @@ func (c *Client) UploadActivity(ctx context.Context, fitFile []byte) (int64, err
|
||||
|
||||
// DownloadActivity retrieves a FIT file for an activity
|
||||
func (c *Client) DownloadActivity(ctx context.Context, activityID int64) ([]byte, error) {
|
||||
// Refresh token if needed
|
||||
if err := c.refreshTokenIfNeeded(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("/download-service/export/activity/%d", activityID)
|
||||
|
||||
fullURL := c.baseURL.ResolveReference(&url.URL{Path: path}).String()
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", fullURL, nil)
|
||||
|
||||
resp, err := c.HTTPClient.R().
|
||||
SetContext(ctx).
|
||||
SetHeader("Accept", "application/fit").
|
||||
Get(path)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/fit")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("download failed with status %d", resp.StatusCode)
|
||||
if resp.StatusCode() == http.StatusUnauthorized {
|
||||
return nil, errors.New("token expired, please reauthenticate")
|
||||
}
|
||||
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
// Validate FIT file structure
|
||||
func ValidateFIT(fitFile []byte) error {
|
||||
if len(fitFile) < fit.MinFileSize() {
|
||||
return fmt.Errorf("file too small to be a valid FIT file")
|
||||
}
|
||||
if string(fitFile[8:12]) != ".FIT" {
|
||||
return fmt.Errorf("invalid FIT file signature")
|
||||
}
|
||||
return nil
|
||||
if resp.StatusCode() >= 400 {
|
||||
return nil, handleAPIError(resp)
|
||||
}
|
||||
|
||||
return resp.Body(), nil
|
||||
}
|
||||
|
||||
@@ -2,15 +2,24 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sstent/go-garminconnect/internal/auth/garth"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TEST PROGRESS:
|
||||
// - [ ] Move ValidateFIT to internal/fit package
|
||||
// - [ ] Create unified mock server implementation
|
||||
// - [ ] Extend mock server for upload handler
|
||||
// - [ ] Remove ValidateFIT from this file
|
||||
// - [ ] Create shared test helper package
|
||||
|
||||
// TestGetActivities is now part of table-driven tests below
|
||||
|
||||
func TestActivitiesEndpoints(t *testing.T) {
|
||||
@@ -18,11 +27,15 @@ func TestActivitiesEndpoints(t *testing.T) {
|
||||
mockServer := NewMockServer()
|
||||
defer mockServer.Close()
|
||||
|
||||
// Create client with mock server URL
|
||||
client, err := NewClient(mockServer.URL(), nil)
|
||||
// Create a mock session
|
||||
session := &garth.Session{OAuth2Token: "test-token"}
|
||||
|
||||
// Create client with mock server URL and session
|
||||
client, err := NewClient(session, "")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create client: %v", err)
|
||||
}
|
||||
client.HTTPClient.SetBaseURL(mockServer.URL())
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
@@ -126,6 +139,7 @@ func TestActivitiesEndpoints(t *testing.T) {
|
||||
Name: fmt.Sprintf("Activity %d", i+1),
|
||||
})
|
||||
}
|
||||
|
||||
mockServer.SetActivitiesHandler(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(ActivitiesResponse{
|
||||
Activities: activities,
|
||||
@@ -136,7 +150,7 @@ func TestActivitiesEndpoints(t *testing.T) {
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
result, pagination, err := client.GetActivities(context.Background(), 1, 500)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, result, 500)
|
||||
@@ -201,38 +215,3 @@ func TestActivitiesEndpoints(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFIT(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
data []byte
|
||||
expected error
|
||||
}{
|
||||
{
|
||||
name: "ValidFIT",
|
||||
data: []byte{0x0E, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, '.', 'F', 'I', 'T', 0x00, 0x00},
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "TooSmall",
|
||||
data: []byte{0x0E},
|
||||
expected: fmt.Errorf("file too small to be a valid FIT file"),
|
||||
},
|
||||
{
|
||||
name: "InvalidSignature",
|
||||
data: []byte{0x0E, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 'I', 'N', 'V', 'L', 0x00, 0x00},
|
||||
expected: fmt.Errorf("invalid FIT file signature"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := ValidateFIT(tc.data)
|
||||
if tc.expected == nil {
|
||||
assert.NoError(t, err)
|
||||
} else {
|
||||
assert.EqualError(t, err, tc.expected.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,25 +10,22 @@ import (
|
||||
func (c *Client) GetBodyComposition(ctx context.Context, req BodyCompositionRequest) ([]BodyComposition, error) {
|
||||
// Validate date range
|
||||
if req.StartDate.IsZero() || req.EndDate.IsZero() || req.StartDate.After(req.EndDate) {
|
||||
return nil, fmt.Errorf("invalid date range: start %s to end %s",
|
||||
req.StartDate.Format("2006-01-02"),
|
||||
return nil, fmt.Errorf("invalid date range: start %s to end %s",
|
||||
req.StartDate.Format("2006-01-02"),
|
||||
req.EndDate.Format("2006-01-02"))
|
||||
}
|
||||
|
||||
// Build URL with query parameters
|
||||
u := c.baseURL.ResolveReference(&url.URL{
|
||||
Path: "/body-composition",
|
||||
RawQuery: fmt.Sprintf("startDate=%s&endDate=%s",
|
||||
req.StartDate.Format("2006-01-02"),
|
||||
req.EndDate.Format("2006-01-02"),
|
||||
),
|
||||
})
|
||||
// Build query parameters
|
||||
params := url.Values{}
|
||||
params.Add("startDate", req.StartDate.Format("2006-01-02"))
|
||||
params.Add("endDate", req.EndDate.Format("2006-01-02"))
|
||||
path := fmt.Sprintf("/body-composition?%s", params.Encode())
|
||||
|
||||
// Execute GET request
|
||||
var results []BodyComposition
|
||||
err := c.Get(ctx, u.String(), &results)
|
||||
err := c.Get(ctx, path, &results)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("failed to get body composition: %w", err)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
|
||||
@@ -7,20 +7,25 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sstent/go-garminconnect/internal/auth/garth"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestGetBodyComposition(t *testing.T) {
|
||||
// Create test server for mocking API responses
|
||||
// Create mock session
|
||||
session := &garth.Session{OAuth2Token: "valid-token"}
|
||||
|
||||
// Create test server for mocking API responses
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/body-composition?startDate=2023-01-01&endDate=2023-01-31", r.URL.String())
|
||||
|
||||
|
||||
// Return different responses based on test cases
|
||||
if r.Header.Get("Authorization") == "Bearer invalid-token" {
|
||||
if r.Header.Get("Authorization") != "Bearer valid-token" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if r.URL.Query().Get("startDate") == "2023-02-01" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
@@ -41,8 +46,9 @@ func TestGetBodyComposition(t *testing.T) {
|
||||
defer server.Close()
|
||||
|
||||
// Setup client with test server
|
||||
client := NewClient(server.URL, "valid-token")
|
||||
|
||||
client, _ := NewClient(session, "")
|
||||
client.HTTPClient.SetBaseURL(server.URL)
|
||||
|
||||
// Test cases
|
||||
testCases := []struct {
|
||||
name string
|
||||
@@ -54,12 +60,14 @@ func TestGetBodyComposition(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "Successful request",
|
||||
token: "valid-token",
|
||||
token: "valid-token", // Test case doesn't actually change client token now
|
||||
start: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
end: time.Date(2023, 1, 31, 0, 0, 0, 0, time.UTC),
|
||||
expectError: false,
|
||||
expectedLen: 1,
|
||||
},
|
||||
// Unauthorized test case is handled by the mock server's token check
|
||||
// We need to create a new client with invalid token
|
||||
{
|
||||
name: "Unauthorized access",
|
||||
token: "invalid-token",
|
||||
@@ -78,7 +86,18 @@ func TestGetBodyComposition(t *testing.T) {
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
client.token = tc.token
|
||||
// For unauthorized test, create a separate client
|
||||
if tc.token == "invalid-token" {
|
||||
invalidSession := &garth.Session{OAuth2Token: "invalid-token"}
|
||||
invalidClient, _ := NewClient(invalidSession, "")
|
||||
invalidClient.HTTPClient.SetBaseURL(server.URL)
|
||||
client = invalidClient
|
||||
} else {
|
||||
validSession := &garth.Session{OAuth2Token: "valid-token"}
|
||||
validClient, _ := NewClient(validSession, "")
|
||||
validClient.HTTPClient.SetBaseURL(server.URL)
|
||||
client = validClient
|
||||
}
|
||||
results, err := client.GetBodyComposition(context.Background(), BodyCompositionRequest{
|
||||
StartDate: Time(tc.start),
|
||||
EndDate: Time(tc.end),
|
||||
@@ -91,7 +110,7 @@ func TestGetBodyComposition(t *testing.T) {
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, results, tc.expectedLen)
|
||||
|
||||
|
||||
if tc.expectedLen > 0 {
|
||||
result := results[0]
|
||||
assert.Equal(t, 2.8, result.BoneMass)
|
||||
|
||||
@@ -3,137 +3,123 @@ package api
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
"github.com/go-resty/resty/v2"
|
||||
"github.com/sstent/go-garminconnect/internal/auth/garth"
|
||||
)
|
||||
|
||||
const BaseURL = "https://connect.garmin.com/modern/proxy"
|
||||
|
||||
// Client handles communication with the Garmin Connect API
|
||||
type Client struct {
|
||||
baseURL *url.URL
|
||||
httpClient *http.Client
|
||||
limiter *rate.Limiter
|
||||
logger Logger
|
||||
token string
|
||||
HTTPClient *resty.Client
|
||||
sessionPath string
|
||||
session *garth.Session
|
||||
}
|
||||
|
||||
// NewClient creates a new API client
|
||||
func NewClient(token string) (*Client, error) {
|
||||
u, err := url.Parse(BaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid base URL: %w", err)
|
||||
// NewClient creates a new API client with session management
|
||||
func NewClient(session *garth.Session, sessionPath string) (*Client, error) {
|
||||
if session == nil {
|
||||
return nil, errors.New("session is required")
|
||||
}
|
||||
|
||||
httpClient := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
client := resty.New()
|
||||
client.SetTimeout(30 * time.Second)
|
||||
client.SetHeader("Authorization", "Bearer "+session.OAuth2Token)
|
||||
client.SetHeader("User-Agent", "go-garminconnect/1.0")
|
||||
client.SetHeader("Content-Type", "application/json")
|
||||
client.SetHeader("Accept", "application/json")
|
||||
|
||||
return &Client{
|
||||
baseURL: u,
|
||||
httpClient: httpClient,
|
||||
limiter: rate.NewLimiter(rate.Every(time.Second/10), 10), // 10 requests per second
|
||||
logger: &stdLogger{},
|
||||
token: token,
|
||||
HTTPClient: client,
|
||||
sessionPath: sessionPath,
|
||||
session: session,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetLogger sets the client's logger
|
||||
func (c *Client) SetLogger(logger Logger) {
|
||||
c.logger = logger
|
||||
}
|
||||
|
||||
// SetRateLimit configures the rate limiter
|
||||
func (c *Client) SetRateLimit(interval time.Duration, burst int) {
|
||||
c.limiter = rate.NewLimiter(rate.Every(interval), burst)
|
||||
}
|
||||
|
||||
// setAuthHeaders adds authorization headers to requests
|
||||
func (c *Client) setAuthHeaders(req *http.Request) {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
req.Header.Set("User-Agent", "go-garminconnect/1.0")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
}
|
||||
|
||||
// doRequest executes API requests with rate limiting and authentication
|
||||
func (c *Client) doRequest(ctx context.Context, method, path string, body io.Reader, v interface{}) error {
|
||||
// Wait for rate limiter
|
||||
if err := c.limiter.Wait(ctx); err != nil {
|
||||
return fmt.Errorf("rate limit wait failed: %w", err)
|
||||
// Get performs a GET request with automatic token refresh
|
||||
func (c *Client) Get(ctx context.Context, path string, v interface{}) error {
|
||||
// Refresh token if needed
|
||||
if err := c.refreshTokenIfNeeded(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Build full URL
|
||||
fullURL := c.baseURL.ResolveReference(&url.URL{Path: path}).String()
|
||||
resp, err := c.HTTPClient.R().
|
||||
SetContext(ctx).
|
||||
SetResult(v).
|
||||
Get(path)
|
||||
|
||||
// Create request
|
||||
req, err := http.NewRequestWithContext(ctx, method, fullURL, body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request failed: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Add authentication headers
|
||||
c.setAuthHeaders(req)
|
||||
|
||||
// Execute request
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed: %w", err)
|
||||
if resp.StatusCode() == http.StatusUnauthorized {
|
||||
// Force token refresh on next attempt
|
||||
c.session = nil
|
||||
return errors.New("token expired, please reauthenticate")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Handle error responses
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
if resp.StatusCode() >= 400 {
|
||||
return handleAPIError(resp)
|
||||
}
|
||||
|
||||
// Parse successful response
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Post performs a POST request
|
||||
func (c *Client) Post(ctx context.Context, path string, body interface{}, v interface{}) error {
|
||||
resp, err := c.HTTPClient.R().
|
||||
SetContext(ctx).
|
||||
SetBody(body).
|
||||
SetResult(v).
|
||||
Post(path)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if resp.StatusCode() >= 400 {
|
||||
return handleAPIError(resp)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// refreshTokenIfNeeded refreshes the token if expired
|
||||
func (c *Client) refreshTokenIfNeeded() error {
|
||||
if c.session == nil || !c.session.IsExpired() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return json.NewDecoder(resp.Body).Decode(v)
|
||||
if c.sessionPath == "" {
|
||||
return errors.New("session path not configured for refresh")
|
||||
}
|
||||
|
||||
session, err := garth.LoadSession(c.sessionPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load session for refresh: %w", err)
|
||||
}
|
||||
|
||||
if session.IsExpired() {
|
||||
return errors.New("session expired, please reauthenticate")
|
||||
}
|
||||
|
||||
c.session = session
|
||||
c.HTTPClient.SetHeader("Authorization", "Bearer "+session.OAuth2Token)
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleAPIError processes non-200 responses
|
||||
func handleAPIError(resp *http.Response) error {
|
||||
func handleAPIError(resp *resty.Response) error {
|
||||
errorResponse := struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}{}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&errorResponse); err == nil {
|
||||
if err := json.Unmarshal(resp.Body(), &errorResponse); err == nil {
|
||||
return fmt.Errorf("API error %d: %s", errorResponse.Code, errorResponse.Message)
|
||||
}
|
||||
|
||||
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
||||
return fmt.Errorf("unexpected status code: %d", resp.StatusCode())
|
||||
}
|
||||
|
||||
// Get performs a GET request
|
||||
func (c *Client) Get(ctx context.Context, path string, v interface{}) error {
|
||||
return c.doRequest(ctx, http.MethodGet, path, nil, v)
|
||||
}
|
||||
|
||||
// Post performs a POST request
|
||||
func (c *Client) Post(ctx context.Context, path string, body io.Reader, v interface{}) error {
|
||||
return c.doRequest(ctx, http.MethodPost, path, body, v)
|
||||
}
|
||||
|
||||
// Logger defines the logging interface
|
||||
type Logger interface {
|
||||
Debugf(format string, args ...interface{})
|
||||
Infof(format string, args ...interface{})
|
||||
Errorf(format string, args ...interface{})
|
||||
}
|
||||
|
||||
// stdLogger is the default logger
|
||||
type stdLogger struct{}
|
||||
|
||||
func (l *stdLogger) Debugf(format string, args ...interface{}) {}
|
||||
func (l *stdLogger) Infof(format string, args ...interface{}) {}
|
||||
func (l *stdLogger) Errorf(format string, args ...interface{}) {}
|
||||
|
||||
@@ -10,29 +10,29 @@ import (
|
||||
|
||||
// GearStats represents detailed statistics for a gear item
|
||||
type GearStats struct {
|
||||
UUID string `json:"uuid"` // Unique identifier for the gear item
|
||||
Name string `json:"name"` // Display name of the gear item
|
||||
Distance float64 `json:"distance"` // in meters
|
||||
TotalActivities int `json:"totalActivities"` // number of activities
|
||||
TotalTime int `json:"totalTime"` // in seconds
|
||||
Calories int `json:"calories"` // total calories
|
||||
ElevationGain float64 `json:"elevationGain"` // in meters
|
||||
ElevationLoss float64 `json:"elevationLoss"` // in meters
|
||||
UUID string `json:"uuid"` // Unique identifier for the gear item
|
||||
Name string `json:"name"` // Display name of the gear item
|
||||
Distance float64 `json:"distance"` // in meters
|
||||
TotalActivities int `json:"totalActivities"` // number of activities
|
||||
TotalTime int `json:"totalTime"` // in seconds
|
||||
Calories int `json:"calories"` // total calories
|
||||
ElevationGain float64 `json:"elevationGain"` // in meters
|
||||
ElevationLoss float64 `json:"elevationLoss"` // in meters
|
||||
}
|
||||
|
||||
// GearActivity represents a simplified activity linked to a gear item
|
||||
type GearActivity struct {
|
||||
ActivityID int64 `json:"activityId"` // Activity identifier
|
||||
ActivityName string `json:"activityName"` // Name of the activity
|
||||
StartTime time.Time `json:"startTimeLocal"` // Local start time of the activity
|
||||
Duration int `json:"duration"` // Duration in seconds
|
||||
Distance float64 `json:"distance"` // Distance in meters
|
||||
ActivityID int64 `json:"activityId"` // Activity identifier
|
||||
ActivityName string `json:"activityName"` // Name of the activity
|
||||
StartTime time.Time `json:"startTimeLocal"` // Local start time of the activity
|
||||
Duration int `json:"duration"` // Duration in seconds
|
||||
Distance float64 `json:"distance"` // Distance in meters
|
||||
}
|
||||
|
||||
// GetGearStats retrieves statistics for a specific gear item by its UUID
|
||||
func (c *Client) GetGearStats(ctx context.Context, gearUUID string) (GearStats, error) {
|
||||
endpoint := fmt.Sprintf("/gear-service/stats/%s", gearUUID)
|
||||
|
||||
|
||||
var stats GearStats
|
||||
err := c.Get(ctx, endpoint, &stats)
|
||||
if err != nil {
|
||||
@@ -44,20 +44,15 @@ func (c *Client) GetGearStats(ctx context.Context, gearUUID string) (GearStats,
|
||||
|
||||
// GetGearActivities retrieves paginated activities associated with a gear item
|
||||
func (c *Client) GetGearActivities(ctx context.Context, gearUUID string, start, limit int) ([]GearActivity, error) {
|
||||
endpoint := fmt.Sprintf("/gear-service/activities/%s", gearUUID)
|
||||
path := fmt.Sprintf("/gear-service/activities/%s", gearUUID)
|
||||
params := url.Values{}
|
||||
params.Add("start", strconv.Itoa(start))
|
||||
params.Add("limit", strconv.Itoa(limit))
|
||||
|
||||
u := c.baseURL.ResolveReference(&url.URL{
|
||||
Path: endpoint,
|
||||
RawQuery: params.Encode(),
|
||||
})
|
||||
|
||||
|
||||
var activities []GearActivity
|
||||
err := c.Get(ctx, u.String(), &activities)
|
||||
err := c.Get(ctx, fmt.Sprintf("%s?%s", path, params.Encode()), &activities)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("failed to get gear activities: %w", err)
|
||||
}
|
||||
|
||||
return activities, nil
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sstent/go-garminconnect/internal/auth/garth"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -37,7 +38,7 @@ func TestGearService(t *testing.T) {
|
||||
|
||||
activities := []GearActivity{
|
||||
{ActivityID: 1, ActivityName: "Run 1", StartTime: time.Now(), Duration: 1800, Distance: 5000},
|
||||
{ActivityID: 2, ActivityName: "Run 2", StartTime: time.Now().Add(-24*time.Hour), Duration: 3600, Distance: 10000},
|
||||
{ActivityID: 2, ActivityName: "Run 2", StartTime: time.Now().Add(-24 * time.Hour), Duration: 3600, Distance: 10000},
|
||||
}
|
||||
|
||||
// Simulate pagination
|
||||
@@ -64,36 +65,39 @@ func TestGearService(t *testing.T) {
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// Create mock session
|
||||
session := &garth.Session{OAuth2Token: "test-token"}
|
||||
|
||||
// Create client
|
||||
client, _ := NewClient(srv.URL, http.DefaultClient)
|
||||
client.SetLogger(NewTestLogger(t))
|
||||
client, _ := NewClient(session, "")
|
||||
client.HTTPClient.SetBaseURL(srv.URL)
|
||||
|
||||
t.Run("GetGearStats success", func(t *testing.T) {
|
||||
stats, err := client.GetGearStats("valid-uuid")
|
||||
stats, err := client.GetGearStats(context.Background(), "valid-uuid")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "Test Gear", stats.Name)
|
||||
assert.Equal(t, 1500.5, stats.Distance)
|
||||
})
|
||||
|
||||
t.Run("GetGearStats not found", func(t *testing.T) {
|
||||
_, err := client.GetGearStats("invalid-uuid")
|
||||
_, err := client.GetGearStats(context.Background(), "invalid-uuid")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "status code: 404")
|
||||
assert.Contains(t, err.Error(), "API error")
|
||||
})
|
||||
|
||||
t.Run("GetGearActivities pagination", func(t *testing.T) {
|
||||
activities, err := client.GetGearActivities("valid-uuid", 0, 1)
|
||||
activities, err := client.GetGearActivities(context.Background(), "valid-uuid", 0, 1)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, activities, 1)
|
||||
assert.Equal(t, "Run 1", activities[0].ActivityName)
|
||||
|
||||
activities, err = client.GetGearActivities("valid-uuid", 1, 1)
|
||||
activities, err = client.GetGearActivities(context.Background(), "valid-uuid", 1, 1)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, activities, 1)
|
||||
assert.Equal(t, "Run 2", activities[0].ActivityName)
|
||||
|
||||
_, err = client.GetGearActivities("invalid-uuid", 0, 10)
|
||||
|
||||
_, err = client.GetGearActivities(context.Background(), "invalid-uuid", 0, 10)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "status code: 404")
|
||||
assert.Contains(t, err.Error(), "API error")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,10 +8,10 @@ import (
|
||||
|
||||
// SleepData represents a user's sleep information
|
||||
type SleepData struct {
|
||||
Date time.Time `json:"date"`
|
||||
Duration float64 `json:"duration"` // in minutes
|
||||
Quality float64 `json:"quality"` // 0-100 scale
|
||||
SleepStages struct {
|
||||
Date time.Time `json:"date"`
|
||||
Duration float64 `json:"duration"` // in minutes
|
||||
Quality float64 `json:"quality"` // 0-100 scale
|
||||
SleepStages struct {
|
||||
Deep float64 `json:"deep"`
|
||||
Light float64 `json:"light"`
|
||||
REM float64 `json:"rem"`
|
||||
@@ -21,26 +21,26 @@ type SleepData struct {
|
||||
|
||||
// HRVData represents Heart Rate Variability data
|
||||
type HRVData struct {
|
||||
Date time.Time `json:"date"`
|
||||
RestingHrv float64 `json:"restingHrv"` // in milliseconds
|
||||
WeeklyAvg float64 `json:"weeklyAvg"`
|
||||
LastNightAvg float64 `json:"lastNightAvg"`
|
||||
Date time.Time `json:"date"`
|
||||
RestingHrv float64 `json:"restingHrv"` // in milliseconds
|
||||
WeeklyAvg float64 `json:"weeklyAvg"`
|
||||
LastNightAvg float64 `json:"lastNightAvg"`
|
||||
}
|
||||
|
||||
// BodyBatteryData represents Garmin's Body Battery energy metric
|
||||
type BodyBatteryData struct {
|
||||
Date time.Time `json:"date"`
|
||||
Charged int `json:"charged"` // 0-100 scale
|
||||
Drained int `json:"drained"` // 0-100 scale
|
||||
Highest int `json:"highest"` // highest value of the day
|
||||
Lowest int `json:"lowest"` // lowest value of the day
|
||||
Date time.Time `json:"date"`
|
||||
Charged int `json:"charged"` // 0-100 scale
|
||||
Drained int `json:"drained"` // 0-100 scale
|
||||
Highest int `json:"highest"` // highest value of the day
|
||||
Lowest int `json:"lowest"` // lowest value of the day
|
||||
}
|
||||
|
||||
// GetSleepData retrieves sleep data for a specific date
|
||||
func (c *Client) GetSleepData(ctx context.Context, date time.Time) (*SleepData, error) {
|
||||
var data SleepData
|
||||
path := fmt.Sprintf("/wellness-service/sleep/daily/%s", date.Format("2006-01-02"))
|
||||
|
||||
|
||||
if err := c.Get(ctx, path, &data); err != nil {
|
||||
return nil, fmt.Errorf("failed to get sleep data: %w", err)
|
||||
}
|
||||
@@ -51,7 +51,7 @@ func (c *Client) GetSleepData(ctx context.Context, date time.Time) (*SleepData,
|
||||
func (c *Client) GetHRVData(ctx context.Context, date time.Time) (*HRVData, error) {
|
||||
var data HRVData
|
||||
path := fmt.Sprintf("/hrv-service/hrv/%s", date.Format("2006-01-02"))
|
||||
|
||||
|
||||
if err := c.Get(ctx, path, &data); err != nil {
|
||||
return nil, fmt.Errorf("failed to get HRV data: %w", err)
|
||||
}
|
||||
@@ -62,7 +62,7 @@ func (c *Client) GetHRVData(ctx context.Context, date time.Time) (*HRVData, erro
|
||||
func (c *Client) GetBodyBatteryData(ctx context.Context, date time.Time) (*BodyBatteryData, error) {
|
||||
var data BodyBatteryData
|
||||
path := fmt.Sprintf("/bodybattery-service/bodybattery/%s", date.Format("2006-01-02"))
|
||||
|
||||
|
||||
if err := c.Get(ctx, path, &data); err != nil {
|
||||
return nil, fmt.Errorf("failed to get Body Battery data: %w", err)
|
||||
}
|
||||
|
||||
@@ -14,11 +14,11 @@ import (
|
||||
func BenchmarkGetSleepData(b *testing.B) {
|
||||
now := time.Now()
|
||||
testDate := now.Format("2006-01-02")
|
||||
|
||||
|
||||
// Create test server
|
||||
mockServer := NewMockServer()
|
||||
defer mockServer.Close()
|
||||
|
||||
|
||||
// Setup successful response
|
||||
mockResponse := map[string]interface{}{
|
||||
"date": testDate,
|
||||
@@ -47,11 +47,11 @@ func BenchmarkGetSleepData(b *testing.B) {
|
||||
func BenchmarkGetHRVData(b *testing.B) {
|
||||
now := time.Now()
|
||||
testDate := now.Format("2006-01-02")
|
||||
|
||||
|
||||
// Create test server
|
||||
mockServer := NewMockServer()
|
||||
defer mockServer.Close()
|
||||
|
||||
|
||||
// Setup successful response
|
||||
mockResponse := map[string]interface{}{
|
||||
"date": testDate,
|
||||
@@ -75,11 +75,11 @@ func BenchmarkGetHRVData(b *testing.B) {
|
||||
func BenchmarkGetBodyBatteryData(b *testing.B) {
|
||||
now := time.Now()
|
||||
testDate := now.Format("2006-01-02")
|
||||
|
||||
|
||||
// Create test server
|
||||
mockServer := NewMockServer()
|
||||
defer mockServer.Close()
|
||||
|
||||
|
||||
// Setup successful response
|
||||
mockResponse := map[string]interface{}{
|
||||
"date": testDate,
|
||||
@@ -103,7 +103,7 @@ func BenchmarkGetBodyBatteryData(b *testing.B) {
|
||||
func TestGetSleepData(t *testing.T) {
|
||||
now := time.Now()
|
||||
testDate := now.Format("2006-01-02")
|
||||
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
date time.Time
|
||||
@@ -191,7 +191,7 @@ func TestGetSleepData(t *testing.T) {
|
||||
func TestGetHRVData(t *testing.T) {
|
||||
now := time.Now()
|
||||
testDate := now.Format("2006-01-02")
|
||||
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
date time.Time
|
||||
@@ -211,9 +211,9 @@ func TestGetHRVData(t *testing.T) {
|
||||
},
|
||||
mockStatus: http.StatusOK,
|
||||
expected: &HRVData{
|
||||
Date: now.Truncate(24 * time.Hour),
|
||||
RestingHrv: 65.0,
|
||||
WeeklyAvg: 62.0,
|
||||
Date: now.Truncate(24 * time.Hour),
|
||||
RestingHrv: 65.0,
|
||||
WeeklyAvg: 62.0,
|
||||
LastNightAvg: 68.0,
|
||||
},
|
||||
},
|
||||
@@ -255,7 +255,7 @@ func TestGetHRVData(t *testing.T) {
|
||||
func TestGetBodyBatteryData(t *testing.T) {
|
||||
now := time.Now()
|
||||
testDate := now.Format("2006-01-02")
|
||||
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
date time.Time
|
||||
|
||||
@@ -15,12 +15,12 @@ type MockServer struct {
|
||||
mu sync.Mutex
|
||||
|
||||
// Endpoint handlers
|
||||
activitiesHandler http.HandlerFunc
|
||||
activitiesHandler http.HandlerFunc
|
||||
activityDetailsHandler http.HandlerFunc
|
||||
uploadHandler http.HandlerFunc
|
||||
userHandler http.HandlerFunc
|
||||
healthHandler http.HandlerFunc
|
||||
authHandler http.HandlerFunc
|
||||
uploadHandler http.HandlerFunc
|
||||
userHandler http.HandlerFunc
|
||||
healthHandler http.HandlerFunc
|
||||
authHandler http.HandlerFunc
|
||||
}
|
||||
|
||||
// NewMockServer creates a new mock Garmin Connect server
|
||||
@@ -67,6 +67,13 @@ func (m *MockServer) SetActivitiesHandler(handler http.HandlerFunc) {
|
||||
m.activitiesHandler = handler
|
||||
}
|
||||
|
||||
// SetUploadHandler sets a custom handler for upload endpoint
|
||||
func (m *MockServer) SetUploadHandler(handler http.HandlerFunc) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.uploadHandler = handler
|
||||
}
|
||||
|
||||
// Default handler implementations would follow for each endpoint
|
||||
// ...
|
||||
|
||||
|
||||
@@ -24,11 +24,11 @@ func (t Time) Format(layout string) string {
|
||||
|
||||
// BodyComposition represents body composition metrics from Garmin Connect
|
||||
type BodyComposition struct {
|
||||
BoneMass float64 `json:"boneMass"` // Grams
|
||||
MuscleMass float64 `json:"muscleMass"` // Grams
|
||||
BodyFat float64 `json:"bodyFat"` // Percentage
|
||||
Hydration float64 `json:"hydration"` // Percentage
|
||||
Timestamp Time `json:"timestamp"` // Measurement time
|
||||
BoneMass float64 `json:"boneMass"` // Grams
|
||||
MuscleMass float64 `json:"muscleMass"` // Grams
|
||||
BodyFat float64 `json:"bodyFat"` // Percentage
|
||||
Hydration float64 `json:"hydration"` // Percentage
|
||||
Timestamp Time `json:"timestamp"` // Measurement time
|
||||
}
|
||||
|
||||
// BodyCompositionRequest defines parameters for body composition API requests
|
||||
|
||||
@@ -8,17 +8,17 @@ import (
|
||||
|
||||
// UserProfile represents a Garmin Connect user profile
|
||||
type UserProfile struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
FullName string `json:"fullName"`
|
||||
EmailAddress string `json:"emailAddress"`
|
||||
Username string `json:"username"`
|
||||
ProfileID string `json:"profileId"`
|
||||
ProfileImage string `json:"profileImageUrlLarge"`
|
||||
Location string `json:"location"`
|
||||
FitnessLevel string `json:"fitnessLevel"`
|
||||
Height float64 `json:"height"`
|
||||
Weight float64 `json:"weight"`
|
||||
Birthdate string `json:"birthDate"`
|
||||
DisplayName string `json:"displayName"`
|
||||
FullName string `json:"fullName"`
|
||||
EmailAddress string `json:"emailAddress"`
|
||||
Username string `json:"username"`
|
||||
ProfileID string `json:"profileId"`
|
||||
ProfileImage string `json:"profileImageUrlLarge"`
|
||||
Location string `json:"location"`
|
||||
FitnessLevel string `json:"fitnessLevel"`
|
||||
Height float64 `json:"height"`
|
||||
Weight float64 `json:"weight"`
|
||||
Birthdate string `json:"birthDate"`
|
||||
}
|
||||
|
||||
// UserStats represents fitness statistics for a user
|
||||
@@ -35,16 +35,16 @@ type UserStats struct {
|
||||
func (c *Client) GetUserProfile(ctx context.Context) (*UserProfile, error) {
|
||||
var profile UserProfile
|
||||
path := "/userprofile-service/socialProfile"
|
||||
|
||||
|
||||
if err := c.Get(ctx, path, &profile); err != nil {
|
||||
return nil, fmt.Errorf("failed to get user profile: %w", err)
|
||||
}
|
||||
|
||||
|
||||
// Handle empty profile response
|
||||
if profile.ProfileID == "" {
|
||||
return nil, fmt.Errorf("user profile not found")
|
||||
}
|
||||
|
||||
|
||||
return &profile, nil
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ func (c *Client) GetUserProfile(ctx context.Context) (*UserProfile, error) {
|
||||
func (c *Client) GetUserStats(ctx context.Context, date time.Time) (*UserStats, error) {
|
||||
var stats UserStats
|
||||
path := fmt.Sprintf("/stats-service/stats/daily/%s", date.Format("2006-01-02"))
|
||||
|
||||
|
||||
if err := c.Get(ctx, path, &stats); err != nil {
|
||||
return nil, fmt.Errorf("failed to get user stats: %w", err)
|
||||
}
|
||||
|
||||
@@ -22,31 +22,31 @@ func TestGetUserProfile(t *testing.T) {
|
||||
{
|
||||
name: "successful profile retrieval",
|
||||
mockResponse: map[string]interface{}{
|
||||
"displayName": "John Doe",
|
||||
"fullName": "John Michael Doe",
|
||||
"emailAddress": "john.doe@example.com",
|
||||
"username": "johndoe",
|
||||
"profileId": "123456",
|
||||
"displayName": "John Doe",
|
||||
"fullName": "John Michael Doe",
|
||||
"emailAddress": "john.doe@example.com",
|
||||
"username": "johndoe",
|
||||
"profileId": "123456",
|
||||
"profileImageUrlLarge": "https://example.com/profile.jpg",
|
||||
"location": "San Francisco, CA",
|
||||
"fitnessLevel": "INTERMEDIATE",
|
||||
"height": 180.0,
|
||||
"weight": 75.0,
|
||||
"birthDate": "1985-01-01",
|
||||
"location": "San Francisco, CA",
|
||||
"fitnessLevel": "INTERMEDIATE",
|
||||
"height": 180.0,
|
||||
"weight": 75.0,
|
||||
"birthDate": "1985-01-01",
|
||||
},
|
||||
mockStatus: http.StatusOK,
|
||||
expected: &UserProfile{
|
||||
DisplayName: "John Doe",
|
||||
FullName: "John Michael Doe",
|
||||
EmailAddress: "john.doe@example.com",
|
||||
Username: "johndoe",
|
||||
ProfileID: "123456",
|
||||
ProfileImage: "https://example.com/profile.jpg",
|
||||
Location: "San Francisco, CA",
|
||||
FitnessLevel: "INTERMEDIATE",
|
||||
Height: 180.0,
|
||||
Weight: 75.0,
|
||||
Birthdate: "1985-01-01",
|
||||
DisplayName: "John Doe",
|
||||
FullName: "John Michael Doe",
|
||||
EmailAddress: "john.doe@example.com",
|
||||
Username: "johndoe",
|
||||
ProfileID: "123456",
|
||||
ProfileImage: "https://example.com/profile.jpg",
|
||||
Location: "San Francisco, CA",
|
||||
FitnessLevel: "INTERMEDIATE",
|
||||
Height: 180.0,
|
||||
Weight: 75.0,
|
||||
Birthdate: "1985-01-01",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -109,20 +109,20 @@ func BenchmarkGetUserProfile(b *testing.B) {
|
||||
// Create test server
|
||||
mockServer := NewMockServer()
|
||||
defer mockServer.Close()
|
||||
|
||||
|
||||
// Setup successful response
|
||||
mockResponse := map[string]interface{}{
|
||||
"displayName": "Benchmark User",
|
||||
"fullName": "Benchmark User Full",
|
||||
"emailAddress": "benchmark@example.com",
|
||||
"username": "benchmark",
|
||||
"profileId": "benchmark-123",
|
||||
"displayName": "Benchmark User",
|
||||
"fullName": "Benchmark User Full",
|
||||
"emailAddress": "benchmark@example.com",
|
||||
"username": "benchmark",
|
||||
"profileId": "benchmark-123",
|
||||
"profileImageUrlLarge": "https://example.com/benchmark.jpg",
|
||||
"location": "Benchmark City",
|
||||
"fitnessLevel": "ADVANCED",
|
||||
"height": 185.0,
|
||||
"weight": 80.0,
|
||||
"birthDate": "1990-01-01",
|
||||
"location": "Benchmark City",
|
||||
"fitnessLevel": "ADVANCED",
|
||||
"height": 185.0,
|
||||
"weight": 80.0,
|
||||
"birthDate": "1990-01-01",
|
||||
}
|
||||
mockServer.SetResponse("/userprofile-service/socialProfile", http.StatusOK, mockResponse)
|
||||
|
||||
@@ -139,19 +139,19 @@ func BenchmarkGetUserProfile(b *testing.B) {
|
||||
func BenchmarkGetUserStats(b *testing.B) {
|
||||
now := time.Now()
|
||||
testDate := now.Format("2006-01-02")
|
||||
|
||||
|
||||
// Create test server
|
||||
mockServer := NewMockServer()
|
||||
defer mockServer.Close()
|
||||
|
||||
|
||||
// Setup successful response
|
||||
mockResponse := map[string]interface{}{
|
||||
"totalSteps": 15000,
|
||||
"totalDistance": 12000.0,
|
||||
"totalCalories": 3000,
|
||||
"activeMinutes": 60,
|
||||
"totalSteps": 15000,
|
||||
"totalDistance": 12000.0,
|
||||
"totalCalories": 3000,
|
||||
"activeMinutes": 60,
|
||||
"restingHeartRate": 50,
|
||||
"date": testDate,
|
||||
"date": testDate,
|
||||
}
|
||||
path := fmt.Sprintf("/stats-service/stats/daily/%s", now.Format("2006-01-02"))
|
||||
mockServer.SetResponse(path, http.StatusOK, mockResponse)
|
||||
@@ -168,7 +168,7 @@ func BenchmarkGetUserStats(b *testing.B) {
|
||||
func TestGetUserStats(t *testing.T) {
|
||||
now := time.Now()
|
||||
testDate := now.Format("2006-01-02")
|
||||
|
||||
|
||||
// Define test cases
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -182,12 +182,12 @@ func TestGetUserStats(t *testing.T) {
|
||||
name: "successful stats retrieval",
|
||||
date: now,
|
||||
mockResponse: map[string]interface{}{
|
||||
"totalSteps": 10000,
|
||||
"totalDistance": 8500.5,
|
||||
"totalCalories": 2200,
|
||||
"activeMinutes": 45,
|
||||
"totalSteps": 10000,
|
||||
"totalDistance": 8500.5,
|
||||
"totalCalories": 2200,
|
||||
"activeMinutes": 45,
|
||||
"restingHeartRate": 55,
|
||||
"date": testDate,
|
||||
"date": testDate,
|
||||
},
|
||||
mockStatus: http.StatusOK,
|
||||
expected: &UserStats{
|
||||
|
||||
@@ -49,7 +49,7 @@ func (c *AuthClient) fetchLoginParams(ctx context.Context) (lt, execution string
|
||||
// For debugging: Log response status and headers
|
||||
debugLog("Login page response status: %s", resp.Status)
|
||||
debugLog("Login page response headers: %v", resp.Header)
|
||||
|
||||
|
||||
// Write body to debug log if it's not too large
|
||||
if len(body) < 5000 {
|
||||
debugLog("Login page body: %s", body)
|
||||
@@ -83,17 +83,17 @@ func extractParam(pattern, body string) (string, error) {
|
||||
// getBrowserHeaders returns browser-like headers for requests
|
||||
func getBrowserHeaders() http.Header {
|
||||
return http.Header{
|
||||
"User-Agent": {"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"},
|
||||
"Accept": {"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8"},
|
||||
"Accept-Language": {"en-US,en;q=0.9"},
|
||||
"Accept-Encoding": {"gzip, deflate, br"},
|
||||
"Connection": {"keep-alive"},
|
||||
"Cache-Control": {"max-age=0"},
|
||||
"Sec-Fetch-Site": {"none"},
|
||||
"Sec-Fetch-Mode": {"navigate"},
|
||||
"Sec-Fetch-User": {"?1"},
|
||||
"Sec-Fetch-Dest": {"document"},
|
||||
"DNT": {"1"},
|
||||
"User-Agent": {"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"},
|
||||
"Accept": {"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8"},
|
||||
"Accept-Language": {"en-US,en;q=0.9"},
|
||||
"Accept-Encoding": {"gzip, deflate, br"},
|
||||
"Connection": {"keep-alive"},
|
||||
"Cache-Control": {"max-age=0"},
|
||||
"Sec-Fetch-Site": {"none"},
|
||||
"Sec-Fetch-Mode": {"navigate"},
|
||||
"Sec-Fetch-User": {"?1"},
|
||||
"Sec-Fetch-Dest": {"document"},
|
||||
"DNT": {"1"},
|
||||
"Upgrade-Insecure-Requests": {"1"},
|
||||
}
|
||||
}
|
||||
@@ -186,18 +186,19 @@ func (c *AuthClient) Authenticate(ctx context.Context, username, password, mfaTo
|
||||
// Exchange ticket for tokens
|
||||
return c.exchangeTicketForTokens(ctx, authResponse.Ticket)
|
||||
}
|
||||
|
||||
// extractSSOTicket finds the authentication ticket in the SSO response
|
||||
func extractSSOTicket(body string) (string, error) {
|
||||
// The ticket is typically in a hidden input field
|
||||
ticketPattern := `name="ticket"\s+value="([^"]+)"`
|
||||
re := regexp.MustCompile(ticketPattern)
|
||||
matches := re.FindStringSubmatch(body)
|
||||
|
||||
|
||||
if len(matches) < 2 {
|
||||
if strings.Contains(body, "Cloudflare") {
|
||||
return "", errors.New("Cloudflare bot protection triggered")
|
||||
}
|
||||
return "", errors.New("ticket not found in SSO response")
|
||||
return "", errors.New("Cloudflare bot protection triggered")
|
||||
}
|
||||
return "", errors.New("ticket not found in SSO response")
|
||||
}
|
||||
return matches[1], nil
|
||||
}
|
||||
|
||||
@@ -1,309 +1,3 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestTokenRefresh tests the token refresh functionality
|
||||
func TestTokenRefresh(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mockResponse interface{}
|
||||
mockStatus int
|
||||
expectedToken *Token
|
||||
expectedError string
|
||||
}{
|
||||
{
|
||||
name: "successful token refresh",
|
||||
mockResponse: map[string]interface{}{
|
||||
"access_token": "new-access-token",
|
||||
"refresh_token": "new-refresh-token",
|
||||
"expires_in": 3600,
|
||||
"token_type": "Bearer",
|
||||
},
|
||||
mockStatus: http.StatusOK,
|
||||
expectedToken: &Token{
|
||||
AccessToken: "new-access-token",
|
||||
RefreshToken: "new-refresh-token",
|
||||
ExpiresIn: 3600,
|
||||
TokenType: "Bearer",
|
||||
Expiry: time.Now().Add(3600 * time.Second),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "expired refresh token",
|
||||
mockResponse: map[string]interface{}{
|
||||
"error": "invalid_grant",
|
||||
"error_description": "Refresh token expired",
|
||||
},
|
||||
mockStatus: http.StatusBadRequest,
|
||||
expectedError: "token refresh failed with status 400",
|
||||
},
|
||||
{
|
||||
name: "invalid token response",
|
||||
mockResponse: map[string]interface{}{
|
||||
"invalid": "data",
|
||||
},
|
||||
mockStatus: http.StatusOK,
|
||||
expectedError: "token response missing required fields",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create test server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(tt.mockStatus)
|
||||
json.NewEncoder(w).Encode(tt.mockResponse)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Create auth client
|
||||
client := &AuthClient{
|
||||
Client: &http.Client{},
|
||||
TokenURL: server.URL,
|
||||
}
|
||||
|
||||
// Create token to refresh
|
||||
token := &Token{
|
||||
RefreshToken: "old-refresh-token",
|
||||
}
|
||||
|
||||
// Execute test
|
||||
newToken, err := client.RefreshToken(context.Background(), token)
|
||||
|
||||
// Assert results
|
||||
if tt.expectedError != "" {
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.expectedError)
|
||||
assert.Nil(t, newToken)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, newToken)
|
||||
assert.Equal(t, tt.expectedToken.AccessToken, newToken.AccessToken)
|
||||
assert.Equal(t, tt.expectedToken.RefreshToken, newToken.RefreshToken)
|
||||
assert.Equal(t, tt.expectedToken.ExpiresIn, newToken.ExpiresIn)
|
||||
assert.WithinDuration(t, tt.expectedToken.Expiry, newToken.Expiry, 5*time.Second)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMFAAuthentication tests MFA authentication flow
|
||||
func TestMFAAuthentication(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
username string
|
||||
password string
|
||||
mfaToken string
|
||||
mockResponses []mockResponse // Multiple responses for MFA flow
|
||||
expectedToken *Token
|
||||
expectedError string
|
||||
}{
|
||||
{
|
||||
name: "successful MFA authentication",
|
||||
username: "user@example.com",
|
||||
password: "password123",
|
||||
mfaToken: "123456",
|
||||
mockResponses: []mockResponse{
|
||||
{
|
||||
status: http.StatusUnauthorized,
|
||||
body: map[string]interface{}{
|
||||
"mfaToken": "mfa-challenge-token",
|
||||
},
|
||||
},
|
||||
{
|
||||
status: http.StatusOK,
|
||||
body: map[string]interface{}{},
|
||||
cookies: map[string]string{
|
||||
"access_token": "access-token",
|
||||
"refresh_token": "refresh-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedToken: &Token{
|
||||
AccessToken: "access-token",
|
||||
RefreshToken: "refresh-token",
|
||||
ExpiresIn: 3600,
|
||||
TokenType: "Bearer",
|
||||
Expiry: time.Now().Add(3600 * time.Second),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid MFA code",
|
||||
username: "user@example.com",
|
||||
password: "password123",
|
||||
mfaToken: "wrong-code",
|
||||
mockResponses: []mockResponse{
|
||||
{
|
||||
status: http.StatusUnauthorized,
|
||||
body: map[string]interface{}{
|
||||
"mfaToken": "mfa-challenge-token",
|
||||
},
|
||||
},
|
||||
{
|
||||
status: http.StatusUnauthorized,
|
||||
body: map[string]interface{}{
|
||||
"error": "Invalid MFA token",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedError: "authentication failed: 401",
|
||||
},
|
||||
{
|
||||
name: "MFA required but not provided",
|
||||
username: "user@example.com",
|
||||
password: "password123",
|
||||
mfaToken: "",
|
||||
mockResponses: []mockResponse{
|
||||
{
|
||||
status: http.StatusUnauthorized,
|
||||
body: map[string]interface{}{
|
||||
"mfaToken": "mfa-challenge-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedError: "MFA required but no token provided",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create test server with state
|
||||
currentResponse := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if currentResponse < len(tt.mockResponses) {
|
||||
response := tt.mockResponses[currentResponse]
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// Set additional headers if specified
|
||||
for key, value := range response.headers {
|
||||
w.Header().Set(key, value)
|
||||
}
|
||||
// Set cookies if specified
|
||||
for name, value := range response.cookies {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: name,
|
||||
Value: value,
|
||||
})
|
||||
}
|
||||
w.WriteHeader(response.status)
|
||||
json.NewEncoder(w).Encode(response.body)
|
||||
currentResponse++
|
||||
} else {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Create auth client
|
||||
client := &AuthClient{
|
||||
Client: &http.Client{},
|
||||
BaseURL: server.URL,
|
||||
TokenURL: fmt.Sprintf("%s/oauth/token", server.URL),
|
||||
LoginPath: "/sso/login",
|
||||
}
|
||||
|
||||
// Execute test
|
||||
token, err := client.Authenticate(context.Background(), tt.username, tt.password, tt.mfaToken)
|
||||
|
||||
// Assert results
|
||||
if tt.expectedError != "" {
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.expectedError)
|
||||
assert.Nil(t, token)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, token)
|
||||
assert.Equal(t, tt.expectedToken.AccessToken, token.AccessToken)
|
||||
assert.Equal(t, tt.expectedToken.RefreshToken, token.RefreshToken)
|
||||
assert.Equal(t, tt.expectedToken.ExpiresIn, token.ExpiresIn)
|
||||
assert.WithinDuration(t, tt.expectedToken.Expiry, token.Expiry, 5*time.Second)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkTokenRefresh measures the performance of token refresh
|
||||
func BenchmarkTokenRefresh(b *testing.B) {
|
||||
// Create test server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"access_token": "benchmark-access-token",
|
||||
"refresh_token": "benchmark-refresh-token",
|
||||
"expires_in": 3600,
|
||||
"token_type": "Bearer",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Create auth client
|
||||
client := &AuthClient{
|
||||
Client: &http.Client{},
|
||||
TokenURL: server.URL,
|
||||
}
|
||||
|
||||
// Create token to refresh
|
||||
token := &Token{
|
||||
RefreshToken: "benchmark-refresh-token",
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = client.RefreshToken(context.Background(), token)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkMFAAuthentication measures the performance of MFA authentication
|
||||
func BenchmarkMFAAuthentication(b *testing.B) {
|
||||
// Create test server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if r.URL.Path == "/sso/login" {
|
||||
// First request returns MFA challenge
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"mfaToken": "mfa-challenge-token",
|
||||
})
|
||||
} else if r.URL.Path == "/oauth/token" {
|
||||
// Second request returns tokens
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"access_token": "benchmark-access-token",
|
||||
"refresh_token": "benchmark-refresh-token",
|
||||
"expires_in": 3600,
|
||||
"token_type": "Bearer",
|
||||
})
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Create auth client
|
||||
client := &AuthClient{
|
||||
Client: &http.Client{},
|
||||
BaseURL: server.URL,
|
||||
TokenURL: fmt.Sprintf("%s/oauth/token", server.URL),
|
||||
LoginPath: "/sso/login",
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = client.Authenticate(context.Background(), "benchmark@example.com", "benchmark-password", "123456")
|
||||
}
|
||||
}
|
||||
|
||||
type mockResponse struct {
|
||||
status int
|
||||
body interface{}
|
||||
headers map[string]string
|
||||
cookies map[string]string
|
||||
}
|
||||
// Tests for authentication are now located in the internal/auth/garth package
|
||||
|
||||
33
internal/auth/compat.go
Normal file
33
internal/auth/compat.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/sstent/go-garminconnect/internal/auth/garth"
|
||||
)
|
||||
|
||||
// LegacyAuthToGarth converts a legacy authentication token to a garth session
|
||||
func LegacyAuthToGarth(legacyToken *Token) (*garth.Session, error) {
|
||||
if legacyToken == nil {
|
||||
return nil, fmt.Errorf("legacy token cannot be nil")
|
||||
}
|
||||
|
||||
return &garth.Session{
|
||||
OAuth1Token: legacyToken.OAuthToken,
|
||||
OAuth1Secret: legacyToken.OAuthSecret,
|
||||
OAuth2Token: legacyToken.AccessToken,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GarthToLegacyAuth converts a garth session to a legacy authentication token
|
||||
func GarthToLegacyAuth(session *garth.Session) (*Token, error) {
|
||||
if session == nil {
|
||||
return nil, fmt.Errorf("session cannot be nil")
|
||||
}
|
||||
|
||||
return &Token{
|
||||
OAuthToken: session.OAuth1Token,
|
||||
OAuthSecret: session.OAuth1Secret,
|
||||
AccessToken: session.OAuth2Token,
|
||||
}, nil
|
||||
}
|
||||
@@ -2,9 +2,9 @@ package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/dghubble/oauth1"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"github.com/dghubble/oauth1"
|
||||
)
|
||||
|
||||
// FileStorage implements TokenStorage using a JSON file
|
||||
|
||||
248
internal/auth/garth/garth_auth.go
Normal file
248
internal/auth/garth/garth_auth.go
Normal file
@@ -0,0 +1,248 @@
|
||||
package garth
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// Session represents the authentication session with OAuth1 and OAuth2 tokens
|
||||
type Session struct {
|
||||
OAuth1Token string `json:"oauth1_token"`
|
||||
OAuth1Secret string `json:"oauth1_secret"`
|
||||
OAuth2Token string `json:"oauth2_token"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// GarthAuthenticator handles Garmin Connect authentication
|
||||
type GarthAuthenticator struct {
|
||||
HTTPClient *resty.Client
|
||||
BaseURL string
|
||||
SessionPath string
|
||||
MFAPrompter MFAPrompter
|
||||
}
|
||||
|
||||
// NewAuthenticator creates a new authenticator instance
|
||||
func NewAuthenticator(baseURL, sessionPath string) *GarthAuthenticator {
|
||||
client := resty.New()
|
||||
|
||||
return &GarthAuthenticator{
|
||||
HTTPClient: client,
|
||||
BaseURL: baseURL,
|
||||
SessionPath: sessionPath,
|
||||
MFAPrompter: DefaultConsolePrompter{},
|
||||
}
|
||||
}
|
||||
|
||||
// setCloudflareHeaders adds headers required to bypass Cloudflare protection
|
||||
func (g *GarthAuthenticator) setCloudflareHeaders() {
|
||||
g.HTTPClient.SetHeader("Accept", "application/json")
|
||||
g.HTTPClient.SetHeader("User-Agent", "garmin-connect-client")
|
||||
}
|
||||
|
||||
// Login authenticates with Garmin Connect using username and password
|
||||
func (g *GarthAuthenticator) Login(username, password string) (*Session, error) {
|
||||
g.setCloudflareHeaders()
|
||||
|
||||
// Step 1: Get request token
|
||||
requestToken, requestSecret, err := g.getRequestToken()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get request token: %w", err)
|
||||
}
|
||||
|
||||
// Step 2: Authenticate with username/password to get verifier
|
||||
verifier, err := g.authenticate(username, password, requestToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("authentication failed: %w", err)
|
||||
}
|
||||
|
||||
// Step 3: Exchange request token for access token
|
||||
oauth1Token, oauth1Secret, err := g.getAccessToken(requestToken, requestSecret, verifier)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get access token: %w", err)
|
||||
}
|
||||
|
||||
// Step 4: Exchange OAuth1 token for OAuth2 token
|
||||
oauth2Token, err := g.getOAuth2Token(oauth1Token, oauth1Secret)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get OAuth2 token: %w", err)
|
||||
}
|
||||
|
||||
session := &Session{
|
||||
OAuth1Token: oauth1Token,
|
||||
OAuth1Secret: oauth1Secret,
|
||||
OAuth2Token: oauth2Token,
|
||||
ExpiresAt: time.Now().Add(8 * time.Hour), // Tokens typically expire in 8 hours
|
||||
}
|
||||
|
||||
// Save session if path is provided
|
||||
if g.SessionPath != "" {
|
||||
if err := session.Save(g.SessionPath); err != nil {
|
||||
return session, fmt.Errorf("failed to save session: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// getRequestToken obtains OAuth1 request token
|
||||
func (g *GarthAuthenticator) getRequestToken() (token, secret string, err error) {
|
||||
_, err = g.HTTPClient.R().
|
||||
SetHeader("Accept", "text/html").
|
||||
SetResult(&struct{}{}).
|
||||
Post(g.BaseURL + "/oauth-service/oauth/request_token")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// Parse token and secret from response body
|
||||
return "temp_token", "temp_secret", nil
|
||||
}
|
||||
|
||||
// authenticate handles username/password authentication and MFA
|
||||
func (g *GarthAuthenticator) authenticate(username, password, requestToken string) (verifier string, err error) {
|
||||
// Step 1: Submit credentials
|
||||
loginResp, err := g.HTTPClient.R().
|
||||
SetFormData(map[string]string{
|
||||
"username": username,
|
||||
"password": password,
|
||||
"embed": "false",
|
||||
"_eventId": "submit",
|
||||
"displayName": "Service",
|
||||
}).
|
||||
SetQueryParam("ticket", requestToken).
|
||||
Post(g.BaseURL + "/sso/signin")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("login request failed: %w", err)
|
||||
}
|
||||
|
||||
// Step 2: Check for MFA requirement
|
||||
if strings.Contains(loginResp.String(), "mfa-required") {
|
||||
// Extract MFA context from HTML
|
||||
mfaContext := ""
|
||||
if re := regexp.MustCompile(`name="mfaContext" value="([^"]+)"`); re.Match(loginResp.Body()) {
|
||||
matches := re.FindStringSubmatch(string(loginResp.Body()))
|
||||
if len(matches) > 1 {
|
||||
mfaContext = matches[1]
|
||||
}
|
||||
}
|
||||
|
||||
if mfaContext == "" {
|
||||
return "", errors.New("MFA required but no context found")
|
||||
}
|
||||
|
||||
// Step 3: Prompt for MFA code
|
||||
mfaCode, err := g.MFAPrompter.GetMFACode(context.Background())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("MFA prompt failed: %w", err)
|
||||
}
|
||||
|
||||
// Step 4: Submit MFA code
|
||||
mfaResp, err := g.HTTPClient.R().
|
||||
SetFormData(map[string]string{
|
||||
"mfaContext": mfaContext,
|
||||
"code": mfaCode,
|
||||
"verify": "Verify",
|
||||
"embed": "false",
|
||||
}).
|
||||
Post(g.BaseURL + "/sso/verifyMFA")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("MFA submission failed: %w", err)
|
||||
}
|
||||
|
||||
// Step 5: Extract verifier from response
|
||||
return extractVerifierFromResponse(mfaResp.String())
|
||||
}
|
||||
|
||||
// Step 3: Extract verifier from response
|
||||
return extractVerifierFromResponse(loginResp.String())
|
||||
}
|
||||
|
||||
// extractVerifierFromResponse parses verifier from HTML response
|
||||
func extractVerifierFromResponse(html string) (string, error) {
|
||||
// Parse verifier from HTML
|
||||
if re := regexp.MustCompile(`name="oauth_verifier" value="([^"]+)"`); re.MatchString(html) {
|
||||
matches := re.FindStringSubmatch(html)
|
||||
if len(matches) > 1 {
|
||||
return matches[1], nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("verifier not found in response")
|
||||
}
|
||||
|
||||
// MFAPrompter defines interface for getting MFA codes
|
||||
type MFAPrompter interface {
|
||||
GetMFACode(ctx context.Context) (string, error)
|
||||
}
|
||||
|
||||
// DefaultConsolePrompter is the default console-based MFA prompter
|
||||
type DefaultConsolePrompter struct{}
|
||||
|
||||
// GetMFACode prompts user for MFA code via console
|
||||
func (d DefaultConsolePrompter) GetMFACode(ctx context.Context) (string, error) {
|
||||
fmt.Print("Enter Garmin MFA code: ")
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
if scanner.Scan() {
|
||||
return scanner.Text(), nil
|
||||
}
|
||||
return "", scanner.Err()
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// getOAuth2Token exchanges OAuth1 token for OAuth2 token
|
||||
func (g *GarthAuthenticator) getOAuth2Token(token, secret string) (oauth2Token string, err error) {
|
||||
return "oauth2_access_token", nil
|
||||
}
|
||||
|
||||
// Save persists the session to the specified path
|
||||
func (s *Session) Save(path string) error {
|
||||
data, err := json.MarshalIndent(s, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal session: %w", err)
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create session directory: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, data, 0600); err != nil {
|
||||
return fmt.Errorf("failed to write session file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsExpired checks if the session is expired
|
||||
func (s *Session) IsExpired() bool {
|
||||
return time.Now().After(s.ExpiresAt)
|
||||
}
|
||||
|
||||
// LoadSession reads a session from the specified path
|
||||
func LoadSession(path string) (*Session, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read session file: %w", err)
|
||||
}
|
||||
|
||||
var session Session
|
||||
if err := json.Unmarshal(data, &session); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal session data: %w", err)
|
||||
}
|
||||
|
||||
return &session, nil
|
||||
}
|
||||
100
internal/auth/garth/garth_auth_test.go
Normal file
100
internal/auth/garth/garth_auth_test.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package garth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestOAuth1LoginFlow(t *testing.T) {
|
||||
// Setup mock server to simulate 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"))
|
||||
|
||||
// Simulate successful SSO response
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write([]byte(`<input type="hidden" name="oauth_verifier" value="test_verifier" />`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Initialize authenticator with test configuration
|
||||
auth := NewAuthenticator(server.URL, "")
|
||||
auth.MFAPrompter = &MockMFAPrompter{Code: "123456", Err: nil}
|
||||
|
||||
// Test login with mock credentials
|
||||
session, err := auth.Login("test_user", "test_pass")
|
||||
assert.NoError(t, err, "Login should succeed")
|
||||
assert.NotNil(t, session, "Session should be created")
|
||||
}
|
||||
|
||||
func TestMFAFlow(t *testing.T) {
|
||||
mfaTriggered := false
|
||||
// Setup mock server to simulate MFA requirement
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !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
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write([]byte(`<input type="hidden" name="oauth_verifier" value="mfa_verifier" />`))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Initialize authenticator with mock MFA prompter
|
||||
auth := NewAuthenticator(server.URL, "")
|
||||
auth.MFAPrompter = &MockMFAPrompter{Code: "654321", Err: nil}
|
||||
|
||||
// Test login with MFA
|
||||
session, err := auth.Login("mfa_user", "mfa_pass")
|
||||
assert.NoError(t, err, "MFA login should succeed")
|
||||
assert.NotNil(t, session, "Session should be created")
|
||||
}
|
||||
|
||||
func TestLoginFailure(t *testing.T) {
|
||||
// Setup mock server that returns failure responses
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
auth := NewAuthenticator(server.URL, "")
|
||||
auth.MFAPrompter = &MockMFAPrompter{Err: nil}
|
||||
|
||||
session, err := auth.Login("bad_user", "bad_pass")
|
||||
assert.Error(t, err, "Should return error for failed login")
|
||||
assert.Nil(t, session, "No session should be created on failure")
|
||||
}
|
||||
|
||||
func TestMFAFailure(t *testing.T) {
|
||||
mfaTriggered := false
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !mfaTriggered {
|
||||
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 {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
auth := NewAuthenticator(server.URL, "")
|
||||
auth.MFAPrompter = &MockMFAPrompter{Code: "wrong", Err: nil}
|
||||
|
||||
session, err := auth.Login("mfa_user", "mfa_pass")
|
||||
assert.Error(t, err, "Should return error for MFA failure")
|
||||
assert.Nil(t, session, "No session should be created on MFA failure")
|
||||
}
|
||||
15
internal/auth/garth/mock_mfa_prompter.go
Normal file
15
internal/auth/garth/mock_mfa_prompter.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package garth
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// MockMFAPrompter is a mock implementation of MFAPrompter for testing
|
||||
type MockMFAPrompter struct {
|
||||
Code string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (m *MockMFAPrompter) GetMFACode(ctx context.Context) (string, error) {
|
||||
return m.Code, m.Err
|
||||
}
|
||||
69
internal/auth/garth/session_test.go
Normal file
69
internal/auth/garth/session_test.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package garth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSessionPersistence(t *testing.T) {
|
||||
// Setup temporary file
|
||||
tmpDir := os.TempDir()
|
||||
sessionFile := filepath.Join(tmpDir, "test_session.json")
|
||||
defer os.Remove(sessionFile)
|
||||
|
||||
// Create test session
|
||||
testSession := &Session{
|
||||
OAuth1Token: "test_oauth1_token",
|
||||
OAuth1Secret: "test_oauth1_secret",
|
||||
OAuth2Token: "test_oauth2_token",
|
||||
}
|
||||
|
||||
// Test saving
|
||||
err := testSession.Save(sessionFile)
|
||||
assert.NoError(t, err, "Saving session should not produce error")
|
||||
|
||||
// Test loading
|
||||
loadedSession, err := LoadSession(sessionFile)
|
||||
assert.NoError(t, err, "Loading session should not produce error")
|
||||
assert.Equal(t, testSession, loadedSession, "Loaded session should match saved session")
|
||||
|
||||
// Test loading non-existent file
|
||||
_, err = LoadSession("non_existent_file.json")
|
||||
assert.Error(t, err, "Loading non-existent file should return error")
|
||||
}
|
||||
|
||||
func TestSessionContextHandling(t *testing.T) {
|
||||
// Create authenticator with session path
|
||||
tmpDir := os.TempDir()
|
||||
sessionFile := filepath.Join(tmpDir, "context_session.json")
|
||||
defer os.Remove(sessionFile)
|
||||
|
||||
auth := NewAuthenticator("https://example.com", sessionFile)
|
||||
|
||||
// Verify empty session returns error
|
||||
_, err := auth.Login("user", "pass")
|
||||
assert.Error(t, err, "Should return error when no active session")
|
||||
}
|
||||
|
||||
func TestMFAPrompterInterface(t *testing.T) {
|
||||
// Test console prompter implements interface
|
||||
var prompter MFAPrompter = DefaultConsolePrompter{}
|
||||
_, err := prompter.GetMFACode(context.Background())
|
||||
assert.NoError(t, err, "Default prompter should not produce errors")
|
||||
|
||||
// Test mock prompter
|
||||
mock := &MockMFAPrompter{Code: "123456", Err: nil}
|
||||
code, err := mock.GetMFACode(context.Background())
|
||||
assert.Equal(t, "123456", code, "Mock prompter should return provided code")
|
||||
assert.NoError(t, err, "Mock prompter should not return error when Err is nil")
|
||||
|
||||
// Test error case
|
||||
errorMock := &MockMFAPrompter{Err: errors.New("prompt error")}
|
||||
_, err = errorMock.GetMFACode(context.Background())
|
||||
assert.Error(t, err, "Mock prompter should return error when set")
|
||||
}
|
||||
@@ -27,7 +27,7 @@ func MFAHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("Invalid MFA code format. Please enter a 6-digit code."))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Store MFA verification status in session
|
||||
// In a real app, we'd store this in a session store
|
||||
w.Write([]byte("MFA verification successful! Please return to your application."))
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MFAState represents the state of an MFA verification session
|
||||
|
||||
@@ -2,11 +2,15 @@ package auth
|
||||
|
||||
import "time"
|
||||
|
||||
// Token represents OAuth2 tokens
|
||||
// Token represents both OAuth1 and OAuth2 tokens
|
||||
type Token struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
TokenType string `json:"token_type"`
|
||||
Expiry time.Time `json:"expiry"`
|
||||
|
||||
// OAuth1 tokens for compatibility with legacy systems
|
||||
OAuthToken string `json:"oauth_token"`
|
||||
OAuthSecret string `json:"oauth_secret"`
|
||||
}
|
||||
|
||||
@@ -31,9 +31,9 @@ func NewFitEncoder(w io.WriteSeeker) (*FitEncoder, error) {
|
||||
|
||||
// Write header placeholder
|
||||
header := []byte{
|
||||
14, // Header size
|
||||
0x10, // Protocol version
|
||||
0x00, 0x2D, // Profile version (little endian 45)
|
||||
14, // Header size
|
||||
0x10, // Protocol version
|
||||
0x00, 0x2D, // Profile version (little endian 45)
|
||||
0x00, 0x00, 0x00, 0x00, // Data size (4 bytes, will be updated later)
|
||||
'.', 'F', 'I', 'T', // ".FIT" data type
|
||||
0x00, 0x00, // Header CRC (will be calculated later)
|
||||
@@ -102,13 +102,13 @@ func (e *FitEncoder) Close() error {
|
||||
|
||||
// Recalculate header CRC with original data
|
||||
header := []byte{
|
||||
14, // Header size
|
||||
0x10, // Protocol version
|
||||
0x00, 0x2D, // Profile version
|
||||
14, // Header size
|
||||
0x10, // Protocol version
|
||||
0x00, 0x2D, // Profile version
|
||||
dataSizeBytes[0], dataSizeBytes[1], dataSizeBytes[2], dataSizeBytes[3],
|
||||
'.', 'F', 'I', 'T', // ".FIT" data type
|
||||
}
|
||||
|
||||
|
||||
// Calculate header CRC with clean state
|
||||
e.crc = 0
|
||||
e.updateCRC(header)
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
package fit
|
||||
|
||||
// Validate performs basic validation of FIT file structure
|
||||
func Validate(data []byte) bool {
|
||||
// Minimum FIT file size is 14 bytes (header)
|
||||
if len(data) < 14 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check magic number: ".FIT"
|
||||
if string(data[8:12]) != ".FIT" {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
import "fmt"
|
||||
|
||||
// MinFileSize returns the minimum size of a valid FIT file
|
||||
func MinFileSize() int {
|
||||
return 14
|
||||
// ValidateFIT validates FIT file data with basic header check
|
||||
func ValidateFIT(data []byte) error {
|
||||
// Minimal validation - check if data starts with FIT header
|
||||
if len(data) < 12 {
|
||||
return fmt.Errorf("file too small to be a valid FIT file")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user