Skip to content

Commit a16d018

Browse files
ChrisJBurnsclaude
andcommitted
Add envtest for authzConfig/authzConfigRef mutual exclusion
CEL XValidation rules run at API admission, which unit tests with the fake client cannot exercise. The three workload specs (MCPServer, MCPRemoteProxy, VirtualMCPServer.IncomingAuth) each declare the same mutex rule between the legacy inline authzConfig and the new authzConfigRef — without envtest coverage the rules could silently regress on a CRD regeneration. For each spec, the new tests apply only-inline, only-ref, and both-set CRs and assert (c) is rejected with the expected message. They follow the existing pattern in cmd/thv-operator/test-integration/. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 52c7dc6 commit a16d018

3 files changed

Lines changed: 204 additions & 0 deletions

File tree

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package controllers
5+
6+
import (
7+
"context"
8+
9+
. "github.com/onsi/ginkgo/v2"
10+
. "github.com/onsi/gomega"
11+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
12+
13+
mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1"
14+
)
15+
16+
// newRemoteProxyWithAuthz builds a minimal MCPRemoteProxy whose authz pair is
17+
// the subject of the CEL XValidation rule under test.
18+
func newRemoteProxyWithAuthz(
19+
namespace, name string,
20+
authzConfig *mcpv1beta1.AuthzConfigRef,
21+
authzConfigRef *mcpv1beta1.MCPAuthzConfigReference,
22+
) *mcpv1beta1.MCPRemoteProxy {
23+
return &mcpv1beta1.MCPRemoteProxy{
24+
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace},
25+
Spec: mcpv1beta1.MCPRemoteProxySpec{
26+
RemoteURL: "https://example.com",
27+
AuthzConfig: authzConfig,
28+
AuthzConfigRef: authzConfigRef,
29+
},
30+
}
31+
}
32+
33+
var _ = Describe("CEL Validation for authzConfig vs authzConfigRef on MCPRemoteProxy",
34+
Label("k8s", "remoteproxy", "cel", "validation"), func() {
35+
var (
36+
testCtx context.Context
37+
testNamespace string
38+
)
39+
40+
BeforeEach(func() {
41+
testCtx = context.Background()
42+
testNamespace = createTestNamespace(testCtx)
43+
})
44+
45+
AfterEach(func() {
46+
deleteTestNamespace(testCtx, testNamespace)
47+
})
48+
49+
It("should accept only inline authzConfig", func() {
50+
proxy := newRemoteProxyWithAuthz(
51+
testNamespace, "rp-authzmutex-inline-only",
52+
&mcpv1beta1.AuthzConfigRef{
53+
Type: "inline",
54+
Inline: &mcpv1beta1.InlineAuthzConfig{Policies: []string{"permit(principal, action, resource);"}},
55+
},
56+
nil,
57+
)
58+
Expect(k8sClient.Create(testCtx, proxy)).To(Succeed())
59+
})
60+
61+
It("should accept only authzConfigRef", func() {
62+
proxy := newRemoteProxyWithAuthz(
63+
testNamespace, "rp-authzmutex-ref-only",
64+
nil,
65+
&mcpv1beta1.MCPAuthzConfigReference{Name: "shared-authz"},
66+
)
67+
Expect(k8sClient.Create(testCtx, proxy)).To(Succeed())
68+
})
69+
70+
It("should reject when both authzConfig and authzConfigRef are set", func() {
71+
proxy := newRemoteProxyWithAuthz(
72+
testNamespace, "rp-authzmutex-both",
73+
&mcpv1beta1.AuthzConfigRef{
74+
Type: "inline",
75+
Inline: &mcpv1beta1.InlineAuthzConfig{Policies: []string{"permit(principal, action, resource);"}},
76+
},
77+
&mcpv1beta1.MCPAuthzConfigReference{Name: "shared-authz"},
78+
)
79+
err := k8sClient.Create(testCtx, proxy)
80+
Expect(err).To(HaveOccurred())
81+
Expect(err.Error()).To(ContainSubstring("authzConfig and authzConfigRef are mutually exclusive"))
82+
})
83+
})

cmd/thv-operator/test-integration/mcp-server/mcpserver_cel_validation_integration_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,4 +120,48 @@ var _ = Describe("CEL Validation for AuthzConfigRef", Label("k8s", "cel", "valid
120120
})
121121
})
122122

123+
Context("authzConfig vs authzConfigRef mutual exclusion", func() {
124+
It("should accept only inline authzConfig", func() {
125+
server := &mcpv1beta1.MCPServer{
126+
ObjectMeta: metav1.ObjectMeta{Name: "authzmutex-inline-only", Namespace: "default"},
127+
Spec: mcpv1beta1.MCPServerSpec{
128+
Image: "example/mcp-server:latest",
129+
AuthzConfig: &mcpv1beta1.AuthzConfigRef{
130+
Type: "inline",
131+
Inline: &mcpv1beta1.InlineAuthzConfig{Policies: []string{"permit(principal, action, resource);"}},
132+
},
133+
},
134+
}
135+
Expect(k8sClient.Create(ctx, server)).To(Succeed())
136+
})
137+
138+
It("should accept only authzConfigRef", func() {
139+
server := &mcpv1beta1.MCPServer{
140+
ObjectMeta: metav1.ObjectMeta{Name: "authzmutex-ref-only", Namespace: "default"},
141+
Spec: mcpv1beta1.MCPServerSpec{
142+
Image: "example/mcp-server:latest",
143+
AuthzConfigRef: &mcpv1beta1.MCPAuthzConfigReference{Name: "shared-authz"},
144+
},
145+
}
146+
Expect(k8sClient.Create(ctx, server)).To(Succeed())
147+
})
148+
149+
It("should reject when both authzConfig and authzConfigRef are set", func() {
150+
server := &mcpv1beta1.MCPServer{
151+
ObjectMeta: metav1.ObjectMeta{Name: "authzmutex-both", Namespace: "default"},
152+
Spec: mcpv1beta1.MCPServerSpec{
153+
Image: "example/mcp-server:latest",
154+
AuthzConfig: &mcpv1beta1.AuthzConfigRef{
155+
Type: "inline",
156+
Inline: &mcpv1beta1.InlineAuthzConfig{Policies: []string{"permit(principal, action, resource);"}},
157+
},
158+
AuthzConfigRef: &mcpv1beta1.MCPAuthzConfigReference{Name: "shared-authz"},
159+
},
160+
}
161+
err := k8sClient.Create(ctx, server)
162+
Expect(err).To(HaveOccurred())
163+
Expect(err.Error()).To(ContainSubstring("authzConfig and authzConfigRef are mutually exclusive"))
164+
})
165+
})
166+
123167
})
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package controllers
5+
6+
import (
7+
. "github.com/onsi/ginkgo/v2"
8+
. "github.com/onsi/gomega"
9+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
10+
11+
mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1"
12+
vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config"
13+
)
14+
15+
// newVirtualMCPServerWithIncomingAuth builds a minimal VirtualMCPServer whose
16+
// IncomingAuth carries the supplied authzConfig / authzConfigRef. The pair is
17+
// the subject of the CEL XValidation rule under test.
18+
func newVirtualMCPServerWithIncomingAuth(
19+
name string,
20+
authzConfig *mcpv1beta1.AuthzConfigRef,
21+
authzConfigRef *mcpv1beta1.MCPAuthzConfigReference,
22+
) *mcpv1beta1.VirtualMCPServer {
23+
return &mcpv1beta1.VirtualMCPServer{
24+
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"},
25+
Spec: mcpv1beta1.VirtualMCPServerSpec{
26+
GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"},
27+
IncomingAuth: &mcpv1beta1.IncomingAuthConfig{
28+
Type: "anonymous",
29+
AuthzConfig: authzConfig,
30+
AuthzConfigRef: authzConfigRef,
31+
},
32+
Config: vmcpconfig.Config{
33+
Group: "test-group",
34+
},
35+
},
36+
}
37+
}
38+
39+
var _ = Describe("CEL Validation for authzConfig vs authzConfigRef on VirtualMCPServer",
40+
Label("k8s", "cel", "validation"), func() {
41+
Context("IncomingAuth.authzConfig vs IncomingAuth.authzConfigRef", func() {
42+
It("should accept only inline authzConfig", func() {
43+
vmcp := newVirtualMCPServerWithIncomingAuth(
44+
"vmcp-authzmutex-inline-only",
45+
&mcpv1beta1.AuthzConfigRef{
46+
Type: "inline",
47+
Inline: &mcpv1beta1.InlineAuthzConfig{Policies: []string{"permit(principal, action, resource);"}},
48+
},
49+
nil,
50+
)
51+
Expect(k8sClient.Create(ctx, vmcp)).To(Succeed())
52+
})
53+
54+
It("should accept only authzConfigRef", func() {
55+
vmcp := newVirtualMCPServerWithIncomingAuth(
56+
"vmcp-authzmutex-ref-only",
57+
nil,
58+
&mcpv1beta1.MCPAuthzConfigReference{Name: "shared-authz"},
59+
)
60+
Expect(k8sClient.Create(ctx, vmcp)).To(Succeed())
61+
})
62+
63+
It("should reject when both authzConfig and authzConfigRef are set", func() {
64+
vmcp := newVirtualMCPServerWithIncomingAuth(
65+
"vmcp-authzmutex-both",
66+
&mcpv1beta1.AuthzConfigRef{
67+
Type: "inline",
68+
Inline: &mcpv1beta1.InlineAuthzConfig{Policies: []string{"permit(principal, action, resource);"}},
69+
},
70+
&mcpv1beta1.MCPAuthzConfigReference{Name: "shared-authz"},
71+
)
72+
err := k8sClient.Create(ctx, vmcp)
73+
Expect(err).To(HaveOccurred())
74+
Expect(err.Error()).To(ContainSubstring("authzConfig and authzConfigRef are mutually exclusive"))
75+
})
76+
})
77+
})

0 commit comments

Comments
 (0)