Skip to content

Commit 68e21ee

Browse files
Copilotintel352
andcommitted
Address review: URL-escape page token, explicit parse errors, nil-safe constructor, connection error body
Co-authored-by: intel352 <77607+intel352@users.noreply.github.com>
1 parent af10103 commit 68e21ee

4 files changed

Lines changed: 108 additions & 9 deletions

File tree

provider/gcp/deploy_test.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -340,7 +340,8 @@ func TestTestConnection_Success(t *testing.T) {
340340
}
341341
}
342342

343-
// TestTestConnection_Failure verifies that TestConnection returns Success=false on HTTP 403.
343+
// TestTestConnection_Failure verifies that TestConnection returns Success=false on HTTP 403
344+
// and includes the response body in the failure message.
344345
func TestTestConnection_Failure(t *testing.T) {
345346
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
346347
w.WriteHeader(http.StatusForbidden)
@@ -359,6 +360,10 @@ func TestTestConnection_Failure(t *testing.T) {
359360
if !strings.Contains(result.Message, "HTTP 403") {
360361
t.Errorf("expected message to mention HTTP 403, got: %s", result.Message)
361362
}
363+
// The response body should be included in the failure message for easier diagnosis.
364+
if !strings.Contains(result.Message, "permission denied") {
365+
t.Errorf("expected message to include response body, got: %s", result.Message)
366+
}
362367
}
363368

364369
// TestGetMetrics_Success verifies that GetMetrics parses a Cloud Monitoring response correctly.

provider/gcp/plugin.go

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,22 @@ func NewGCPProvider(config GCPConfig) *GCPProvider {
5656
}
5757

5858
// NewGCPProviderWithClient creates a GCPProvider with an injectable HTTP client and token
59-
// function. This constructor is intended for testing.
60-
func NewGCPProviderWithClient(config GCPConfig, client HTTPDoer, tokenFunc func(ctx context.Context) (string, error)) *GCPProvider {
61-
return &GCPProvider{
59+
// function. Nil arguments are replaced with safe defaults: a 30-second http.Client and
60+
// the provider's Application Default Credentials token function.
61+
func NewGCPProviderWithClient(config GCPConfig, client HTTPDoer, tokenFn func(ctx context.Context) (string, error)) *GCPProvider {
62+
if client == nil {
63+
client = &http.Client{Timeout: 30 * time.Second}
64+
}
65+
p := &GCPProvider{
6266
config: config,
6367
httpClient: client,
64-
tokenFunc: tokenFunc,
6568
}
69+
if tokenFn == nil {
70+
p.tokenFunc = p.defaultTokenFunc
71+
} else {
72+
p.tokenFunc = tokenFn
73+
}
74+
return p
6675
}
6776

6877
// defaultTokenFunc retrieves a GCP OAuth2 access token using Application Default Credentials
@@ -286,9 +295,18 @@ func (p *GCPProvider) TestConnection(ctx context.Context, _ map[string]any) (*pr
286295
}
287296
defer resp.Body.Close()
288297
if resp.StatusCode != http.StatusOK {
298+
const maxBodyBytes = 1024
299+
var bodySnippet string
300+
if data, readErr := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes)); readErr == nil && len(data) > 0 {
301+
bodySnippet = strings.TrimSpace(string(data))
302+
}
303+
msg := fmt.Sprintf("gcp: project %q returned HTTP %d", p.config.ProjectID, resp.StatusCode)
304+
if bodySnippet != "" {
305+
msg = fmt.Sprintf("%s: %s", msg, bodySnippet)
306+
}
289307
return &provider.ConnectionResult{
290308
Success: false,
291-
Message: fmt.Sprintf("gcp: project %q returned HTTP %d", p.config.ProjectID, resp.StatusCode),
309+
Message: msg,
292310
Latency: latency,
293311
}, nil
294312
}

provider/gcp/registry.go

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"io"
88
"net/http"
9+
"net/url"
910
"strconv"
1011
"strings"
1112
"time"
@@ -47,7 +48,7 @@ func (p *GCPProvider) ListImages(ctx context.Context, repo string) ([]provider.I
4748
for {
4849
reqURL := endpoint
4950
if pageToken != "" {
50-
reqURL += "?pageToken=" + pageToken
51+
reqURL += "?pageToken=" + url.QueryEscape(pageToken)
5152
}
5253
resp, err := p.doRequest(ctx, http.MethodGet, reqURL, nil)
5354
if err != nil {
@@ -67,8 +68,19 @@ func (p *GCPProvider) ListImages(ctx context.Context, repo string) ([]provider.I
6768
return nil, fmt.Errorf("gcp: failed to parse ListImages response: %w", err)
6869
}
6970
for _, img := range result.DockerImages {
70-
sizeBytes, _ := strconv.ParseInt(img.ImageSizeBytes, 10, 64)
71-
pushedAt, _ := time.Parse(time.RFC3339, img.UploadTime)
71+
sizeBytes, err := strconv.ParseInt(img.ImageSizeBytes, 10, 64)
72+
if err != nil && img.ImageSizeBytes != "" {
73+
return nil, fmt.Errorf("gcp: failed to parse image size %q for image %q: %w",
74+
img.ImageSizeBytes, img.URI, err)
75+
}
76+
var pushedAt time.Time
77+
if img.UploadTime != "" {
78+
pushedAt, err = time.Parse(time.RFC3339, img.UploadTime)
79+
if err != nil {
80+
return nil, fmt.Errorf("gcp: failed to parse upload time %q for image %q: %w",
81+
img.UploadTime, img.URI, err)
82+
}
83+
}
7284
tags := img.Tags
7385
if len(tags) == 0 {
7486
tags = []string{""}

provider/gcp/registry_test.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,3 +297,67 @@ func TestPushImage_FallbackTokenFromProvider(t *testing.T) {
297297
t.Fatalf("PushImage with fallback token: %v", err)
298298
}
299299
}
300+
301+
// TestListImages_InvalidSizeBytes verifies that ListImages returns an error when
302+
// the API returns a non-numeric imageSizeBytes value.
303+
func TestListImages_InvalidSizeBytes(t *testing.T) {
304+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
305+
resp := artifactRegistryDockerImagesResponse{
306+
DockerImages: []artifactRegistryDockerImage{
307+
{URI: "gcr.io/proj/img", Tags: []string{"v1"}, ImageSizeBytes: "not-a-number"},
308+
},
309+
}
310+
w.Header().Set("Content-Type", "application/json")
311+
json.NewEncoder(w).Encode(resp)
312+
}))
313+
defer server.Close()
314+
315+
p := newTestProvider(server, GCPConfig{ProjectID: "proj", Region: "us"})
316+
_, err := p.ListImages(context.Background(), "repo")
317+
if err == nil {
318+
t.Fatal("expected error for invalid imageSizeBytes")
319+
}
320+
if !strings.Contains(err.Error(), "parse image size") {
321+
t.Errorf("unexpected error: %v", err)
322+
}
323+
}
324+
325+
// TestListImages_InvalidUploadTime verifies that ListImages returns an error when
326+
// the API returns a non-RFC3339 uploadTime value.
327+
func TestListImages_InvalidUploadTime(t *testing.T) {
328+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
329+
resp := artifactRegistryDockerImagesResponse{
330+
DockerImages: []artifactRegistryDockerImage{
331+
{URI: "gcr.io/proj/img", Tags: []string{"v1"}, ImageSizeBytes: "100", UploadTime: "not-a-time"},
332+
},
333+
}
334+
w.Header().Set("Content-Type", "application/json")
335+
json.NewEncoder(w).Encode(resp)
336+
}))
337+
defer server.Close()
338+
339+
p := newTestProvider(server, GCPConfig{ProjectID: "proj", Region: "us"})
340+
_, err := p.ListImages(context.Background(), "repo")
341+
if err == nil {
342+
t.Fatal("expected error for invalid uploadTime")
343+
}
344+
if !strings.Contains(err.Error(), "parse upload time") {
345+
t.Errorf("unexpected error: %v", err)
346+
}
347+
}
348+
349+
// TestNewGCPProviderWithClient_NilSafety verifies that nil client and tokenFunc
350+
// arguments do not cause panics; the provider falls back to safe defaults.
351+
func TestNewGCPProviderWithClient_NilSafety(t *testing.T) {
352+
// Should not panic.
353+
p := NewGCPProviderWithClient(GCPConfig{ProjectID: "proj"}, nil, nil)
354+
if p == nil {
355+
t.Fatal("expected non-nil provider")
356+
}
357+
if p.httpClient == nil {
358+
t.Error("expected non-nil httpClient after nil default")
359+
}
360+
if p.tokenFunc == nil {
361+
t.Error("expected non-nil tokenFunc after nil default")
362+
}
363+
}

0 commit comments

Comments
 (0)