Skip to content

Commit 02e3f46

Browse files
BillLeoutsakosvl346Bill Leoutsakoscursoragent
authored
test(e2e): cover credential settings workflows (#5846)
* test(e2e): cover credential settings workflows Co-authored-by: Cursor <cursoragent@cursor.com> * fix(e2e): address credential review findings Co-authored-by: Cursor <cursoragent@cursor.com> * style(e2e): format credential helper Co-authored-by: Cursor <cursoragent@cursor.com> * fix(settings): prioritize API key load errors Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 029f501 commit 02e3f46

25 files changed

Lines changed: 2024 additions & 183 deletions

apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx

Lines changed: 180 additions & 142 deletions
Large diffs are not rendered by default.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act, type ComponentProps } from 'react'
5+
import { createRoot, type Root } from 'react-dom/client'
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7+
import { SecretValueField } from './secret-value-field'
8+
9+
vi.mock('@sim/emcn', () => ({
10+
ChipInput: (props: ComponentProps<'input'>) => <input {...props} />,
11+
}))
12+
13+
describe('SecretValueField', () => {
14+
let container: HTMLDivElement
15+
let root: Root
16+
17+
beforeEach(() => {
18+
container = document.createElement('div')
19+
document.body.appendChild(container)
20+
root = createRoot(container)
21+
})
22+
23+
afterEach(() => {
24+
act(() => root.unmount())
25+
container.remove()
26+
})
27+
28+
it('keeps editable plaintext out of the DOM until focus', () => {
29+
act(() => root.render(<SecretValueField value='private-value' onChange={() => undefined} />))
30+
const input = container.querySelector('input')
31+
expect(input).not.toBeNull()
32+
expect(input?.type).toBe('text')
33+
expect(input?.value).toBe('••••••••••')
34+
35+
act(() => input?.focus())
36+
expect(input?.type).toBe('text')
37+
expect(input?.value).toBe('private-value')
38+
39+
act(() => input?.blur())
40+
expect(input?.type).toBe('text')
41+
expect(input?.value).toBe('••••••••••')
42+
})
43+
44+
it('uses a fixed mask for viewers regardless of secret length', () => {
45+
act(() => root.render(<SecretValueField value='short' canEdit={false} />))
46+
const input = container.querySelector('input')
47+
expect(input?.value).toBe('••••••••••')
48+
49+
act(() => root.render(<SecretValueField value='a-much-longer-private-value' canEdit={false} />))
50+
expect(input?.value).toBe('••••••••••')
51+
})
52+
})

apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secret-value-field/secret-value-field.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,11 @@ export function SecretValueField({
5757
const [focused, setFocused] = useState(false)
5858
const editable = canEdit && !readOnly
5959
const maskActive = canEdit && !unmasked && !focused
60-
const displayValue = canEdit ? value : BULLET.repeat(VIEWER_MASK_LENGTH)
60+
const displayValue = canEdit
61+
? maskActive && value
62+
? BULLET.repeat(VIEWER_MASK_LENGTH)
63+
: value
64+
: BULLET.repeat(VIEWER_MASK_LENGTH)
6165

6266
const mergedStyle: CSSProperties | undefined = maskActive
6367
? ({ ...style, WebkitTextSecurity: 'disc' } as CSSProperties)
@@ -69,13 +73,12 @@ export function SecretValueField({
6973
className={className}
7074
type='text'
7175
value={displayValue}
72-
readOnly
76+
readOnly={!editable || !focused}
7377
style={mergedStyle}
7478
onChange={(event) => {
7579
if (editable) onChange?.(event.target.value)
7680
}}
7781
onFocus={(event) => {
78-
if (editable) event.currentTarget.removeAttribute('readOnly')
7982
event.currentTarget.scrollLeft = 0
8083
setFocused(true)
8184
onFocus?.(event)

apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ function copyName(key: string) {
4646
}
4747

4848
interface SecretRowMenuProps {
49+
/** Accessible name that identifies the row owning this menu. */
50+
label: string
4951
/** Copies the secret's name. */
5052
onCopyName: () => void
5153
/** Opens credential details; omit when the row has no backing credential. */
@@ -58,10 +60,10 @@ interface SecretRowMenuProps {
5860
* Trailing `...` actions menu for a secret row. Mirrors the Teammates /
5961
* Organization member menu so the settings experience is consistent.
6062
*/
61-
function SecretRowMenu({ onCopyName, onViewDetails, onDelete }: SecretRowMenuProps) {
63+
function SecretRowMenu({ label, onCopyName, onViewDetails, onDelete }: SecretRowMenuProps) {
6264
return (
6365
<RowActionsMenu
64-
label='Secret actions'
66+
label={label}
6567
triggerClassName='ml-2'
6668
actions={[
6769
...(onViewDetails ? [{ label: 'View details', onSelect: onViewDetails }] : []),
@@ -220,6 +222,7 @@ function WorkspaceVariableRow({
220222
return (
221223
<div className='contents'>
222224
<ChipInput
225+
aria-label={`Workspace secret name ${envKey}`}
223226
className={cn(!canRename && 'cursor-text')}
224227
value={renamingKey === envKey ? pendingKeyValue : envKey}
225228
onChange={(e) => {
@@ -238,12 +241,14 @@ function WorkspaceVariableRow({
238241
/>
239242
<div />
240243
<SecretValueField
244+
aria-label={`Workspace secret value ${envKey}`}
241245
value={value}
242246
onChange={(next) => onValueChange(envKey, next)}
243247
canEdit={canEdit}
244248
name={`workspace_env_value_${envKey}_${generateShortId()}`}
245249
/>
246250
<SecretRowMenu
251+
label={`Secret actions for ${envKey}`}
247252
onCopyName={() => copyName(envKey)}
248253
onViewDetails={hasCredential && onViewDetails ? () => onViewDetails(envKey) : undefined}
249254
onDelete={canEdit ? () => onDelete(envKey) : undefined}
@@ -298,6 +303,7 @@ function NewWorkspaceVariableRow({
298303
/>
299304
{hasContent ? (
300305
<SecretRowMenu
306+
label={`Secret actions for ${envVar.key || 'new workspace secret'}`}
301307
onCopyName={() => copyName(envVar.key)}
302308
onDelete={() => {
303309
onUpdate(index, 'key', '')
@@ -861,6 +867,9 @@ export function SecretsManager() {
861867
return (
862868
<div className='contents'>
863869
<ChipInput
870+
aria-label={
871+
envVar.key ? `Personal secret name ${envVar.key}` : 'New personal secret name'
872+
}
864873
data-input-type='key'
865874
error={Boolean(isConflicted || keyError)}
866875
value={envVar.key}
@@ -876,6 +885,9 @@ export function SecretsManager() {
876885
/>
877886
<div />
878887
<SecretValueField
888+
aria-label={
889+
envVar.key ? `Personal secret value ${envVar.key}` : 'New personal secret value'
890+
}
879891
data-input-type='value'
880892
value={envVar.value}
881893
onChange={(next) => updateEnvVar(originalIndex, 'value', next)}
@@ -888,6 +900,7 @@ export function SecretsManager() {
888900
/>
889901
{hasContent ? (
890902
<SecretRowMenu
903+
label={`Secret actions for ${envVar.key || 'new personal secret'}`}
891904
onCopyName={() => copyName(envVar.key)}
892905
onDelete={() => removeEnvVar(originalIndex)}
893906
/>

apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/page.tsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import type { Metadata } from 'next'
2+
import { notFound, redirect } from 'next/navigation'
3+
import { getSession } from '@/lib/auth'
4+
import { getCredentialActorContext } from '@/lib/credentials/access'
5+
import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context'
26
import { SecretDetail } from '@/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail'
7+
import { resolveWorkspaceGroup } from '@/ee/access-control/utils/permission-check'
8+
import { canOpenSecretDetail } from './secret-detail-access'
39

410
export const metadata: Metadata = {
511
title: 'Secret',
@@ -10,6 +16,34 @@ export default async function SecretDetailPage({
1016
}: {
1117
params: Promise<{ workspaceId: string; credentialId: string }>
1218
}) {
19+
const session = await getSession()
20+
if (!session?.user) redirect('/login')
21+
1322
const { workspaceId, credentialId } = await params
23+
const hostContext = await getWorkspaceHostContextForViewer(workspaceId, session.user.id)
24+
if (!hostContext) notFound()
25+
26+
const [permissionGroup, access] = await Promise.all([
27+
hostContext.hostOrganizationId && hostContext.ownerBilling.isEnterprise
28+
? resolveWorkspaceGroup(session.user.id, hostContext.hostOrganizationId, workspaceId)
29+
: null,
30+
getCredentialActorContext(credentialId, session.user.id),
31+
])
32+
33+
if (
34+
!canOpenSecretDetail({
35+
workspaceId,
36+
secretsHidden: permissionGroup?.config.hideSecretsTab === true,
37+
access: {
38+
credential: access.credential,
39+
hasWorkspaceAccess: access.hasWorkspaceAccess,
40+
hasActiveMembership: access.member?.status === 'active',
41+
isAdmin: access.isAdmin,
42+
},
43+
})
44+
) {
45+
notFound()
46+
}
47+
1448
return <SecretDetail workspaceId={workspaceId} credentialId={credentialId} />
1549
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { canOpenSecretDetail } from './secret-detail-access'
3+
4+
const allowedAccess = {
5+
credential: { workspaceId: 'workspace-a', type: 'env_workspace' },
6+
hasWorkspaceAccess: true,
7+
hasActiveMembership: true,
8+
isAdmin: false,
9+
}
10+
11+
describe('canOpenSecretDetail', () => {
12+
it('allows active members and derived admins for matching environment credentials', () => {
13+
expect(
14+
canOpenSecretDetail({
15+
workspaceId: 'workspace-a',
16+
secretsHidden: false,
17+
access: allowedAccess,
18+
})
19+
).toBe(true)
20+
expect(
21+
canOpenSecretDetail({
22+
workspaceId: 'workspace-a',
23+
secretsHidden: false,
24+
access: {
25+
...allowedAccess,
26+
credential: { workspaceId: 'workspace-a', type: 'env_personal' },
27+
hasActiveMembership: false,
28+
isAdmin: true,
29+
},
30+
})
31+
).toBe(true)
32+
})
33+
34+
it.each([
35+
['missing credential', { ...allowedAccess, credential: null }, false],
36+
[
37+
'cross-workspace credential',
38+
{
39+
...allowedAccess,
40+
credential: { workspaceId: 'workspace-b', type: 'env_workspace' },
41+
},
42+
false,
43+
],
44+
[
45+
'wrong credential type',
46+
{ ...allowedAccess, credential: { workspaceId: 'workspace-a', type: 'oauth' } },
47+
false,
48+
],
49+
['missing workspace access', { ...allowedAccess, hasWorkspaceAccess: false }, false],
50+
[
51+
'missing credential membership',
52+
{ ...allowedAccess, hasActiveMembership: false, isAdmin: false },
53+
false,
54+
],
55+
])('rejects %s', (_label, access, expected) => {
56+
expect(
57+
canOpenSecretDetail({
58+
workspaceId: 'workspace-a',
59+
secretsHidden: false,
60+
access,
61+
})
62+
).toBe(expected)
63+
})
64+
65+
it('rejects permission-group hidden Secrets', () => {
66+
expect(
67+
canOpenSecretDetail({
68+
workspaceId: 'workspace-a',
69+
secretsHidden: true,
70+
access: allowedAccess,
71+
})
72+
).toBe(false)
73+
})
74+
})
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
interface SecretDetailCredential {
2+
workspaceId: string
3+
type: string
4+
}
5+
6+
interface SecretDetailAccess {
7+
credential: SecretDetailCredential | null
8+
hasWorkspaceAccess: boolean
9+
hasActiveMembership: boolean
10+
isAdmin: boolean
11+
}
12+
13+
export function canOpenSecretDetail(options: {
14+
workspaceId: string
15+
secretsHidden: boolean
16+
access: SecretDetailAccess
17+
}): boolean {
18+
const { access } = options
19+
return Boolean(
20+
!options.secretsHidden &&
21+
access.credential &&
22+
access.credential.workspaceId === options.workspaceId &&
23+
(access.credential.type === 'env_personal' || access.credential.type === 'env_workspace') &&
24+
access.hasWorkspaceAccess &&
25+
(access.hasActiveMembership || access.isAdmin)
26+
)
27+
}

apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ export function SecretDetail({ workspaceId, credentialId }: SecretDetailProps) {
9595

9696
<DetailSection title='Value'>
9797
<SecretValueField
98+
aria-label='Secret value'
9899
value={valueField.value}
99100
onChange={valueField.setValue}
100101
canEdit={valueField.canEdit}

0 commit comments

Comments
 (0)