From 3b4eb5f9fca950e2ed81eecbb6fd57573defb3de Mon Sep 17 00:00:00 2001 From: Brian Zimmer Date: Sun, 22 Feb 2026 19:04:17 +0100 Subject: [PATCH 1/6] go 1.26 upgrade --- cyclinganalytics/cyclinganalytics.go | 2 +- go.mod | 14 +++++------ go.sum | 30 +++++++++++------------- rwgps/model.go | 2 +- strava/model.go | 2 +- strava/strava.go | 2 +- xfer.go | 2 +- xfer_test.go | 1 - zwift/activity.go | 35 ++++++++++++++++++++++++++++ zwift/zwift.go | 2 +- 10 files changed, 62 insertions(+), 30 deletions(-) diff --git a/cyclinganalytics/cyclinganalytics.go b/cyclinganalytics/cyclinganalytics.go index 77cdf97..a665c19 100644 --- a/cyclinganalytics/cyclinganalytics.go +++ b/cyclinganalytics/cyclinganalytics.go @@ -33,7 +33,7 @@ type Client struct { // Endpoint is CyclingAnalytics's OAuth 2.0 endpoint func Endpoint() oauth2.Endpoint { - return oauth2.Endpoint{ + return oauth2.Endpoint{ //nolint:gosec // not a secret AuthURL: "https://www.cyclinganalytics.com/api/auth", TokenURL: "https://www.cyclinganalytics.com/api/token", AuthStyle: oauth2.AuthStyleAutoDetect, diff --git a/go.mod b/go.mod index 19045e7..334ab9d 100644 --- a/go.mod +++ b/go.mod @@ -1,24 +1,24 @@ module github.com/bzimmer/activity -go 1.24.2 +go 1.26.0 require ( github.com/bzimmer/httpwares v0.1.3 github.com/martinlindhe/unit v0.0.0-20230420213220-4adfd7d0a0d6 github.com/stretchr/testify v1.8.1 github.com/twpayne/go-geom v1.6.1 - github.com/twpayne/go-gpx v1.4.1 + github.com/twpayne/go-gpx v1.5.0 github.com/twpayne/go-polyline v1.1.1 - golang.org/x/oauth2 v0.29.0 - golang.org/x/sync v0.13.0 - golang.org/x/time v0.11.0 + golang.org/x/oauth2 v0.35.0 + golang.org/x/sync v0.19.0 + golang.org/x/time v0.14.0 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - golang.org/x/net v0.39.0 // indirect - golang.org/x/text v0.24.0 // indirect + golang.org/x/net v0.50.0 // indirect + golang.org/x/text v0.34.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index f7809c2..ab5ee65 100644 --- a/go.sum +++ b/go.sum @@ -8,8 +8,6 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs 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/dvyukov/go-fuzz v0.0.0-20200318091601-be3528f3a813/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= @@ -23,8 +21,8 @@ github.com/martinlindhe/unit v0.0.0-20230420213220-4adfd7d0a0d6 h1:muzoir7BEy+lD github.com/martinlindhe/unit v0.0.0-20230420213220-4adfd7d0a0d6/go.mod h1:8QbxAolnDKw/JhUJMU80MRjHjEs0tLwkjZAPrTn+xLA= 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/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.3.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= @@ -38,20 +36,20 @@ github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKs github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/twpayne/go-geom v1.6.1 h1:iLE+Opv0Ihm/ABIcvQFGIiFBXd76oBIar9drAwHFhR4= github.com/twpayne/go-geom v1.6.1/go.mod h1:Kr+Nly6BswFsKM5sd31YaoWS5PeDDH2NftJTK7Gd028= -github.com/twpayne/go-gpx v1.4.1 h1:Y41EDC/r49OH6pTAQUk4Qpcp9z96fOvVLchRq/P4iys= -github.com/twpayne/go-gpx v1.4.1/go.mod h1:6bVeKyVqzHRZ25UdFOWxv0f6SMW0P9lO7GO1aNNznEU= +github.com/twpayne/go-gpx v1.5.0 h1:HvFSJ+0r0sbhOQ8mTvd0/n0FhcgjTFsKQGG6o7PV6G4= +github.com/twpayne/go-gpx v1.5.0/go.mod h1:vjvu/125399qj6k+px2v2v8dm08DM4I4dFBJmHHt2TE= github.com/twpayne/go-polyline v1.1.1 h1:/tSF1BR7rN4HWj4XKqvRUNrCiYVMCvywxTFVofvDV0w= github.com/twpayne/go-polyline v1.1.1/go.mod h1:ybd9IWWivW/rlXPXuuckeKUyF3yrIim+iqA7kSl4NFY= -golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= -golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= -golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98= -golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= -golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= -golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= -golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= -golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/rwgps/model.go b/rwgps/model.go index 06e0bdf..3c889b1 100644 --- a/rwgps/model.go +++ b/rwgps/model.go @@ -40,7 +40,7 @@ func (f *Fault) Error() string { type User struct { ID UserID `json:"id"` Name string `json:"name"` - AuthToken string `json:"auth_token"` + AuthToken string `json:"auth_token"` //#nosec G117 -- Field unmarshals API token, not a hardcoded credential } type Summary struct { diff --git a/strava/model.go b/strava/model.go index cb88abe..bf94d3c 100644 --- a/strava/model.go +++ b/strava/model.go @@ -329,7 +329,7 @@ type Photo struct { Ref string `json:"ref"` UID string `json:"uid"` Caption string `json:"caption"` - Type string `json:"type"` + Type int `json:"type"` Source int `json:"source"` UploadedAt time.Time `json:"uploaded_at"` CreatedAt time.Time `json:"created_at"` diff --git a/strava/strava.go b/strava/strava.go index 7289612..e980808 100644 --- a/strava/strava.go +++ b/strava/strava.go @@ -27,7 +27,7 @@ type APIOption func(url.Values) error // Endpoint is Strava's OAuth 2.0 endpoint func Endpoint() oauth2.Endpoint { - return oauth2.Endpoint{ + return oauth2.Endpoint{ //nolint:gosec // not a secret AuthURL: "https://www.strava.com/oauth/authorize", TokenURL: "https://www.strava.com/oauth/token", AuthStyle: oauth2.AuthStyleAutoDetect, diff --git a/xfer.go b/xfer.go index e7d0a24..2a4b8c4 100644 --- a/xfer.go +++ b/xfer.go @@ -93,7 +93,7 @@ const ( // MarshalJSON converts a Format enum to a string representation func (f Format) MarshalJSON() ([]byte, error) { - return []byte(fmt.Sprintf(`"%s"`, f.String())), nil + return fmt.Appendf(nil, `"%s"`, f.String()), nil } // ToFormat converts a file extension (with or without the ".") to a Format diff --git a/xfer_test.go b/xfer_test.go index 3902a1e..15435e8 100644 --- a/xfer_test.go +++ b/xfer_test.go @@ -59,7 +59,6 @@ func TestPoller(t *testing.T) { {name: "ctx timeout", status: 1, it: 5, in: time.Second, to: time.Millisecond * 10, err: true}, } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() ctx := context.Background() diff --git a/zwift/activity.go b/zwift/activity.go index 6392516..0b5a0e0 100644 --- a/zwift/activity.go +++ b/zwift/activity.go @@ -8,6 +8,8 @@ import ( "io" "mime" "net/http" + "net/url" + "strings" "github.com/bzimmer/activity" ) @@ -101,10 +103,17 @@ func (s *ActivityService) Export(ctx context.Context, activityID int64) (*activi // ExportActivity exports the data file for the activity func (s *ActivityService) ExportActivity(ctx context.Context, act *Activity) (*activity.Export, error) { uri := fmt.Sprintf("https://%s.s3.amazonaws.com/%s", act.FitFileBucket, act.FitFileKey) + + // Validate URL to prevent SSRF + if err := validateZwiftS3URL(uri); err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, uri, nil) if err != nil { return nil, err } + // #nosec G704 -- URL has been validated to ensure it's a legitimate Zwift S3 bucket res, err := s.client.client.Do(req) if err != nil { select { @@ -153,3 +162,29 @@ func (s *ActivityService) ExportActivity(ctx context.Context, act *Activity) (*a Format: activity.FormatFIT}, }, nil } + +// validateZwiftS3URL validates that the URL is a legitimate Zwift S3 bucket URL +func validateZwiftS3URL(rawURL string) error { + parsedURL, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("invalid URL: %w", err) + } + + // Ensure HTTPS scheme + if parsedURL.Scheme != "https" { + return fmt.Errorf("invalid URL scheme: expected https, got %s", parsedURL.Scheme) + } + + // Validate that the host is an S3 bucket in amazonaws.com + if !strings.HasSuffix(parsedURL.Host, ".s3.amazonaws.com") { + return fmt.Errorf("invalid host: expected *.s3.amazonaws.com, got %s", parsedURL.Host) + } + + // Validate bucket name contains "zwift" (case-insensitive) + bucketName := strings.TrimSuffix(parsedURL.Host, ".s3.amazonaws.com") + if !strings.Contains(strings.ToLower(bucketName), "zwift") { + return fmt.Errorf("invalid bucket: expected Zwift bucket, got %s", bucketName) + } + + return nil +} diff --git a/zwift/zwift.go b/zwift/zwift.go index 3c87d33..9f1fe69 100644 --- a/zwift/zwift.go +++ b/zwift/zwift.go @@ -21,7 +21,7 @@ const userAgent = "CNL/3.4.1 (Darwin Kernel 20.3.0) zwift/1.0.61590 curl/7.64.1" // Endpoint is Zwifts's OAuth 2.0 endpoint func Endpoint() oauth2.Endpoint { - return oauth2.Endpoint{ + return oauth2.Endpoint{ //nolint:gosec // not a secret TokenURL: "https://secure.zwift.com/auth/realms/zwift/tokens/access/codes", AuthStyle: oauth2.AuthStyleAutoDetect, } From b485e72e13ad528745dc495f81bc850b45dd9ae2 Mon Sep 17 00:00:00 2001 From: Brian Zimmer Date: Sun, 22 Feb 2026 19:06:24 +0100 Subject: [PATCH 2/6] generate enums --- rwgps/model_string.go | 5 +- xfer_string.go | 5 +- zwift/activity_internal_test.go | 109 ++++++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 zwift/activity_internal_test.go diff --git a/rwgps/model_string.go b/rwgps/model_string.go index 1a1afa4..fc1d62a 100644 --- a/rwgps/model_string.go +++ b/rwgps/model_string.go @@ -17,8 +17,9 @@ const _Type_name = "triproute" var _Type_index = [...]uint8{0, 4, 9} func (i Type) String() string { - if i < 0 || i >= Type(len(_Type_index)-1) { + idx := int(i) - 0 + if i < 0 || idx >= len(_Type_index)-1 { return "Type(" + strconv.FormatInt(int64(i), 10) + ")" } - return _Type_name[_Type_index[i]:_Type_index[i+1]] + return _Type_name[_Type_index[idx]:_Type_index[idx+1]] } diff --git a/xfer_string.go b/xfer_string.go index 9e84f76..da28b1a 100644 --- a/xfer_string.go +++ b/xfer_string.go @@ -19,8 +19,9 @@ const _Format_name = "originalgpxtcxfit" var _Format_index = [...]uint8{0, 8, 11, 14, 17} func (i Format) String() string { - if i < 0 || i >= Format(len(_Format_index)-1) { + idx := int(i) - 0 + if i < 0 || idx >= len(_Format_index)-1 { return "Format(" + strconv.FormatInt(int64(i), 10) + ")" } - return _Format_name[_Format_index[i]:_Format_index[i+1]] + return _Format_name[_Format_index[idx]:_Format_index[idx+1]] } diff --git a/zwift/activity_internal_test.go b/zwift/activity_internal_test.go new file mode 100644 index 0000000..effc671 --- /dev/null +++ b/zwift/activity_internal_test.go @@ -0,0 +1,109 @@ +package zwift + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestValidateZwiftS3URL(t *testing.T) { + t.Parallel() + a := assert.New(t) + + tests := []struct { + name string + url string + wantErr bool + errMsg string + }{ + { + name: "valid zwift S3 URL", + url: "https://zwift-activity-prod.s3.amazonaws.com/activity.fit", + wantErr: false, + }, + { + name: "valid zwift S3 URL with path", + url: "https://zwift-exports.s3.amazonaws.com/path/to/file.fit", + wantErr: false, + }, + { + name: "valid zwift S3 URL - case insensitive bucket", + url: "https://ZWIFT-data.s3.amazonaws.com/file.fit", + wantErr: false, + }, + { + name: "valid zwift S3 URL - zwift in middle of bucket name", + url: "https://prod-zwift-data.s3.amazonaws.com/file.fit", + wantErr: false, + }, + { + name: "invalid URL - malformed", + url: "://invalid-url", + wantErr: true, + errMsg: "invalid URL", + }, + { + name: "invalid URL - http scheme", + url: "http://zwift-activity.s3.amazonaws.com/file.fit", + wantErr: true, + errMsg: "invalid URL scheme: expected https, got http", + }, + { + name: "invalid URL - ftp scheme", + url: "ftp://zwift-activity.s3.amazonaws.com/file.fit", + wantErr: true, + errMsg: "invalid URL scheme: expected https, got ftp", + }, + { + name: "invalid URL - no scheme", + url: "zwift-activity.s3.amazonaws.com/file.fit", + wantErr: true, + errMsg: "invalid URL scheme: expected https, got", + }, + { + name: "invalid host - not S3", + url: "https://zwift.com/file.fit", + wantErr: true, + errMsg: "invalid host: expected *.s3.amazonaws.com, got zwift.com", + }, + { + name: "invalid host - not amazonaws", + url: "https://zwift-bucket.s3.google.com/file.fit", + wantErr: true, + errMsg: "invalid host: expected *.s3.amazonaws.com, got zwift-bucket.s3.google.com", + }, + { + name: "invalid host - missing bucket name", + url: "https://s3.amazonaws.com/file.fit", + wantErr: true, + errMsg: "invalid host: expected *.s3.amazonaws.com, got s3.amazonaws.com", + }, + { + name: "invalid bucket - no zwift in name", + url: "https://activity-prod.s3.amazonaws.com/file.fit", + wantErr: true, + errMsg: "invalid bucket: expected Zwift bucket, got activity-prod", + }, + { + name: "invalid bucket - wrong service name", + url: "https://strava-exports.s3.amazonaws.com/file.fit", + wantErr: true, + errMsg: "invalid bucket: expected Zwift bucket, got strava-exports", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := validateZwiftS3URL(tt.url) + if tt.wantErr { + a.Error(err) + if tt.errMsg != "" { + a.Contains(err.Error(), tt.errMsg) + } + } else { + a.NoError(err) + } + }) + } +} From 4a5419c80d0a3dcc2d20996c53649a50ab37fcf3 Mon Sep 17 00:00:00 2001 From: Brian Zimmer Date: Sun, 22 Feb 2026 19:08:53 +0100 Subject: [PATCH 3/6] Update rwgps/model.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- rwgps/model.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rwgps/model.go b/rwgps/model.go index 3c889b1..02d0ade 100644 --- a/rwgps/model.go +++ b/rwgps/model.go @@ -40,7 +40,7 @@ func (f *Fault) Error() string { type User struct { ID UserID `json:"id"` Name string `json:"name"` - AuthToken string `json:"auth_token"` //#nosec G117 -- Field unmarshals API token, not a hardcoded credential + AuthToken string `json:"auth_token"` //nolint:gosec -- Field unmarshals API token, not a hardcoded credential } type Summary struct { From 4fcccc4e8939f5f6f8fce882df8ac191b2b94162 Mon Sep 17 00:00:00 2001 From: Brian Zimmer Date: Sun, 22 Feb 2026 19:09:05 +0100 Subject: [PATCH 4/6] Update zwift/activity.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- zwift/activity.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zwift/activity.go b/zwift/activity.go index 0b5a0e0..ecfebed 100644 --- a/zwift/activity.go +++ b/zwift/activity.go @@ -113,7 +113,7 @@ func (s *ActivityService) ExportActivity(ctx context.Context, act *Activity) (*a if err != nil { return nil, err } - // #nosec G704 -- URL has been validated to ensure it's a legitimate Zwift S3 bucket + //nolint:gosec // G704 -- URL has been validated to ensure it's a legitimate Zwift S3 bucket res, err := s.client.client.Do(req) if err != nil { select { From 4697bfb1ef68d58c925160e4cb721e29e4a5c4ab Mon Sep 17 00:00:00 2001 From: Brian Zimmer Date: Sun, 22 Feb 2026 19:20:39 +0100 Subject: [PATCH 5/6] added tests --- zwift/activity_test.go | 266 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 266 insertions(+) diff --git a/zwift/activity_test.go b/zwift/activity_test.go index 751135b..b1095aa 100644 --- a/zwift/activity_test.go +++ b/zwift/activity_test.go @@ -1,12 +1,17 @@ package zwift_test import ( + "bytes" "context" "encoding/json" + "io" "net/http" + "net/http/httptest" + "strings" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/bzimmer/activity" "github.com/bzimmer/activity/zwift" @@ -112,3 +117,264 @@ func TestActivities(t *testing.T) { }) } } + +func TestExportActivity(t *testing.T) { + t.Parallel() + + // Mock FIT file content + mockFitData := []byte("MOCK_FIT_FILE_CONTENT") + + tests := []struct { + name string + activity *zwift.Activity + mockResponse func(w http.ResponseWriter, r *http.Request) + wantErr bool + errMsg string + validateResult func(t *testing.T, export *activity.Export) + }{ + { + name: "successful export", + activity: &zwift.Activity{ + ID: 12345, + FitFileBucket: "zwift-activity-prod", + FitFileKey: "2024/01/activity.fit", + }, + mockResponse: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Disposition", "filename=2024-01-15.fit") + w.Header().Set("Content-Type", "application/octet-stream") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(mockFitData) + }, + wantErr: false, + validateResult: func(t *testing.T, export *activity.Export) { + a := assert.New(t) + r := require.New(t) + r.NotNil(export) + a.Equal(int64(12345), export.ID) + a.Equal("2024-01-15.fit", export.Name) + a.Equal(activity.FormatFIT, export.Format) + + // Read and verify content + buf := &bytes.Buffer{} + _, err := io.Copy(buf, export.Reader) + a.NoError(err) + a.Equal(mockFitData, buf.Bytes()) + }, + }, + { + name: "activity not found - 404", + activity: &zwift.Activity{ + ID: 99999, + FitFileBucket: "zwift-activity-prod", + FitFileKey: "nonexistent.fit", + }, + mockResponse: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }, + wantErr: true, + errMsg: "activity not found", + }, + { + name: "server error - 500", + activity: &zwift.Activity{ + ID: 12345, + FitFileBucket: "zwift-activity-prod", + FitFileKey: "error.fit", + }, + mockResponse: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }, + wantErr: true, + errMsg: "error code: 500", + }, + { + name: "forbidden - 403", + activity: &zwift.Activity{ + ID: 12345, + FitFileBucket: "zwift-activity-prod", + FitFileKey: "forbidden.fit", + }, + mockResponse: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + }, + wantErr: true, + errMsg: "error code: 403", + }, + { + name: "invalid bucket name - validation fails", + activity: &zwift.Activity{ + ID: 12345, + FitFileBucket: "malicious-bucket", + FitFileKey: "activity.fit", + }, + mockResponse: func(_ http.ResponseWriter, _ *http.Request) { + // Should not be called due to validation failure + t.Error("mockResponse should not be called for invalid bucket") + }, + wantErr: true, + errMsg: "invalid bucket: expected Zwift bucket", + }, + { + name: "export with path in key", + activity: &zwift.Activity{ + ID: 67890, + FitFileBucket: "zwift-exports", + FitFileKey: "user/123/activities/2024-01-15-ride.fit", + }, + mockResponse: func(w http.ResponseWriter, r *http.Request) { + // Verify the URL path + expectedPath := "/user/123/activities/2024-01-15-ride.fit" + if !strings.Contains(r.URL.Path, expectedPath) { + t.Errorf("Expected path to contain %s, got %s", expectedPath, r.URL.Path) + } + w.Header().Set("Content-Disposition", "filename=2024-01-15-ride.fit") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(mockFitData) + }, + wantErr: false, + validateResult: func(t *testing.T, export *activity.Export) { + a := assert.New(t) + a.Equal("2024-01-15-ride.fit", export.Name) + }, + }, + { + name: "malformed content-disposition header", + activity: &zwift.Activity{ + ID: 12345, + FitFileBucket: "zwift-activity-prod", + FitFileKey: "activity.fit", + }, + mockResponse: func(w http.ResponseWriter, _ *http.Request) { + // Invalid Content-Disposition format + w.Header().Set("Content-Disposition", "invalid disposition header") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(mockFitData) + }, + wantErr: true, + errMsg: "mime:", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + a := assert.New(t) + + // Create a test server to mock S3 responses + s3Server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify it's a GET request + a.Equal(http.MethodGet, r.Method) + + // Verify the URL path matches the FitFileKey + a.Equal("/"+tt.activity.FitFileKey, r.URL.Path) + + tt.mockResponse(w, r) + })) + defer s3Server.Close() + + // Create a custom HTTP client that redirects S3 requests to our test server + customTransport := &mockS3Transport{ + testServer: s3Server, + } + + // Create a test client using newClient helper with custom transport + mux := http.NewServeMux() + client, svr := newClient(t, mux, zwift.WithHTTPClient(&http.Client{ + Transport: customTransport, + })) + defer svr.Close() + + // Execute the test + ctx := context.Background() + export, err := client.Activity.ExportActivity(ctx, tt.activity) + + if tt.wantErr { + a.Error(err) + if tt.errMsg != "" { + a.Contains(err.Error(), tt.errMsg) + } + a.Nil(export) + } else { + a.NoError(err) + if tt.validateResult != nil { + tt.validateResult(t, export) + } + } + }) + } +} + +// mockS3Transport is a custom RoundTripper that redirects S3 requests to a test server +type mockS3Transport struct { + testServer *httptest.Server +} + +func (m *mockS3Transport) RoundTrip(req *http.Request) (*http.Response, error) { + // Redirect S3 requests to test server + if strings.Contains(req.URL.Host, ".s3.amazonaws.com") { + // Replace the host with test server host + newURL := *req.URL + newURL.Scheme = "http" + newURL.Host = strings.TrimPrefix(m.testServer.URL, "http://") + + // Create new request with test server URL + newReq, err := http.NewRequestWithContext(req.Context(), req.Method, newURL.String(), req.Body) + if err != nil { + return nil, err + } + + // Copy headers + newReq.Header = req.Header + + // Use default transport for the test server request + return http.DefaultTransport.RoundTrip(newReq) + } + + // For non-S3 requests, use default transport + return http.DefaultTransport.RoundTrip(req) +} + +func TestExportActivityContextCancellation(t *testing.T) { + t.Parallel() + a := assert.New(t) + + // Create a custom HTTP client that simulates a slow response + slowTransport := &slowTransport{} + + mux := http.NewServeMux() + client, svr := newClient(t, mux, zwift.WithHTTPClient(&http.Client{ + Transport: slowTransport, + })) + defer svr.Close() + + act := &zwift.Activity{ + ID: 12345, + FitFileBucket: "zwift-activity-prod", + FitFileKey: "activity.fit", + } + + // Create a context that's already cancelled + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + export, err := client.Activity.ExportActivity(ctx, act) + a.Error(err) + a.Nil(export) + a.Equal(context.Canceled, err) +} + +// slowTransport simulates a slow/hanging request +type slowTransport struct{} + +func (s *slowTransport) RoundTrip(req *http.Request) (*http.Response, error) { + // Check if context is already done + select { + case <-req.Context().Done(): + return nil, req.Context().Err() + default: + } + + // Simulate slow response by blocking until context is done + <-req.Context().Done() + return nil, req.Context().Err() +} From 2f3aa125d8acf3526bf37c6528261d83b42fc270 Mon Sep 17 00:00:00 2001 From: Brian Zimmer Date: Sun, 22 Feb 2026 19:25:22 +0100 Subject: [PATCH 6/6] fixed lint issue --- rwgps/model.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rwgps/model.go b/rwgps/model.go index 02d0ade..9c2f355 100644 --- a/rwgps/model.go +++ b/rwgps/model.go @@ -40,7 +40,7 @@ func (f *Fault) Error() string { type User struct { ID UserID `json:"id"` Name string `json:"name"` - AuthToken string `json:"auth_token"` //nolint:gosec -- Field unmarshals API token, not a hardcoded credential + AuthToken string `json:"auth_token"` //nolint:gosec // field unmarshals API token, not a hardcoded credential } type Summary struct {