Skip to content

Commit 46f5b23

Browse files
committed
test(sso): cover client secret preservation, and disambiguate the back-out label
1 parent f0ca0fd commit 46f5b23

2 files changed

Lines changed: 139 additions & 37 deletions

File tree

apps/sim/ee/sso/components/sso-settings.test.tsx

Lines changed: 136 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
6171
vi.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

6577
vi.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+
125150
let container: HTMLDivElement
126151
let root: Root
127152

@@ -137,46 +162,43 @@ beforeAll(() => {
137162

138163
afterAll(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+
})

apps/sim/ee/sso/components/sso-settings.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,9 @@ function ClientSecretField({
162162
) : undefined
163163
}
164164
/>
165-
{hasStoredSecret && <Chip onClick={onCancelReplace}>Cancel</Chip>}
165+
{/* Not "Cancel" — the header already owns that label for discarding the
166+
whole edit, and these two do very different things. */}
167+
{hasStoredSecret && <Chip onClick={onCancelReplace}>Keep saved</Chip>}
166168
</div>
167169
)
168170
}

0 commit comments

Comments
 (0)