@@ -20,15 +20,24 @@ vi.mock('@sim/emcn', () => ({
2020 { children }
2121 </ button >
2222 ) ,
23+ Chip : ( { children, ...props } : { children ?: ReactNode } ) => (
24+ < button type = 'button' { ...props } >
25+ { children }
26+ </ button >
27+ ) ,
2328 ChipCombobox : ( ) => < div /> ,
2429 ChipCopyInput : ( { value } : { value ?: string } ) => < input readOnly value = { value ?? '' } /> ,
2530 ChipInput : ( {
2631 value,
2732 onChange,
33+ id,
34+ placeholder,
2835 } : {
2936 value ?: string
3037 onChange ?: ChangeEventHandler < HTMLInputElement >
31- } ) => < input value = { value ?? '' } onChange = { onChange } /> ,
38+ id ?: string
39+ placeholder ?: string
40+ } ) => < input id = { id } placeholder = { placeholder } value = { value ?? '' } onChange = { onChange } /> ,
3241 ChipSelect : ( ) => < div /> ,
3342 ChipTextarea : ( {
3443 value,
@@ -58,8 +67,11 @@ vi.mock('@/ee/sso/components/verified-domains-section', () => ({
5867 VerifiedDomainsSection : ( ) => < div /> ,
5968} ) )
6069
70+ // Surface the real Save/Update action so submit paths are reachable from tests.
6171vi . mock ( '@/components/settings/save-discard-actions' , ( ) => ( {
62- saveDiscardActions : ( ) => [ ] ,
72+ saveDiscardActions : ( { saveLabel, onSave } : { saveLabel ?: string ; onSave ?: ( ) => void } ) => [
73+ { text : saveLabel ?? 'Save' , onSelect : onSave } ,
74+ ] ,
6375} ) )
6476
6577vi . mock ( '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' , ( ) => ( {
@@ -115,13 +127,26 @@ function provider(organizationId: string) {
115127 organizationId,
116128 providerType : 'oidc' ,
117129 oidcConfig : JSON . stringify ( {
130+ // What the API actually returns: the sentinel plus a display-only hint,
131+ // never the secret itself.
118132 clientId : `client-${ suffix } ` ,
119- clientSecret : `secret-${ suffix } ` ,
133+ clientSecret : '[REDACTED]' ,
134+ clientSecretHint : '4f2a' ,
120135 scopes : [ 'openid' ] ,
121136 } ) ,
122137 }
123138}
124139
140+ function findButton ( text : string ) {
141+ return Array . from ( container . querySelectorAll ( 'button' ) ) . find (
142+ ( button ) => button . textContent === text
143+ )
144+ }
145+
146+ function startEditing ( ) {
147+ act ( ( ) => findButton ( 'Edit' ) ?. click ( ) )
148+ }
149+
125150let container : HTMLDivElement
126151let root : Root
127152
@@ -137,46 +162,43 @@ beforeAll(() => {
137162
138163afterAll ( resetEnvFlagsMock )
139164
140- describe ( 'SSO organization transitions' , ( ) => {
141- beforeEach ( ( ) => {
142- // The component reads getBaseUrl() during render; make sure the env var is
143- // present even when the suite runs without a local .env or after another
144- // test file mutated the environment (auto-restored via unstubEnvs).
145- vi . stubEnv ( 'NEXT_PUBLIC_APP_URL' , 'http://localhost:3000' )
146- ; ( globalThis as { IS_REACT_ACT_ENVIRONMENT ?: boolean } ) . IS_REACT_ACT_ENVIRONMENT = true
147- container = document . createElement ( 'div' )
148- document . body . appendChild ( container )
149- root = createRoot ( container )
150- mockUseSession . mockReturnValue ( { data : { user : { id : 'user-1' } } } )
151- mockUseOrganizationBilling . mockReturnValue ( {
152- data : { data : { subscriptionPlan : 'enterprise' } } ,
153- isLoading : false ,
154- } )
155- mockUseConfigureSSO . mockReturnValue ( {
156- isPending : false ,
157- mutateAsync : vi . fn ( ) ,
158- } )
159- mockUseSSOProviders . mockImplementation ( ( { organizationId } : { organizationId : string } ) => ( {
160- data : { providers : [ provider ( organizationId ) ] } ,
161- isLoading : false ,
162- } ) )
165+ beforeEach ( ( ) => {
166+ // The component reads getBaseUrl() during render; make sure the env var is
167+ // present even when the suite runs without a local .env or after another
168+ // test file mutated the environment (auto-restored via unstubEnvs).
169+ vi . stubEnv ( 'NEXT_PUBLIC_APP_URL' , 'http://localhost:3000' )
170+ ; ( globalThis as { IS_REACT_ACT_ENVIRONMENT ?: boolean } ) . IS_REACT_ACT_ENVIRONMENT = true
171+ container = document . createElement ( 'div' )
172+ document . body . appendChild ( container )
173+ root = createRoot ( container )
174+ mockUseSession . mockReturnValue ( { data : { user : { id : 'user-1' } } } )
175+ mockUseOrganizationBilling . mockReturnValue ( {
176+ data : { data : { subscriptionPlan : 'enterprise' } } ,
177+ isLoading : false ,
163178 } )
164-
165- afterEach ( ( ) => {
166- act ( ( ) => root . unmount ( ) )
167- container . remove ( )
168- vi . clearAllMocks ( )
179+ mockUseConfigureSSO . mockReturnValue ( {
180+ isPending : false ,
181+ mutateAsync : vi . fn ( ) ,
169182 } )
183+ mockUseSSOProviders . mockImplementation ( ( { organizationId } : { organizationId : string } ) => ( {
184+ data : { providers : [ provider ( organizationId ) ] } ,
185+ isLoading : false ,
186+ } ) )
187+ } )
188+
189+ afterEach ( ( ) => {
190+ act ( ( ) => root . unmount ( ) )
191+ container . remove ( )
192+ vi . clearAllMocks ( )
193+ } )
170194
195+ describe ( 'SSO organization transitions' , ( ) => {
171196 it ( 'discards org A edit state before rendering org B settings' , ( ) => {
172197 renderSso ( 'org-a' )
173198 expect ( container ) . toHaveTextContent ( 'org-a.example.com' )
174199
175- const editButton = Array . from ( container . querySelectorAll ( 'button' ) ) . find (
176- ( button ) => button . textContent === 'Edit'
177- )
178- expect ( editButton ) . toBeDefined ( )
179- act ( ( ) => editButton ?. click ( ) )
200+ expect ( findButton ( 'Edit' ) ) . toBeDefined ( )
201+ startEditing ( )
180202 expect ( container . querySelector ( 'input[value="client-a"]' ) ) . not . toBeNull ( )
181203
182204 renderSso ( 'org-b' )
@@ -186,3 +208,81 @@ describe('SSO organization transitions', () => {
186208 expect ( container . querySelector ( 'input[value="client-a"]' ) ) . toBeNull ( )
187209 } )
188210} )
211+
212+ /**
213+ * The stored client secret never reaches the browser — the API sends a sentinel.
214+ * Three pieces have to agree for an edit to preserve it: hydration must not put the
215+ * sentinel in the form, validation must not demand a value, and submit must send the
216+ * sentinel back. If any one drifts, an admin editing an unrelated field either wipes
217+ * their secret or saves the literal string "[REDACTED]" as one.
218+ */
219+ describe ( 'SSO client secret preservation' , ( ) => {
220+ function secretInput ( ) {
221+ return container . querySelector < HTMLInputElement > ( '#sso-client-secret' )
222+ }
223+
224+ it ( 'shows the saved secret as a masked hint rather than the sentinel' , ( ) => {
225+ renderSso ( 'org-a' )
226+ startEditing ( )
227+
228+ expect ( container ) . not . toHaveTextContent ( '[REDACTED]' )
229+ expect ( secretInput ( ) ?. value ) . toBe ( '••••••••••••4f2a' )
230+ expect ( findButton ( 'Replace' ) ) . toBeDefined ( )
231+ } )
232+
233+ it ( 'keeps the stored secret when the admin edits without replacing it' , async ( ) => {
234+ const mutateAsync = vi . fn ( ) . mockResolvedValue ( { } )
235+ mockUseConfigureSSO . mockReturnValue ( { isPending : false , mutateAsync } )
236+
237+ renderSso ( 'org-a' )
238+ startEditing ( )
239+ await act ( async ( ) => {
240+ findButton ( 'Update' ) ?. click ( )
241+ } )
242+
243+ expect ( mutateAsync ) . toHaveBeenCalledTimes ( 1 )
244+ expect ( mutateAsync . mock . calls [ 0 ] [ 0 ] . clientSecret ) . toBe ( '[REDACTED]' )
245+ } )
246+
247+ it ( 'sends the new value when the admin replaces the secret' , async ( ) => {
248+ const mutateAsync = vi . fn ( ) . mockResolvedValue ( { } )
249+ mockUseConfigureSSO . mockReturnValue ( { isPending : false , mutateAsync } )
250+
251+ renderSso ( 'org-a' )
252+ startEditing ( )
253+ act ( ( ) => findButton ( 'Replace' ) ?. click ( ) )
254+
255+ const input = secretInput ( )
256+ expect ( input ) . not . toBeNull ( )
257+ act ( ( ) => {
258+ const setter = Object . getOwnPropertyDescriptor (
259+ window . HTMLInputElement . prototype ,
260+ 'value'
261+ ) ?. set
262+ setter ?. call ( input , 'brand-new-secret' )
263+ input ?. dispatchEvent ( new Event ( 'input' , { bubbles : true } ) )
264+ } )
265+
266+ await act ( async ( ) => {
267+ findButton ( 'Update' ) ?. click ( )
268+ } )
269+
270+ expect ( mutateAsync ) . toHaveBeenCalledTimes ( 1 )
271+ expect ( mutateAsync . mock . calls [ 0 ] [ 0 ] . clientSecret ) . toBe ( 'brand-new-secret' )
272+ } )
273+
274+ /**
275+ * The label is deliberately not "Cancel": the header already uses that to discard
276+ * the whole edit, and matching it here would make two very different actions
277+ * indistinguishable.
278+ */
279+ it ( 'restores the masked row and drops the typed value when the replace is backed out' , ( ) => {
280+ renderSso ( 'org-a' )
281+ startEditing ( )
282+ act ( ( ) => findButton ( 'Replace' ) ?. click ( ) )
283+ act ( ( ) => findButton ( 'Keep saved' ) ?. click ( ) )
284+
285+ expect ( secretInput ( ) ?. value ) . toBe ( '••••••••••••4f2a' )
286+ expect ( findButton ( 'Replace' ) ) . toBeDefined ( )
287+ } )
288+ } )
0 commit comments