// 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.
// To run these tests, set the following environment variables:
// LOGADMIN_PROJECT: Google Cloud project ID.
package cloudloggingadmin
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"regexp"
"strings"
"testing"
"time"
"cloud.google.com/go/logging"
"cloud.google.com/go/logging/logadmin"
"github.com/google/uuid"
"github.com/googleapis/genai-toolbox/internal/testutils"
"github.com/googleapis/genai-toolbox/tests"
"golang.org/x/oauth2/google"
"google.golang.org/api/option"
)
var (
LogAdminSourceType = "cloud-logging-admin"
LogAdminProject = os.Getenv("LOGADMIN_PROJECT")
)
func getLogAdminVars(t *testing.T) map[string]any {
switch "" {
case LogAdminProject:
t.Fatal("'LOGADMIN_PROJECT' not set")
}
return map[string]any{
"type": LogAdminSourceType,
"project": LogAdminProject,
}
}
// Copied over from cloud_logging_admin.go
func initLogAdminConnection(project string) (*logadmin.Client, error) {
ctx := context.Background()
cred, err := google.FindDefaultCredentials(ctx, logging.AdminScope)
if err != nil {
return nil, fmt.Errorf("failed to find default Google Cloud credentials with scope %q: %w", logging.AdminScope, err)
}
client, err := logadmin.NewClient(ctx, project, option.WithCredentials(cred))
if err != nil {
return nil, fmt.Errorf("failed to create Cloud Logging Admin client for project %q: %w", project, err)
}
return client, nil
}
// This client will be used to add logs to the project
func initLogConnection(project string) (*logging.Client, error) {
ctx := context.Background()
cred, err := google.FindDefaultCredentials(ctx, logging.WriteScope)
if err != nil {
return nil, fmt.Errorf("failed to find default Google Cloud credentials with scope %q: %w", logging.WriteScope, err)
}
client, err := logging.NewClient(ctx, project, option.WithCredentials(cred))
if err != nil {
return nil, fmt.Errorf("failed to create Cloud Logging client for project %q: %w", project, err)
}
return client, nil
}
func TestLogAdminToolEndpoints(t *testing.T) {
sourceConfig := getLogAdminVars(t)
ctx, cancel := context.WithTimeout(context.Background(), 7*time.Minute)
defer cancel()
var args []string
_, err := initLogAdminConnection(LogAdminProject)
if err != nil {
t.Fatalf("unable to connect to logs: %s", err)
}
loggingClient, err := initLogConnection(LogAdminProject)
if err != nil {
t.Fatalf("unable to connect to logging: %s", err)
}
defer loggingClient.Close()
testUUID := strings.ReplaceAll(uuid.New().String(), "-", "")
logName := fmt.Sprintf("toolbox-integration-test-%s", testUUID)
// set up test logs and wait for logs to be injested.
setupTestLogs(t, loggingClient, logName)
t.Logf("Waiting 15 seconds for log ingestion...")
time.Sleep(15 * time.Second)
// Delete test logs once test is over
defer teardownTestLogs(t, ctx, LogAdminProject, logName)
toolsFile := getCloudLoggingAdminToolsConfig(sourceConfig)
cmd, cleanup, err := tests.StartCmd(ctx, toolsFile, args...)
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)
}
runListLogNamesTest(t, logName)
runAuthListLogNamesTest(t, logName)
runListResourceTypesTest(t)
runQueryLogsTest(t, logName)
runQueryLogsErrorTest(t)
}
func setupTestLogs(t *testing.T, client *logging.Client, logName string) {
logger := client.Logger(logName)
logger.Log(logging.Entry{
Payload: map[string]string{"test_id": logName, "message": "test entry 1"},
Severity: logging.Info,
Labels: map[string]string{"env": "test", "run_id": "1"},
})
logger.Log(logging.Entry{
Payload: map[string]string{"test_id": logName, "message": "test entry 2"},
Severity: logging.Warning,
})
logger.Log(logging.Entry{
Payload: map[string]string{"test_id": logName, "message": "test entry 3"},
Severity: logging.Error,
})
if err := logger.Flush(); err != nil {
t.Fatalf("failed to flush logs: %v", err)
}
}
func teardownTestLogs(t *testing.T, ctx context.Context, projectID, logName string) {
adminClient, err := logadmin.NewClient(ctx, projectID)
if err != nil {
t.Errorf("failed to create admin client for cleanup: %v", err)
return
}
defer adminClient.Close()
if err := adminClient.DeleteLog(ctx, logName); err != nil {
t.Logf("failed to delete test log %s: %v", logName, err)
}
}
func getCloudLoggingAdminToolsConfig(sourceConfig map[string]any) map[string]any {
return map[string]any{
"sources": map[string]any{
"my-logging-instance": sourceConfig,
},
"authServices": map[string]any{
"my-google-auth": map[string]any{
"type": "google",
"clientId": tests.ClientId,
},
},
"tools": map[string]any{
"list-log-names": map[string]any{
"type": "cloud-logging-admin-list-log-names",
"source": "my-logging-instance",
"description": "Lists log names in the project",
},
"list-resource-types": map[string]any{
"type": "cloud-logging-admin-list-resource-types",
"source": "my-logging-instance",
"description": "Lists monitored resource types",
},
"query-logs": map[string]any{
"type": "cloud-logging-admin-query-logs",
"source": "my-logging-instance",
"description": "Queries log entries",
},
"auth-list-log-names": map[string]any{
"type": "cloud-logging-admin-list-log-names",
"source": "my-logging-instance",
"authRequired": []string{"my-google-auth"},
"description": "Lists log names with authentication",
},
},
}
}
func runListLogNamesTest(t *testing.T, expectedLogName string) {
t.Run("list-log-names", func(t *testing.T) {
resp, respBody := tests.RunRequest(t, http.MethodPost, "http://127.0.0.1:5000/api/tool/list-log-names/invoke", bytes.NewBuffer([]byte(`{}`)), nil)
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("expected status 200, got %d", resp.StatusCode)
}
var body map[string]interface{}
if err := json.Unmarshal(respBody, &body); err != nil {
t.Fatalf("error parsing response body")
}
result, ok := body["result"].(string)
if !ok {
t.Fatalf("expected result to be string")
}
if !strings.Contains(result, expectedLogName) {
t.Errorf("expected log name %s not found in result: %s", expectedLogName, result)
}
})
}
func runListResourceTypesTest(t *testing.T) {
t.Run("list-resource-types", func(t *testing.T) {
resp, respBody := tests.RunRequest(t, http.MethodPost, "http://127.0.0.1:5000/api/tool/list-resource-types/invoke", bytes.NewBuffer([]byte(`{}`)), nil)
if resp.StatusCode != 200 {
t.Fatalf("expected status 200, got %d", resp.StatusCode)
}
var body map[string]interface{}
if err := json.Unmarshal(respBody, &body); err != nil {
t.Fatalf("error parsing response body")
}
result, ok := body["result"].(string)
if !ok {
t.Fatalf("expected result to be string")
}
expectedTypes := []string{"global", "gce_instance", "gcs_bucket", "project"}
for _, resourceType := range expectedTypes {
if !strings.Contains(result, resourceType) {
t.Errorf("expected '%s' resource type in result, but it was missing", resourceType)
}
}
})
}
func runQueryLogsTest(t *testing.T, logName string) {
baseFilter := fmt.Sprintf(`logName="projects/%s/logs/%s"`, LogAdminProject, logName)
t.Run("query-logs-simple", func(t *testing.T) {
requestBody := fmt.Sprintf(`{"filter": %q, "limit": 10}`, baseFilter)
result := invokeQueryTool(t, requestBody)
if !strings.Contains(result, "test entry") {
t.Errorf("expected test entries in result: %s", result)
}
})
t.Run("query-logs-newest-first", func(t *testing.T) {
requestBody := fmt.Sprintf(`{"filter": %q, "limit": 10, "newestFirst": true}`, baseFilter)
result := invokeQueryTool(t, requestBody)
idx3 := strings.Index(result, "test entry 3")
idx1 := strings.Index(result, "test entry 1")
if idx3 == -1 || idx1 == -1 {
t.Fatalf("missing expected entries in result: %s", result)
}
if idx3 > idx1 {
t.Errorf("expected entry 3 to appear before entry 1 with newestFirst=true, but got: ...%s... then ...%s...", "test entry 3", "test entry 1")
}
})
t.Run("query-logs-verbose", func(t *testing.T) {
requestBody := fmt.Sprintf(`{"filter": %q, "limit": 10, "verbose": true}`, baseFilter)
result := invokeQueryTool(t, requestBody)
if !strings.Contains(result, `"labels":`) {
t.Errorf("expected 'labels' field in verbose output, got: %s", result)
}
if !strings.Contains(result, `"env":"test"`) && !strings.Contains(result, `"env": "test"`) {
t.Errorf("expected label 'env: test' in verbose output, got: %s", result)
}
})
}
func invokeQueryTool(t *testing.T, requestBody string) string {
t.Helper()
resp, respBody := tests.RunRequest(t, http.MethodPost, "http://127.0.0.1:5000/api/tool/query-logs/invoke", bytes.NewBuffer([]byte(requestBody)), nil)
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("expected status 200, got %d", resp.StatusCode)
}
var body map[string]interface{}
if err := json.Unmarshal(respBody, &body); err != nil {
t.Fatalf("error parsing response body")
}
result, ok := body["result"].(string)
if !ok {
t.Fatalf("expected result to be string")
}
return result
}
func runAuthListLogNamesTest(t *testing.T, expectedLogName string) {
t.Run("auth-list-log-names", func(t *testing.T) {
resp, _ := tests.RunRequest(t, http.MethodPost, "http://127.0.0.1:5000/api/tool/auth-list-log-names/invoke", bytes.NewBuffer([]byte(`{}`)), nil)
if resp.StatusCode != 401 {
t.Fatalf("expected status 401 (Unauthorized), got %d", resp.StatusCode)
}
})
}
func runQueryLogsErrorTest(t *testing.T) {
t.Run("query-logs-error", func(t *testing.T) {
requestBody := `{"filter": "INVALID_FILTER_SYNTAX :::", "limit": 10}`
resp, _ := tests.RunRequest(t, http.MethodPost, "http://127.0.0.1:5000/api/tool/query-logs/invoke", bytes.NewBuffer([]byte(requestBody)), nil)
if resp.StatusCode == 200 {
t.Errorf("expected error status code, got 200 OK")
}
})
}