@@ -221,6 +221,20 @@ describe('SSO client secret preservation', () => {
221221 return container . querySelector < HTMLInputElement > ( '#sso-client-secret' )
222222 }
223223
224+ /** Sets the input through the native setter so React's onChange fires. */
225+ function typeSecret ( value : string ) {
226+ const input = secretInput ( )
227+ expect ( input ) . not . toBeNull ( )
228+ act ( ( ) => {
229+ const setter = Object . getOwnPropertyDescriptor (
230+ window . HTMLInputElement . prototype ,
231+ 'value'
232+ ) ?. set
233+ setter ?. call ( input , value )
234+ input ?. dispatchEvent ( new Event ( 'input' , { bubbles : true } ) )
235+ } )
236+ }
237+
224238 it ( 'shows the saved secret as a masked hint rather than the sentinel' , ( ) => {
225239 renderSso ( 'org-a' )
226240 startEditing ( )
@@ -252,16 +266,7 @@ describe('SSO client secret preservation', () => {
252266 startEditing ( )
253267 act ( ( ) => findButton ( 'Replace' ) ?. click ( ) )
254268
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- } )
269+ typeSecret ( 'brand-new-secret' )
265270
266271 await act ( async ( ) => {
267272 findButton ( 'Update' ) ?. click ( )
@@ -271,6 +276,28 @@ describe('SSO client secret preservation', () => {
271276 expect ( mutateAsync . mock . calls [ 0 ] [ 0 ] . clientSecret ) . toBe ( 'brand-new-secret' )
272277 } )
273278
279+ /**
280+ * A whitespace-only value must not reach the server. Validation is skipped only
281+ * while the stored secret is being kept; once Replace is clicked the field is a
282+ * real input, so blank input has to fail rather than overwrite a working secret.
283+ */
284+ it ( 'refuses to submit a whitespace-only replacement' , async ( ) => {
285+ const mutateAsync = vi . fn ( ) . mockResolvedValue ( { } )
286+ mockUseConfigureSSO . mockReturnValue ( { isPending : false , mutateAsync } )
287+
288+ renderSso ( 'org-a' )
289+ startEditing ( )
290+ act ( ( ) => findButton ( 'Replace' ) ?. click ( ) )
291+ typeSecret ( ' ' )
292+
293+ await act ( async ( ) => {
294+ findButton ( 'Update' ) ?. click ( )
295+ } )
296+
297+ expect ( mutateAsync ) . not . toHaveBeenCalled ( )
298+ expect ( container ) . toHaveTextContent ( 'Client Secret is required.' )
299+ } )
300+
274301 /**
275302 * The label is deliberately not "Cancel": the header already uses that to discard
276303 * the whole edit, and matching it here would make two very different actions
0 commit comments