@@ -5,12 +5,14 @@ import (
55 "testing"
66 "time"
77
8+ "github.com/launchdarkly/ld-relay/v8/internal/credential"
89 "github.com/launchdarkly/ld-relay/v8/internal/sdkauth"
910
1011 c "github.com/launchdarkly/ld-relay/v8/config"
1112 "github.com/launchdarkly/ld-relay/v8/internal/sdks"
1213 st "github.com/launchdarkly/ld-relay/v8/internal/sharedtest"
1314 "github.com/launchdarkly/ld-relay/v8/internal/sharedtest/testclient"
15+ "github.com/launchdarkly/ld-relay/v8/internal/util"
1416
1517 ct "github.com/launchdarkly/go-configtypes"
1618 "github.com/launchdarkly/go-sdk-common/v3/ldtime"
@@ -56,6 +58,36 @@ func TestEndpointsStatus(t *testing.T) {
5658 st .AssertJSONPathMatch (t , p .relay .version , status , "version" )
5759 st .AssertJSONPathMatch (t , ld .Version , status , "clientVersion" )
5860 })
61+
62+ t .Run ("sdkKeys/mobileKeys arrays carry the full accepted set including the anchor" , func (t * testing.T ) {
63+ var config c.Config
64+ config .Environment = st .MakeEnvConfigs (st .EnvMain , st .EnvMobile )
65+
66+ withStartedRelay (t , config , func (p relayTestParams ) {
67+ r , _ := http .NewRequest ("GET" , "http://localhost/status" , nil )
68+ result , body := st .DoRequest (r , p .relay )
69+ assert .Equal (t , http .StatusOK , result .StatusCode )
70+ status := ldvalue .Parse (body )
71+
72+ // EnvMain is a manually-configured single-key env: sdkKeys[] is present and contains
73+ // exactly the anchor (full set includes it); manual config carries no key identifier.
74+ sdkKeys := status .GetByKey ("environments" ).GetByKey (st .EnvMain .Name ).GetByKey ("sdkKeys" )
75+ require .Equal (t , 1 , sdkKeys .Count (), "sdkKeys must contain the anchor" )
76+ assert .Equal (t , sdks .ObscureKey (string (st .EnvMain .Config .SDKKey )),
77+ sdkKeys .GetByIndex (0 ).GetByKey ("value" ).StringValue ())
78+
79+ // EnvMain has no mobile key — mobileKeys is present but empty.
80+ mobileKeys := status .GetByKey ("environments" ).GetByKey (st .EnvMain .Name ).GetByKey ("mobileKeys" )
81+ assert .Equal (t , 0 , mobileKeys .Count ())
82+ assert .Equal (t , ldvalue .ArrayType , mobileKeys .Type (), "mobileKeys present (not null) even when empty" )
83+
84+ // EnvMobile has both: its mobile key appears in mobileKeys[].
85+ mobMobileKeys := status .GetByKey ("environments" ).GetByKey (st .EnvMobile .Name ).GetByKey ("mobileKeys" )
86+ require .Equal (t , 1 , mobMobileKeys .Count ())
87+ assert .Equal (t , sdks .ObscureKey (string (st .EnvMobile .Config .MobileKey )),
88+ mobMobileKeys .GetByIndex (0 ).GetByKey ("value" ).StringValue ())
89+ })
90+ })
5991 })
6092
6193 t .Run ("connection interruption - less than DisconnectedStatusTime" , func (t * testing.T ) {
@@ -131,3 +163,124 @@ func TestEndpointsStatus(t *testing.T) {
131163 })
132164 })
133165}
166+
167+ // findKeyStatusByValue returns the sdkKeys[]/mobileKeys[] entry whose obscured "value" matches, or a
168+ // null value. Array entry order is unspecified, so callers look entries up by value.
169+ func findKeyStatusByValue (arr ldvalue.Value , obscuredValue string ) ldvalue.Value {
170+ for i := 0 ; i < arr .Count (); i ++ {
171+ if arr .GetByIndex (i ).GetByKey ("value" ).StringValue () == obscuredValue {
172+ return arr .GetByIndex (i )
173+ }
174+ }
175+ return ldvalue .Null ()
176+ }
177+
178+ // TestEndpointsStatusExpiringSDKKey drives a multi-key environment through the real /status handler:
179+ // it reconciles an env to an anchor plus two non-anchor expiring SDK keys and asserts the
180+ // expiringSdkKey selection, the per-key expiry/identifier serialization in sdkKeys[], and that the
181+ // soonest-expiry pick is deterministic on an exact expiry tie.
182+ func TestEndpointsStatusExpiringSDKKey (t * testing.T ) {
183+ getStatus := func (t * testing.T , p relayTestParams , set credential.AcceptedSet ) ldvalue.Value {
184+ env , err := p .relay .getEnvironment (sdkauth .New (st .EnvMain .Config .SDKKey ))
185+ require .NoError (t , err )
186+ require .NotNil (t , env )
187+ env .ReconcileCredentials (set )
188+
189+ r , _ := http .NewRequest ("GET" , "http://localhost/status" , nil )
190+ result , body := st .DoRequest (r , p .relay )
191+ require .Equal (t , http .StatusOK , result .StatusCode )
192+ return ldvalue .Parse (body ).GetByKey ("environments" ).GetByKey (st .EnvMain .Name )
193+ }
194+
195+ t .Run ("soonest-expiring non-anchor key, with expiry and identifier surfaced" , func (t * testing.T ) {
196+ var config c.Config
197+ config .Environment = st .MakeEnvConfigs (st .EnvMain )
198+ withStartedRelay (t , config , func (p relayTestParams ) {
199+ anchor := st .EnvMain .Config .SDKKey
200+ soon := time .Now ().Add (1 * time .Hour )
201+ later := time .Now ().Add (2 * time .Hour )
202+ set , err := credential .NewAcceptedSetBuilder ().
203+ WithAnchor (credential.SDKKeyParams {Value : anchor }).
204+ WithSDKKey (credential.SDKKeyParams {Value : "sdk-soon" , Key : util .PtrOrNil ("soon-key" ), Expiry : util .PtrOrNil (soon )}).
205+ WithSDKKey (credential.SDKKeyParams {Value : "sdk-later" , Expiry : util .PtrOrNil (later )}).
206+ Build ()
207+ require .NoError (t , err )
208+
209+ envStatus := getStatus (t , p , set )
210+
211+ // expiringSdkKey is the obscured soonest-expiring non-anchor key.
212+ st .AssertJSONPathMatch (t , sdks .ObscureKey ("sdk-soon" ), envStatus , "expiringSdkKey" )
213+
214+ // sdkKeys[] carries the full set (anchor + both non-anchor keys).
215+ sdkKeys := envStatus .GetByKey ("sdkKeys" )
216+ require .Equal (t , 3 , sdkKeys .Count ())
217+
218+ // The expiring key surfaces its identifier and Unix-millis expiry.
219+ soonEntry := findKeyStatusByValue (sdkKeys , sdks .ObscureKey ("sdk-soon" ))
220+ require .False (t , soonEntry .IsNull ())
221+ assert .Equal (t , "soon-key" , soonEntry .GetByKey ("key" ).StringValue ())
222+ assert .Equal (t , float64 (soon .UnixMilli ()), soonEntry .GetByKey ("expiry" ).Float64Value ())
223+
224+ // A key with no identifier omits "key" entirely (omitempty, nil pointer).
225+ laterEntry := findKeyStatusByValue (sdkKeys , sdks .ObscureKey ("sdk-later" ))
226+ require .False (t , laterEntry .IsNull ())
227+ assert .True (t , laterEntry .GetByKey ("key" ).IsNull (), `"key" must be omitted when the source carried no identifier` )
228+ })
229+ })
230+
231+ t .Run ("tie on expiry resolves deterministically to the smaller value" , func (t * testing.T ) {
232+ var config c.Config
233+ config .Environment = st .MakeEnvConfigs (st .EnvMain )
234+ withStartedRelay (t , config , func (p relayTestParams ) {
235+ anchor := st .EnvMain .Config .SDKKey
236+ sameExpiry := time .Now ().Add (1 * time .Hour )
237+ set , err := credential .NewAcceptedSetBuilder ().
238+ WithAnchor (credential.SDKKeyParams {Value : anchor }).
239+ WithSDKKey (credential.SDKKeyParams {Value : "sdk-bbb" , Expiry : util .PtrOrNil (sameExpiry )}).
240+ WithSDKKey (credential.SDKKeyParams {Value : "sdk-aaa" , Expiry : util .PtrOrNil (sameExpiry )}).
241+ Build ()
242+ require .NoError (t , err )
243+
244+ envStatus := getStatus (t , p , set )
245+ // With equal expiries, the smaller value (sdk-aaa) wins deterministically.
246+ st .AssertJSONPathMatch (t , sdks .ObscureKey ("sdk-aaa" ), envStatus , "expiringSdkKey" )
247+ })
248+ })
249+ }
250+
251+ // TestKeyStatus verifies the helper that converts an accepted key into its status-endpoint JSON form.
252+ func TestKeyStatus (t * testing.T ) {
253+ strptr := func (s string ) * string { return & s }
254+
255+ t .Run ("permanent key with identifier" , func (t * testing.T ) {
256+ ks := keyStatus (credential.AcceptedKey {
257+ Type : credential .KeyTypeServer , Value : "sdk-abc123" , Key : strptr ("default" ),
258+ })
259+ assert .Equal (t , "default" , ks .Key )
260+ assert .Equal (t , sdks .ObscureKey ("sdk-abc123" ), ks .Value )
261+ assert .Nil (t , ks .Expiry )
262+ })
263+
264+ t .Run ("nil identifier yields empty Key (omitted in JSON)" , func (t * testing.T ) {
265+ ks := keyStatus (credential.AcceptedKey {
266+ Type : credential .KeyTypeServer , Value : "sdk-legacy" , Key : nil ,
267+ })
268+ assert .Equal (t , "" , ks .Key )
269+ })
270+
271+ t .Run ("expiring key has expiry in Unix milliseconds" , func (t * testing.T ) {
272+ expiry := time .Date (2099 , 6 , 1 , 12 , 0 , 0 , 0 , time .UTC )
273+ ks := keyStatus (credential.AcceptedKey {
274+ Type : credential .KeyTypeServer , Value : "sdk-old" , Key : strptr ("old-key" ), Expiry : & expiry ,
275+ })
276+ require .NotNil (t , ks .Expiry )
277+ assert .Equal (t , expiry .UnixMilli (), * ks .Expiry )
278+ })
279+
280+ t .Run ("mobile key value is obscured" , func (t * testing.T ) {
281+ ks := keyStatus (credential.AcceptedKey {
282+ Type : credential .KeyTypeMobile , Value : "mob-secret" , Key : strptr ("mob-1" ),
283+ })
284+ assert .Equal (t , sdks .ObscureKey ("mob-secret" ), ks .Value )
285+ })
286+ }
0 commit comments