Files
googleapis--mcp-toolbox/tests/cloudstorage/cloud_storage_integration_test.go
wehub-resource-sync e04ed9c211
CF: Deploy Dev Docs / deploy (push) Waiting to run
Sync Labels / build (push) Waiting to run
tests / unit tests (macos-latest) (push) Waiting to run
tests / unit tests (ubuntu-latest) (push) Waiting to run
tests / unit tests (windows-latest) (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:32:45 +08:00

2318 lines
80 KiB
Go

// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package cloudstorage
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
"cloud.google.com/go/storage"
"github.com/google/uuid"
"github.com/googleapis/mcp-toolbox/internal/testutils"
"github.com/googleapis/mcp-toolbox/tests"
"google.golang.org/api/iterator"
)
var (
CloudStorageSourceType = "cloud-storage"
CloudStorageProject = os.Getenv("CLOUD_STORAGE_PROJECT")
)
const (
helloObject = "seed/hello.txt"
jsonObject = "seed/nested/data.json"
largeObject = "seed/large.bin"
binaryObject = "seed/binary.bin"
downloadObject = "seed/download.txt"
helloBody = "hello world"
jsonBody = `{"foo":"bar"}`
downloadBody = "download-me"
// largeObjectSize is > the 8 MiB read cap so we can assert the size-limit
// agent-error path on the read_object tool.
largeObjectSize = (8 << 20) + 1024
)
func getCloudStorageVars(t *testing.T) map[string]any {
if CloudStorageProject == "" {
t.Fatal("'CLOUD_STORAGE_PROJECT' not set")
}
return map[string]any{
"type": CloudStorageSourceType,
"project": CloudStorageProject,
}
}
func TestCloudStorageToolEndpoints(t *testing.T) {
sourceConfig := getCloudStorageVars(t)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
client, err := storage.NewClient(ctx)
if err != nil {
t.Fatalf("unable to create Cloud Storage client: %s", err)
}
defer client.Close()
// Bucket names must be globally unique and match [a-z0-9_.-]{3,63}.
bucketName := "toolbox-it-" + strings.ReplaceAll(uuid.New().String(), "-", "")[:20]
t.Logf("Using test bucket %q", bucketName)
teardown := setupCloudStorageTestData(t, ctx, client, CloudStorageProject, bucketName)
defer teardown(t)
configuredDownloadDir := t.TempDir()
toolsFile := getCloudStorageToolsConfig(sourceConfig, bucketName, configuredDownloadDir)
cmd, cleanup, err := tests.StartCmd(ctx, toolsFile, "--enable-api")
if err != nil {
t.Fatalf("command initialization returned an error: %s", err)
}
defer cleanup()
waitCtx, waitCancel := context.WithTimeout(ctx, 10*time.Second)
defer waitCancel()
out, err := testutils.WaitForString(waitCtx, regexp.MustCompile(`Server ready to serve`), cmd.Out)
if err != nil {
t.Logf("toolbox command logs: \n%s", out)
t.Fatalf("toolbox didn't start successfully: %s", err)
}
tests.RunToolGetTestByName(t, "my_list_objects",
map[string]any{
"my_list_objects": map[string]any{
"description": "List objects in a Cloud Storage bucket.",
"authRequired": []any{},
"parameters": []any{
map[string]any{
"authServices": []any{},
"description": "Name of the Cloud Storage bucket to list objects from.",
"name": "bucket",
"required": true,
"type": "string",
},
map[string]any{
"authServices": []any{},
"description": "Filter results to objects whose names begin with this prefix.",
"name": "prefix",
"required": false,
"type": "string",
"default": "",
},
map[string]any{
"authServices": []any{},
"description": "Delimiter used to group object names (typically '/'). When set, common prefixes are returned as 'prefixes'.",
"name": "delimiter",
"required": false,
"type": "string",
"default": "",
},
map[string]any{
"authServices": []any{},
"description": "Maximum number of objects to return per page. A value of 0 uses the API default (1000); negative values and values above 1000 are rejected.",
"name": "max_results",
"required": false,
"type": "integer",
"default": float64(0),
},
map[string]any{
"authServices": []any{},
"description": "A previously-returned page token for retrieving the next page of results.",
"name": "page_token",
"required": false,
"type": "string",
"default": "",
},
},
},
},
)
tests.RunToolGetTestByName(t, "my_read_object",
map[string]any{
"my_read_object": map[string]any{
"description": "Read a Cloud Storage object.",
"authRequired": []any{},
"parameters": []any{
map[string]any{
"authServices": []any{},
"description": "Name of the Cloud Storage bucket containing the object.",
"name": "bucket",
"required": true,
"type": "string",
},
map[string]any{
"authServices": []any{},
"description": "Full object name (path) within the bucket, e.g. 'path/to/file.txt'.",
"name": "object",
"required": true,
"type": "string",
},
map[string]any{
"authServices": []any{},
"description": "Optional HTTP byte range, e.g. 'bytes=0-999' (first 1000 bytes), 'bytes=-500' (last 500 bytes), or 'bytes=500-' (from byte 500 to end). Empty reads the full object.",
"name": "range",
"required": false,
"type": "string",
"default": "",
},
},
},
},
)
tests.RunToolGetTestByName(t, "my_list_buckets",
map[string]any{
"my_list_buckets": map[string]any{
"description": "List Cloud Storage buckets in the project.",
"authRequired": []any{},
"parameters": []any{
map[string]any{
"authServices": []any{},
"description": "Project ID to list buckets in. When empty, the source's configured project is used.",
"name": "project",
"required": false,
"type": "string",
"default": "",
},
map[string]any{
"authServices": []any{},
"description": "Filter results to buckets whose names begin with this prefix.",
"name": "prefix",
"required": false,
"type": "string",
"default": "",
},
map[string]any{
"authServices": []any{},
"description": "Maximum number of buckets to return per page. A value of 0 uses the API default (1000); negative values and values above 1000 are rejected.",
"name": "max_results",
"required": false,
"type": "integer",
"default": float64(0),
},
map[string]any{
"authServices": []any{},
"description": "A previously-returned page token for retrieving the next page of results.",
"name": "page_token",
"required": false,
"type": "string",
"default": "",
},
},
},
},
)
tests.RunToolGetTestByName(t, "my_create_bucket",
map[string]any{
"my_create_bucket": map[string]any{
"description": "Create a Cloud Storage bucket.",
"authRequired": []any{},
"parameters": []any{
map[string]any{
"authServices": []any{},
"description": "Name of the Cloud Storage bucket to create.",
"name": "bucket",
"required": true,
"type": "string",
},
map[string]any{
"authServices": []any{},
"description": "Project ID to create the bucket in. When empty, the source's configured project is used.",
"name": "project",
"required": false,
"type": "string",
"default": "",
},
map[string]any{
"authServices": []any{},
"description": "Location for the bucket, e.g. 'US', 'EU', or 'us-central1'. Omit to use the Cloud Storage service default.",
"name": "location",
"required": false,
"type": "string",
},
map[string]any{
"authServices": []any{},
"description": "Whether to enable uniform bucket-level access on the bucket.",
"name": "uniform_bucket_level_access",
"required": false,
"type": "boolean",
"default": false,
},
},
},
},
)
tests.RunToolGetTestByName(t, "my_get_bucket_metadata",
map[string]any{
"my_get_bucket_metadata": map[string]any{
"description": "Get metadata for a Cloud Storage bucket.",
"authRequired": []any{},
"parameters": []any{
map[string]any{
"authServices": []any{},
"description": "Name of the Cloud Storage bucket to inspect.",
"name": "bucket",
"required": true,
"type": "string",
},
},
},
},
)
tests.RunToolGetTestByName(t, "my_get_bucket_iam_policy",
map[string]any{
"my_get_bucket_iam_policy": map[string]any{
"description": "Get the IAM policy for a Cloud Storage bucket.",
"authRequired": []any{},
"parameters": []any{
map[string]any{
"authServices": []any{},
"description": "Name of the Cloud Storage bucket whose IAM policy should be returned.",
"name": "bucket",
"required": true,
"type": "string",
},
},
},
},
)
tests.RunToolGetTestByName(t, "my_delete_bucket",
map[string]any{
"my_delete_bucket": map[string]any{
"description": "Delete an empty Cloud Storage bucket.",
"authRequired": []any{},
"parameters": []any{
map[string]any{
"authServices": []any{},
"description": "Name of the empty Cloud Storage bucket to delete.",
"name": "bucket",
"required": true,
"type": "string",
},
},
},
},
)
tests.RunToolGetTestByName(t, "my_get_object_metadata",
map[string]any{
"my_get_object_metadata": map[string]any{
"description": "Get metadata for a Cloud Storage object.",
"authRequired": []any{},
"parameters": []any{
map[string]any{
"authServices": []any{},
"description": "Name of the Cloud Storage bucket containing the object.",
"name": "bucket",
"required": true,
"type": "string",
},
map[string]any{
"authServices": []any{},
"description": "Full object name (path) within the bucket, e.g. 'path/to/file.txt'.",
"name": "object",
"required": true,
"type": "string",
},
},
},
},
)
tests.RunToolGetTestByName(t, "my_download_object",
map[string]any{
"my_download_object": map[string]any{
"description": "Download a Cloud Storage object to a local file.",
"authRequired": []any{},
"parameters": []any{
map[string]any{
"authServices": []any{},
"description": "Name of the Cloud Storage bucket containing the object.",
"name": "bucket",
"required": true,
"type": "string",
},
map[string]any{
"authServices": []any{},
"description": "Full object name (path) within the bucket, e.g. 'path/to/file.txt'.",
"name": "object",
"required": true,
"type": "string",
},
map[string]any{
"authServices": []any{},
"description": "Absolute local filesystem path where the object will be written. Relative paths and paths containing '..' are rejected.",
"name": "destination",
"required": true,
"type": "string",
},
map[string]any{
"authServices": []any{},
"description": "If true, overwrite the destination when it already exists. If false (default), the tool returns an error when the destination exists.",
"name": "overwrite",
"required": false,
"type": "boolean",
"default": false,
},
},
},
},
)
tests.RunToolGetTestByName(t, "my_upload_object",
map[string]any{
"my_upload_object": map[string]any{
"description": "Upload a local file to a Cloud Storage object.",
"authRequired": []any{},
"parameters": []any{
map[string]any{
"authServices": []any{},
"description": "Name of the Cloud Storage bucket to upload into.",
"name": "bucket",
"required": true,
"type": "string",
},
map[string]any{
"authServices": []any{},
"description": "Full object name (path) within the bucket, e.g. 'path/to/file.txt'.",
"name": "object",
"required": true,
"type": "string",
},
map[string]any{
"authServices": []any{},
"description": "Absolute local filesystem path of the file to upload. Relative paths and paths containing '..' are rejected.",
"name": "source",
"required": true,
"type": "string",
},
map[string]any{
"authServices": []any{},
"description": "MIME type to record on the uploaded object. When empty, it is inferred from the source file's extension; if that fails, Cloud Storage auto-detects from the first 512 bytes of content.",
"name": "content_type",
"required": false,
"type": "string",
"default": "",
},
},
},
},
)
tests.RunToolGetTestByName(t, "my_write_object",
map[string]any{
"my_write_object": map[string]any{
"description": "Write text content to a Cloud Storage object.",
"authRequired": []any{},
"parameters": []any{
map[string]any{
"authServices": []any{},
"description": "Name of the Cloud Storage bucket to write into.",
"name": "bucket",
"required": true,
"type": "string",
},
map[string]any{
"authServices": []any{},
"description": "Full object name (path) within the bucket, e.g. 'path/to/file.txt'.",
"name": "object",
"required": true,
"type": "string",
},
map[string]any{
"authServices": []any{},
"description": "Text content to write to the Cloud Storage object.",
"name": "content",
"required": true,
"type": "string",
},
map[string]any{
"authServices": []any{},
"description": "MIME type to record on the written object. When empty, Cloud Storage auto-detects from the first 512 bytes of content.",
"name": "content_type",
"required": false,
"type": "string",
"default": "",
},
},
},
},
)
tests.RunToolGetTestByName(t, "my_copy_object",
map[string]any{
"my_copy_object": map[string]any{
"description": "Copy a Cloud Storage object.",
"authRequired": []any{},
"parameters": []any{
map[string]any{
"authServices": []any{},
"description": "Name of the Cloud Storage bucket containing the source object.",
"name": "source_bucket",
"required": true,
"type": "string",
},
map[string]any{
"authServices": []any{},
"description": "Full source object name (path) within the source bucket, e.g. 'path/to/file.txt'.",
"name": "source_object",
"required": true,
"type": "string",
},
map[string]any{
"authServices": []any{},
"description": "Name of the Cloud Storage bucket to copy into.",
"name": "destination_bucket",
"required": true,
"type": "string",
},
map[string]any{
"authServices": []any{},
"description": "Full destination object name (path) within the destination bucket, e.g. 'path/to/file.txt'.",
"name": "destination_object",
"required": true,
"type": "string",
},
},
},
},
)
tests.RunToolGetTestByName(t, "my_move_object",
map[string]any{
"my_move_object": map[string]any{
"description": "Move a Cloud Storage object within the same bucket.",
"authRequired": []any{},
"parameters": []any{
map[string]any{
"authServices": []any{},
"description": "Name of the Cloud Storage bucket containing the object to move.",
"name": "bucket",
"required": true,
"type": "string",
},
map[string]any{
"authServices": []any{},
"description": "Full source object name (path) within the bucket, e.g. 'path/to/file.txt'.",
"name": "source_object",
"required": true,
"type": "string",
},
map[string]any{
"authServices": []any{},
"description": "Full destination object name (path) within the same bucket, e.g. 'path/to/file.txt'.",
"name": "destination_object",
"required": true,
"type": "string",
},
},
},
},
)
tests.RunToolGetTestByName(t, "my_delete_object",
map[string]any{
"my_delete_object": map[string]any{
"description": "Delete a Cloud Storage object.",
"authRequired": []any{},
"parameters": []any{
map[string]any{
"authServices": []any{},
"description": "Name of the Cloud Storage bucket containing the object to delete.",
"name": "bucket",
"required": true,
"type": "string",
},
map[string]any{
"authServices": []any{},
"description": "Full object name (path) within the bucket, e.g. 'path/to/file.txt'.",
"name": "object",
"required": true,
"type": "string",
},
},
},
},
)
// Tools with parameters baked into their config drop those parameters from
// the schema exposed to the LLM. The assertions below confirm each
// configured parameter is absent from the manifest.
tests.RunToolGetTestByName(t, "my_list_objects_configured",
map[string]any{
"my_list_objects_configured": map[string]any{
"description": "List objects in a configured Cloud Storage bucket.",
"authRequired": []any{},
"parameters": []any{
map[string]any{
"authServices": []any{},
"description": "Maximum number of objects to return per page. A value of 0 uses the API default (1000); negative values and values above 1000 are rejected.",
"name": "max_results",
"required": false,
"type": "integer",
"default": float64(0),
},
map[string]any{
"authServices": []any{},
"description": "A previously-returned page token for retrieving the next page of results.",
"name": "page_token",
"required": false,
"type": "string",
"default": "",
},
},
},
},
)
tests.RunToolGetTestByName(t, "my_list_buckets_configured",
map[string]any{
"my_list_buckets_configured": map[string]any{
"description": "List Cloud Storage buckets in a configured project.",
"authRequired": []any{},
"parameters": []any{
map[string]any{
"authServices": []any{},
"description": "Maximum number of buckets to return per page. A value of 0 uses the API default (1000); negative values and values above 1000 are rejected.",
"name": "max_results",
"required": false,
"type": "integer",
"default": float64(0),
},
map[string]any{
"authServices": []any{},
"description": "A previously-returned page token for retrieving the next page of results.",
"name": "page_token",
"required": false,
"type": "string",
"default": "",
},
},
},
},
)
tests.RunToolGetTestByName(t, "my_read_object_configured",
map[string]any{
"my_read_object_configured": map[string]any{
"description": "Read an object from a configured Cloud Storage bucket.",
"authRequired": []any{},
"parameters": []any{
map[string]any{
"authServices": []any{},
"description": "Full object name (path) within the bucket, e.g. 'path/to/file.txt'.",
"name": "object",
"required": true,
"type": "string",
},
map[string]any{
"authServices": []any{},
"description": "Optional HTTP byte range, e.g. 'bytes=0-999' (first 1000 bytes), 'bytes=-500' (last 500 bytes), or 'bytes=500-' (from byte 500 to end). Empty reads the full object.",
"name": "range",
"required": false,
"type": "string",
"default": "",
},
},
},
},
)
tests.RunToolGetTestByName(t, "my_get_object_metadata_configured",
map[string]any{
"my_get_object_metadata_configured": map[string]any{
"description": "Get object metadata from a configured Cloud Storage bucket.",
"authRequired": []any{},
"parameters": []any{
map[string]any{
"authServices": []any{},
"description": "Full object name (path) within the bucket, e.g. 'path/to/file.txt'.",
"name": "object",
"required": true,
"type": "string",
},
},
},
},
)
tests.RunToolGetTestByName(t, "my_get_bucket_metadata_configured",
map[string]any{
"my_get_bucket_metadata_configured": map[string]any{
"description": "Get metadata for a configured Cloud Storage bucket.",
"authRequired": []any{},
"parameters": []any{},
},
},
)
tests.RunToolGetTestByName(t, "my_get_bucket_iam_policy_configured",
map[string]any{
"my_get_bucket_iam_policy_configured": map[string]any{
"description": "Get the IAM policy for a configured Cloud Storage bucket.",
"authRequired": []any{},
"parameters": []any{},
},
},
)
tests.RunToolGetTestByName(t, "my_create_bucket_configured",
map[string]any{
"my_create_bucket_configured": map[string]any{
"description": "Create a Cloud Storage bucket with configured settings.",
"authRequired": []any{},
"parameters": []any{
map[string]any{
"authServices": []any{},
"description": "Name of the Cloud Storage bucket to create.",
"name": "bucket",
"required": true,
"type": "string",
},
},
},
},
)
tests.RunToolGetTestByName(t, "my_download_object_configured",
map[string]any{
"my_download_object_configured": map[string]any{
"description": "Download a Cloud Storage object with configured storage settings.",
"authRequired": []any{},
"parameters": []any{
map[string]any{
"authServices": []any{},
"description": "Full object name (path) within the bucket, e.g. 'path/to/file.txt'.",
"name": "object",
"required": true,
"type": "string",
},
map[string]any{
"authServices": []any{},
"description": "Relative path under the configured destination_dir where the object will be written. Absolute paths and paths that escape destination_dir are rejected.",
"name": "destination",
"required": true,
"type": "string",
},
},
},
},
)
tests.RunToolGetTestByName(t, "my_upload_object_configured",
map[string]any{
"my_upload_object_configured": map[string]any{
"description": "Upload a local file to a configured Cloud Storage bucket.",
"authRequired": []any{},
"parameters": []any{
map[string]any{
"authServices": []any{},
"description": "Full object name (path) within the bucket, e.g. 'path/to/file.txt'.",
"name": "object",
"required": true,
"type": "string",
},
map[string]any{
"authServices": []any{},
"description": "Absolute local filesystem path of the file to upload. Relative paths and paths containing '..' are rejected.",
"name": "source",
"required": true,
"type": "string",
},
map[string]any{
"authServices": []any{},
"description": "MIME type to record on the uploaded object. When empty, it is inferred from the source file's extension; if that fails, Cloud Storage auto-detects from the first 512 bytes of content.",
"name": "content_type",
"required": false,
"type": "string",
"default": "",
},
},
},
},
)
tests.RunToolGetTestByName(t, "my_write_object_configured",
map[string]any{
"my_write_object_configured": map[string]any{
"description": "Write text content to a configured Cloud Storage bucket.",
"authRequired": []any{},
"parameters": []any{
map[string]any{
"authServices": []any{},
"description": "Full object name (path) within the bucket, e.g. 'path/to/file.txt'.",
"name": "object",
"required": true,
"type": "string",
},
map[string]any{
"authServices": []any{},
"description": "Text content to write to the Cloud Storage object.",
"name": "content",
"required": true,
"type": "string",
},
map[string]any{
"authServices": []any{},
"description": "MIME type to record on the written object. When empty, Cloud Storage auto-detects from the first 512 bytes of content.",
"name": "content_type",
"required": false,
"type": "string",
"default": "",
},
},
},
},
)
tests.RunToolGetTestByName(t, "my_copy_object_configured",
map[string]any{
"my_copy_object_configured": map[string]any{
"description": "Copy a Cloud Storage object between configured buckets.",
"authRequired": []any{},
"parameters": []any{
map[string]any{
"authServices": []any{},
"description": "Full source object name (path) within the source bucket, e.g. 'path/to/file.txt'.",
"name": "source_object",
"required": true,
"type": "string",
},
map[string]any{
"authServices": []any{},
"description": "Full destination object name (path) within the destination bucket, e.g. 'path/to/file.txt'.",
"name": "destination_object",
"required": true,
"type": "string",
},
},
},
},
)
tests.RunToolGetTestByName(t, "my_move_object_configured",
map[string]any{
"my_move_object_configured": map[string]any{
"description": "Move a Cloud Storage object within a configured bucket.",
"authRequired": []any{},
"parameters": []any{
map[string]any{
"authServices": []any{},
"description": "Full source object name (path) within the bucket, e.g. 'path/to/file.txt'.",
"name": "source_object",
"required": true,
"type": "string",
},
map[string]any{
"authServices": []any{},
"description": "Full destination object name (path) within the same bucket, e.g. 'path/to/file.txt'.",
"name": "destination_object",
"required": true,
"type": "string",
},
},
},
},
)
tests.RunToolGetTestByName(t, "my_delete_object_configured",
map[string]any{
"my_delete_object_configured": map[string]any{
"description": "Delete an object from a configured Cloud Storage bucket.",
"authRequired": []any{},
"parameters": []any{
map[string]any{
"authServices": []any{},
"description": "Full object name (path) within the bucket, e.g. 'path/to/file.txt'.",
"name": "object",
"required": true,
"type": "string",
},
},
},
},
)
runCloudStorageListObjectsTest(t, bucketName)
runCloudStorageReadObjectTest(t, bucketName)
runCloudStorageListBucketsTest(t, bucketName)
runCloudStorageGetObjectMetadataTest(t, bucketName)
runCloudStorageConfiguredParamsTest(ctx, t, client, bucketName, configuredDownloadDir)
bucketToolName := "toolbox-it-bucket-" + strings.ReplaceAll(uuid.New().String(), "-", "")[:17]
defer cleanupGCSBucket(ctx, t, client, bucketToolName)
runCloudStorageCreateBucketTest(ctx, t, client, bucketToolName)
runCloudStorageGetBucketMetadataTest(t, bucketToolName)
runCloudStorageGetBucketIAMPolicyTest(t, bucketToolName)
runCloudStorageDeleteBucketTest(ctx, t, client, bucketToolName)
runCloudStorageDownloadObjectTest(t, bucketName)
runCloudStorageUploadObjectTest(ctx, t, client, bucketName)
runCloudStorageWriteObjectTest(ctx, t, client, bucketName)
runCloudStorageCopyObjectTest(ctx, t, client, bucketName)
runCloudStorageMoveObjectTest(ctx, t, client, bucketName)
runCloudStorageDeleteObjectTest(ctx, t, client, bucketName)
}
func getCloudStorageToolsConfig(sourceConfig map[string]any, bucketName, configuredDownloadDir string) map[string]any {
return map[string]any{
"sources": map[string]any{
"my_instance": sourceConfig,
},
"tools": map[string]any{
"my_list_objects": map[string]any{
"type": "cloud-storage-list-objects",
"source": "my_instance",
"description": "List objects in a Cloud Storage bucket.",
},
"my_read_object": map[string]any{
"type": "cloud-storage-read-object",
"source": "my_instance",
"description": "Read a Cloud Storage object.",
},
"my_list_buckets": map[string]any{
"type": "cloud-storage-list-buckets",
"source": "my_instance",
"description": "List Cloud Storage buckets in the project.",
},
"my_create_bucket": map[string]any{
"type": "cloud-storage-create-bucket",
"source": "my_instance",
"description": "Create a Cloud Storage bucket.",
},
"my_get_bucket_metadata": map[string]any{
"type": "cloud-storage-get-bucket-metadata",
"source": "my_instance",
"description": "Get metadata for a Cloud Storage bucket.",
},
"my_get_bucket_iam_policy": map[string]any{
"type": "cloud-storage-get-bucket-iam-policy",
"source": "my_instance",
"description": "Get the IAM policy for a Cloud Storage bucket.",
},
"my_delete_bucket": map[string]any{
"type": "cloud-storage-delete-bucket",
"source": "my_instance",
"description": "Delete an empty Cloud Storage bucket.",
},
"my_get_object_metadata": map[string]any{
"type": "cloud-storage-get-object-metadata",
"source": "my_instance",
"description": "Get metadata for a Cloud Storage object.",
},
"my_download_object": map[string]any{
"type": "cloud-storage-download-object",
"source": "my_instance",
"description": "Download a Cloud Storage object to a local file.",
},
"my_upload_object": map[string]any{
"type": "cloud-storage-upload-object",
"source": "my_instance",
"description": "Upload a local file to a Cloud Storage object.",
},
"my_write_object": map[string]any{
"type": "cloud-storage-write-object",
"source": "my_instance",
"description": "Write text content to a Cloud Storage object.",
},
"my_copy_object": map[string]any{
"type": "cloud-storage-copy-object",
"source": "my_instance",
"description": "Copy a Cloud Storage object.",
},
"my_move_object": map[string]any{
"type": "cloud-storage-move-object",
"source": "my_instance",
"description": "Move a Cloud Storage object within the same bucket.",
},
"my_delete_object": map[string]any{
"type": "cloud-storage-delete-object",
"source": "my_instance",
"description": "Delete a Cloud Storage object.",
},
// Configured variants bake static parameter values into the tool
// config so those parameters are removed from the schema and the
// configured values are used at invocation time.
"my_list_objects_configured": map[string]any{
"type": "cloud-storage-list-objects",
"source": "my_instance",
"description": "List objects in a configured Cloud Storage bucket.",
"bucket": bucketName,
"prefix": "seed/",
"delimiter": "",
},
"my_list_buckets_configured": map[string]any{
"type": "cloud-storage-list-buckets",
"source": "my_instance",
"description": "List Cloud Storage buckets in a configured project.",
"project": CloudStorageProject,
"prefix": "toolbox-it-",
},
"my_read_object_configured": map[string]any{
"type": "cloud-storage-read-object",
"source": "my_instance",
"description": "Read an object from a configured Cloud Storage bucket.",
"bucket": bucketName,
},
"my_get_object_metadata_configured": map[string]any{
"type": "cloud-storage-get-object-metadata",
"source": "my_instance",
"description": "Get object metadata from a configured Cloud Storage bucket.",
"bucket": bucketName,
},
"my_get_bucket_metadata_configured": map[string]any{
"type": "cloud-storage-get-bucket-metadata",
"source": "my_instance",
"description": "Get metadata for a configured Cloud Storage bucket.",
"bucket": bucketName,
},
"my_get_bucket_iam_policy_configured": map[string]any{
"type": "cloud-storage-get-bucket-iam-policy",
"source": "my_instance",
"description": "Get the IAM policy for a configured Cloud Storage bucket.",
"bucket": bucketName,
},
"my_create_bucket_configured": map[string]any{
"type": "cloud-storage-create-bucket",
"source": "my_instance",
"description": "Create a Cloud Storage bucket with configured settings.",
"project": CloudStorageProject,
"location": "US",
"uniform_bucket_level_access": true,
},
"my_download_object_configured": map[string]any{
"type": "cloud-storage-download-object",
"source": "my_instance",
"description": "Download a Cloud Storage object with configured storage settings.",
"bucket": bucketName,
"destination_dir": configuredDownloadDir,
"overwrite": true,
},
"my_upload_object_configured": map[string]any{
"type": "cloud-storage-upload-object",
"source": "my_instance",
"description": "Upload a local file to a configured Cloud Storage bucket.",
"bucket": bucketName,
},
"my_write_object_configured": map[string]any{
"type": "cloud-storage-write-object",
"source": "my_instance",
"description": "Write text content to a configured Cloud Storage bucket.",
"bucket": bucketName,
},
"my_copy_object_configured": map[string]any{
"type": "cloud-storage-copy-object",
"source": "my_instance",
"description": "Copy a Cloud Storage object between configured buckets.",
"source_bucket": bucketName,
"destination_bucket": bucketName,
},
"my_move_object_configured": map[string]any{
"type": "cloud-storage-move-object",
"source": "my_instance",
"description": "Move a Cloud Storage object within a configured bucket.",
"bucket": bucketName,
},
"my_delete_object_configured": map[string]any{
"type": "cloud-storage-delete-object",
"source": "my_instance",
"description": "Delete an object from a configured Cloud Storage bucket.",
"bucket": bucketName,
},
},
}
}
func setupCloudStorageTestData(t *testing.T, ctx context.Context, client *storage.Client, project, bucket string) func(*testing.T) {
bkt := client.Bucket(bucket)
if err := bkt.Create(ctx, project, &storage.BucketAttrs{Location: "US"}); err != nil {
t.Fatalf("failed to create bucket %q: %v", bucket, err)
}
writeSeed := func(name, contentType, body string) {
w := bkt.Object(name).NewWriter(ctx)
w.ContentType = contentType
if _, err := io.WriteString(w, body); err != nil {
_ = w.Close()
t.Fatalf("failed to write seed object %q: %v", name, err)
}
if err := w.Close(); err != nil {
t.Fatalf("failed to close writer for seed object %q: %v", name, err)
}
}
writeSeed(helloObject, "text/plain", helloBody)
writeSeed(jsonObject, "application/json", jsonBody)
writeSeed(downloadObject, "text/plain", downloadBody)
// Seed an oversize object to exercise the read-size cap.
large := bytes.Repeat([]byte{'A'}, largeObjectSize)
lw := bkt.Object(largeObject).NewWriter(ctx)
lw.ContentType = "application/octet-stream"
if _, err := lw.Write(large); err != nil {
_ = lw.Close()
t.Fatalf("failed to write seed object %q: %v", largeObject, err)
}
if err := lw.Close(); err != nil {
t.Fatalf("failed to close writer for seed object %q: %v", largeObject, err)
}
// Seed a small binary (non-UTF-8) object to exercise the
// ErrBinaryContent path on read_object.
binary := []byte{0xff, 0xfe, 0xfd, 0xfc}
bw := bkt.Object(binaryObject).NewWriter(ctx)
bw.ContentType = "application/octet-stream"
if _, err := bw.Write(binary); err != nil {
_ = bw.Close()
t.Fatalf("failed to write seed object %q: %v", binaryObject, err)
}
if err := bw.Close(); err != nil {
t.Fatalf("failed to close writer for seed object %q: %v", binaryObject, err)
}
return func(t *testing.T) {
cleanupCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
it := bkt.Objects(cleanupCtx, nil)
for {
attrs, err := it.Next()
if err == iterator.Done {
break
}
if err != nil {
t.Logf("cleanup: iterator error, aborting object delete loop: %v", err)
break
}
if delErr := bkt.Object(attrs.Name).Delete(cleanupCtx); delErr != nil {
t.Logf("cleanup: failed to delete object %q: %v", attrs.Name, delErr)
}
}
if err := bkt.Delete(cleanupCtx); err != nil {
t.Logf("cleanup: failed to delete bucket %q: %v", bucket, err)
}
}
}
// invokeTool POSTs to the tool invoke endpoint and returns the parsed `result`
// string (which is itself a JSON-encoded payload). On non-200 responses, the
// full body is returned as the error.
func invokeTool(t *testing.T, toolName, requestBody string) (string, int) {
t.Helper()
url := fmt.Sprintf("http://127.0.0.1:5000/api/tool/%s/invoke", toolName)
req, err := http.NewRequest(http.MethodPost, url, bytes.NewBufferString(requestBody))
if err != nil {
t.Fatalf("unable to create request: %s", err)
}
req.Header.Add("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("unable to send request: %s", err)
}
defer resp.Body.Close()
bodyBytes, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return string(bodyBytes), resp.StatusCode
}
var body map[string]any
if err := json.Unmarshal(bodyBytes, &body); err != nil {
t.Fatalf("failed to parse response JSON: %s (body=%s)", err, string(bodyBytes))
}
result, _ := body["result"].(string)
return result, resp.StatusCode
}
func runCloudStorageListObjectsTest(t *testing.T, bucket string) {
fakeBucket := "toolbox-it-does-not-exist-" + strings.ReplaceAll(uuid.New().String(), "-", "")[:12]
tcs := []struct {
name string
body string
wantSubstrings []string
}{
{
name: "list with prefix",
body: fmt.Sprintf(`{"bucket": %q, "prefix": "seed/"}`, bucket),
wantSubstrings: []string{helloObject, jsonObject},
},
{
name: "empty prefix and delimiter lists all objects",
body: fmt.Sprintf(`{"bucket": %q, "prefix": "", "delimiter": ""}`, bucket),
wantSubstrings: []string{helloObject, jsonObject},
},
{
name: "empty page_token behaves as first page",
body: fmt.Sprintf(`{"bucket": %q, "prefix": "seed/", "page_token": ""}`, bucket),
wantSubstrings: []string{helloObject, jsonObject},
},
{
name: "list with delimiter returns prefixes",
body: fmt.Sprintf(`{"bucket": %q, "prefix": "seed/", "delimiter": "/"}`, bucket),
wantSubstrings: []string{helloObject, `"seed/nested/"`},
},
{
name: "missing bucket parameter returns agent error",
body: `{}`,
wantSubstrings: []string{"bucket"},
},
{
name: "max_results above 1000 returns agent error",
body: fmt.Sprintf(`{"bucket": %q, "max_results": 1001}`, bucket),
wantSubstrings: []string{"max_results", "1000"},
},
{
name: "negative max_results returns agent error",
body: fmt.Sprintf(`{"bucket": %q, "max_results": -1}`, bucket),
wantSubstrings: []string{"max_results", "must be"},
},
{
name: "nonexistent bucket returns error",
body: fmt.Sprintf(`{"bucket": %q}`, fakeBucket),
wantSubstrings: []string{fakeBucket},
},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
result, status := invokeTool(t, "my_list_objects", tc.body)
if status != http.StatusOK {
t.Fatalf("unexpected status %d: %s", status, result)
}
for _, want := range tc.wantSubstrings {
if !strings.Contains(result, want) {
t.Errorf("expected result to contain %q, got %s", want, result)
}
}
})
}
// Pagination is inherently two-step (fetch page one, reuse its token for
// page two), so it doesn't fit the single-request table above.
t.Run("pagination via max_results and page_token", func(t *testing.T) {
result, status := invokeTool(t, "my_list_objects",
fmt.Sprintf(`{"bucket": %q, "prefix": "seed/", "max_results": 1}`, bucket))
if status != http.StatusOK {
t.Fatalf("unexpected status %d: %s", status, result)
}
token := extractStringField(t, result, "nextPageToken")
if token == "" {
t.Fatalf("expected non-empty nextPageToken, got %s", result)
}
result2, status := invokeTool(t, "my_list_objects",
fmt.Sprintf(`{"bucket": %q, "prefix": "seed/", "page_token": %q}`, bucket, token))
if status != http.StatusOK {
t.Fatalf("unexpected status %d: %s", status, result2)
}
combined := result + result2
if !strings.Contains(combined, helloObject) || !strings.Contains(combined, jsonObject) {
t.Errorf("expected both %q and %q across paginated results, got page1=%s page2=%s",
helloObject, jsonObject, result, result2)
}
})
}
func runCloudStorageReadObjectTest(t *testing.T, bucket string) {
tcs := []struct {
name string
body string
wantContent string
wantContentType string
wantSubstrings []string
}{
{
name: "read full object",
body: fmt.Sprintf(`{"bucket": %q, "object": %q}`, bucket, helloObject),
wantContent: helloBody,
wantContentType: "text/plain",
},
{
name: "read range bytes=0-4",
body: fmt.Sprintf(`{"bucket": %q, "object": %q, "range": "bytes=0-4"}`, bucket, helloObject),
wantContent: "hello",
},
{
name: "read suffix range bytes=-5",
body: fmt.Sprintf(`{"bucket": %q, "object": %q, "range": "bytes=-5"}`, bucket, helloObject),
wantContent: "world",
},
{
name: "read open-ended range bytes=6-",
body: fmt.Sprintf(`{"bucket": %q, "object": %q, "range": "bytes=6-"}`, bucket, helloObject),
wantContent: "world",
},
{
name: "oversize read narrowed by range succeeds",
body: fmt.Sprintf(`{"bucket": %q, "object": %q, "range": "bytes=0-9"}`, bucket, largeObject),
wantContent: "AAAAAAAAAA",
},
{
name: "missing object parameter returns agent error",
body: fmt.Sprintf(`{"bucket": %q}`, bucket),
wantSubstrings: []string{"object"},
},
{
name: "nonexistent object returns error",
body: fmt.Sprintf(`{"bucket": %q, "object": "does/not/exist.bin"}`, bucket),
wantSubstrings: []string{"does/not/exist.bin"},
},
{
name: "invalid range returns agent error",
body: fmt.Sprintf(`{"bucket": %q, "object": %q, "range": "garbage"}`, bucket, helloObject),
wantSubstrings: []string{"range"},
},
{
name: "oversize read returns agent error",
body: fmt.Sprintf(`{"bucket": %q, "object": %q}`, bucket, largeObject),
wantSubstrings: []string{"size limit"},
},
{
name: "binary object returns agent error",
body: fmt.Sprintf(`{"bucket": %q, "object": %q}`, bucket, binaryObject),
wantSubstrings: []string{"UTF-8"},
},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
result, status := invokeTool(t, "my_read_object", tc.body)
if status != http.StatusOK {
t.Fatalf("unexpected status %d: %s", status, result)
}
if tc.wantContent != "" {
if got := extractStringField(t, result, "content"); got != tc.wantContent {
t.Errorf("expected content %q, got %q (raw %s)", tc.wantContent, got, result)
}
}
if tc.wantContentType != "" {
if got := extractStringField(t, result, "contentType"); got != tc.wantContentType {
t.Errorf("expected contentType %q, got %q (raw %s)", tc.wantContentType, got, result)
}
}
for _, want := range tc.wantSubstrings {
if !strings.Contains(result, want) {
t.Errorf("expected result to contain %q, got %s", want, result)
}
}
})
}
}
// extractStringField pulls a top-level string field out of a JSON-encoded result
// string (the kind the tool invoke API wraps in the `result` property).
func extractStringField(t *testing.T, result, field string) string {
t.Helper()
var parsed map[string]any
if err := json.Unmarshal([]byte(result), &parsed); err != nil {
t.Fatalf("failed to parse tool result JSON: %s (raw=%s)", err, result)
}
v, _ := parsed[field].(string)
return v
}
func runCloudStorageListBucketsTest(t *testing.T, bucket string) {
tcs := []struct {
name string
body string
wantSubstrings []string
unwantSubstrings []string
}{
{
name: "list with matching prefix finds the test bucket",
body: fmt.Sprintf(`{"prefix": %q}`, bucket[:10]),
wantSubstrings: []string{bucket},
},
{
name: "list with non-matching prefix omits the test bucket",
body: `{"prefix": "toolbox-it-definitely-not-a-real-prefix-"}`,
unwantSubstrings: []string{bucket},
},
{
name: "explicit project override returns the test bucket",
body: fmt.Sprintf(`{"project": %q, "prefix": %q}`, CloudStorageProject, bucket[:10]),
wantSubstrings: []string{bucket},
},
{
name: "max_results above 1000 returns agent error",
body: `{"max_results": 1001}`,
wantSubstrings: []string{"max_results", "1000"},
},
{
name: "negative max_results returns agent error",
body: `{"max_results": -1}`,
wantSubstrings: []string{"max_results", "must be"},
},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
result, status := invokeTool(t, "my_list_buckets", tc.body)
if status != http.StatusOK {
t.Fatalf("unexpected status %d: %s", status, result)
}
for _, want := range tc.wantSubstrings {
if !strings.Contains(result, want) {
t.Errorf("expected result to contain %q, got %s", want, result)
}
}
for _, unwant := range tc.unwantSubstrings {
if strings.Contains(result, unwant) {
t.Errorf("did not expect result to contain %q, got %s", unwant, result)
}
}
})
}
}
func runCloudStorageCreateBucketTest(ctx context.Context, t *testing.T, client *storage.Client, bucket string) {
t.Run("create bucket with omitted location", func(t *testing.T) {
body := fmt.Sprintf(`{"bucket": %q, "uniform_bucket_level_access": true}`, bucket)
result, status := invokeTool(t, "my_create_bucket", body)
if status != http.StatusOK {
t.Fatalf("unexpected status %d: %s", status, result)
}
for _, want := range []string{bucket, `"created":true`, `"metadata"`} {
if !strings.Contains(result, want) {
t.Errorf("expected result to contain %q, got %s", want, result)
}
}
attrs, err := client.Bucket(bucket).Attrs(ctx)
if err != nil {
t.Fatalf("expected created bucket to exist: %v", err)
}
if attrs.Location != "US" {
t.Errorf("bucket location = %q, want US", attrs.Location)
}
if !attrs.UniformBucketLevelAccess.Enabled {
t.Errorf("expected uniform bucket-level access to be enabled")
}
})
t.Run("missing bucket returns agent error", func(t *testing.T) {
result, status := invokeTool(t, "my_create_bucket", `{}`)
if status != http.StatusOK {
t.Fatalf("unexpected status %d: %s", status, result)
}
if !strings.Contains(result, "bucket") {
t.Errorf("expected result to contain bucket error, got %s", result)
}
})
}
func runCloudStorageGetBucketMetadataTest(t *testing.T, bucket string) {
fakeBucket := "toolbox-it-does-not-exist-" + strings.ReplaceAll(uuid.New().String(), "-", "")[:12]
tcs := []struct {
name string
body string
wantSubstrings []string
}{
{
name: "metadata for created bucket",
body: fmt.Sprintf(`{"bucket": %q}`, bucket),
wantSubstrings: []string{`"Name":"` + bucket + `"`, `"Location":"US"`, `"Enabled":true`},
},
{
name: "missing bucket returns agent error",
body: `{}`,
wantSubstrings: []string{"bucket"},
},
{
name: "nonexistent bucket returns error",
body: fmt.Sprintf(`{"bucket": %q}`, fakeBucket),
wantSubstrings: []string{fakeBucket},
},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
result, status := invokeTool(t, "my_get_bucket_metadata", tc.body)
if status != http.StatusOK {
t.Fatalf("unexpected status %d: %s", status, result)
}
for _, want := range tc.wantSubstrings {
if !strings.Contains(result, want) {
t.Errorf("expected result to contain %q, got %s", want, result)
}
}
})
}
}
func runCloudStorageGetBucketIAMPolicyTest(t *testing.T, bucket string) {
fakeBucket := "toolbox-it-does-not-exist-" + strings.ReplaceAll(uuid.New().String(), "-", "")[:12]
tcs := []struct {
name string
body string
wantSubstrings []string
}{
{
name: "IAM policy for created bucket",
body: fmt.Sprintf(`{"bucket": %q}`, bucket),
wantSubstrings: []string{`"bucket":"` + bucket + `"`, `"bindings"`},
},
{
name: "missing bucket returns agent error",
body: `{}`,
wantSubstrings: []string{"bucket"},
},
{
name: "nonexistent bucket returns error",
body: fmt.Sprintf(`{"bucket": %q}`, fakeBucket),
wantSubstrings: []string{fakeBucket},
},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
result, status := invokeTool(t, "my_get_bucket_iam_policy", tc.body)
if status != http.StatusOK {
t.Fatalf("unexpected status %d: %s", status, result)
}
for _, want := range tc.wantSubstrings {
if !strings.Contains(result, want) {
t.Errorf("expected result to contain %q, got %s", want, result)
}
}
})
}
}
func runCloudStorageDeleteBucketTest(ctx context.Context, t *testing.T, client *storage.Client, bucket string) {
t.Run("delete empty bucket", func(t *testing.T) {
body := fmt.Sprintf(`{"bucket": %q}`, bucket)
result, status := invokeTool(t, "my_delete_bucket", body)
if status != http.StatusOK {
t.Fatalf("unexpected status %d: %s", status, result)
}
if !strings.Contains(result, `"deleted":true`) {
t.Errorf("expected deleted confirmation, got %s", result)
}
if gcsBucketExists(t, ctx, client, bucket) {
t.Errorf("expected bucket %q to be deleted", bucket)
}
})
tcs := []struct {
name string
body string
wantSubstrings []string
}{
{
name: "missing bucket returns agent error",
body: `{}`,
wantSubstrings: []string{"bucket"},
},
{
name: "missing bucket in storage returns agent-visible error",
body: fmt.Sprintf(`{"bucket": %q}`, bucket),
wantSubstrings: []string{"bucket"},
},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
result, status := invokeTool(t, "my_delete_bucket", tc.body)
if status != http.StatusOK {
t.Fatalf("unexpected status %d: %s", status, result)
}
for _, want := range tc.wantSubstrings {
if !strings.Contains(result, want) {
t.Errorf("expected result to contain %q, got %s", want, result)
}
}
})
}
}
func runCloudStorageGetObjectMetadataTest(t *testing.T, bucket string) {
tcs := []struct {
name string
body string
wantSubstrings []string
}{
{
name: "metadata for hello.txt",
body: fmt.Sprintf(`{"bucket": %q, "object": %q}`, bucket, helloObject),
wantSubstrings: []string{`"ContentType":"text/plain"`, `"Size":11`, `"Name":"seed/hello.txt"`},
},
{
name: "metadata for JSON object",
body: fmt.Sprintf(`{"bucket": %q, "object": %q}`, bucket, jsonObject),
wantSubstrings: []string{`"ContentType":"application/json"`},
},
{
name: "missing bucket returns agent error",
body: `{"object": "x"}`,
wantSubstrings: []string{"bucket"},
},
{
name: "missing object returns agent error",
body: fmt.Sprintf(`{"bucket": %q}`, bucket),
wantSubstrings: []string{"object"},
},
{
name: "nonexistent object returns error",
body: fmt.Sprintf(`{"bucket": %q, "object": "does/not/exist.bin"}`, bucket),
wantSubstrings: []string{"does/not/exist.bin"},
},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
result, status := invokeTool(t, "my_get_object_metadata", tc.body)
if status != http.StatusOK {
t.Fatalf("unexpected status %d: %s", status, result)
}
for _, want := range tc.wantSubstrings {
if !strings.Contains(result, want) {
t.Errorf("expected result to contain %q, got %s", want, result)
}
}
})
}
}
// runCloudStorageConfiguredParamsTest exercises the tool variants that bake
// parameter values into their config. Each invocation omits the configured
// parameters (they are absent from the schema) and relies on the configured
// values instead. The seeded `bucket` is the configured bucket for the
// bucket-scoped tools.
func runCloudStorageConfiguredParamsTest(ctx context.Context, t *testing.T, client *storage.Client, bucket, configuredDownloadDir string) {
t.Run("list_objects uses configured bucket and prefix", func(t *testing.T) {
result, status := invokeTool(t, "my_list_objects_configured", `{}`)
if status != http.StatusOK {
t.Fatalf("unexpected status %d: %s", status, result)
}
for _, want := range []string{helloObject, jsonObject} {
if !strings.Contains(result, want) {
t.Errorf("expected result to contain %q, got %s", want, result)
}
}
})
t.Run("list_buckets uses configured project and prefix", func(t *testing.T) {
result, status := invokeTool(t, "my_list_buckets_configured", `{}`)
if status != http.StatusOK {
t.Fatalf("unexpected status %d: %s", status, result)
}
if !strings.Contains(result, bucket) {
t.Errorf("expected result to contain configured bucket %q, got %s", bucket, result)
}
})
t.Run("read_object uses configured bucket", func(t *testing.T) {
body := fmt.Sprintf(`{"object": %q}`, helloObject)
result, status := invokeTool(t, "my_read_object_configured", body)
if status != http.StatusOK {
t.Fatalf("unexpected status %d: %s", status, result)
}
if !strings.Contains(result, helloBody) {
t.Errorf("expected result to contain %q, got %s", helloBody, result)
}
})
t.Run("get_object_metadata uses configured bucket", func(t *testing.T) {
body := fmt.Sprintf(`{"object": %q}`, helloObject)
result, status := invokeTool(t, "my_get_object_metadata_configured", body)
if status != http.StatusOK {
t.Fatalf("unexpected status %d: %s", status, result)
}
if !strings.Contains(result, `"ContentType":"text/plain"`) {
t.Errorf("expected result to contain content type, got %s", result)
}
})
t.Run("get_bucket_metadata uses configured bucket", func(t *testing.T) {
result, status := invokeTool(t, "my_get_bucket_metadata_configured", `{}`)
if status != http.StatusOK {
t.Fatalf("unexpected status %d: %s", status, result)
}
if !strings.Contains(result, `"Name":"`+bucket+`"`) {
t.Errorf("expected result to contain bucket name %q, got %s", bucket, result)
}
})
t.Run("get_bucket_iam_policy uses configured bucket", func(t *testing.T) {
result, status := invokeTool(t, "my_get_bucket_iam_policy_configured", `{}`)
if status != http.StatusOK {
t.Fatalf("unexpected status %d: %s", status, result)
}
for _, want := range []string{`"bucket":"` + bucket + `"`, `"bindings"`} {
if !strings.Contains(result, want) {
t.Errorf("expected result to contain %q, got %s", want, result)
}
}
})
t.Run("create_bucket uses configured project, location and uniform access", func(t *testing.T) {
newBucket := "toolbox-it-cfg-" + strings.ReplaceAll(uuid.New().String(), "-", "")[:17]
defer cleanupGCSBucket(ctx, t, client, newBucket)
body := fmt.Sprintf(`{"bucket": %q}`, newBucket)
result, status := invokeTool(t, "my_create_bucket_configured", body)
if status != http.StatusOK {
t.Fatalf("unexpected status %d: %s", status, result)
}
for _, want := range []string{newBucket, `"created":true`} {
if !strings.Contains(result, want) {
t.Errorf("expected result to contain %q, got %s", want, result)
}
}
attrs, err := client.Bucket(newBucket).Attrs(ctx)
if err != nil {
t.Fatalf("expected created bucket to exist: %v", err)
}
if attrs.Location != "US" {
t.Errorf("bucket location = %q, want US", attrs.Location)
}
if !attrs.UniformBucketLevelAccess.Enabled {
t.Errorf("expected uniform bucket-level access to be enabled")
}
})
t.Run("download_object uses configured bucket destination_dir and overwrite", func(t *testing.T) {
relDest := filepath.Join("configured", "downloaded.txt")
dest := filepath.Join(configuredDownloadDir, relDest)
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
t.Fatalf("setup: %v", err)
}
if err := os.WriteFile(dest, []byte("pre-existing"), 0o644); err != nil {
t.Fatalf("setup: %v", err)
}
body := fmt.Sprintf(`{"object": %q, "destination": %q}`, downloadObject, relDest)
result, status := invokeTool(t, "my_download_object_configured", body)
if status != http.StatusOK {
t.Fatalf("unexpected status %d: %s", status, result)
}
got, err := os.ReadFile(dest)
if err != nil {
t.Fatalf("failed to read downloaded file: %v", err)
}
if string(got) != downloadBody {
t.Errorf("downloaded content = %q, want %q (raw %s)", string(got), downloadBody, result)
}
})
t.Run("upload_object uses configured bucket", func(t *testing.T) {
srcPath := filepath.Join(t.TempDir(), "configured-upload.txt")
content := "configured upload"
if err := os.WriteFile(srcPath, []byte(content), 0o644); err != nil {
t.Fatalf("setup: %v", err)
}
obj := "configured/upload.txt"
body := fmt.Sprintf(`{"object": %q, "source": %q, "content_type": "text/plain"}`, obj, srcPath)
result, status := invokeTool(t, "my_upload_object_configured", body)
if status != http.StatusOK {
t.Fatalf("unexpected status %d: %s", status, result)
}
got, attrs := readGCSObject(t, ctx, client, bucket, obj)
if got != content {
t.Errorf("uploaded content = %q, want %q", got, content)
}
if attrs.ContentType != "text/plain" {
t.Errorf("GCS ContentType = %q, want text/plain (raw %s)", attrs.ContentType, result)
}
})
t.Run("write_object uses configured bucket", func(t *testing.T) {
obj := "configured/write.txt"
content := "configured write"
body := fmt.Sprintf(`{"object": %q, "content": %q, "content_type": "text/plain"}`, obj, content)
result, status := invokeTool(t, "my_write_object_configured", body)
if status != http.StatusOK {
t.Fatalf("unexpected status %d: %s", status, result)
}
got, attrs := readGCSObject(t, ctx, client, bucket, obj)
if got != content {
t.Errorf("written content = %q, want %q", got, content)
}
if attrs.ContentType != "text/plain" {
t.Errorf("GCS ContentType = %q, want text/plain (raw %s)", attrs.ContentType, result)
}
})
t.Run("copy_object uses configured buckets", func(t *testing.T) {
dest := "configured/copied.txt"
body := fmt.Sprintf(`{"source_object": %q, "destination_object": %q}`, helloObject, dest)
result, status := invokeTool(t, "my_copy_object_configured", body)
if status != http.StatusOK {
t.Fatalf("unexpected status %d: %s", status, result)
}
got, _ := readGCSObject(t, ctx, client, bucket, dest)
if got != helloBody {
t.Errorf("copied content = %q, want %q (raw %s)", got, helloBody, result)
}
})
t.Run("move_object uses configured bucket", func(t *testing.T) {
src := "configured/move-source.txt"
dest := "configured/move-destination.txt"
writeGCSObject(t, ctx, client, bucket, src, "text/plain", "configured move")
body := fmt.Sprintf(`{"source_object": %q, "destination_object": %q}`, src, dest)
result, status := invokeTool(t, "my_move_object_configured", body)
if status != http.StatusOK {
t.Fatalf("unexpected status %d: %s", status, result)
}
got, _ := readGCSObject(t, ctx, client, bucket, dest)
if got != "configured move" {
t.Errorf("moved content = %q, want configured move (raw %s)", got, result)
}
if gcsObjectExists(t, ctx, client, bucket, src) {
t.Errorf("expected source object %q to be gone after move", src)
}
})
t.Run("delete_object uses configured bucket", func(t *testing.T) {
obj := "configured/delete.txt"
writeGCSObject(t, ctx, client, bucket, obj, "text/plain", "configured delete")
body := fmt.Sprintf(`{"object": %q}`, obj)
result, status := invokeTool(t, "my_delete_object_configured", body)
if status != http.StatusOK {
t.Fatalf("unexpected status %d: %s", status, result)
}
if !strings.Contains(result, `"deleted":true`) {
t.Errorf("expected deleted confirmation, got %s", result)
}
if gcsObjectExists(t, ctx, client, bucket, obj) {
t.Errorf("expected object %q to be deleted", obj)
}
})
}
func runCloudStorageDownloadObjectTest(t *testing.T, bucket string) {
t.Run("happy path writes expected bytes", func(t *testing.T) {
dest := filepath.Join(t.TempDir(), "downloaded.txt")
body := fmt.Sprintf(`{"bucket": %q, "object": %q, "destination": %q}`, bucket, downloadObject, dest)
result, status := invokeTool(t, "my_download_object", body)
if status != http.StatusOK {
t.Fatalf("unexpected status %d: %s", status, result)
}
if ct := extractStringField(t, result, "contentType"); ct != "text/plain" {
t.Errorf("contentType = %q, want text/plain (raw %s)", ct, result)
}
got, err := os.ReadFile(dest)
if err != nil {
t.Fatalf("failed to read downloaded file: %v", err)
}
if string(got) != downloadBody {
t.Errorf("downloaded content = %q, want %q", string(got), downloadBody)
}
})
t.Run("overwrite=false on existing file returns agent error", func(t *testing.T) {
dest := filepath.Join(t.TempDir(), "existing.txt")
if err := os.WriteFile(dest, []byte("pre-existing"), 0o644); err != nil {
t.Fatalf("setup: %v", err)
}
body := fmt.Sprintf(`{"bucket": %q, "object": %q, "destination": %q}`, bucket, downloadObject, dest)
result, status := invokeTool(t, "my_download_object", body)
if status != http.StatusOK {
t.Fatalf("unexpected status %d: %s", status, result)
}
if !strings.Contains(result, "overwrite") && !strings.Contains(result, "exists") {
t.Errorf("expected error referencing overwrite/exists, got %s", result)
}
got, err := os.ReadFile(dest)
if err != nil || string(got) != "pre-existing" {
t.Errorf("destination was modified or unreadable: %q, %v", string(got), err)
}
})
t.Run("overwrite=true replaces existing file", func(t *testing.T) {
dest := filepath.Join(t.TempDir(), "existing.txt")
if err := os.WriteFile(dest, []byte("pre-existing"), 0o644); err != nil {
t.Fatalf("setup: %v", err)
}
body := fmt.Sprintf(`{"bucket": %q, "object": %q, "destination": %q, "overwrite": true}`, bucket, downloadObject, dest)
result, status := invokeTool(t, "my_download_object", body)
if status != http.StatusOK {
t.Fatalf("unexpected status %d: %s", status, result)
}
got, err := os.ReadFile(dest)
if err != nil {
t.Fatalf("failed to read downloaded file: %v", err)
}
if string(got) != downloadBody {
t.Errorf("downloaded content = %q, want %q", string(got), downloadBody)
}
})
tcs := []struct {
name string
body string
wantSubstrings []string
}{
{
name: "relative destination returns agent error",
body: fmt.Sprintf(`{"bucket": %q, "object": %q, "destination": "relative/out.bin"}`, bucket, downloadObject),
wantSubstrings: []string{"destination"},
},
{
name: "destination with traversal returns agent error",
body: fmt.Sprintf(`{"bucket": %q, "object": %q, "destination": "/tmp/../etc/passwd"}`, bucket, downloadObject),
wantSubstrings: []string{"destination"},
},
{
name: "missing destination returns agent error",
body: fmt.Sprintf(`{"bucket": %q, "object": %q}`, bucket, downloadObject),
wantSubstrings: []string{"destination"},
},
{
name: "nonexistent object returns error",
body: fmt.Sprintf(`{"bucket": %q, "object": "does/not/exist.bin", "destination": "/tmp/nope-should-not-be-created-%s.bin"}`, bucket, uuid.New().String()[:8]),
wantSubstrings: []string{"does/not/exist.bin"},
},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
result, status := invokeTool(t, "my_download_object", tc.body)
if status != http.StatusOK {
t.Fatalf("unexpected status %d: %s", status, result)
}
for _, want := range tc.wantSubstrings {
if !strings.Contains(result, want) {
t.Errorf("expected result to contain %q, got %s", want, result)
}
}
})
}
}
func runCloudStorageUploadObjectTest(ctx context.Context, t *testing.T, client *storage.Client, bucket string) {
// Seed a local file that the explicit-content-type and MIME-auto-detect
// cases both read from.
srcDir := t.TempDir()
csvPath := filepath.Join(srcDir, "data.csv")
if err := os.WriteFile(csvPath, []byte("a,b\n1,2\n"), 0o644); err != nil {
t.Fatalf("setup: %v", err)
}
binPath := filepath.Join(srcDir, "blob.unknownext")
if err := os.WriteFile(binPath, []byte("<html>hi</html>"), 0o644); err != nil {
t.Fatalf("setup: %v", err)
}
t.Run("upload with explicit content_type", func(t *testing.T) {
obj := "uploaded/explicit.bin"
body := fmt.Sprintf(`{"bucket": %q, "object": %q, "source": %q, "content_type": "application/octet-stream"}`,
bucket, obj, csvPath)
result, status := invokeTool(t, "my_upload_object", body)
if status != http.StatusOK {
t.Fatalf("unexpected status %d: %s", status, result)
}
if ct := extractStringField(t, result, "contentType"); ct != "application/octet-stream" {
t.Errorf("contentType = %q, want application/octet-stream (raw %s)", ct, result)
}
attrs, err := client.Bucket(bucket).Object(obj).Attrs(ctx)
if err != nil {
t.Fatalf("expected uploaded object to exist: %v", err)
}
if attrs.ContentType != "application/octet-stream" {
t.Errorf("GCS ContentType = %q, want application/octet-stream", attrs.ContentType)
}
})
t.Run("upload infers MIME from .csv extension", func(t *testing.T) {
obj := "uploaded/inferred.csv"
body := fmt.Sprintf(`{"bucket": %q, "object": %q, "source": %q}`, bucket, obj, csvPath)
result, status := invokeTool(t, "my_upload_object", body)
if status != http.StatusOK {
t.Fatalf("unexpected status %d: %s", status, result)
}
// mime.TypeByExtension(".csv") is "text/csv" on most systems but may
// carry a charset suffix — assert containment rather than equality.
if ct := extractStringField(t, result, "contentType"); !strings.Contains(ct, "csv") {
t.Errorf("contentType = %q, want to contain 'csv' (raw %s)", ct, result)
}
})
t.Run("upload with unknown extension lets GCS auto-detect", func(t *testing.T) {
obj := "uploaded/unknown.bin"
body := fmt.Sprintf(`{"bucket": %q, "object": %q, "source": %q}`, bucket, obj, binPath)
result, status := invokeTool(t, "my_upload_object", body)
if status != http.StatusOK {
t.Fatalf("unexpected status %d: %s", status, result)
}
attrs, err := client.Bucket(bucket).Object(obj).Attrs(ctx)
if err != nil {
t.Fatalf("expected uploaded object to exist: %v", err)
}
// GCS always records *something* — verify the bytes landed regardless
// of whichever content type the server ended up storing.
if attrs.Size != int64(len("<html>hi</html>")) {
t.Errorf("object size = %d, want %d", attrs.Size, len("<html>hi</html>"))
}
})
tcs := []struct {
name string
body string
wantSubstrings []string
}{
{
name: "missing source returns agent error",
body: fmt.Sprintf(`{"bucket": %q, "object": "uploaded/nope.bin"}`, bucket),
wantSubstrings: []string{"source"},
},
{
name: "relative source returns agent error",
body: fmt.Sprintf(`{"bucket": %q, "object": "uploaded/nope.bin", "source": "relative/path"}`, bucket),
wantSubstrings: []string{"source"},
},
{
name: "nonexistent local source returns error",
body: fmt.Sprintf(`{"bucket": %q, "object": "uploaded/nope.bin", "source": "/tmp/definitely-not-a-real-source-%s"}`, bucket, uuid.New().String()[:8]),
wantSubstrings: []string{"source", "no such"},
},
{
name: "missing bucket returns agent error",
body: fmt.Sprintf(`{"object": "uploaded/nope.bin", "source": %q}`, csvPath),
wantSubstrings: []string{"bucket"},
},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
result, status := invokeTool(t, "my_upload_object", tc.body)
if status != http.StatusOK {
t.Fatalf("unexpected status %d: %s", status, result)
}
for _, want := range tc.wantSubstrings {
if !strings.Contains(result, want) {
t.Errorf("expected result to contain %q, got %s", want, result)
}
}
})
}
}
func runCloudStorageWriteObjectTest(ctx context.Context, t *testing.T, client *storage.Client, bucket string) {
tcs := []struct {
name string
body string
object string
wantContent string
wantContentType string
wantSize int64
wantSubstrings []string
}{
{
name: "write object with explicit content_type",
object: "written/explicit.txt",
wantContent: "written from tool",
wantContentType: "text/plain",
wantSize: int64(len("written from tool")),
},
{
name: "write empty object",
object: "written/empty.txt",
wantContent: "",
wantContentType: "text/plain",
wantSize: 0,
},
{
name: "missing bucket returns agent error",
body: `{"object": "written/nope.txt", "content": "x"}`,
wantSubstrings: []string{"bucket"},
},
{
name: "missing object returns agent error",
body: fmt.Sprintf(`{"bucket": %q, "content": "x"}`, bucket),
wantSubstrings: []string{"object"},
},
{
name: "missing content returns agent error",
body: fmt.Sprintf(`{"bucket": %q, "object": "written/nope.txt"}`, bucket),
wantSubstrings: []string{"content"},
},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
body := tc.body
if body == "" {
body = fmt.Sprintf(`{"bucket": %q, "object": %q, "content": %q, "content_type": %q}`,
bucket, tc.object, tc.wantContent, tc.wantContentType)
}
result, status := invokeTool(t, "my_write_object", body)
if status != http.StatusOK {
t.Fatalf("unexpected status %d: %s", status, result)
}
if tc.object != "" {
if ct := extractStringField(t, result, "contentType"); ct != tc.wantContentType {
t.Errorf("contentType = %q, want %q (raw %s)", ct, tc.wantContentType, result)
}
got, attrs := readGCSObject(t, ctx, client, bucket, tc.object)
if got != tc.wantContent {
t.Errorf("written content = %q, want %q", got, tc.wantContent)
}
if attrs.ContentType != tc.wantContentType {
t.Errorf("GCS ContentType = %q, want %q", attrs.ContentType, tc.wantContentType)
}
if attrs.Size != tc.wantSize {
t.Errorf("object size = %d, want %d", attrs.Size, tc.wantSize)
}
}
for _, want := range tc.wantSubstrings {
if !strings.Contains(result, want) {
t.Errorf("expected result to contain %q, got %s", want, result)
}
}
})
}
}
func runCloudStorageCopyObjectTest(ctx context.Context, t *testing.T, client *storage.Client, bucket string) {
t.Run("copy creates destination and leaves source", func(t *testing.T) {
dest := "copied/hello.txt"
body := fmt.Sprintf(`{"source_bucket": %q, "source_object": %q, "destination_bucket": %q, "destination_object": %q}`,
bucket, helloObject, bucket, dest)
result, status := invokeTool(t, "my_copy_object", body)
if status != http.StatusOK {
t.Fatalf("unexpected status %d: %s", status, result)
}
got, attrs := readGCSObject(t, ctx, client, bucket, dest)
if got != helloBody {
t.Errorf("copied content = %q, want %q", got, helloBody)
}
if attrs.ContentType != "text/plain" {
t.Errorf("copied ContentType = %q, want text/plain", attrs.ContentType)
}
if !gcsObjectExists(t, ctx, client, bucket, helloObject) {
t.Errorf("expected source object %q to remain after copy", helloObject)
}
})
tcs := []struct {
name string
body string
wantSubstrings []string
}{
{
name: "missing source_bucket returns agent error",
body: fmt.Sprintf(`{"source_object": %q, "destination_bucket": %q, "destination_object": "copied/nope.txt"}`, helloObject, bucket),
wantSubstrings: []string{"source_bucket"},
},
{
name: "missing source_object returns agent error",
body: fmt.Sprintf(`{"source_bucket": %q, "destination_bucket": %q, "destination_object": "copied/nope.txt"}`, bucket, bucket),
wantSubstrings: []string{"source_object"},
},
{
name: "missing destination_bucket returns agent error",
body: fmt.Sprintf(`{"source_bucket": %q, "source_object": %q, "destination_object": "copied/nope.txt"}`, bucket, helloObject),
wantSubstrings: []string{"destination_bucket"},
},
{
name: "missing destination_object returns agent error",
body: fmt.Sprintf(`{"source_bucket": %q, "source_object": %q, "destination_bucket": %q}`, bucket, helloObject, bucket),
wantSubstrings: []string{"destination_object"},
},
{
name: "nonexistent source object returns error",
body: fmt.Sprintf(`{"source_bucket": %q, "source_object": "does/not/exist.txt", "destination_bucket": %q, "destination_object": "copied/nope.txt"}`, bucket, bucket),
wantSubstrings: []string{"does/not/exist.txt"},
},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
result, status := invokeTool(t, "my_copy_object", tc.body)
if status != http.StatusOK {
t.Fatalf("unexpected status %d: %s", status, result)
}
for _, want := range tc.wantSubstrings {
if !strings.Contains(result, want) {
t.Errorf("expected result to contain %q, got %s", want, result)
}
}
})
}
}
func runCloudStorageMoveObjectTest(ctx context.Context, t *testing.T, client *storage.Client, bucket string) {
t.Run("move atomically renames within bucket", func(t *testing.T) {
src := "move/source.txt"
dest := "move/destination.txt"
writeGCSObject(t, ctx, client, bucket, src, "text/plain", "move me")
body := fmt.Sprintf(`{"bucket": %q, "source_object": %q, "destination_object": %q}`,
bucket, src, dest)
result, status := invokeTool(t, "my_move_object", body)
if status != http.StatusOK {
t.Fatalf("unexpected status %d: %s", status, result)
}
got, _ := readGCSObject(t, ctx, client, bucket, dest)
if got != "move me" {
t.Errorf("moved content = %q, want %q", got, "move me")
}
if gcsObjectExists(t, ctx, client, bucket, src) {
t.Errorf("expected source object %q to be gone after move", src)
}
})
tcs := []struct {
name string
body string
wantSubstrings []string
}{
{
name: "missing bucket returns agent error",
body: `{"source_object": "move/nope.txt", "destination_object": "move/nope2.txt"}`,
wantSubstrings: []string{"bucket"},
},
{
name: "missing source_object returns agent error",
body: fmt.Sprintf(`{"bucket": %q, "destination_object": "move/nope2.txt"}`, bucket),
wantSubstrings: []string{"source_object"},
},
{
name: "missing destination_object returns agent error",
body: fmt.Sprintf(`{"bucket": %q, "source_object": "move/nope.txt"}`, bucket),
wantSubstrings: []string{"destination_object"},
},
{
name: "nonexistent source object returns error",
body: fmt.Sprintf(`{"bucket": %q, "source_object": "move/does-not-exist.txt", "destination_object": "move/nope2.txt"}`, bucket),
wantSubstrings: []string{"move/does-not-exist.txt"},
},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
result, status := invokeTool(t, "my_move_object", tc.body)
if status != http.StatusOK {
t.Fatalf("unexpected status %d: %s", status, result)
}
for _, want := range tc.wantSubstrings {
if !strings.Contains(result, want) {
t.Errorf("expected result to contain %q, got %s", want, result)
}
}
})
}
}
func runCloudStorageDeleteObjectTest(ctx context.Context, t *testing.T, client *storage.Client, bucket string) {
t.Run("delete removes object", func(t *testing.T) {
obj := "delete/delete-me.txt"
writeGCSObject(t, ctx, client, bucket, obj, "text/plain", "delete me")
body := fmt.Sprintf(`{"bucket": %q, "object": %q}`, bucket, obj)
result, status := invokeTool(t, "my_delete_object", body)
if status != http.StatusOK {
t.Fatalf("unexpected status %d: %s", status, result)
}
if !strings.Contains(result, `"deleted":true`) {
t.Errorf("expected deleted confirmation, got %s", result)
}
if gcsObjectExists(t, ctx, client, bucket, obj) {
t.Errorf("expected object %q to be deleted", obj)
}
})
tcs := []struct {
name string
body string
wantSubstrings []string
}{
{
name: "missing bucket returns agent error",
body: `{"object": "delete/nope.txt"}`,
wantSubstrings: []string{"bucket"},
},
{
name: "missing object returns agent error",
body: fmt.Sprintf(`{"bucket": %q}`, bucket),
wantSubstrings: []string{"object"},
},
{
name: "missing object in storage returns agent-visible error",
body: fmt.Sprintf(`{"bucket": %q, "object": "delete/does-not-exist.txt"}`, bucket),
wantSubstrings: []string{"object", "does-not-exist"},
},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
result, status := invokeTool(t, "my_delete_object", tc.body)
if status != http.StatusOK {
t.Fatalf("unexpected status %d: %s", status, result)
}
for _, want := range tc.wantSubstrings {
if !strings.Contains(result, want) {
t.Errorf("expected result to contain %q, got %s", want, result)
}
}
})
}
}
func writeGCSObject(t *testing.T, ctx context.Context, client *storage.Client, bucket, object, contentType, body string) {
t.Helper()
w := client.Bucket(bucket).Object(object).NewWriter(ctx)
w.ContentType = contentType
if _, err := io.WriteString(w, body); err != nil {
_ = w.Close()
t.Fatalf("failed to write object %q: %v", object, err)
}
if err := w.Close(); err != nil {
t.Fatalf("failed to close writer for object %q: %v", object, err)
}
}
func readGCSObject(t *testing.T, ctx context.Context, client *storage.Client, bucket, object string) (string, *storage.ReaderObjectAttrs) {
t.Helper()
r, err := client.Bucket(bucket).Object(object).NewReader(ctx)
if err != nil {
t.Fatalf("failed to open object %q: %v", object, err)
}
defer r.Close()
data, err := io.ReadAll(r)
if err != nil {
t.Fatalf("failed to read object %q: %v", object, err)
}
return string(data), &r.Attrs
}
func gcsObjectExists(t *testing.T, ctx context.Context, client *storage.Client, bucket, object string) bool {
t.Helper()
_, err := client.Bucket(bucket).Object(object).Attrs(ctx)
if err == nil {
return true
}
if errors.Is(err, storage.ErrObjectNotExist) {
return false
}
t.Fatalf("failed checking object %q existence: %v", object, err)
return false
}
func gcsBucketExists(t *testing.T, ctx context.Context, client *storage.Client, bucket string) bool {
t.Helper()
_, err := client.Bucket(bucket).Attrs(ctx)
if err == nil {
return true
}
if errors.Is(err, storage.ErrBucketNotExist) {
return false
}
t.Fatalf("failed checking bucket %q existence: %v", bucket, err)
return false
}
func cleanupGCSBucket(ctx context.Context, t *testing.T, client *storage.Client, bucket string) {
t.Helper()
cleanupCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
bkt := client.Bucket(bucket)
it := bkt.Objects(cleanupCtx, nil)
for {
attrs, err := it.Next()
if err == iterator.Done {
break
}
if err != nil {
if errors.Is(err, storage.ErrBucketNotExist) {
return
}
t.Logf("cleanup: failed listing bucket %q: %v", bucket, err)
break
}
if delErr := bkt.Object(attrs.Name).Delete(cleanupCtx); delErr != nil {
t.Logf("cleanup: failed deleting object %q from bucket %q: %v", attrs.Name, bucket, delErr)
}
}
if err := bkt.Delete(cleanupCtx); err != nil && !errors.Is(err, storage.ErrBucketNotExist) {
t.Logf("cleanup: failed deleting bucket %q: %v", bucket, err)
}
}