Skip to content

Commit e742ac9

Browse files
committed
Merge remote-tracking branch 'origin/staging' into feat/embeddings-multi-provider
2 parents 73e12df + c4ccee0 commit e742ac9

3 files changed

Lines changed: 377 additions & 73 deletions

File tree

apps/sim/app/api/auth/sso/providers/route.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,21 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1111

1212
const logger = createLogger('SSOProvidersRoute')
1313

14+
/** Secrets shorter than this reveal too large a fraction of themselves in 4 characters. */
15+
const MIN_LENGTH_FOR_HINT = 16
16+
17+
/**
18+
* Last four characters of a stored client secret, so an admin can tell *which*
19+
* secret is saved rather than only that one exists. Four characters of a
20+
* high-entropy secret is not a meaningful disclosure to an owner or admin, who
21+
* can rotate it anyway — but short secrets are left unhinted, where the same four
22+
* characters would be a large share of the value.
23+
*/
24+
function buildClientSecretHint(clientSecret: unknown): string | null {
25+
if (typeof clientSecret !== 'string' || clientSecret.length < MIN_LENGTH_FOR_HINT) return null
26+
return clientSecret.slice(-4)
27+
}
28+
1429
export const GET = withRouteHandler(async (request: NextRequest) => {
1530
try {
1631
const session = await getSession()
@@ -69,7 +84,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
6984
if (oidcConfig) {
7085
try {
7186
const parsed = JSON.parse(oidcConfig)
87+
const hint = buildClientSecretHint(parsed.clientSecret)
7288
parsed.clientSecret = REDACTED_MARKER
89+
if (hint) parsed.clientSecretHint = hint
7390
oidcConfig = JSON.stringify(parsed)
7491
} catch {
7592
oidcConfig = null

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

Lines changed: 184 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,129 @@ 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+
/** 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+
238+
it('shows the saved secret as a masked hint rather than the sentinel', () => {
239+
renderSso('org-a')
240+
startEditing()
241+
242+
expect(container).not.toHaveTextContent('[REDACTED]')
243+
expect(secretInput()?.value).toBe('••••••••••••4f2a')
244+
expect(findButton('Replace')).toBeDefined()
245+
})
246+
247+
it('keeps the stored secret when the admin edits without replacing it', async () => {
248+
const mutateAsync = vi.fn().mockResolvedValue({})
249+
mockUseConfigureSSO.mockReturnValue({ isPending: false, mutateAsync })
250+
251+
renderSso('org-a')
252+
startEditing()
253+
await act(async () => {
254+
findButton('Update')?.click()
255+
})
256+
257+
expect(mutateAsync).toHaveBeenCalledTimes(1)
258+
expect(mutateAsync.mock.calls[0][0].clientSecret).toBe('[REDACTED]')
259+
})
260+
261+
it('sends the new value when the admin replaces the secret', async () => {
262+
const mutateAsync = vi.fn().mockResolvedValue({})
263+
mockUseConfigureSSO.mockReturnValue({ isPending: false, mutateAsync })
264+
265+
renderSso('org-a')
266+
startEditing()
267+
act(() => findButton('Replace')?.click())
268+
269+
typeSecret('brand-new-secret')
270+
271+
await act(async () => {
272+
findButton('Update')?.click()
273+
})
274+
275+
expect(mutateAsync).toHaveBeenCalledTimes(1)
276+
expect(mutateAsync.mock.calls[0][0].clientSecret).toBe('brand-new-secret')
277+
})
278+
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+
301+
/**
302+
* Backing out has to revalidate as "keeping the saved secret". Validating against
303+
* the pre-toggle value would leave a required-error stranded on the masked row,
304+
* where there is no longer an input to fix it in.
305+
*/
306+
it('clears a stranded required-error when the replacement is backed out', async () => {
307+
renderSso('org-a')
308+
startEditing()
309+
act(() => findButton('Replace')?.click())
310+
typeSecret(' ')
311+
await act(async () => {
312+
findButton('Update')?.click()
313+
})
314+
expect(container).toHaveTextContent('Client Secret is required.')
315+
316+
act(() => findButton('Keep saved')?.click())
317+
318+
expect(container).not.toHaveTextContent('Client Secret is required.')
319+
expect(secretInput()?.value).toBe('••••••••••••4f2a')
320+
})
321+
322+
/**
323+
* The label is deliberately not "Cancel": the header already uses that to discard
324+
* the whole edit, and matching it here would make two very different actions
325+
* indistinguishable.
326+
*/
327+
it('restores the masked row and drops the typed value when the replace is backed out', () => {
328+
renderSso('org-a')
329+
startEditing()
330+
act(() => findButton('Replace')?.click())
331+
act(() => findButton('Keep saved')?.click())
332+
333+
expect(secretInput()?.value).toBe('••••••••••••4f2a')
334+
expect(findButton('Replace')).toBeDefined()
335+
})
336+
})

0 commit comments

Comments
 (0)