diff --git a/go/adk/pkg/auth/token_test.go b/go/adk/pkg/auth/token_test.go new file mode 100644 index 000000000..6d1e0b65d --- /dev/null +++ b/go/adk/pkg/auth/token_test.go @@ -0,0 +1,134 @@ +package auth + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWithUserIDAndUserIDFromContext(t *testing.T) { + ctx := context.Background() + assert.Equal(t, "", UserIDFromContext(ctx)) + + ctx = WithUserID(ctx, "user-123") + assert.Equal(t, "user-123", UserIDFromContext(ctx)) +} + +func TestKAgentTokenService_GetToken_InitiallyEmpty(t *testing.T) { + svc := NewKAgentTokenService("my-agent") + assert.Equal(t, "", svc.GetToken()) +} + +func TestKAgentTokenService_AddHeaders(t *testing.T) { + svc := NewKAgentTokenService("my-agent") + svc.mu.Lock() + svc.token = "abc123" + svc.mu.Unlock() + + req := httptest.NewRequest(http.MethodGet, "http://example.com", nil) + req = req.WithContext(WithUserID(req.Context(), "user-42")) + + svc.AddHeaders(req) + + assert.Equal(t, "my-agent", req.Header.Get("X-Agent-Name")) + assert.Equal(t, "Bearer abc123", req.Header.Get("Authorization")) + assert.Equal(t, "user-42", req.Header.Get("X-User-Id")) +} + +func TestKAgentTokenService_AddHeaders_NoTokenNoUser(t *testing.T) { + svc := NewKAgentTokenService("my-agent") + req := httptest.NewRequest(http.MethodGet, "http://example.com", nil) + + svc.AddHeaders(req) + + assert.Equal(t, "my-agent", req.Header.Get("X-Agent-Name")) + assert.Equal(t, "", req.Header.Get("Authorization")) + assert.Equal(t, "", req.Header.Get("X-User-Id")) +} + +func TestKAgentTokenService_Stop_SafeMultipleCalls(t *testing.T) { + svc := NewKAgentTokenService("my-agent") + assert.NotPanics(t, func() { + svc.Stop() + svc.Stop() + svc.Stop() + }) +} + +func TestKAgentTokenService_Start_MissingTokenFile(t *testing.T) { + svc := NewKAgentTokenService("my-agent") + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + err := svc.Start(ctx) + require.NoError(t, err) + assert.Equal(t, "", svc.GetToken()) + + svc.Stop() +} + +func TestKAgentTokenService_ReadToken_MissingFile(t *testing.T) { + svc := NewKAgentTokenService("my-agent") + token, err := svc.readToken() + assert.Error(t, err) + assert.Empty(t, token) +} + +type stubRoundTripper struct { + req *http.Request + resp *http.Response + err error +} + +func (s *stubRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + s.req = req + if s.resp != nil { + return s.resp, s.err + } + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, s.err +} + +func TestTokenRoundTripper_InjectsHeaders(t *testing.T) { + svc := NewKAgentTokenService("my-agent") + svc.mu.Lock() + svc.token = "xyz" + svc.mu.Unlock() + + base := &stubRoundTripper{} + rt := &TokenRoundTripper{base: base, tokenService: svc} + + req := httptest.NewRequest(http.MethodGet, "http://example.com", nil) + resp, err := rt.RoundTrip(req) + + require.NoError(t, err) + require.NotNil(t, resp) + assert.Equal(t, "Bearer xyz", base.req.Header.Get("Authorization")) + assert.Equal(t, "my-agent", base.req.Header.Get("X-Agent-Name")) +} + +func TestTokenRoundTripper_NilTokenService(t *testing.T) { + base := &stubRoundTripper{} + rt := &TokenRoundTripper{base: base, tokenService: nil} + + req := httptest.NewRequest(http.MethodGet, "http://example.com", nil) + _, err := rt.RoundTrip(req) + + require.NoError(t, err) + assert.Equal(t, "", base.req.Header.Get("Authorization")) +} + +func TestNewHTTPClientWithToken(t *testing.T) { + svc := NewKAgentTokenService("my-agent") + client := NewHTTPClientWithToken(svc) + + require.NotNil(t, client) + rt, ok := client.Transport.(*TokenRoundTripper) + require.True(t, ok) + assert.Equal(t, svc, rt.tokenService) + assert.Equal(t, 30*time.Second, client.Timeout) +} diff --git a/go/adk/pkg/runner/adapter_test.go b/go/adk/pkg/runner/adapter_test.go new file mode 100644 index 000000000..9859aa2f7 --- /dev/null +++ b/go/adk/pkg/runner/adapter_test.go @@ -0,0 +1,182 @@ +package runner + +import ( + "context" + "embed" + "encoding/json" + "strings" + "testing" + + "github.com/go-logr/logr" + "github.com/kagent-dev/kagent/go/adk/pkg/controllerclient" + "github.com/kagent-dev/kagent/go/api/adk" + "github.com/kagent-dev/mockllm" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + adksession "google.golang.org/adk/v2/session" +) + +//go:embed testdata +var testdata embed.FS + +func startMock(t *testing.T, mockFile string) string { + t.Helper() + cfg, err := mockllm.LoadConfigFromFile(mockFile, testdata) + require.NoError(t, err) + server := mockllm.NewServer(cfg) + baseURL, err := server.Start(t.Context()) + require.NoError(t, err) + t.Cleanup(func() { server.Stop(context.Background()) }) //nolint:errcheck + return baseURL +} + +func loadConfig(t *testing.T, path string, baseURL string) *adk.AgentConfig { + t.Helper() + data, err := testdata.ReadFile(path) + require.NoError(t, err) + + raw := strings.ReplaceAll(string(data), "{{BASE_URL}}", baseURL) + + var cfg adk.AgentConfig + require.NoError(t, json.Unmarshal([]byte(raw), &cfg)) + return &cfg +} + +// Pure helper functions. + +func TestAgentNameFromAppName(t *testing.T) { + tests := []struct { + name string + appName string + want string + }{ + {"no namespace marker returns as-is", "myagent", "myagent"}, + {"namespace marker strips prefix", "default__NS__myagent", "myagent"}, + {"multiple markers uses last occurrence", "a__NS__b__NS__c", "c"}, + {"empty string", "", ""}, + {"marker at end returns empty suffix", "default__NS__", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, agentNameFromAppName(tt.appName)) + }) + } +} + +func TestSplitCSV(t *testing.T) { + tests := []struct { + name string + in string + want []string + }{ + {"empty string returns nil", "", nil}, + {"single value", "foo", []string{"foo"}}, + {"multiple values", "foo,bar,baz", []string{"foo", "bar", "baz"}}, + {"trims whitespace around values", " foo , bar ,baz ", []string{"foo", "bar", "baz"}}, + {"skips empty entries", "foo,,bar,", []string{"foo", "bar"}}, + {"all empty entries returns nil", " , , ", nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, splitCSV(tt.in)) + }) + } +} + +// buildTokenPropagationPlugin: env-var driven branching. + +func TestBuildTokenPropagationPlugin_DisabledByDefault(t *testing.T) { + t.Setenv("KAGENT_PROPAGATE_TOKEN", "") + t.Setenv("STS_WELL_KNOWN_URI", "") + + plugin, err := buildTokenPropagationPlugin(context.Background(), logr.Discard()) + require.NoError(t, err) + assert.Nil(t, plugin, "plugin should be disabled when neither env var is set") +} + +func TestBuildTokenPropagationPlugin_PropagateOnlyMode(t *testing.T) { + t.Setenv("KAGENT_PROPAGATE_TOKEN", "true") + t.Setenv("STS_WELL_KNOWN_URI", "") + + plugin, err := buildTokenPropagationPlugin(context.Background(), logr.Discard()) + require.NoError(t, err) + require.NotNil(t, plugin, "plugin should be enabled in propagate-only mode without STS exchange") +} + +func TestBuildTokenPropagationPlugin_PropagateFlagCaseInsensitive(t *testing.T) { + t.Setenv("KAGENT_PROPAGATE_TOKEN", "TRUE") + t.Setenv("STS_WELL_KNOWN_URI", "") + + plugin, err := buildTokenPropagationPlugin(context.Background(), logr.Discard()) + require.NoError(t, err) + require.NotNil(t, plugin) +} + +// CreateRunnerConfig: full path via mockllm. + +func TestCreateRunnerConfig_MinimalOpenAI(t *testing.T) { + baseURL := startMock(t, "testdata/mock_openai.json") + t.Setenv("OPENAI_API_KEY", "test-key") + + cfg := loadConfig(t, "testdata/config_openai.json", baseURL) + sessionService := adksession.InMemoryService() + + runnerCfg, err := CreateRunnerConfig(context.Background(), cfg, sessionService, "myapp", nil, nil) + require.NoError(t, err) + assert.Equal(t, "myapp", runnerCfg.AppName) + assert.NotNil(t, runnerCfg.Agent) + assert.Equal(t, sessionService, runnerCfg.SessionService) +} + +func TestCreateRunnerConfig_DefaultsAppNameWhenEmpty(t *testing.T) { + baseURL := startMock(t, "testdata/mock_openai.json") + t.Setenv("OPENAI_API_KEY", "test-key") + + cfg := loadConfig(t, "testdata/config_openai.json", baseURL) + + runnerCfg, err := CreateRunnerConfig(context.Background(), cfg, nil, "", nil, nil) + require.NoError(t, err) + assert.Equal(t, "kagent-app", runnerCfg.AppName) + assert.NotNil(t, runnerCfg.SessionService, "should fall back to an in-memory session service") +} + +func TestCreateRunnerConfig_ShareToolsRequiresControllerClient(t *testing.T) { + baseURL := startMock(t, "testdata/mock_openai.json") + t.Setenv("OPENAI_API_KEY", "test-key") + + cfg := loadConfig(t, "testdata/config_openai.json", baseURL) + shareTools := true + cfg.ShareTools = &shareTools + + // controllerClient is nil, so share tools should be silently skipped + // rather than causing an error (per the `controllerClient != nil` guard). + runnerCfg, err := CreateRunnerConfig(context.Background(), cfg, nil, "myapp", nil, nil) + require.NoError(t, err) + assert.NotNil(t, runnerCfg.Agent) +} + +func TestCreateRunnerConfig_ShareToolsWithControllerClient(t *testing.T) { + baseURL := startMock(t, "testdata/mock_openai.json") + t.Setenv("OPENAI_API_KEY", "test-key") + + cfg := loadConfig(t, "testdata/config_openai.json", baseURL) + shareTools := true + cfg.ShareTools = &shareTools + + controllerClient := &controllerclient.Client{} + + runnerCfg, err := CreateRunnerConfig(context.Background(), cfg, nil, "myapp", nil, controllerClient) + require.NoError(t, err) + assert.NotNil(t, runnerCfg.Agent, "agent should build successfully with share tools wired in") +} + +func TestCreateRunnerConfig_MissingModelFails(t *testing.T) { + cfg := &adk.AgentConfig{ + Description: "test", + Instruction: "test", + // Model deliberately omitted. + } + + _, err := CreateRunnerConfig(context.Background(), cfg, nil, "myapp", nil, nil) + require.Error(t, err) +} diff --git a/go/adk/pkg/runner/testdata/config_openai.json b/go/adk/pkg/runner/testdata/config_openai.json new file mode 100644 index 000000000..6b5620691 --- /dev/null +++ b/go/adk/pkg/runner/testdata/config_openai.json @@ -0,0 +1,9 @@ +{ + "model": { + "type": "openai", + "model": "gpt-4.1-mini", + "base_url": "{{BASE_URL}}/v1" + }, + "description": "test", + "instruction": "You are helpful. Answer concisely." +} diff --git a/go/adk/pkg/runner/testdata/mock_openai.json b/go/adk/pkg/runner/testdata/mock_openai.json new file mode 100644 index 000000000..84e865819 --- /dev/null +++ b/go/adk/pkg/runner/testdata/mock_openai.json @@ -0,0 +1,35 @@ +{ + "openai": [ + { + "name": "catch_all", + "match": { + "match_type": "contains", + "message": { + "content": "2+2", + "role": "user" + } + }, + "response": { + "id": "chatcmpl-mock-1", + "object": "chat.completion", + "created": 1700000000, + "model": "mock-model", + "choices": [ + { + "index": 0, + "message": { + "content": "The answer is 4.", + "role": "assistant" + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 10, + "completion_tokens": 5, + "total_tokens": 15 + } + } + } + ] +} diff --git a/go/core/internal/controller/reconciler/status/mcp_server_test.go b/go/core/internal/controller/reconciler/status/mcp_server_test.go new file mode 100644 index 000000000..978256f5b --- /dev/null +++ b/go/core/internal/controller/reconciler/status/mcp_server_test.go @@ -0,0 +1,144 @@ +package status + +import ( + "context" + "errors" + "testing" + + "github.com/kagent-dev/kmcp/api/v1alpha1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func newScheme(t *testing.T) *runtime.Scheme { + t.Helper() + scheme := runtime.NewScheme() + require.NoError(t, appsv1.AddToScheme(scheme)) + require.NoError(t, v1alpha1.AddToScheme(scheme)) + return scheme +} + +func conditionOf(mcp *v1alpha1.MCPServer, t v1alpha1.MCPServerConditionType) *metav1.Condition { + for i := range mcp.Status.Conditions { + if mcp.Status.Conditions[i].Type == string(t) { + return &mcp.Status.Conditions[i] + } + } + return nil +} + +func TestReconcileMCPServerStatus_ReconcileErrSetsAcceptedFalse(t *testing.T) { + scheme := newScheme(t) + mcp := &v1alpha1.MCPServer{ObjectMeta: metav1.ObjectMeta{Name: "srv", Namespace: "default"}} + kube := fake.NewClientBuilder().WithScheme(scheme).WithObjects(mcp).WithStatusSubresource(mcp).Build() + + requeue, err := ReconcileMCPServerStatus(context.Background(), kube, mcp, errors.New("bad config")) + require.NoError(t, err) + assert.False(t, requeue) + + accepted := conditionOf(mcp, v1alpha1.MCPServerConditionAccepted) + require.NotNil(t, accepted) + assert.Equal(t, metav1.ConditionFalse, accepted.Status) + assert.Equal(t, string(v1alpha1.MCPServerReasonInvalidConfig), accepted.Reason) +} + +func TestReconcileMCPServerStatus_DeploymentMissing_NotReadyNoRequeue(t *testing.T) { + scheme := newScheme(t) + mcp := &v1alpha1.MCPServer{ObjectMeta: metav1.ObjectMeta{Name: "srv", Namespace: "default"}} + kube := fake.NewClientBuilder().WithScheme(scheme).WithObjects(mcp).WithStatusSubresource(mcp).Build() + + requeue, err := ReconcileMCPServerStatus(context.Background(), kube, mcp, nil) + require.NoError(t, err) + assert.False(t, requeue, "deployment not found should not trigger requeue") + + ready := conditionOf(mcp, v1alpha1.MCPServerConditionReady) + require.NotNil(t, ready) + assert.Equal(t, metav1.ConditionFalse, ready.Status) + assert.Equal(t, string(v1alpha1.MCPServerReasonPodsNotReady), ready.Reason) +} + +func TestReconcileMCPServerStatus_DeploymentNotFullyAvailable_Requeues(t *testing.T) { + scheme := newScheme(t) + mcp := &v1alpha1.MCPServer{ObjectMeta: metav1.ObjectMeta{Name: "srv", Namespace: "default"}} + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "srv", Namespace: "default"}, + Status: appsv1.DeploymentStatus{AvailableReplicas: 1, Replicas: 2}, + } + kube := fake.NewClientBuilder().WithScheme(scheme).WithObjects(mcp, deployment).WithStatusSubresource(mcp).Build() + + requeue, err := ReconcileMCPServerStatus(context.Background(), kube, mcp, nil) + require.NoError(t, err) + assert.True(t, requeue) + + ready := conditionOf(mcp, v1alpha1.MCPServerConditionReady) + require.NotNil(t, ready) + assert.Equal(t, metav1.ConditionFalse, ready.Status) + assert.Equal(t, string(v1alpha1.MCPServerReasonNotAvailable), ready.Reason) +} + +func TestReconcileMCPServerStatus_DeploymentFullyAvailable_Ready(t *testing.T) { + scheme := newScheme(t) + mcp := &v1alpha1.MCPServer{ObjectMeta: metav1.ObjectMeta{Name: "srv", Namespace: "default"}} + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "srv", Namespace: "default"}, + Status: appsv1.DeploymentStatus{AvailableReplicas: 2, Replicas: 2}, + } + kube := fake.NewClientBuilder().WithScheme(scheme).WithObjects(mcp, deployment).WithStatusSubresource(mcp).Build() + + requeue, err := ReconcileMCPServerStatus(context.Background(), kube, mcp, nil) + require.NoError(t, err) + assert.False(t, requeue) + + ready := conditionOf(mcp, v1alpha1.MCPServerConditionReady) + require.NotNil(t, ready) + assert.Equal(t, metav1.ConditionTrue, ready.Status) + assert.Equal(t, string(v1alpha1.MCPServerReasonAvailable), ready.Reason) + + accepted := conditionOf(mcp, v1alpha1.MCPServerConditionAccepted) + require.NotNil(t, accepted) + assert.Equal(t, metav1.ConditionTrue, accepted.Status) +} + +func TestReconcileMCPServerStatus_ObservedGenerationUpdated(t *testing.T) { + scheme := newScheme(t) + mcp := &v1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: "srv", Namespace: "default", Generation: 5}, + } + kube := fake.NewClientBuilder().WithScheme(scheme).WithObjects(mcp).WithStatusSubresource(mcp).Build() + + _, err := ReconcileMCPServerStatus(context.Background(), kube, mcp, nil) + require.NoError(t, err) + + assert.Equal(t, int64(5), mcp.Status.ObservedGeneration) +} + +func TestSetCondition_PreservesLastTransitionTimeWhenStatusUnchanged(t *testing.T) { + mcp := &v1alpha1.MCPServer{} + setCondition(mcp, v1alpha1.MCPServerConditionReady, metav1.ConditionTrue, v1alpha1.MCPServerReasonAvailable, "first") + first := conditionOf(mcp, v1alpha1.MCPServerConditionReady) + require.NotNil(t, first) + firstTransition := first.LastTransitionTime + + setCondition(mcp, v1alpha1.MCPServerConditionReady, metav1.ConditionTrue, v1alpha1.MCPServerReasonAvailable, "second message") + second := conditionOf(mcp, v1alpha1.MCPServerConditionReady) + require.NotNil(t, second) + + assert.Equal(t, firstTransition, second.LastTransitionTime, "transition time should not change when status is unchanged") + assert.Equal(t, "second message", second.Message, "message should still update") + require.Len(t, mcp.Status.Conditions, 1, "should update existing condition, not append") +} + +func TestSetCondition_UpdatesTransitionTimeWhenStatusChanges(t *testing.T) { + mcp := &v1alpha1.MCPServer{} + setCondition(mcp, v1alpha1.MCPServerConditionReady, metav1.ConditionFalse, v1alpha1.MCPServerReasonPodsNotReady, "not ready") + setCondition(mcp, v1alpha1.MCPServerConditionReady, metav1.ConditionTrue, v1alpha1.MCPServerReasonAvailable, "now ready") + + cond := conditionOf(mcp, v1alpha1.MCPServerConditionReady) + require.NotNil(t, cond) + assert.Equal(t, metav1.ConditionTrue, cond.Status) + require.Len(t, mcp.Status.Conditions, 1) +} diff --git a/go/core/internal/controller/reconciler/utils/reconciler_utils_test.go b/go/core/internal/controller/reconciler/utils/reconciler_utils_test.go new file mode 100644 index 000000000..cb024c4de --- /dev/null +++ b/go/core/internal/controller/reconciler/utils/reconciler_utils_test.go @@ -0,0 +1,63 @@ +package utils + +import ( + "testing" + + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestObjectMetasEqual_SameNamespaceNameLabelsAnnotations(t *testing.T) { + a := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{ + Namespace: "ns", Name: "n", + Labels: map[string]string{"a": "1"}, + Annotations: map[string]string{"b": "2"}, + }} + b := a.DeepCopy() + assert.True(t, ObjectMetasEqual(a, b)) +} + +func TestObjectMetasEqual_DifferentName(t *testing.T) { + a := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Namespace: "ns", Name: "n1"}} + b := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Namespace: "ns", Name: "n2"}} + assert.False(t, ObjectMetasEqual(a, b)) +} + +func TestObjectMetasEqual_DifferentLabels(t *testing.T) { + a := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"a": "1"}}} + b := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"a": "2"}}} + assert.False(t, ObjectMetasEqual(a, b)) +} + +func TestObjectsEqual_DifferentData(t *testing.T) { + a := &corev1.ConfigMap{Data: map[string]string{"k": "v1"}} + b := &corev1.ConfigMap{Data: map[string]string{"k": "v2"}} + assert.False(t, ObjectsEqual(a, b)) +} + +func TestObjectsEqual_SameDataEqual(t *testing.T) { + a := &corev1.ConfigMap{Data: map[string]string{"k": "v1"}} + b := &corev1.ConfigMap{Data: map[string]string{"k": "v1"}} + assert.True(t, ObjectsEqual(a, b)) +} + +func TestObjectsEqual_DifferentTypesNotEqual(t *testing.T) { + a := &corev1.ConfigMap{} + b := &corev1.Secret{} + assert.False(t, ObjectsEqual(a, b)) +} + +func TestMapStringEqual(t *testing.T) { + assert.True(t, mapStringEqual(nil, nil)) + assert.True(t, mapStringEqual(map[string]string{}, nil)) + assert.True(t, mapStringEqual(map[string]string{"a": "1"}, map[string]string{"a": "1"})) + assert.False(t, mapStringEqual(map[string]string{"a": "1"}, map[string]string{"a": "2"})) + assert.False(t, mapStringEqual(map[string]string{"a": "1"}, map[string]string{"b": "1"})) + assert.False(t, mapStringEqual(map[string]string{"a": "1", "b": "2"}, map[string]string{"a": "1"})) +} + +func TestDeepEqual_NonProtoFallsBackToReflect(t *testing.T) { + assert.True(t, DeepEqual(map[string]string{"a": "1"}, map[string]string{"a": "1"})) + assert.False(t, DeepEqual(map[string]string{"a": "1"}, map[string]string{"a": "2"})) +} diff --git a/go/core/internal/controller/translator/mutate_test.go b/go/core/internal/controller/translator/mutate_test.go new file mode 100644 index 000000000..f6dff8826 --- /dev/null +++ b/go/core/internal/controller/translator/mutate_test.go @@ -0,0 +1,179 @@ +package translator + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestMutateFuncFor_MergesAnnotationsAndLabels(t *testing.T) { + existing := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{"keep": "me", "override": "old"}, + Labels: map[string]string{"keepLabel": "me", "overrideLabel": "old"}, + }, + } + desired := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{"override": "new"}, + Labels: map[string]string{"overrideLabel": "new"}, + }, + } + + mutate := MutateFuncFor(existing, desired) + require.NoError(t, mutate()) + + assert.Equal(t, "me", existing.Annotations["keep"]) + assert.Equal(t, "new", existing.Annotations["override"]) + assert.Equal(t, "me", existing.Labels["keepLabel"]) + assert.Equal(t, "new", existing.Labels["overrideLabel"]) +} + +func TestMutateFuncFor_SetsOwnerReferencesWhenDesiredHasThem(t *testing.T) { + existing := &corev1.ConfigMap{} + desired := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + OwnerReferences: []metav1.OwnerReference{{Name: "owner"}}, + }, + } + + mutate := MutateFuncFor(existing, desired) + require.NoError(t, mutate()) + + require.Len(t, existing.OwnerReferences, 1) + assert.Equal(t, "owner", existing.OwnerReferences[0].Name) +} + +func TestMutateFuncFor_LeavesOwnerReferencesWhenDesiredHasNone(t *testing.T) { + existing := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + OwnerReferences: []metav1.OwnerReference{{Name: "existing-owner"}}, + }, + } + desired := &corev1.ConfigMap{} + + mutate := MutateFuncFor(existing, desired) + require.NoError(t, mutate()) + + require.Len(t, existing.OwnerReferences, 1) + assert.Equal(t, "existing-owner", existing.OwnerReferences[0].Name) +} + +func TestMutateFuncFor_ConfigMap(t *testing.T) { + existing := &corev1.ConfigMap{ + Data: map[string]string{"old": "value"}, + BinaryData: map[string][]byte{"oldBin": []byte("x")}, + } + desired := &corev1.ConfigMap{ + Data: map[string]string{"new": "value"}, + BinaryData: map[string][]byte{"newBin": []byte("y")}, + } + + mutate := MutateFuncFor(existing, desired) + require.NoError(t, mutate()) + + assert.Equal(t, desired.Data, existing.Data) + assert.Equal(t, desired.BinaryData, existing.BinaryData) +} + +func TestMutateFuncFor_Secret(t *testing.T) { + existing := &corev1.Secret{ + StringData: map[string]string{"old": "value"}, + Data: map[string][]byte{"oldBin": []byte("x")}, + } + desired := &corev1.Secret{ + StringData: map[string]string{"new": "value"}, + Data: map[string][]byte{"newBin": []byte("y")}, + } + + mutate := MutateFuncFor(existing, desired) + require.NoError(t, mutate()) + + assert.Equal(t, desired.StringData, existing.StringData) + assert.Equal(t, desired.Data, existing.Data) +} + +func TestMutateFuncFor_Service(t *testing.T) { + existing := &corev1.Service{ + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{{Name: "old", Port: 1}}, + Selector: map[string]string{"old": "sel"}, + }, + } + desired := &corev1.Service{ + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{{Name: "new", Port: 2}}, + Selector: map[string]string{"new": "sel"}, + }, + } + + mutate := MutateFuncFor(existing, desired) + require.NoError(t, mutate()) + + assert.Equal(t, desired.Spec.Ports, existing.Spec.Ports) + assert.Equal(t, desired.Spec.Selector, existing.Spec.Selector) +} + +func TestMutateFuncFor_ServiceAccount_NoOp(t *testing.T) { + existing := &corev1.ServiceAccount{Secrets: []corev1.ObjectReference{{Name: "keep-this"}}} + desired := &corev1.ServiceAccount{} + + mutate := MutateFuncFor(existing, desired) + require.NoError(t, mutate()) + + // mutateServiceAccount is intentionally a no-op besides existence. + require.Len(t, existing.Secrets, 1) + assert.Equal(t, "keep-this", existing.Secrets[0].Name) +} + +func TestMutateFuncFor_Deployment(t *testing.T) { + replicas := int32(3) + existing := &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Replicas: new(int32(1)), + MinReadySeconds: 1, + Paused: false, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"old": "label"}}, + }, + }, + } + desired := &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Replicas: &replicas, + MinReadySeconds: 5, + Paused: true, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"new": "label"}}, + }, + }, + } + + mutate := MutateFuncFor(existing, desired) + require.NoError(t, mutate()) + + require.NotNil(t, existing.Spec.Replicas) + assert.Equal(t, int32(3), *existing.Spec.Replicas) + assert.Equal(t, int32(5), existing.Spec.MinReadySeconds) + assert.True(t, existing.Spec.Paused) + assert.Equal(t, desired.Spec.Template.Spec, existing.Spec.Template.Spec) +} + +func TestMutateFuncFor_Deployment_NilReplicasPreservesExisting(t *testing.T) { + existing := &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{Replicas: new(int32(7))}, + } + desired := &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{Replicas: nil}, + } + + mutate := MutateFuncFor(existing, desired) + require.NoError(t, mutate()) + + require.NotNil(t, existing.Spec.Replicas) + assert.Equal(t, int32(7), *existing.Spec.Replicas, "replicas should be preserved (e.g. for HPA) when desired is nil") +} diff --git a/go/core/internal/service/agent/service_test.go b/go/core/internal/service/agent/service_test.go new file mode 100644 index 000000000..f06a39bf7 --- /dev/null +++ b/go/core/internal/service/agent/service_test.go @@ -0,0 +1,299 @@ +package agent_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "github.com/kagent-dev/kagent/go/api/v1alpha3" + agentsvc "github.com/kagent-dev/kagent/go/core/internal/service/agent" + "github.com/kagent-dev/kagent/go/core/internal/service/serviceerrors" + "github.com/kagent-dev/kagent/go/core/pkg/auth" + "github.com/kagent-dev/kagent/go/core/pkg/sandboxbackend" + "github.com/kagent-dev/kagent/go/core/pkg/sandboxbackend/substrate" +) + +type testSession struct { + principal auth.Principal +} + +func (s testSession) Principal() auth.Principal { return s.principal } + +func authedContext(userID string) context.Context { + return auth.AuthSessionTo(context.Background(), testSession{ + principal: auth.Principal{User: auth.User{ID: userID}}, + }) +} + +type stubAuthorizer struct { + err error +} + +func (a *stubAuthorizer) Check(context.Context, auth.Principal, auth.Verb, auth.Resource) error { + return a.err +} + +type stubActorLifecycle struct { + ensureResult sandboxbackend.EnsureResult + ensureErr error + suspendErr error + state substrate.SessionActorState + stateErr error + ensureCalls int + suspendCalls int + getStateCalls int +} + +func (s *stubActorLifecycle) EnsureSessionActor(context.Context, *v1alpha3.AgentHarness, string) (sandboxbackend.EnsureResult, error) { + s.ensureCalls++ + return s.ensureResult, s.ensureErr +} + +func (s *stubActorLifecycle) SuspendSessionActor(context.Context, *v1alpha3.AgentHarness, string) error { + s.suspendCalls++ + return s.suspendErr +} + +func (s *stubActorLifecycle) GetSessionActorState(context.Context, *v1alpha3.AgentHarness, string) (substrate.SessionActorState, error) { + s.getStateCalls++ + return s.state, s.stateErr +} + +func newScheme(t *testing.T) *runtime.Scheme { + t.Helper() + scheme := runtime.NewScheme() + require.NoError(t, v1alpha3.AddToScheme(scheme)) + return scheme +} + +func TestList_RequiresAuthentication(t *testing.T) { + kube := fake.NewClientBuilder().WithScheme(newScheme(t)).Build() + service := agentsvc.NewService(kube, &stubAuthorizer{}, "default") + + _, err := service.List(context.Background(), agentsvc.ListRequest{}) + require.Error(t, err) + assert.True(t, serviceerrors.IsCode(err, serviceerrors.CodeUnauthenticated)) +} + +func TestList_RejectsWhitespaceNamespace(t *testing.T) { + kube := fake.NewClientBuilder().WithScheme(newScheme(t)).Build() + service := agentsvc.NewService(kube, &stubAuthorizer{}, "default") + + _, err := service.List(authedContext("user-a"), agentsvc.ListRequest{Namespace: " bad "}) + require.Error(t, err) + assert.True(t, serviceerrors.IsCode(err, serviceerrors.CodeInvalidArgument)) +} + +func TestList_ReturnsSandboxAgents(t *testing.T) { + scheme := newScheme(t) + sandboxAgent := &v1alpha3.SandboxAgent{ + ObjectMeta: metav1.ObjectMeta{Name: "agent-1", Namespace: "default"}, + Spec: v1alpha3.SandboxAgentSpec{Type: v1alpha3.AgentType_Declarative}, + } + kube := fake.NewClientBuilder().WithScheme(scheme).WithObjects(sandboxAgent).Build() + service := agentsvc.NewService(kube, &stubAuthorizer{}, "default") + + views, err := service.List(authedContext("user-a"), agentsvc.ListRequest{Namespace: "default"}) + require.NoError(t, err) + require.Len(t, views, 1) + assert.Equal(t, agentsvc.KindSandboxAgent, views[0].Kind) + assert.Equal(t, "agent-1", views[0].Ref.Name) +} + +func TestList_SkipsUnknownAgentHarnessBackend(t *testing.T) { + scheme := newScheme(t) + harness := &v1alpha3.AgentHarness{ + ObjectMeta: metav1.ObjectMeta{Name: "harness-1", Namespace: "default"}, + Spec: v1alpha3.AgentHarnessSpec{Backend: "totally-unknown-backend"}, + } + kube := fake.NewClientBuilder().WithScheme(scheme).WithObjects(harness).Build() + service := agentsvc.NewService(kube, &stubAuthorizer{}, "default") + + views, err := service.List(authedContext("user-a"), agentsvc.ListRequest{Namespace: "default"}) + require.NoError(t, err) + assert.Empty(t, views, "unknown harness backend should be filtered out of listing") +} + +func TestGetSandboxAgent_NotFound(t *testing.T) { + kube := fake.NewClientBuilder().WithScheme(newScheme(t)).Build() + service := agentsvc.NewService(kube, &stubAuthorizer{}, "default") + + _, err := service.GetSandboxAgent(authedContext("user-a"), agentsvc.GetRequest{ + Ref: types.NamespacedName{Namespace: "default", Name: "missing"}, + }) + require.Error(t, err) + assert.True(t, serviceerrors.IsCode(err, serviceerrors.CodeNotFound)) +} + +func TestGetAgentHarness_UnknownBackendReportsNotFound(t *testing.T) { + scheme := newScheme(t) + harness := &v1alpha3.AgentHarness{ + ObjectMeta: metav1.ObjectMeta{Name: "harness-1", Namespace: "default"}, + Spec: v1alpha3.AgentHarnessSpec{Backend: "totally-unknown-backend"}, + } + kube := fake.NewClientBuilder().WithScheme(scheme).WithObjects(harness).Build() + service := agentsvc.NewService(kube, &stubAuthorizer{}, "default") + + _, err := service.GetAgentHarness(authedContext("user-a"), agentsvc.GetRequest{ + Ref: types.NamespacedName{Namespace: "default", Name: "harness-1"}, + }) + require.Error(t, err) + assert.True(t, serviceerrors.IsCode(err, serviceerrors.CodeNotFound)) +} + +func TestCreateSandboxAgent_RequiresAgent(t *testing.T) { + kube := fake.NewClientBuilder().WithScheme(newScheme(t)).Build() + service := agentsvc.NewService(kube, &stubAuthorizer{}, "default") + + _, err := service.CreateSandboxAgent(authedContext("user-a"), agentsvc.CreateSandboxAgentRequest{Agent: nil}) + require.Error(t, err) + assert.True(t, serviceerrors.IsCode(err, serviceerrors.CodeInvalidArgument)) +} + +func TestCreateSandboxAgent_AlreadyExists(t *testing.T) { + scheme := newScheme(t) + existing := &v1alpha3.SandboxAgent{ + ObjectMeta: metav1.ObjectMeta{Name: "agent-1", Namespace: "default"}, + Spec: v1alpha3.SandboxAgentSpec{Type: v1alpha3.AgentType_Declarative}, + } + kube := fake.NewClientBuilder().WithScheme(scheme).WithObjects(existing).Build() + service := agentsvc.NewService(kube, &stubAuthorizer{}, "default", + agentsvc.WithValidator(func(context.Context, *v1alpha3.SandboxAgent) error { return nil })) + + _, err := service.CreateSandboxAgent(authedContext("user-a"), agentsvc.CreateSandboxAgentRequest{ + Agent: &v1alpha3.SandboxAgent{ + ObjectMeta: metav1.ObjectMeta{Name: "agent-1", Namespace: "default"}, + Spec: v1alpha3.SandboxAgentSpec{Type: v1alpha3.AgentType_Declarative}, + }, + }) + require.Error(t, err) + assert.True(t, serviceerrors.IsCode(err, serviceerrors.CodeAlreadyExists)) +} + +func TestDeleteAgentHarness_RequiresNamespaceAndName(t *testing.T) { + kube := fake.NewClientBuilder().WithScheme(newScheme(t)).Build() + service := agentsvc.NewService(kube, &stubAuthorizer{}, "default") + + err := service.DeleteAgentHarness(authedContext("user-a"), agentsvc.DeleteRequest{}) + require.Error(t, err) + assert.True(t, serviceerrors.IsCode(err, serviceerrors.CodeInvalidArgument)) +} + +func TestEnsureAgentHarnessSessionActor_NoLifecycleConfigured(t *testing.T) { + scheme := newScheme(t) + harness := &v1alpha3.AgentHarness{ + ObjectMeta: metav1.ObjectMeta{Name: "harness-1", Namespace: "default"}, + Spec: v1alpha3.AgentHarnessSpec{Backend: "substrate"}, + } + kube := fake.NewClientBuilder().WithScheme(scheme).WithObjects(harness).Build() + // No WithActorLifecycle option supplied. + service := agentsvc.NewService(kube, &stubAuthorizer{}, "default") + + _, err := service.EnsureAgentHarnessSessionActor(authedContext("user-a"), agentsvc.ActorRequest{ + Ref: types.NamespacedName{Namespace: "default", Name: "harness-1"}, + SessionID: "sess-1", + }) + require.Error(t, err) + assert.True(t, serviceerrors.IsCode(err, serviceerrors.CodeFailedPrecondition)) +} + +func TestEnsureAgentHarnessSessionActor_RequiresSessionID(t *testing.T) { + scheme := newScheme(t) + harness := &v1alpha3.AgentHarness{ + ObjectMeta: metav1.ObjectMeta{Name: "harness-1", Namespace: "default"}, + Spec: v1alpha3.AgentHarnessSpec{Backend: "substrate"}, + } + kube := fake.NewClientBuilder().WithScheme(scheme).WithObjects(harness).Build() + lifecycle := &stubActorLifecycle{} + service := agentsvc.NewService(kube, &stubAuthorizer{}, "default", agentsvc.WithActorLifecycle(lifecycle)) + + _, err := service.EnsureAgentHarnessSessionActor(authedContext("user-a"), agentsvc.ActorRequest{ + Ref: types.NamespacedName{Namespace: "default", Name: "harness-1"}, + SessionID: " ", + }) + require.Error(t, err) + assert.True(t, serviceerrors.IsCode(err, serviceerrors.CodeInvalidArgument)) + assert.Equal(t, 0, lifecycle.ensureCalls, "lifecycle should not be invoked when validation fails") +} + +func TestEnsureAgentHarnessSessionActor_Success(t *testing.T) { + scheme := newScheme(t) + harness := &v1alpha3.AgentHarness{ + ObjectMeta: metav1.ObjectMeta{Name: "harness-1", Namespace: "default"}, + Spec: v1alpha3.AgentHarnessSpec{Backend: "substrate"}, + } + kube := fake.NewClientBuilder().WithScheme(scheme).WithObjects(harness).Build() + lifecycle := &stubActorLifecycle{ + ensureResult: sandboxbackend.EnsureResult{Handle: sandboxbackend.Handle{ID: "actor-123"}}, + } + service := agentsvc.NewService(kube, &stubAuthorizer{}, "default", agentsvc.WithActorLifecycle(lifecycle)) + + actor, err := service.EnsureAgentHarnessSessionActor(authedContext("user-a"), agentsvc.ActorRequest{ + Ref: types.NamespacedName{Namespace: "default", Name: "harness-1"}, + SessionID: "sess-1", + }) + require.NoError(t, err) + assert.Equal(t, "actor-123", actor.ActorID) + assert.Equal(t, agentsvc.ActorStateRunning, actor.State) + assert.Equal(t, 1, lifecycle.ensureCalls) +} + +func TestSuspendAgentHarnessSessionActor_PropagatesLifecycleError(t *testing.T) { + scheme := newScheme(t) + harness := &v1alpha3.AgentHarness{ + ObjectMeta: metav1.ObjectMeta{Name: "harness-1", Namespace: "default"}, + Spec: v1alpha3.AgentHarnessSpec{Backend: "substrate"}, + } + kube := fake.NewClientBuilder().WithScheme(scheme).WithObjects(harness).Build() + lifecycle := &stubActorLifecycle{suspendErr: newTestError()} + service := agentsvc.NewService(kube, &stubAuthorizer{}, "default", agentsvc.WithActorLifecycle(lifecycle)) + + _, err := service.SuspendAgentHarnessSessionActor(authedContext("user-a"), agentsvc.ActorRequest{ + Ref: types.NamespacedName{Namespace: "default", Name: "harness-1"}, + SessionID: "sess-1", + }) + require.Error(t, err) + assert.True(t, serviceerrors.IsCode(err, serviceerrors.CodeInternal)) + assert.Equal(t, 1, lifecycle.suspendCalls) +} + +func TestGetAgentHarnessSessionActor_MapsSuspendedState(t *testing.T) { + scheme := newScheme(t) + harness := &v1alpha3.AgentHarness{ + ObjectMeta: metav1.ObjectMeta{Name: "harness-1", Namespace: "default"}, + Spec: v1alpha3.AgentHarnessSpec{Backend: "substrate"}, + } + kube := fake.NewClientBuilder().WithScheme(scheme).WithObjects(harness).Build() + lifecycle := &stubActorLifecycle{state: substrate.SessionActorStateSuspended} + service := agentsvc.NewService(kube, &stubAuthorizer{}, "default", agentsvc.WithActorLifecycle(lifecycle)) + + actor, err := service.GetAgentHarnessSessionActor(authedContext("user-a"), agentsvc.ActorRequest{ + Ref: types.NamespacedName{Namespace: "default", Name: "harness-1"}, + SessionID: "sess-1", + }) + require.NoError(t, err) + assert.Equal(t, agentsvc.ActorStateSuspended, actor.State) +} + +func TestAuthorizationDenied_ReturnsPermissionDenied(t *testing.T) { + kube := fake.NewClientBuilder().WithScheme(newScheme(t)).Build() + service := agentsvc.NewService(kube, &stubAuthorizer{err: newTestError()}, "default") + + _, err := service.List(authedContext("user-a"), agentsvc.ListRequest{}) + require.Error(t, err) + assert.True(t, serviceerrors.IsCode(err, serviceerrors.CodePermissionDenied)) +} + +func newTestError() error { + return &testError{"boom"} +} + +type testError struct{ msg string } + +func (e *testError) Error() string { return e.msg } diff --git a/go/core/internal/service/agent/validator_test.go b/go/core/internal/service/agent/validator_test.go new file mode 100644 index 000000000..20ef15508 --- /dev/null +++ b/go/core/internal/service/agent/validator_test.go @@ -0,0 +1,83 @@ +package agent_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "github.com/kagent-dev/kagent/go/api/v1alpha3" + agentsvc "github.com/kagent-dev/kagent/go/core/internal/service/agent" + "github.com/kagent-dev/kagent/go/core/internal/service/serviceerrors" +) + +func TestNewManifestValidator_UnknownAgentTypeIsInvalidArgument(t *testing.T) { + scheme := newScheme(t) + kube := fake.NewClientBuilder().WithScheme(scheme).Build() + + validator := agentsvc.NewManifestValidator(agentsvc.ManifestValidatorConfig{ + KubeClient: kube, + DefaultModelConfig: types.NamespacedName{Namespace: "default", Name: "default-model"}, + }) + + agent := &v1alpha3.SandboxAgent{ + ObjectMeta: metav1.ObjectMeta{Name: "agent-1", Namespace: "default"}, + Spec: v1alpha3.SandboxAgentSpec{Type: "not-a-real-type"}, + } + + err := validator(context.Background(), agent) + require.Error(t, err) + assert.True(t, serviceerrors.IsCode(err, serviceerrors.CodeInvalidArgument)) +} + +func TestNewManifestValidator_DeclarativeMissingModelConfigIsInvalidArgument(t *testing.T) { + scheme := newScheme(t) + kube := fake.NewClientBuilder().WithScheme(scheme).Build() + + validator := agentsvc.NewManifestValidator(agentsvc.ManifestValidatorConfig{ + KubeClient: kube, + DefaultModelConfig: types.NamespacedName{Namespace: "default", Name: "default-model"}, + }) + + agent := &v1alpha3.SandboxAgent{ + ObjectMeta: metav1.ObjectMeta{Name: "agent-1", Namespace: "default"}, + Spec: v1alpha3.SandboxAgentSpec{ + Type: v1alpha3.AgentType_Declarative, + Declarative: &v1alpha3.DeclarativeAgentSpec{ + // ModelConfig points at a name that does not exist as a resource + // in the fake client, so compilation must fail. + ModelConfig: "missing-model-config", + }, + }, + } + + err := validator(context.Background(), agent) + require.Error(t, err, "compiling against a nonexistent ModelConfig must fail") + assert.True(t, serviceerrors.IsCode(err, serviceerrors.CodeInvalidArgument)) +} + +func TestNewManifestValidator_ByoMissingSpecIsInvalidArgument(t *testing.T) { + scheme := newScheme(t) + kube := fake.NewClientBuilder().WithScheme(scheme).Build() + + validator := agentsvc.NewManifestValidator(agentsvc.ManifestValidatorConfig{ + KubeClient: kube, + DefaultModelConfig: types.NamespacedName{Namespace: "default", Name: "default-model"}, + }) + + agent := &v1alpha3.SandboxAgent{ + ObjectMeta: metav1.ObjectMeta{Name: "agent-1", Namespace: "default"}, + Spec: v1alpha3.SandboxAgentSpec{ + Type: v1alpha3.AgentType_BYO, + // BYO field deliberately left nil - required for this type. + }, + } + + err := validator(context.Background(), agent) + require.Error(t, err, "BYO type without a BYO spec must fail validation") + assert.True(t, serviceerrors.IsCode(err, serviceerrors.CodeInvalidArgument)) +}