@@ -2,6 +2,7 @@ package config_test
22
33import (
44 "context"
5+ "encoding/json"
56 "errors"
67 "io"
78
@@ -172,3 +173,129 @@ func TestFeatureConfigMigrate(t *testing.T) {
172173 }})
173174 })
174175}
176+
177+ // This mirrors configsource/resources.go's viewEffectiveResource exactly
178+ // (fold layers via Merge, then yaml.Marshal, then re-parse) rather than the
179+ // "merge feature config" Convey block above, which stops at
180+ // SetFieldDefaults/Migrate on the in-memory merged struct and so never
181+ // exercises the yaml.Marshal step -- the one place a field tagged
182+ // `omitempty` (as opposed to `omitzero`) can silently turn an explicit
183+ // empty slice back into an absent/nil field once re-parsed.
184+ func TestFeatureConfigEffectiveResourceRoundTrip (t * testing.T ) {
185+ Convey ("an explicit empty phone_input.allowlist override survives the merge fold's marshal-and-reparse round trip" , t , func () {
186+ ctx := context .Background ()
187+
188+ planYAML := []byte (`
189+ ui:
190+ phone_input:
191+ allowlist:
192+ - US
193+ - GB
194+ ` )
195+ appYAML := []byte (`
196+ ui:
197+ phone_input:
198+ allowlist: []
199+ ` )
200+
201+ planCfg , err := config .ParseFeatureConfigWithoutDefaults (ctx , planYAML )
202+ So (err , ShouldBeNil )
203+ appCfg , err := config .ParseFeatureConfigWithoutDefaults (ctx , appYAML )
204+ So (err , ShouldBeNil )
205+
206+ mergedConfig := & config.FeatureConfig {}
207+ mergedConfig = mergedConfig .Merge (planCfg )
208+ mergedConfig = mergedConfig .Merge (appCfg )
209+
210+ mergedYAML , err := yaml .Marshal (mergedConfig )
211+ So (err , ShouldBeNil )
212+
213+ effective , err := config .ParseFeatureConfig (ctx , mergedYAML )
214+ So (err , ShouldBeNil )
215+
216+ So (effective .UI , ShouldNotBeNil )
217+ So (effective .UI .PhoneInput , ShouldNotBeNil )
218+ So (effective .UI .PhoneInput .AllowList , ShouldResemble , []string {})
219+ })
220+
221+ Convey ("an app override that doesn't touch phone_input.allowlist inherits the plan's list" , t , func () {
222+ ctx := context .Background ()
223+
224+ planYAML := []byte (`
225+ ui:
226+ phone_input:
227+ allowlist:
228+ - US
229+ - GB
230+ ` )
231+ // The app layer is present and non-empty, but never mentions
232+ // phone_input -- this is the "not set" case, distinct from the
233+ // explicit-empty case above, and must inherit the plan's list.
234+ appYAML := []byte (`
235+ collaborator:
236+ maximum: 5
237+ ` )
238+
239+ planCfg , err := config .ParseFeatureConfigWithoutDefaults (ctx , planYAML )
240+ So (err , ShouldBeNil )
241+ appCfg , err := config .ParseFeatureConfigWithoutDefaults (ctx , appYAML )
242+ So (err , ShouldBeNil )
243+
244+ mergedConfig := & config.FeatureConfig {}
245+ mergedConfig = mergedConfig .Merge (planCfg )
246+ mergedConfig = mergedConfig .Merge (appCfg )
247+
248+ mergedYAML , err := yaml .Marshal (mergedConfig )
249+ So (err , ShouldBeNil )
250+
251+ effective , err := config .ParseFeatureConfig (ctx , mergedYAML )
252+ So (err , ShouldBeNil )
253+
254+ So (effective .UI , ShouldNotBeNil )
255+ So (effective .UI .PhoneInput , ShouldNotBeNil )
256+ So (effective .UI .PhoneInput .AllowList , ShouldResemble , []string {"US" , "GB" })
257+ })
258+ }
259+
260+ // These single-field sections have `false` as their real, correct default
261+ // (not disabled) -- `omitempty` hid that from JSON output, making a
262+ // fully-resolved section marshal as an empty object indistinguishable from
263+ // one with no fields at all. Sections stay non-nil with a `false` value
264+ // regardless, from SetFieldDefaults's generic pointer-to-struct
265+ // initialization (default.go) -- this test is specifically about the
266+ // *serialized shape*, which is what the Site Admin API's feature-config UI
267+ // (and any other JSON consumer of FeatureConfig) actually sees.
268+ func TestFeatureConfigDisabledFieldsSerializeExplicitly (t * testing.T ) {
269+ Convey ("false-valued Disabled fields marshal explicitly, not as an empty object" , t , func () {
270+ ctx := context .Background ()
271+ cfg , err := config .ParseFeatureConfig (ctx , []byte (`{}` ))
272+ So (err , ShouldBeNil )
273+
274+ data , err := json .Marshal (cfg )
275+ So (err , ShouldBeNil )
276+
277+ var raw map [string ]any
278+ err = json .Unmarshal (data , & raw )
279+ So (err , ShouldBeNil )
280+
281+ So (raw ["custom_domain" ], ShouldResemble , map [string ]any {"disabled" : false })
282+ So (raw ["google_tag_manager" ], ShouldResemble , map [string ]any {"disabled" : false })
283+ So (raw ["rate_limits" ], ShouldResemble , map [string ]any {"disabled" : false })
284+
285+ ui , _ := raw ["ui" ].(map [string ]any )
286+ So (ui ["white_labeling" ], ShouldResemble , map [string ]any {"disabled" : false })
287+
288+ authentication , _ := raw ["authentication" ].(map [string ]any )
289+ secondaryAuthenticators , _ := authentication ["secondary_authenticators" ].(map [string ]any )
290+ So (secondaryAuthenticators ["oob_otp_sms" ], ShouldResemble , map [string ]any {"disabled" : false })
291+
292+ identity , _ := raw ["identity" ].(map [string ]any )
293+ loginID , _ := identity ["login_id" ].(map [string ]any )
294+ types , _ := loginID ["types" ].(map [string ]any )
295+ So (types ["phone" ], ShouldResemble , map [string ]any {"disabled" : false })
296+
297+ oauth , _ := identity ["oauth" ].(map [string ]any )
298+ providers , _ := oauth ["providers" ].(map [string ]any )
299+ So (providers ["google" ], ShouldResemble , map [string ]any {"disabled" : false })
300+ })
301+ }
0 commit comments