Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ require (
gopkg.in/yaml.v3 v3.0.1
)

require gopkg.in/validator.v2 v2.0.1 // indirect

require (
buf.build/gen/go/gogo/protobuf/protocolbuffers/go v1.36.10-20240617172848-e1dbca2775a7.1 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
Expand All @@ -79,6 +81,7 @@ require (
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/pprof v0.0.0-20230728192033-2ba5b33183c6 // indirect
github.com/grafana/k6-cloud-openapi-client-go v0.0.0-20251103103337-5e94fff86cd6
github.com/grafana/k6build v0.5.15 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect
github.com/inconshreveable/mousetrap v1.0.0 // indirect
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grafana/k6-cloud-openapi-client-go v0.0.0-20251103103337-5e94fff86cd6 h1:jYEStHl9iT8AMqHTVPz/XFhj5AqOQfvH0ZsH4tQa7Jg=
github.com/grafana/k6-cloud-openapi-client-go v0.0.0-20251103103337-5e94fff86cd6/go.mod h1:RBPBP7qIR/K6qzQEQYESVhp/XJspiBTOyBEBCbPXrvI=
github.com/grafana/k6build v0.5.15 h1:4I5dkAWSMvXsElS1OpLbHj6ZXnebXZGnmwDXy5vcwSQ=
github.com/grafana/k6build v0.5.15/go.mod h1:Sk7SUiCnx2AgkirG3PrCtmJKYL+a8EeRdTzFfbxP0X8=
github.com/grafana/k6foundry v0.4.7 h1:1YXkTBwO/2dSx0pqJrraJATsFlsIX0vEpaEjV7E35w4=
Expand Down Expand Up @@ -380,6 +382,8 @@ gopkg.in/guregu/null.v3 v3.3.0 h1:8j3ggqq+NgKt/O7mbFVUFKUMWN+l1AmT5jQmJ6nPh2c=
gopkg.in/guregu/null.v3 v3.3.0/go.mod h1:E4tX2Qe3h7QdL+uZ3a0vqvYwKQsRSQKM5V4YltdgH9Y=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/validator.v2 v2.0.1 h1:xF0KWyGWXm/LM2G1TrEjqOu4pa6coO9AlWSf3msVfDY=
gopkg.in/validator.v2 v2.0.1/go.mod h1:lIUZBlB3Im4s/eYp39Ry/wkR02yOPhZ9IwIRBjuPuG8=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
Expand Down
42 changes: 42 additions & 0 deletions internal/cloudapi/v6/api.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package cloudapi

import (
"context"
"errors"
"fmt"
"io"

k6cloud "github.com/grafana/k6-cloud-openapi-client-go/k6"
)

// ValidateToken calls the endpoint to validate the Client's token and returns the result.
func (c *Client) ValidateToken(stackURL string) (*k6cloud.AuthenticationResponse, error) {
ctx := context.WithValue(context.Background(), k6cloud.ContextAccessToken, c.token)

req := c.apiClient.AuthorizationAPI.
Auth(ctx).
XStackUrl(stackURL)

resp, httpRes, err := req.Execute()
defer func() {
if httpRes != nil {
_, _ = io.Copy(io.Discard, httpRes.Body)
if cerr := httpRes.Body.Close(); cerr != nil && err == nil {
err = cerr
}
}
}()

if err != nil {
var apiErr *k6cloud.GenericOpenAPIError
if !errors.As(err, &apiErr) {
return nil, fmt.Errorf("failed to validate token: %w", err)
}
}

if err := CheckResponse(httpRes); err != nil {
return nil, fmt.Errorf("failed to validate token: %w", err)
}

return resp, nil
}
87 changes: 87 additions & 0 deletions internal/cloudapi/v6/api_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package cloudapi

import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.k6.io/k6/internal/lib/testutils"
)

func TestValidateToken(t *testing.T) {
t.Parallel()

t.Run("successful token validation", func(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify the authorization header
authHeader := r.Header.Get("Authorization")
assert.Equal(t, "Bearer test-token", authHeader)

// Verify the stack URL
stackURL := r.Header.Get("X-Stack-Url")
assert.Equal(t, stackURL, "https://stack.grafana.net")

w.Header().Add("Content-Type", "application/json")
fprint(t, w, `{
"stack_id": 123,
"default_project_id": 456
}`)
}))
defer server.Close()

client := NewClient(testutils.NewLogger(t), "test-token", server.URL, "1.0", 1*time.Second)

resp, err := client.ValidateToken("https://stack.grafana.net")

require.NoError(t, err)
require.NotNil(t, resp)
assert.Equal(t, int32(123), resp.StackId)
assert.Equal(t, int32(456), resp.DefaultProjectId)
})

t.Run("unauthorized token should fail", func(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Add("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
fprint(t, w, `{
"error": {
"code": "error",
"message": "Invalid token"
}
}`)
}))
defer server.Close()

client := NewClient(testutils.NewLogger(t), "invalid-token", server.URL, "1.0", 1*time.Second)

resp, err := client.ValidateToken("https://stack.grafana.net")

assert.Error(t, err)
assert.Nil(t, resp)
assert.Contains(t, err.Error(), "(401/error) Invalid token")
})

t.Run("network error should fail", func(t *testing.T) {
t.Parallel()
// Use an invalid URL to simulate network error
client := NewClient(testutils.NewLogger(t), "test-token", "http://invalid-url-that-does-not-exist", "1.0", 1*time.Second)

resp, err := client.ValidateToken("https://stack.grafana.net")

assert.Error(t, err)
assert.Nil(t, resp)
})
}

func fprint(t *testing.T, w io.Writer, format string) int {
n, err := fmt.Fprint(w, format)
require.NoError(t, err)
return n
}
104 changes: 104 additions & 0 deletions internal/cloudapi/v6/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package cloudapi

import (
"encoding/json"
"fmt"
"io"
"net/http"
"time"

k6cloud "github.com/grafana/k6-cloud-openapi-client-go/k6"
"github.com/sirupsen/logrus"
)

const (
// RetryInterval is the default cloud request retry interval
RetryInterval = 500 * time.Millisecond
// MaxRetries specifies max retry attempts
MaxRetries = 3
)

// Client handles communication with the k6 Cloud API.
type Client struct {
apiClient *k6cloud.APIClient
token string
stackID int64
baseURL string

logger logrus.FieldLogger

retries int
retryInterval time.Duration
}

// NewClient return a new client for the cloud API
func NewClient(logger logrus.FieldLogger, token, host, version string, timeout time.Duration) *Client {
cfg := &k6cloud.Configuration{
DefaultHeader: make(map[string]string),
UserAgent: "k6cloud/" + version,
Servers: k6cloud.ServerConfigurations{
{
URL: host,
Description: "Global k6 Cloud API.",
},
},
OperationServers: map[string]k6cloud.ServerConfigurations{},
HTTPClient: &http.Client{Timeout: timeout},
}

c := &Client{
apiClient: k6cloud.NewAPIClient(cfg),
token: token,
baseURL: fmt.Sprintf("%s/cloud/v6", host),
retries: MaxRetries,
retryInterval: RetryInterval,
logger: logger,
}
return c
}

// SetStackID sets the stack ID for the client.
func (c *Client) SetStackID(stackID int64) {
c.stackID = stackID
}

// BaseURL returns configured host.
func (c *Client) BaseURL() string {
return c.baseURL
}

// CheckResponse checks the parsed response.
// It returns nil if the code is in the successful range,
// otherwise it tries to parse the body and return a parsed error.
func CheckResponse(r *http.Response) error {
if r == nil {
return errUnknown
}

if c := r.StatusCode; c >= 200 && c <= 299 {
return nil
}

data, err := io.ReadAll(r.Body)
if err != nil {
return err
}

var payload ResponseError
if err := json.Unmarshal(data, &payload); err != nil {
if r.StatusCode == http.StatusUnauthorized {
return errNotAuthenticated
}
if r.StatusCode == http.StatusForbidden {
return errNotAuthorized
}
return fmt.Errorf(
"unexpected HTTP error from %s: %d %s",
r.Request.URL,
r.StatusCode,
http.StatusText(r.StatusCode),
)
}
payload.Response = r
return payload
}
104 changes: 104 additions & 0 deletions internal/cloudapi/v6/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package cloudapi

import (
"errors"
"io"
"net/http"
"net/url"
"strings"
"testing"

"github.com/stretchr/testify/assert"
)

func TestCheckResponse(t *testing.T) {
t.Parallel()

tests := []struct {
name string
response *http.Response
expectResponseError bool
expectedError string
}{
{
name: "nil response",
response: nil,
expectedError: errUnknown.Error(),
},
{
name: "successful response 200",
response: &http.Response{StatusCode: http.StatusOK},
expectedError: "",
},
{
name: "unauthorized 401 with invalid JSON",
response: &http.Response{
StatusCode: http.StatusUnauthorized,
Body: io.NopCloser(strings.NewReader("invalid json")),
},
expectedError: errNotAuthenticated.Error(),
},
{
name: "forbidden 403 with invalid JSON",
response: &http.Response{
StatusCode: http.StatusForbidden,
Body: io.NopCloser(strings.NewReader("invalid json")),
},
expectedError: errNotAuthorized.Error(),
},
{
name: "server error 500 with invalid JSON",
response: &http.Response{
StatusCode: http.StatusInternalServerError,
Body: io.NopCloser(strings.NewReader("invalid json")),
Request: &http.Request{URL: mustParseURL(t, "https://api.k6.io/test")},
},
expectedError: "unexpected HTTP error from https://api.k6.io/test: 500 Internal Server Error",
},
{
name: "error with valid JSON payload",
response: &http.Response{
StatusCode: http.StatusBadRequest,
Body: io.NopCloser(strings.NewReader(`{
"error": {
"message": "validation failed",
"code": "error"
}
}`)),
Request: &http.Request{URL: mustParseURL(t, "https://api.k6.io/test")},
},
expectResponseError: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

err := CheckResponse(tt.response)

if tt.expectedError == "" && !tt.expectResponseError {
assert.NoError(t, err)
return
}

assert.Error(t, err)

if tt.expectResponseError {
var respErr ResponseError
assert.True(t, errors.As(err, &respErr))
assert.Equal(t, tt.response, respErr.Response)
} else {
assert.Equal(t, tt.expectedError, err.Error())
}
})
}
}

func mustParseURL(t *testing.T, rawURL string) *url.URL {
u, err := url.Parse(rawURL)
if err != nil {
t.Fatal(err)
}
return u
}
Loading
Loading