1+ package service_credential_binding_rotation
2+
3+ import (
4+ "encoding/json"
5+ "fmt"
6+ "regexp"
7+ "time"
8+
9+ . "github.com/cloudfoundry/cf-acceptance-tests/cats_suite_helpers"
10+ "github.com/cloudfoundry/cf-acceptance-tests/helpers/app_helpers"
11+ "github.com/cloudfoundry/cf-acceptance-tests/helpers/assets"
12+ "github.com/cloudfoundry/cf-acceptance-tests/helpers/random_name"
13+ svchelper "github.com/cloudfoundry/cf-acceptance-tests/helpers/services"
14+ "github.com/cloudfoundry/cf-test-helpers/v2/cf"
15+ "github.com/cloudfoundry/cf-test-helpers/v2/helpers"
16+
17+ . "github.com/onsi/ginkgo/v2"
18+ . "github.com/onsi/gomega"
19+ . "github.com/onsi/gomega/gexec"
20+ )
21+
22+ type bindingResource struct {
23+ GUID string `json:"guid"`
24+ Name string `json:"name"`
25+ CreatedAt time.Time `json:"created_at"`
26+ }
27+
28+ type bindingListResponse struct {
29+ Resources []bindingResource `json:"resources"`
30+ }
31+
32+ type vcapServiceEntry struct {
33+ BindingName string `json:"binding_name"`
34+ BindingGUID string `json:"binding_guid"`
35+ }
36+
37+ var _ = ServiceCredentialBindingRotationDescribe ("Service Credential Binding Rotation" , func () {
38+ var broker svchelper.ServiceBroker
39+ var appName string
40+ var serviceName string
41+ var bindingName string
42+
43+ extractBindingGUIDFromVCAPServices := func (vcapServicesStr , serviceBindingName string ) string {
44+ vcapServices := map [string ][]vcapServiceEntry {}
45+ Expect (json .Unmarshal ([]byte (vcapServicesStr ), & vcapServices )).NotTo (HaveOccurred (), "failed to parse VCAP_SERVICES" )
46+
47+ serviceEntries := vcapServices [broker .Service .Name ]
48+ for _ , serviceEntry := range serviceEntries {
49+ if serviceEntry .BindingName == serviceBindingName {
50+ return serviceEntry .BindingGUID
51+ }
52+ }
53+
54+ Fail (fmt .Sprintf ("expected VCAP_SERVICES to contain binding_guid for service binding %q under label %q" , serviceBindingName , broker .Service .Name ))
55+ return ""
56+ }
57+
58+ listBindingsForAppAndService := func (appName , serviceName string ) []bindingResource {
59+ appGUID := app_helpers .GetAppGuid (appName )
60+ serviceGUID := svchelper .GetServiceInstanceGuid (serviceName )
61+
62+ bindingEndpoint := fmt .Sprintf ("/v3/service_credential_bindings?app_guids=%s&service_instance_guids=%s" , appGUID , serviceGUID )
63+ session := cf .Cf ("curl" , bindingEndpoint ).Wait ()
64+ Expect (session ).To (Exit (0 ), "failed to list service credential bindings" )
65+
66+ var response bindingListResponse
67+ Expect (json .Unmarshal (session .Out .Contents (), & response )).NotTo (HaveOccurred ())
68+ return response .Resources
69+ }
70+
71+ oldestBindingGUID := func (bindings []bindingResource ) string {
72+ oldest := bindings [0 ]
73+ for _ , binding := range bindings [1 :] {
74+ if binding .CreatedAt .Before (oldest .CreatedAt ) {
75+ oldest = binding
76+ }
77+ }
78+ return oldest .GUID
79+ }
80+
81+ BeforeEach (func () {
82+ appName = random_name .CATSRandomName ("APP" )
83+ serviceName = random_name .CATSRandomName ("SVIN" )
84+
85+ broker = svchelper .NewServiceBroker (
86+ random_name .CATSRandomName ("BRKR" ),
87+ assets .NewAssets ().ServiceBroker ,
88+ TestSetup ,
89+ )
90+ broker .Push (Config )
91+ broker .Configure ()
92+ broker .Create ()
93+ broker .PublicizePlans ()
94+
95+ Expect (cf .Cf (app_helpers .CatnipWithArgs (
96+ appName ,
97+ "-m" , DEFAULT_MEMORY_LIMIT )... ,
98+ ).Wait (Config .CfPushTimeoutDuration ())).To (Exit (0 ), "failed pushing app" )
99+
100+ Expect (cf .Cf ("create-service" , broker .Service .Name , broker .SyncPlans [0 ].Name , serviceName ).Wait ()).To (Exit (0 ))
101+
102+ bindingName = random_name .CATSRandomName ("BIND" )
103+ Expect (cf .Cf ("bind-service" , appName , serviceName , "--binding-name" , bindingName , "--strategy" , "multiple" ).Wait ()).To (
104+ Exit (0 ),
105+ fmt .Sprintf ("failed binding app %s to service %s with binding name %s" , appName , serviceName , bindingName ),
106+ )
107+
108+ Expect (cf .Cf ("restage" , appName , "--strategy" , "rolling" ).Wait (Config .CfPushTimeoutDuration ())).To (Exit (0 ), "failed rolling restage" )
109+ })
110+
111+ AfterEach (func () {
112+ app_helpers .AppReport (appName )
113+ app_helpers .AppReport (broker .Name )
114+
115+ Expect (cf .Cf ("delete-service" , serviceName , "-f" ).Wait ()).To (Exit (0 ))
116+ Expect (cf .Cf ("delete" , appName , "-f" , "-r" ).Wait (Config .CfPushTimeoutDuration ())).To (Exit (0 ))
117+
118+ broker .Destroy ()
119+ })
120+
121+ Context ("one binding exists for the test application and test service instance" , func () {
122+
123+ It ("rotates credentials when creating the second binding" , func () {
124+ vcapServices := helpers .CurlApp (Config , appName , "/env/VCAP_SERVICES" )
125+ // test service broker supports only static credentials for service bindings,
126+ // so compare binding_guids to verify that the second bind-service call caused credential rotation (instead of being a no-op)
127+ initialBindingGUID := extractBindingGUIDFromVCAPServices (vcapServices , bindingName )
128+
129+ secondBindSession := cf .Cf ("bind-service" , appName , serviceName , "--binding-name" , bindingName , "--strategy" , "multiple" ).Wait ()
130+ Expect (secondBindSession ).To (
131+ Exit (0 ),
132+ fmt .Sprintf ("failed binding app %s to service %s with binding name %s" , appName , serviceName , bindingName ),
133+ )
134+ Expect (string (secondBindSession .Out .Contents ())).ToNot (
135+ ContainSubstring (fmt .Sprintf ("App %s is already bound to service instance %s." , appName , serviceName )),
136+ "Make sure to enable the multi-service-binding feature in your test backend." ,
137+ )
138+
139+ Expect (cf .Cf ("restage" , appName , "--strategy" , "rolling" ).Wait (Config .CfPushTimeoutDuration ())).To (Exit (0 ), "failed rolling restage" )
140+
141+ vcapServices = helpers .CurlApp (Config , appName , "/env/VCAP_SERVICES" )
142+ rotatedBindingGUID := extractBindingGUIDFromVCAPServices (vcapServices , bindingName )
143+
144+ Expect (rotatedBindingGUID ).ToNot (Equal (initialBindingGUID ), fmt .Sprintf ("expected new service binding guid after completing credential rotation" ))
145+ })
146+ })
147+
148+ Context ("two service bindings exist for the test application and test service instance" , func () {
149+
150+ BeforeEach (func () {
151+ secondBindSession := cf .Cf ("bind-service" , appName , serviceName , "--binding-name" , bindingName , "--strategy" , "multiple" ).Wait ()
152+ Expect (secondBindSession ).To (
153+ Exit (0 ),
154+ fmt .Sprintf ("failed binding app %s to service %s with binding name %s" , appName , serviceName , bindingName ),
155+ )
156+ Expect (string (secondBindSession .Out .Contents ())).ToNot (
157+ ContainSubstring (fmt .Sprintf ("App %s is already bound to service instance %s." , appName , serviceName )),
158+ "Make sure to enable the multi-service-binding feature in your test backend." ,
159+ )
160+
161+ Expect (cf .Cf ("restage" , appName , "--strategy" , "rolling" ).Wait (Config .CfPushTimeoutDuration ())).To (Exit (0 ), "failed rolling restage" )
162+
163+ bindings := listBindingsForAppAndService (appName , serviceName )
164+ Expect (len (bindings )).To (Equal (2 ), fmt .Sprintf ("expected two bindings for app %s and service %s" , appName , serviceName ))
165+ })
166+
167+ Describe ("show service instance information" , func () {
168+ It ("shows all bindings" , func () {
169+ serviceSession := cf .Cf ("service" , serviceName ).Wait ()
170+ Expect (serviceSession ).To (Exit (0 ))
171+
172+ serviceOutput := string (serviceSession .Out .Contents ())
173+
174+ bindings := listBindingsForAppAndService (appName , serviceName )
175+ for _ , binding := range bindings {
176+ /* "cf service SERVICE_INSTANCE" output has a table of bindings with columns like:
177+ name binding name status message guid created_at
178+ testapp create succeeded 9ec888c4-547c-4c7b-bc51-6f15a7821e5d 2026-03-16T12:37:31Z
179+ testapp create succeeded ccee98fb-8146-4a4f-8b72-a8f96d44f525 2026-03-16T12:16:27Z
180+ */
181+ linePattern := fmt .Sprintf (
182+ `(?m)^\s+%s\s+%s\s+create succeeded\s+%s\s+\S+$` ,
183+ regexp .QuoteMeta (appName ),
184+ regexp .QuoteMeta (binding .Name ),
185+ regexp .QuoteMeta (binding .GUID ),
186+ )
187+ Expect (serviceOutput ).To (
188+ MatchRegexp (linePattern ),
189+ fmt .Sprintf ("expected cf service output to contain row matching app=%s binding=%s guid=%s" , appName , binding .Name , binding .GUID ),
190+ )
191+ }
192+ })
193+ })
194+
195+ Describe ("unbind-service" , func () {
196+ It ("deletes both service bindings" , func () {
197+ Expect (cf .Cf ("unbind-service" , appName , serviceName ).Wait ()).To (Exit (0 ))
198+
199+ bindings := listBindingsForAppAndService (appName , serviceName )
200+ Expect (len (bindings )).To (Equal (0 ), fmt .Sprintf ("expected no bindings for app %s and service %s after unbind-service" , appName , serviceName ))
201+ })
202+ })
203+
204+ Describe ("cleanup-outdated-service-bindings" , func () {
205+ It ("deletes the oldest binding" , func () {
206+ bindings := listBindingsForAppAndService (appName , serviceName )
207+ oldestBindingGUID := oldestBindingGUID (bindings )
208+
209+ Expect (cf .Cf ("cleanup-outdated-service-bindings" , appName , "--force" ).Wait ()).To (Exit (0 ))
210+
211+ bindings = listBindingsForAppAndService (appName , serviceName )
212+ Expect (len (bindings )).To (Equal (1 ), fmt .Sprintf ("expected one binding for app %s and service %s after cleanup" , appName , serviceName ))
213+ Expect (bindings [0 ].GUID ).ToNot (Equal (oldestBindingGUID ), fmt .Sprintf ("expected oldest binding for app %s / service %s to be deleted" , appName , serviceName ))
214+ })
215+ })
216+ })
217+ })
0 commit comments