Skip to content

Commit 3d34a58

Browse files
ChrisJBurnsclaude
andcommitted
Add MCPRemoteProxy authzConfigRef envtest integration test
Mirrors the MCPServer Stage 2 integration suite: drives the registered MCPRemoteProxy controller against envtest with a pre-seeded MCPAuthzConfig and asserts the observable runtime wiring: - valid ref (cedarv1 AND httpv1) sets AuthzConfigRefValidated=True, tracks the hash, and materializes the <name>-authz-ref ConfigMap the proxy mounts - a config hash bump re-reconciles the proxy via the MCPAuthzConfig watch - the config going invalid flips the condition to False/NotValid - an initially-invalid ref surfaces NotValid Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e9feee1 commit 3d34a58

1 file changed

Lines changed: 191 additions & 0 deletions

File tree

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package controllers
5+
6+
import (
7+
"time"
8+
9+
. "github.com/onsi/ginkgo/v2"
10+
. "github.com/onsi/gomega"
11+
corev1 "k8s.io/api/core/v1"
12+
"k8s.io/apimachinery/pkg/api/meta"
13+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
14+
"k8s.io/apimachinery/pkg/runtime"
15+
"k8s.io/apimachinery/pkg/types"
16+
17+
mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1"
18+
)
19+
20+
// The MCPAuthzConfig controller is not registered in this suite, so we pre-seed
21+
// the config's Valid condition + ConfigHash directly; the MCPRemoteProxy
22+
// controller (which is registered) only reads them.
23+
var _ = Describe("MCPRemoteProxy AuthzConfigRef Integration Tests", func() {
24+
const (
25+
timeout = time.Second * 30
26+
interval = time.Millisecond * 250
27+
)
28+
29+
// seedAuthzConfig creates an MCPAuthzConfig and stamps its status (Valid
30+
// condition + ConfigHash) as the MCPAuthzConfig controller would.
31+
seedAuthzConfig := func(name, namespace, typ, rawConfig, hash string, valid bool) *mcpv1beta1.MCPAuthzConfig {
32+
cfg := &mcpv1beta1.MCPAuthzConfig{
33+
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace},
34+
Spec: mcpv1beta1.MCPAuthzConfigSpec{
35+
Type: typ,
36+
Config: runtime.RawExtension{Raw: []byte(rawConfig)},
37+
},
38+
}
39+
Expect(k8sClient.Create(ctx, cfg)).To(Succeed())
40+
41+
status := metav1.ConditionFalse
42+
if valid {
43+
status = metav1.ConditionTrue
44+
}
45+
cfg.Status.ConfigHash = hash
46+
meta.SetStatusCondition(&cfg.Status.Conditions, metav1.Condition{
47+
Type: mcpv1beta1.ConditionTypeAuthzConfigValid,
48+
Status: status,
49+
Reason: "Test",
50+
Message: "seeded by integration test",
51+
})
52+
Expect(k8sClient.Status().Update(ctx, cfg)).To(Succeed())
53+
return cfg
54+
}
55+
56+
newProxy := func(name, namespace, authzRefName string) *mcpv1beta1.MCPRemoteProxy {
57+
return &mcpv1beta1.MCPRemoteProxy{
58+
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace},
59+
Spec: mcpv1beta1.MCPRemoteProxySpec{
60+
RemoteURL: "https://example.com",
61+
Transport: "streamable-http",
62+
AuthzConfigRef: &mcpv1beta1.MCPAuthzConfigReference{Name: authzRefName},
63+
},
64+
}
65+
}
66+
67+
const (
68+
cedarConfig = `{"policies":["permit(principal, action, resource);"],"entities_json":"[]"}`
69+
httpConfig = `{"http":{"url":"https://pdp.example.com"},"claim_mapping":"standard"}`
70+
)
71+
72+
DescribeTable("a valid referenced MCPAuthzConfig is validated and hash-tracked, for any backend",
73+
func(typ, rawConfig string) {
74+
namespace := createTestNamespace(ctx)
75+
DeferCleanup(func() { deleteTestNamespace(ctx, namespace) })
76+
77+
seedAuthzConfig("authz-cfg", namespace, typ, rawConfig, "hash-1", true)
78+
Expect(k8sClient.Create(ctx, newProxy("rp-authz", namespace, "authz-cfg"))).To(Succeed())
79+
80+
By("setting AuthzConfigRefValidated=True and tracking the hash")
81+
Eventually(func(g Gomega) {
82+
var got mcpv1beta1.MCPRemoteProxy
83+
g.Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "rp-authz", Namespace: namespace}, &got)).To(Succeed())
84+
cond := meta.FindStatusCondition(got.Status.Conditions, mcpv1beta1.ConditionAuthzConfigRefValidated)
85+
g.Expect(cond).NotTo(BeNil())
86+
g.Expect(cond.Status).To(Equal(metav1.ConditionTrue))
87+
g.Expect(got.Status.AuthzConfigHash).To(Equal("hash-1"))
88+
}, timeout, interval).Should(Succeed())
89+
90+
By("materializing the authz ConfigMap the proxy mounts (any backend)")
91+
Eventually(func(g Gomega) {
92+
var cm corev1.ConfigMap
93+
g.Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "rp-authz-authz-ref", Namespace: namespace}, &cm)).To(Succeed())
94+
g.Expect(cm.Data).To(HaveKey("authz.json"))
95+
g.Expect(cm.Data["authz.json"]).To(ContainSubstring(typ))
96+
}, timeout, interval).Should(Succeed())
97+
},
98+
Entry("cedarv1", "cedarv1", cedarConfig),
99+
Entry("httpv1", "httpv1", httpConfig),
100+
)
101+
102+
Context("when the referenced MCPAuthzConfig changes", Ordered, func() {
103+
var namespace string
104+
const (
105+
cfgName = "authz-watch"
106+
rpName = "rp-watch"
107+
)
108+
BeforeAll(func() {
109+
namespace = createTestNamespace(ctx)
110+
seedAuthzConfig(cfgName, namespace, "cedarv1", cedarConfig, "hash-1", true)
111+
Expect(k8sClient.Create(ctx, newProxy(rpName, namespace, cfgName))).To(Succeed())
112+
})
113+
AfterAll(func() {
114+
deleteTestNamespace(ctx, namespace)
115+
})
116+
117+
It("reflects a config hash change on the referencing MCPRemoteProxy", func() {
118+
Eventually(func(g Gomega) {
119+
var got mcpv1beta1.MCPRemoteProxy
120+
g.Expect(k8sClient.Get(ctx, types.NamespacedName{Name: rpName, Namespace: namespace}, &got)).To(Succeed())
121+
g.Expect(got.Status.AuthzConfigHash).To(Equal("hash-1"))
122+
}, timeout, interval).Should(Succeed())
123+
124+
By("bumping the config hash")
125+
var cfg mcpv1beta1.MCPAuthzConfig
126+
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: cfgName, Namespace: namespace}, &cfg)).To(Succeed())
127+
cfg.Status.ConfigHash = "hash-2"
128+
meta.SetStatusCondition(&cfg.Status.Conditions, metav1.Condition{
129+
Type: mcpv1beta1.ConditionTypeAuthzConfigValid, Status: metav1.ConditionTrue, Reason: "Test",
130+
})
131+
Expect(k8sClient.Status().Update(ctx, &cfg)).To(Succeed())
132+
133+
// The MCPRemoteProxy controller watches MCPAuthzConfig, so the change is
134+
// picked up without an external nudge. (This asserts the observable
135+
// outcome; it does not attempt to prove the watch is the sole trigger.)
136+
By("observing the MCPRemoteProxy eventually reflect the new hash")
137+
Eventually(func(g Gomega) {
138+
var got mcpv1beta1.MCPRemoteProxy
139+
g.Expect(k8sClient.Get(ctx, types.NamespacedName{Name: rpName, Namespace: namespace}, &got)).To(Succeed())
140+
g.Expect(got.Status.AuthzConfigHash).To(Equal("hash-2"))
141+
}, timeout, interval).Should(Succeed())
142+
})
143+
144+
It("transitions AuthzConfigRefValidated to False when the config becomes invalid", func() {
145+
By("flagging the referenced config invalid")
146+
var cfg mcpv1beta1.MCPAuthzConfig
147+
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: cfgName, Namespace: namespace}, &cfg)).To(Succeed())
148+
meta.SetStatusCondition(&cfg.Status.Conditions, metav1.Condition{
149+
Type: mcpv1beta1.ConditionTypeAuthzConfigValid, Status: metav1.ConditionFalse, Reason: "Invalidated",
150+
})
151+
Expect(k8sClient.Status().Update(ctx, &cfg)).To(Succeed())
152+
153+
By("observing the MCPRemoteProxy condition flip to False/NotValid")
154+
Eventually(func(g Gomega) {
155+
var got mcpv1beta1.MCPRemoteProxy
156+
g.Expect(k8sClient.Get(ctx, types.NamespacedName{Name: rpName, Namespace: namespace}, &got)).To(Succeed())
157+
cond := meta.FindStatusCondition(got.Status.Conditions, mcpv1beta1.ConditionAuthzConfigRefValidated)
158+
g.Expect(cond).NotTo(BeNil())
159+
g.Expect(cond.Status).To(Equal(metav1.ConditionFalse))
160+
g.Expect(cond.Reason).To(Equal(mcpv1beta1.ConditionReasonAuthzConfigRefNotValid))
161+
}, timeout, interval).Should(Succeed())
162+
})
163+
})
164+
165+
Context("when the referenced MCPAuthzConfig is not valid", Ordered, func() {
166+
var namespace string
167+
const (
168+
cfgName = "authz-invalid"
169+
rpName = "rp-invalid"
170+
)
171+
BeforeAll(func() {
172+
namespace = createTestNamespace(ctx)
173+
seedAuthzConfig(cfgName, namespace, "cedarv1", cedarConfig, "", false)
174+
Expect(k8sClient.Create(ctx, newProxy(rpName, namespace, cfgName))).To(Succeed())
175+
})
176+
AfterAll(func() {
177+
deleteTestNamespace(ctx, namespace)
178+
})
179+
180+
It("sets AuthzConfigRefValidated=False with reason NotValid", func() {
181+
Eventually(func(g Gomega) {
182+
var got mcpv1beta1.MCPRemoteProxy
183+
g.Expect(k8sClient.Get(ctx, types.NamespacedName{Name: rpName, Namespace: namespace}, &got)).To(Succeed())
184+
cond := meta.FindStatusCondition(got.Status.Conditions, mcpv1beta1.ConditionAuthzConfigRefValidated)
185+
g.Expect(cond).NotTo(BeNil())
186+
g.Expect(cond.Status).To(Equal(metav1.ConditionFalse))
187+
g.Expect(cond.Reason).To(Equal(mcpv1beta1.ConditionReasonAuthzConfigRefNotValid))
188+
}, timeout, interval).Should(Succeed())
189+
})
190+
})
191+
})

0 commit comments

Comments
 (0)