From 8dc48de26aa76e0480b5f32305217bb558a966b6 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Wed, 22 Jul 2026 05:16:57 -0700 Subject: [PATCH 1/4] test(e2e): cover credential settings workflows Co-authored-by: Cursor --- .../settings/components/api-keys/api-keys.tsx | 321 ++++++------ .../secret-value-field.test.tsx | 52 ++ .../secret-value-field/secret-value-field.tsx | 9 +- .../secrets-manager/secrets-manager.tsx | 17 +- .../settings/secrets/[credentialId]/page.tsx | 34 ++ .../secret-detail-access.test.ts | 74 +++ .../[credentialId]/secret-detail-access.ts | 27 + .../secrets/[credentialId]/secret-detail.tsx | 1 + apps/sim/e2e/README.md | 62 ++- apps/sim/e2e/foundation/leak-canary.spec.ts | 78 ++- apps/sim/e2e/foundation/safety.spec.ts | 8 + apps/sim/e2e/scripts/options.ts | 2 + .../authorization/mutation-controls.spec.ts | 5 + .../e2e/settings/credentials/api-keys.spec.ts | 316 ++++++++++++ .../credentials/contract-integrity.spec.ts | 15 + .../sim/e2e/settings/credentials/contracts.ts | 10 + .../settings/credentials/credential-test.ts | 86 ++++ apps/sim/e2e/settings/credentials/helpers.ts | 412 ++++++++++++++++ .../e2e/settings/credentials/secrets.spec.ts | 465 ++++++++++++++++++ apps/sim/e2e/support/leak-canary.ts | 87 +++- .../credential-diagnostic-redaction.test.ts | 35 ++ .../credential-diagnostic-redaction.ts | 14 + apps/sim/playwright.config.ts | 14 +- 23 files changed, 1967 insertions(+), 177 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secret-value-field/secret-value-field.test.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail-access.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail-access.ts create mode 100644 apps/sim/e2e/settings/credentials/api-keys.spec.ts create mode 100644 apps/sim/e2e/settings/credentials/contract-integrity.spec.ts create mode 100644 apps/sim/e2e/settings/credentials/contracts.ts create mode 100644 apps/sim/e2e/settings/credentials/credential-test.ts create mode 100644 apps/sim/e2e/settings/credentials/helpers.ts create mode 100644 apps/sim/e2e/settings/credentials/secrets.spec.ts create mode 100644 apps/sim/lib/testing/credential-diagnostic-redaction.test.ts create mode 100644 apps/sim/lib/testing/credential-diagnostic-redaction.ts diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx index f6b74fa033f..8065d397f8d 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx @@ -88,17 +88,28 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) { const { data: apiKeysData, isLoading: isLoadingKeys, + isError: isKeysError, + isPlaceholderData: isKeysPlaceholderData, refetch: refetchApiKeys, } = useApiKeys(workspaceId, scope) - const { data: workspaceSettingsData, isLoading: isLoadingSettings } = - useWorkspaceSettings(workspaceId) + const { + data: workspaceSettingsData, + isLoading: isLoadingSettings, + isError: isSettingsError, + isPlaceholderData: isSettingsPlaceholderData, + } = useWorkspaceSettings(workspaceId) const deleteApiKeyMutation = useDeleteApiKey() const updateSettingsMutation = useUpdateWorkspaceApiKeySettings() const workspaceKeys = apiKeysData?.workspaceKeys ?? EMPTY_KEYS const personalKeys = apiKeysData?.personalKeys ?? EMPTY_KEYS const conflicts = apiKeysData?.conflicts ?? EMPTY_KEY_NAMES - const isLoading = isLoadingKeys || (showsWorkspaceKeys && isLoadingSettings) + const isLoading = + isLoadingKeys || + isKeysPlaceholderData || + (showsWorkspaceKeys && (isLoadingSettings || isSettingsPlaceholderData)) + const hasLoadError = isKeysError || (showsWorkspaceKeys && isSettingsError) + const dataState = isLoading ? 'loading' : hasLoadError ? 'error' : 'ready' const allowPersonalApiKeys = workspaceSettingsData?.settings?.workspace?.allowPersonalApiKeys ?? true @@ -115,6 +126,7 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) { : 'workspace' const createButtonDisabled = isLoading || + hasLoadError || (isWorkspaceScope && !canManageWorkspaceKeys) || (isCombinedScope && !allowPersonalApiKeys && !canManageWorkspaceKeys) @@ -190,18 +202,69 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) { }} actions={actions} > - {isLoading ? null : personalKeys.length === 0 && workspaceKeys.length === 0 ? ( - Click "Create API key" above to get started - ) : ( -
- {showsWorkspaceKeys && !searchTerm.trim() ? ( - - {workspaceKeys.length === 0 ? ( -
No workspace API keys yet
- ) : ( +
+ {isLoading ? null : hasLoadError ? ( + Unable to load API keys + ) : personalKeys.length === 0 && workspaceKeys.length === 0 ? ( + Click "Create API key" above to get started + ) : ( +
+ {showsWorkspaceKeys && !searchTerm.trim() ? ( + + {workspaceKeys.length === 0 ? ( +
+ No workspace API keys yet +
+ ) : ( +
+ {workspaceKeys.map((key) => ( +
+
+
+ + {key.name} + + + (last used: {formatLastUsed(key.lastUsed).toLowerCase()}) + +
+

+ {key.displayKey} +

+
+ { + setDeleteKey(key) + setShowDeleteDialog(true) + }} + canDelete={canManageWorkspaceKeys} + /> +
+ ))} +
+ )} +
+ ) : showsWorkspaceKeys && filteredWorkspaceKeys.length > 0 ? ( +
- {workspaceKeys.map((key) => ( -
+ {filteredWorkspaceKeys.map(({ key }) => ( +
@@ -226,141 +289,115 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) {
))}
- )} - - ) : showsWorkspaceKeys && filteredWorkspaceKeys.length > 0 ? ( - -
- {filteredWorkspaceKeys.map(({ key }) => ( -
-
-
- - {key.name} - - - (last used: {formatLastUsed(key.lastUsed).toLowerCase()}) - -
-

- {key.displayKey} -

-
- { - setDeleteKey(key) - setShowDeleteDialog(true) - }} - canDelete={canManageWorkspaceKeys} - /> -
- ))} -
-
- ) : null} + + ) : null} - {showsPersonalKeys && (!searchTerm.trim() || filteredPersonalKeys.length > 0) && ( - -
- {filteredPersonalKeys.map(({ key }) => { - const isConflict = conflicts.includes(key.name) - return ( -
-
-
-
- - {key.name} - - - (last used: {formatLastUsed(key.lastUsed).toLowerCase()}) - + {showsPersonalKeys && (!searchTerm.trim() || filteredPersonalKeys.length > 0) && ( + +
+ {filteredPersonalKeys.map(({ key }) => { + const isConflict = conflicts.includes(key.name) + return ( +
+
+
+
+ + {key.name} + + + (last used: {formatLastUsed(key.lastUsed).toLowerCase()}) + +
+

+ {key.displayKey} +

-

- {key.displayKey} -

+ { + setDeleteKey(key) + setShowDeleteDialog(true) + }} + />
- { - setDeleteKey(key) - setShowDeleteDialog(true) - }} - /> + {isConflict && ( +
+ Workspace API key with the same name overrides this. Rename your + personal key to use it. +
+ )}
- {isConflict && ( -
- Workspace API key with the same name overrides this. Rename your - personal key to use it. -
- )} -
- ) - })} -
- - )} - - {searchTerm.trim() && - filteredPersonalKeys.length === 0 && - filteredWorkspaceKeys.length === 0 && - (personalKeys.length > 0 || workspaceKeys.length > 0) && ( - - No API keys found matching "{searchTerm}" - + ) + })} +
+ )} -
- )} - {showsWorkspaceKeys && !isLoading && canManageWorkspaceKeys && ( - - -
-
- - - - - - - Allow collaborators to authenticate with their own keys. Hosted usage is - billed to this workspace, attributed to the key owner, and counted toward - their member cap. - - -
- {isLoadingSettings ? null : ( - { - try { - await updateSettingsMutation.mutateAsync({ - workspaceId, - allowPersonalApiKeys: checked, - }) - } catch (error) { - logger.error('Error updating workspace settings:', { error }) - } - }} - /> + {searchTerm.trim() && + filteredPersonalKeys.length === 0 && + filteredWorkspaceKeys.length === 0 && + (personalKeys.length > 0 || workspaceKeys.length > 0) && ( + + No API keys found matching "{searchTerm}" + )} -
-
-
- )} +
+ )} + + {showsWorkspaceKeys && !isLoading && !hasLoadError && canManageWorkspaceKeys && ( + + +
+
+ + + + + + + Allow collaborators to authenticate with their own keys. Hosted usage is + billed to this workspace, attributed to the key owner, and counted toward + their member cap. + + +
+ {isLoadingSettings ? null : ( + { + try { + await updateSettingsMutation.mutateAsync({ + workspaceId, + allowPersonalApiKeys: checked, + }) + } catch (error) { + logger.error('Error updating workspace settings:', { error }) + } + }} + /> + )} +
+
+
+ )} +
({ + ChipInput: (props: ComponentProps<'input'>) => , +})) + +describe('SecretValueField', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + }) + + it('keeps editable plaintext out of the DOM until focus', () => { + act(() => root.render( undefined} />)) + const input = container.querySelector('input') + expect(input).not.toBeNull() + expect(input?.type).toBe('text') + expect(input?.value).toBe('••••••••••') + + act(() => input?.focus()) + expect(input?.type).toBe('text') + expect(input?.value).toBe('private-value') + + act(() => input?.blur()) + expect(input?.type).toBe('text') + expect(input?.value).toBe('••••••••••') + }) + + it('uses a fixed mask for viewers regardless of secret length', () => { + act(() => root.render()) + const input = container.querySelector('input') + expect(input?.value).toBe('••••••••••') + + act(() => root.render()) + expect(input?.value).toBe('••••••••••') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secret-value-field/secret-value-field.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secret-value-field/secret-value-field.tsx index 9c5e61c8df1..d32509f113a 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secret-value-field/secret-value-field.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secret-value-field/secret-value-field.tsx @@ -57,7 +57,11 @@ export function SecretValueField({ const [focused, setFocused] = useState(false) const editable = canEdit && !readOnly const maskActive = canEdit && !unmasked && !focused - const displayValue = canEdit ? value : BULLET.repeat(VIEWER_MASK_LENGTH) + const displayValue = canEdit + ? maskActive && value + ? BULLET.repeat(VIEWER_MASK_LENGTH) + : value + : BULLET.repeat(VIEWER_MASK_LENGTH) const mergedStyle: CSSProperties | undefined = maskActive ? ({ ...style, WebkitTextSecurity: 'disc' } as CSSProperties) @@ -69,13 +73,12 @@ export function SecretValueField({ className={className} type='text' value={displayValue} - readOnly + readOnly={!editable || !focused} style={mergedStyle} onChange={(event) => { if (editable) onChange?.(event.target.value) }} onFocus={(event) => { - if (editable) event.currentTarget.removeAttribute('readOnly') event.currentTarget.scrollLeft = 0 setFocused(true) onFocus?.(event) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx index fd30b72c435..fd84f6c14ef 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx @@ -46,6 +46,8 @@ function copyName(key: string) { } interface SecretRowMenuProps { + /** Accessible name that identifies the row owning this menu. */ + label: string /** Copies the secret's name. */ onCopyName: () => void /** Opens credential details; omit when the row has no backing credential. */ @@ -58,10 +60,10 @@ interface SecretRowMenuProps { * Trailing `...` actions menu for a secret row. Mirrors the Teammates / * Organization member menu so the settings experience is consistent. */ -function SecretRowMenu({ onCopyName, onViewDetails, onDelete }: SecretRowMenuProps) { +function SecretRowMenu({ label, onCopyName, onViewDetails, onDelete }: SecretRowMenuProps) { return ( { @@ -238,12 +241,14 @@ function WorkspaceVariableRow({ />
onValueChange(envKey, next)} canEdit={canEdit} name={`workspace_env_value_${envKey}_${generateShortId()}`} /> copyName(envKey)} onViewDetails={hasCredential && onViewDetails ? () => onViewDetails(envKey) : undefined} onDelete={canEdit ? () => onDelete(envKey) : undefined} @@ -298,6 +303,7 @@ function NewWorkspaceVariableRow({ /> {hasContent ? ( copyName(envVar.key)} onDelete={() => { onUpdate(index, 'key', '') @@ -861,6 +867,9 @@ export function SecretsManager() { return (
updateEnvVar(originalIndex, 'value', next)} @@ -888,6 +900,7 @@ export function SecretsManager() { /> {hasContent ? ( copyName(envVar.key)} onDelete={() => removeEnvVar(originalIndex)} /> diff --git a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/page.tsx b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/page.tsx index 153c32ac615..f71d1c94fe1 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/page.tsx @@ -1,5 +1,11 @@ import type { Metadata } from 'next' +import { notFound, redirect } from 'next/navigation' +import { getSession } from '@/lib/auth' +import { getCredentialActorContext } from '@/lib/credentials/access' +import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context' import { SecretDetail } from '@/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail' +import { resolveWorkspaceGroup } from '@/ee/access-control/utils/permission-check' +import { canOpenSecretDetail } from './secret-detail-access' export const metadata: Metadata = { title: 'Secret', @@ -10,6 +16,34 @@ export default async function SecretDetailPage({ }: { params: Promise<{ workspaceId: string; credentialId: string }> }) { + const session = await getSession() + if (!session?.user) redirect('/login') + const { workspaceId, credentialId } = await params + const hostContext = await getWorkspaceHostContextForViewer(workspaceId, session.user.id) + if (!hostContext) notFound() + + const [permissionGroup, access] = await Promise.all([ + hostContext.hostOrganizationId && hostContext.ownerBilling.isEnterprise + ? resolveWorkspaceGroup(session.user.id, hostContext.hostOrganizationId, workspaceId) + : null, + getCredentialActorContext(credentialId, session.user.id), + ]) + + if ( + !canOpenSecretDetail({ + workspaceId, + secretsHidden: permissionGroup?.config.hideSecretsTab === true, + access: { + credential: access.credential, + hasWorkspaceAccess: access.hasWorkspaceAccess, + hasActiveMembership: access.member?.status === 'active', + isAdmin: access.isAdmin, + }, + }) + ) { + notFound() + } + return } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail-access.test.ts b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail-access.test.ts new file mode 100644 index 00000000000..45397b88534 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail-access.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' +import { canOpenSecretDetail } from './secret-detail-access' + +const allowedAccess = { + credential: { workspaceId: 'workspace-a', type: 'env_workspace' }, + hasWorkspaceAccess: true, + hasActiveMembership: true, + isAdmin: false, +} + +describe('canOpenSecretDetail', () => { + it('allows active members and derived admins for matching environment credentials', () => { + expect( + canOpenSecretDetail({ + workspaceId: 'workspace-a', + secretsHidden: false, + access: allowedAccess, + }) + ).toBe(true) + expect( + canOpenSecretDetail({ + workspaceId: 'workspace-a', + secretsHidden: false, + access: { + ...allowedAccess, + credential: { workspaceId: 'workspace-a', type: 'env_personal' }, + hasActiveMembership: false, + isAdmin: true, + }, + }) + ).toBe(true) + }) + + it.each([ + ['missing credential', { ...allowedAccess, credential: null }, false], + [ + 'cross-workspace credential', + { + ...allowedAccess, + credential: { workspaceId: 'workspace-b', type: 'env_workspace' }, + }, + false, + ], + [ + 'wrong credential type', + { ...allowedAccess, credential: { workspaceId: 'workspace-a', type: 'oauth' } }, + false, + ], + ['missing workspace access', { ...allowedAccess, hasWorkspaceAccess: false }, false], + [ + 'missing credential membership', + { ...allowedAccess, hasActiveMembership: false, isAdmin: false }, + false, + ], + ])('rejects %s', (_label, access, expected) => { + expect( + canOpenSecretDetail({ + workspaceId: 'workspace-a', + secretsHidden: false, + access, + }) + ).toBe(expected) + }) + + it('rejects permission-group hidden Secrets', () => { + expect( + canOpenSecretDetail({ + workspaceId: 'workspace-a', + secretsHidden: true, + access: allowedAccess, + }) + ).toBe(false) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail-access.ts b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail-access.ts new file mode 100644 index 00000000000..55110c1d29b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail-access.ts @@ -0,0 +1,27 @@ +interface SecretDetailCredential { + workspaceId: string + type: string +} + +interface SecretDetailAccess { + credential: SecretDetailCredential | null + hasWorkspaceAccess: boolean + hasActiveMembership: boolean + isAdmin: boolean +} + +export function canOpenSecretDetail(options: { + workspaceId: string + secretsHidden: boolean + access: SecretDetailAccess +}): boolean { + const { access } = options + return Boolean( + !options.secretsHidden && + access.credential && + access.credential.workspaceId === options.workspaceId && + (access.credential.type === 'env_personal' || access.credential.type === 'env_workspace') && + access.hasWorkspaceAccess && + (access.hasActiveMembership || access.isAdmin) + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx index 7aca00a8c7f..55b7db36be7 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx @@ -95,6 +95,7 @@ export function SecretDetail({ workspaceId, credentialId }: SecretDetailProps) { /` +The credentials project keeps the HTML report, redacted automatic error +contexts, and redacted service logs but does not produce browser traces, +screenshots, videos, or test-authored attachments. + Open the report: ```bash diff --git a/apps/sim/e2e/foundation/leak-canary.spec.ts b/apps/sim/e2e/foundation/leak-canary.spec.ts index 0a1014437ac..5ed2db77fea 100644 --- a/apps/sim/e2e/foundation/leak-canary.spec.ts +++ b/apps/sim/e2e/foundation/leak-canary.spec.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' import { expect, test } from '@playwright/test' @@ -68,6 +68,82 @@ test('credential leak canary scans artifacts but excludes its private source', a } }) +test('credential patterns are bounded and ZIP entries are scanned independently', async () => { + const directory = mkdtempSync(path.join(os.tmpdir(), 'sim-e2e-credential-patterns-')) + const token = Array.from({ length: 32 }, (_, index) => + String.fromCharCode('A'.charCodeAt(0) + (index % 26)) + ).join('') + const encryptedKey = `${['sk', 'sim'].join('-')}-${token}` + const legacyKey = `${['sim', ''].join('_')}${token}` + const runtimeSecret = `${['E2E', 'RUNTIME', 'SECRET', 'V1'].join('_')}_${token}` + const scan = () => + assertNoSyntheticSecretLeaks({ + secrets: ['exact-canary-not-present'], + roots: [directory], + }) + + try { + const positivePath = path.join(directory, 'report.html') + writeFileSync(positivePath, `response: ${encryptedKey}`) + await expect(scan()).rejects.toThrow(/artifact-1-1/) + expect(await scan().catch((error: Error) => error.message)).not.toContain(positivePath) + unlinkSync(positivePath) + + const positiveZip = new JSZip() + positiveZip.file('network.txt', `response: ${legacyKey}`) + positiveZip.file(`entry-${runtimeSecret}.txt`, 'entry names are scanned too') + const zipPath = path.join(directory, 'trace.zip') + writeFileSync(zipPath, await positiveZip.generateAsync({ type: 'nodebuffer' })) + await expect(scan()).rejects.toThrow(/secret leaked/) + unlinkSync(zipPath) + + const directoryNameZip = new JSZip() + directoryNameZip.folder(runtimeSecret) + writeFileSync(zipPath, await directoryNameZip.generateAsync({ type: 'nodebuffer' })) + await expect(scan()).rejects.toThrow(/runtime-secret/) + unlinkSync(zipPath) + + const directoryPath = path.join(directory, runtimeSecret) + mkdirSync(directoryPath) + await expect(scan()).rejects.toThrow(/runtime-secret/) + rmSync(directoryPath, { recursive: true }) + + const binaryPath = path.join(directory, `${encryptedKey}.png`) + writeFileSync(binaryPath, Buffer.alloc(0)) + await expect(scan()).rejects.toThrow(/current-api-key/) + unlinkSync(binaryPath) + + const outerZipPath = path.join(directory, `${legacyKey}.zip`) + const cleanZip = new JSZip() + cleanZip.file('clean.txt', 'clean') + writeFileSync(outerZipPath, await cleanZip.generateAsync({ type: 'nodebuffer' })) + await expect(scan()).rejects.toThrow(/legacy-api-key/) + unlinkSync(outerZipPath) + + writeFileSync( + path.join(directory, 'clean.txt'), + [ + 'sk-sim-••••••••', + 'sk-sim-prefix-only', + `sk-sim-${token.slice(0, 31)}`, + `sk-sim-${token}A`, + `sim_e2e_${token}`, + `E2E_RUNTIME_SECRET_V1_${token.slice(0, 31)}`, + ].join('\n') + ) + const splitZip = new JSZip() + splitZip.file('first.txt', `sk-sim-${token.slice(0, 16)}`) + splitZip.file('second.txt', token.slice(16)) + writeFileSync( + path.join(directory, 'split.zip'), + await splitZip.generateAsync({ type: 'nodebuffer' }) + ) + await expect(scan()).resolves.toBeUndefined() + } finally { + rmSync(directory, { recursive: true, force: true }) + } +}) + test('malformed canaries fail closed by scrubbing unscannable diagnostics', () => { const directory = mkdtempSync(path.join(os.tmpdir(), 'sim-e2e-malformed-canary-')) const secretsPath = path.join(directory, 'synthetic-secrets.json') diff --git a/apps/sim/e2e/foundation/safety.spec.ts b/apps/sim/e2e/foundation/safety.spec.ts index 5d9fbeb5bb5..678029dd04a 100644 --- a/apps/sim/e2e/foundation/safety.spec.ts +++ b/apps/sim/e2e/foundation/safety.spec.ts @@ -189,6 +189,14 @@ test.describe('foundation safety guards', () => { expect(() => parseRunOptions(['--project=hosted-billing-chromium-authorization', '--shard=1/2']) ).toThrow(/coupled E2E projects must remain unsharded/) + expect(() => + parseRunOptions(['--project=hosted-billing-chromium-credentials', '--no-deps'], { + ci: false, + }) + ).not.toThrow() + expect(() => + parseRunOptions(['--project=hosted-billing-chromium-credentials', '--shard=1/2']) + ).toThrow(/coupled E2E projects must remain unsharded/) expect(() => parseRunOptions(['--project=hosted-billing-chromium-workflows', '--shard=1/2']) ).toThrow(/coupled E2E projects must remain unsharded/) diff --git a/apps/sim/e2e/scripts/options.ts b/apps/sim/e2e/scripts/options.ts index 5577a1aa97d..73d9e24ec9b 100644 --- a/apps/sim/e2e/scripts/options.ts +++ b/apps/sim/e2e/scripts/options.ts @@ -1,11 +1,13 @@ const NAVIGATION_PROJECT = 'hosted-billing-chromium-navigation' const AUTHORIZATION_PROJECT = 'hosted-billing-chromium-authorization' +const CREDENTIALS_PROJECT = 'hosted-billing-chromium-credentials' const WORKFLOWS_PROJECT = 'hosted-billing-chromium-workflows' const PERSONAS_PROJECT = 'hosted-billing-chromium-personas' const PERSONA_ISOLATION_PROJECT = 'hosted-billing-chromium-persona-isolation' const E2E_PROJECTS = new Set([ NAVIGATION_PROJECT, AUTHORIZATION_PROJECT, + CREDENTIALS_PROJECT, WORKFLOWS_PROJECT, PERSONAS_PROJECT, PERSONA_ISOLATION_PROJECT, diff --git a/apps/sim/e2e/settings/authorization/mutation-controls.spec.ts b/apps/sim/e2e/settings/authorization/mutation-controls.spec.ts index 7f20bc3b318..76534ee3500 100644 --- a/apps/sim/e2e/settings/authorization/mutation-controls.spec.ts +++ b/apps/sim/e2e/settings/authorization/mutation-controls.spec.ts @@ -146,6 +146,11 @@ async function expectMutationPermissionsReady(page: Page, pathTemplate: string): const navigation = sidebar.getByRole('navigation', { name: 'Workspace settings sections' }) await expect(navigation).toHaveAttribute('aria-busy', 'false') await expect(navigation).toHaveAttribute('data-authorization-state', 'granted') + if (pathTemplate.endsWith('/settings/apikeys')) { + const apiKeys = page.getByRole('region', { name: 'API keys data', exact: true }) + await expect(apiKeys).toHaveAttribute('aria-busy', 'false') + await expect(apiKeys).toHaveAttribute('data-api-keys-state', 'ready') + } return } diff --git a/apps/sim/e2e/settings/credentials/api-keys.spec.ts b/apps/sim/e2e/settings/credentials/api-keys.spec.ts new file mode 100644 index 00000000000..e75a0b92ebf --- /dev/null +++ b/apps/sim/e2e/settings/credentials/api-keys.spec.ts @@ -0,0 +1,316 @@ +import type { Page } from '@playwright/test' +import { absoluteE2eUrl } from '../navigation/contract-resolver' +import { expect, test } from './credential-test' +import { + attemptWorkspaceApiKeyCreate, + attemptWorkspaceApiKeyDelete, + deleteApiKeyByName, + expectWorkspaceSettingsReady, + listApiKeys, + newPersonaPage, + setWorkspacePersonalKeyPolicy, + uniqueResourceName, + waitForSameOriginResponse, + workspaceId, + workspacePersonalKeyPolicy, +} from './helpers' + +test('creates and revokes a personal API key from account settings', async ({ + contextForPersona, + credentialCleanup, +}) => { + const page = await newPersonaPage(contextForPersona, 'personalPaidOwner', credentialCleanup) + const name = uniqueResourceName('account-personal-key') + credentialCleanup.register('account personal API key cleanup', async () => { + expect(await deleteApiKeyByName(page, 'personal', name)).toBe(200) + }) + + await page.goto(absoluteE2eUrl('/account/settings/api-keys')) + await expect(page.getByRole('textbox', { name: 'Search API keys...', exact: true })).toBeVisible() + await expectApiKeysReady(page) + await createApiKeyThroughUi(page, name, { + responsePath: '/api/users/me/api-keys', + }) + + const listed = await expectKeyByName(page, 'personal', name) + await deleteApiKeyThroughUi(page, name, `/api/users/me/api-keys/${listed.id}`) + await expectKeyAbsent(page, 'personal', name) +}) + +test('workspace admin creates and revokes a workspace API key', async ({ + contextForPersona, + credentialCleanup, + personaManifest, +}) => { + const page = await newPersonaPage(contextForPersona, 'workspaceAdminMember', credentialCleanup) + const targetWorkspaceId = workspaceId(personaManifest) + const name = uniqueResourceName('workspace-admin-key') + credentialCleanup.register('workspace admin API key cleanup', async () => { + expect(await deleteApiKeyByName(page, 'workspace', name, targetWorkspaceId)).toBe(200) + }) + + await page.goto(absoluteE2eUrl(`/workspace/${targetWorkspaceId}/settings/apikeys`)) + await expectWorkspaceSettingsReady(page) + await expectApiKeysReady(page) + await createApiKeyThroughUi(page, name, { + keyType: 'Workspace', + responsePath: `/api/workspaces/${targetWorkspaceId}/api-keys`, + }) + + const listed = await expectKeyByName(page, 'workspace', name, targetWorkspaceId) + await deleteApiKeyThroughUi( + page, + name, + `/api/workspaces/${targetWorkspaceId}/api-keys/${listed.id}` + ) + await expectKeyAbsent(page, 'workspace', name, targetWorkspaceId) +}) + +test('write member cannot create or revoke workspace API keys', async ({ + contextForPersona, + credentialCleanup, + personaManifest, +}) => { + const adminPage = await newPersonaPage( + contextForPersona, + 'workspaceAdminMember', + credentialCleanup + ) + const writePage = await newPersonaPage( + contextForPersona, + 'workspaceWriteMember', + credentialCleanup + ) + const targetWorkspaceId = workspaceId(personaManifest) + const name = uniqueResourceName('workspace-write-denial') + credentialCleanup.register('workspace write-denial API key cleanup', async () => { + expect(await deleteApiKeyByName(adminPage, 'workspace', name, targetWorkspaceId)).toBe(200) + }) + + await adminPage.goto(absoluteE2eUrl(`/workspace/${targetWorkspaceId}/settings/apikeys`)) + await expectWorkspaceSettingsReady(adminPage) + await expectApiKeysReady(adminPage) + await createApiKeyThroughUi(adminPage, name, { + keyType: 'Workspace', + responsePath: `/api/workspaces/${targetWorkspaceId}/api-keys`, + }) + const listed = await expectKeyByName(adminPage, 'workspace', name, targetWorkspaceId) + + await writePage.goto(absoluteE2eUrl(`/workspace/${targetWorkspaceId}/settings/apikeys`)) + await expectWorkspaceSettingsReady(writePage) + await expectApiKeysReady(writePage) + const row = writePage.getByRole('group', { name, exact: true }) + await expect(row).toBeVisible() + await row.getByRole('button', { name: 'API key actions', exact: true }).click() + await expect(writePage.getByRole('menuitem', { name: 'Delete', exact: true })).toBeDisabled() + await writePage.keyboard.press('Escape') + + const forbiddenName = uniqueResourceName('forbidden-workspace-key') + credentialCleanup.register('forbidden workspace API key cleanup', async () => { + expect(await deleteApiKeyByName(adminPage, 'workspace', forbiddenName, targetWorkspaceId)).toBe( + 200 + ) + }) + expect(await attemptWorkspaceApiKeyCreate(writePage, targetWorkspaceId, forbiddenName)).toBe(403) + expect(await attemptWorkspaceApiKeyDelete(writePage, targetWorkspaceId, listed.id)).toBe(403) + expect(await expectKeyByName(adminPage, 'workspace', name, targetWorkspaceId)).toMatchObject({ + id: listed.id, + }) +}) + +test('workspace personal-key policy gates combined-page creation', async ({ + contextForPersona, + credentialCleanup, + personaManifest, +}) => { + const adminPage = await newPersonaPage( + contextForPersona, + 'workspaceAdminMember', + credentialCleanup + ) + const writePage = await newPersonaPage( + contextForPersona, + 'workspaceWriteMember', + credentialCleanup + ) + const targetWorkspaceId = workspaceId(personaManifest) + const name = uniqueResourceName('workspace-personal-policy') + + expect(await setWorkspacePersonalKeyPolicy(adminPage, targetWorkspaceId, true)).toBe(200) + expect(await workspacePersonalKeyPolicy(adminPage, targetWorkspaceId)).toEqual({ + status: 200, + allowed: true, + }) + credentialCleanup.register('workspace personal-key policy reset', async () => { + expect(await setWorkspacePersonalKeyPolicy(adminPage, targetWorkspaceId, true)).toBe(200) + expect(await workspacePersonalKeyPolicy(adminPage, targetWorkspaceId)).toEqual({ + status: 200, + allowed: true, + }) + }) + credentialCleanup.register('workspace personal API key cleanup', async () => { + expect(await deleteApiKeyByName(writePage, 'personal', name)).toBe(200) + }) + + await writePage.goto(absoluteE2eUrl(`/workspace/${targetWorkspaceId}/settings/apikeys`)) + await expectWorkspaceSettingsReady(writePage) + await expectApiKeysReady(writePage) + await createApiKeyThroughUi(writePage, name, { + responsePath: '/api/users/me/api-keys', + }) + const personalKey = await expectKeyByName(writePage, 'personal', name) + await deleteApiKeyThroughUi( + writePage, + name, + `/api/users/me/api-keys/${encodeURIComponent(personalKey.id)}` + ) + await expectKeyAbsent(writePage, 'personal', name) + + await adminPage.goto(absoluteE2eUrl(`/workspace/${targetWorkspaceId}/settings/apikeys`)) + await expectWorkspaceSettingsReady(adminPage) + await expectApiKeysReady(adminPage) + const policySwitch = adminPage.getByRole('switch', { + name: 'Allow personal API keys', + exact: true, + }) + await expect(policySwitch).toBeChecked() + const policyResponse = waitForSameOriginResponse( + adminPage, + 'PATCH', + `/api/workspaces/${targetWorkspaceId}` + ) + await policySwitch.click() + expect((await policyResponse).status()).toBe(200) + await expect(policySwitch).not.toBeChecked() + expect(await workspacePersonalKeyPolicy(adminPage, targetWorkspaceId)).toEqual({ + status: 200, + allowed: false, + }) + + await writePage.goto(absoluteE2eUrl(`/workspace/${targetWorkspaceId}/settings/apikeys`)) + await expectWorkspaceSettingsReady(writePage) + await expectApiKeysReady(writePage) + await expect( + writePage.getByRole('button', { name: 'Create API key', exact: true }) + ).toBeDisabled() + expect(await setWorkspacePersonalKeyPolicy(writePage, targetWorkspaceId, true)).toBe(403) + expect(await workspacePersonalKeyPolicy(adminPage, targetWorkspaceId)).toEqual({ + status: 200, + allowed: false, + }) +}) + +interface CreateApiKeyOptions { + responsePath: string + keyType?: 'Personal' | 'Workspace' +} + +async function expectApiKeysReady(page: Page): Promise { + const region = page.getByRole('region', { name: 'API keys data', exact: true }) + await expect(region).toHaveAttribute('aria-busy', 'false') + await expect(region).toHaveAttribute('data-api-keys-state', 'ready') +} + +async function createApiKeyThroughUi( + page: Page, + name: string, + options: CreateApiKeyOptions +): Promise { + await page.getByRole('button', { name: 'Create API key', exact: true }).click() + const dialog = page.getByRole('dialog', { name: 'Create new API key', exact: true }) + await expect(dialog).toBeVisible() + if (options.keyType) { + await dialog.getByRole('radio', { name: options.keyType, exact: true }).click() + } + await dialog.getByPlaceholder('e.g., Development, Production', { exact: true }).fill(name) + const response = waitForSameOriginResponse(page, 'POST', options.responsePath) + await dialog.getByRole('button', { name: 'Create', exact: true }).click() + expect((await response).status()).toBe(200) + + await page.waitForFunction(() => + [...document.querySelectorAll('[role="dialog"]')].some( + (candidate) => + candidate.textContent?.includes('Your API key has been created') && + [...candidate.querySelectorAll('button')].some( + (button) => button.textContent?.trim() === 'Done' + ) + ) + ) + const revealClosed = await page.evaluate(() => { + const dialog = [...document.querySelectorAll('[role="dialog"]')].find( + (candidate) => candidate.textContent?.includes('Your API key has been created') + ) + if (!dialog) return false + for (const code of dialog.querySelectorAll('code')) code.textContent = '[redacted]' + const done = [...dialog.querySelectorAll('button')].find( + (button) => button.textContent?.trim() === 'Done' + ) + done?.click() + return Boolean(done) + }) + expect(revealClosed).toBe(true) + await page.waitForFunction( + () => + ![...document.querySelectorAll('[role="dialog"]')].some((candidate) => + candidate.textContent?.includes('Your API key has been created') + ) + ) +} + +async function expectKeyByName( + page: Page, + scope: 'personal' | 'workspace', + name: string, + targetWorkspaceId?: string +): Promise<{ id: string; name: string; displayKey: string }> { + let match: { id: string; name: string; displayKey: string } | undefined + await expect + .poll(async () => { + const listed = await listApiKeys(page, scope, targetWorkspaceId) + expect(listed.status).toBe(200) + expect(listed.containsPlaintextField).toBe(false) + match = listed.keys.find((key) => key.name === name) + return match + }) + .toEqual({ + id: expect.any(String), + name, + displayKey: expect.stringMatching(/^(?:sk-sim-|sim_)\.\.\.[A-Za-z0-9_-]{4}$/), + }) + if (!match) throw new Error('Created API key metadata was not found') + return match +} + +async function expectKeyAbsent( + page: Page, + scope: 'personal' | 'workspace', + name: string, + targetWorkspaceId?: string +): Promise { + await expect + .poll(async () => { + const listed = await listApiKeys(page, scope, targetWorkspaceId) + return { + status: listed.status, + containsPlaintextField: listed.containsPlaintextField, + present: listed.keys.some((key) => key.name === name), + } + }) + .toEqual({ status: 200, containsPlaintextField: false, present: false }) +} + +async function deleteApiKeyThroughUi( + page: Page, + name: string, + responsePath: string +): Promise { + const row = page.getByRole('group', { name, exact: true }) + await row.getByRole('button', { name: 'API key actions', exact: true }).click() + await page.getByRole('menuitem', { name: 'Delete', exact: true }).click() + const dialog = page.getByRole('dialog', { name: 'Delete API key', exact: true }) + await expect(dialog).toBeVisible() + const response = waitForSameOriginResponse(page, 'DELETE', responsePath) + await dialog.getByRole('button', { name: 'Delete', exact: true }).click() + expect((await response).status()).toBe(200) + await expect(dialog).toHaveCount(0) +} diff --git a/apps/sim/e2e/settings/credentials/contract-integrity.spec.ts b/apps/sim/e2e/settings/credentials/contract-integrity.spec.ts new file mode 100644 index 00000000000..b8a8551542b --- /dev/null +++ b/apps/sim/e2e/settings/credentials/contract-integrity.spec.ts @@ -0,0 +1,15 @@ +import { accessGateCases, mutationControlCases } from '../authorization/contracts' +import { existingAuthorizationProofs } from './contracts' +import { expect, test } from './credential-test' + +test('credential workflows reference stable Step 4 authorization proofs', () => { + const accessIds = new Set(accessGateCases.map(({ caseId }) => caseId)) + const mutationIds = new Set(mutationControlCases.map(({ caseId }) => caseId)) + + expect(new Set(existingAuthorizationProofs.map(({ caseId }) => caseId)).size).toBe( + existingAuthorizationProofs.length + ) + for (const proof of existingAuthorizationProofs) { + expect(proof.kind === 'access' ? accessIds : mutationIds).toContain(proof.caseId) + } +}) diff --git a/apps/sim/e2e/settings/credentials/contracts.ts b/apps/sim/e2e/settings/credentials/contracts.ts new file mode 100644 index 00000000000..5c902c70271 --- /dev/null +++ b/apps/sim/e2e/settings/credentials/contracts.ts @@ -0,0 +1,10 @@ +export const existingAuthorizationProofs = [ + { kind: 'access', caseId: 'workspace-permission-group-secrets-denied' }, + { kind: 'access', caseId: 'workspace-permission-group-apikeys-denied' }, + { kind: 'mutation', caseId: 'workspace-secrets-workspaceReadMember' }, + { kind: 'mutation', caseId: 'workspace-secrets-workspaceWriteMember' }, + { kind: 'mutation', caseId: 'workspace-secrets-workspaceAdminMember' }, + { kind: 'mutation', caseId: 'workspace-api-keys-workspaceReadMember' }, + { kind: 'mutation', caseId: 'workspace-api-keys-workspaceWriteMember' }, + { kind: 'mutation', caseId: 'workspace-api-keys-workspaceAdminMember' }, +] as const diff --git a/apps/sim/e2e/settings/credentials/credential-test.ts b/apps/sim/e2e/settings/credentials/credential-test.ts new file mode 100644 index 00000000000..7e3d4a94e50 --- /dev/null +++ b/apps/sim/e2e/settings/credentials/credential-test.ts @@ -0,0 +1,86 @@ +import type { Page, TestInfo } from '@playwright/test' +import { redactCredentialDiagnostic } from '../../../lib/testing/credential-diagnostic-redaction' +import { test as personaTest } from '../../fixtures/persona-test' + +export interface CredentialCleanupRegistry { + register(label: string, cleanup: () => Promise): void + protect(page: Page): void +} + +interface CredentialFixtures { + credentialArtifactSafety: undefined + credentialCleanup: CredentialCleanupRegistry +} + +export const test = personaTest.extend({ + credentialArtifactSafety: [ + async ({ browserName: _browserName }, use, testInfo) => { + assertCredentialArtifactPolicy(testInfo) + const attachmentCount = testInfo.attachments.length + await use(undefined) + if (testInfo.attachments.length !== attachmentCount) { + throw new Error('Credential E2E tests must not create report attachments') + } + }, + { auto: true }, + ], + credentialCleanup: async ({ contextForPersona: _contextForPersona }, use, testInfo) => { + const cleanups: Array<{ label: string; run: () => Promise }> = [] + const protectedPages = new Set() + await use({ + register(label, cleanup) { + cleanups.push({ label, run: cleanup }) + }, + protect(page) { + protectedPages.add(page) + }, + }) + + const failures: unknown[] = [] + for (const cleanup of cleanups.reverse()) { + try { + await cleanup.run() + } catch (error) { + failures.push(new Error(`Credential cleanup failed: ${cleanup.label}`, { cause: error })) + } + } + if (testInfo.status !== testInfo.expectedStatus || failures.length > 0) { + redactCredentialTestErrors(testInfo) + for (const page of protectedPages) { + if (page.isClosed()) continue + try { + await page.evaluate(() => { + document.documentElement.replaceChildren(document.createElement('head')) + }) + } catch (error) { + failures.push(new Error('Unable to sanitize a failed credential page', { cause: error })) + } + } + } + if (failures.length > 0) { + throw new AggregateError(failures, 'Credential E2E cleanup failed') + } + }, +}) + +export { expect } from '@playwright/test' + +function assertCredentialArtifactPolicy(testInfo: TestInfo): void { + const { trace, screenshot, video } = testInfo.project.use + if (trace !== 'off' || screenshot !== 'off' || video !== 'off') { + throw new Error('Credential E2E project must disable trace, screenshot, and video artifacts') + } +} + +function redactCredentialTestErrors(testInfo: TestInfo): void { + for (const testError of testInfo.errors) { + const mutableError = testError as { + message?: string + stack?: string + errorContext?: string + } + mutableError.message = redactCredentialDiagnostic(mutableError.message) + mutableError.stack = redactCredentialDiagnostic(mutableError.stack) + mutableError.errorContext = redactCredentialDiagnostic(mutableError.errorContext) + } +} diff --git a/apps/sim/e2e/settings/credentials/helpers.ts b/apps/sim/e2e/settings/credentials/helpers.ts new file mode 100644 index 00000000000..12dcec27778 --- /dev/null +++ b/apps/sim/e2e/settings/credentials/helpers.ts @@ -0,0 +1,412 @@ +import { randomUUID } from 'node:crypto' +import type { BrowserContext, Locator, Page, Response } from '@playwright/test' +import type { ScenarioManifest } from '../../fixtures/e2e-world' +import { absoluteE2eUrl } from '../navigation/contract-resolver' +import type { CredentialCleanupRegistry } from './credential-test' +import { expect } from './credential-test' + +const RUNTIME_SECRET_PREFIX = 'E2E_RUNTIME_SECRET_V1_' + +type SecretApiCommand = + | { kind: 'read-workspace'; workspaceId: string; key: string } + | { kind: 'upsert-workspace'; workspaceId: string; key: string } + | { kind: 'delete-workspace'; workspaceId: string; key: string } + | { kind: 'read-personal'; key: string } + | { kind: 'remove-personal'; key: string } + +export interface SecretApiResult { + status: number + readStatus?: number + present?: boolean + withheld?: boolean + fingerprint?: string +} + +export interface EnvironmentCredentialMetadata { + status: number + id: string | null + role: string | null +} + +export interface ApiKeyListResult { + status: number + containsPlaintextField: boolean + keys: Array<{ id: string; name: string; displayKey: string }> +} + +export function uniqueEnvironmentKey(label: string): string { + return `E2E_${label.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}_${randomUUID() + .replaceAll('-', '') + .slice(0, 12) + .toUpperCase()}` +} + +export function uniqueResourceName(label: string): string { + return `e2e-${label}-${randomUUID()}` +} + +export function workspaceId(manifest: ScenarioManifest, key = 'team-workspace'): string { + const id = manifest.worlds['settings-primary']?.workspaceIds[key] + if (!id) throw new Error(`Missing settings-primary workspace binding: ${key}`) + return id +} + +export async function newPersonaPage( + contextForPersona: (personaKey: string) => Promise, + personaKey: string, + cleanup: CredentialCleanupRegistry +): Promise { + const context = await contextForPersona(personaKey) + const page = await context.newPage() + cleanup.protect(page) + const response = await page.goto(absoluteE2eUrl('/account/settings/general')) + if (!response?.ok()) throw new Error('Unable to initialize credential test page origin') + return page +} + +export async function expectWorkspaceSettingsReady(page: Page): Promise { + const sidebar = page.getByRole('complementary', { name: 'Workspace sidebar' }) + const navigation = sidebar.getByRole('navigation', { name: 'Workspace settings sections' }) + await expect(navigation).toHaveAttribute('aria-busy', 'false') + await expect(navigation).toHaveAttribute('data-authorization-state', 'granted') +} + +export async function setSensitiveInput(locator: Locator): Promise { + await locator.focus() + await expect(locator).toHaveJSProperty('readOnly', false) + try { + const expectedFingerprint = await locator.evaluate((node, prefix) => { + if (!(node instanceof HTMLInputElement)) { + throw new Error('Sensitive credential control is not an input') + } + const bytes = new Uint8Array(24) + crypto.getRandomValues(bytes) + const token = btoa(String.fromCharCode(...bytes)) + .replaceAll('+', '-') + .replaceAll('/', '_') + .replaceAll('=', '') + const value = `${prefix}${token}` + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + if (!setter) throw new Error('Native input value setter is unavailable') + setter.call(node, value) + node.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText' })) + return fingerprint(value) + + function fingerprint(input: string): string { + const bytes = new TextEncoder().encode(input) + return [0xcbf29ce484222325n, 0x84222325cbf29cen] + .map((seed) => { + let hash = seed + for (const byte of bytes) { + hash ^= BigInt(byte) + hash = BigInt.asUintN(64, hash * 0x100000001b3n) + } + return hash.toString(16).padStart(16, '0') + }) + .join('') + } + }, RUNTIME_SECRET_PREFIX) + + await locator.blur() + await expect(locator).toHaveJSProperty('readOnly', true) + await locator.focus() + const adoptedFingerprint = await sensitiveInputFingerprint(locator) + expect(adoptedFingerprint).toBe(expectedFingerprint) + return expectedFingerprint + } finally { + await locator.blur().catch(() => undefined) + } +} + +export async function sensitiveInputFingerprint(locator: Locator): Promise { + await locator.focus() + await expect(locator).toHaveJSProperty('readOnly', false) + try { + return await locator.evaluate((node) => { + if (!(node instanceof HTMLInputElement)) { + throw new Error('Sensitive credential control is not an input') + } + const bytes = new TextEncoder().encode(node.value) + return [0xcbf29ce484222325n, 0x84222325cbf29cen] + .map((seed) => { + let hash = seed + for (const byte of bytes) { + hash ^= BigInt(byte) + hash = BigInt.asUintN(64, hash * 0x100000001b3n) + } + return hash.toString(16).padStart(16, '0') + }) + .join('') + }) + } finally { + await locator.blur().catch(() => undefined) + } +} + +export async function runSecretApi( + page: Page, + command: SecretApiCommand +): Promise { + return page.evaluate(async (operation) => { + const headers = { 'content-type': 'application/json' } + + if (operation.kind === 'read-workspace') { + const response = await fetch( + `/api/workspaces/${encodeURIComponent(operation.workspaceId)}/environment` + ) + if (response.status !== 200) return { status: response.status } + const payload = (await response.json()) as { + data?: { workspace?: Record } + } + const value = payload.data?.workspace?.[operation.key] + return { + status: response.status, + present: value !== undefined, + withheld: value === '', + ...(value ? { fingerprint: fingerprint(value) } : {}), + } + } + + if (operation.kind === 'upsert-workspace') { + const value = generateSecret() + const response = await fetch( + `/api/workspaces/${encodeURIComponent(operation.workspaceId)}/environment`, + { + method: 'PUT', + headers, + body: JSON.stringify({ variables: { [operation.key]: value } }), + } + ) + return { status: response.status, fingerprint: fingerprint(value) } + } + + if (operation.kind === 'delete-workspace') { + const response = await fetch( + `/api/workspaces/${encodeURIComponent(operation.workspaceId)}/environment`, + { + method: 'DELETE', + headers, + body: JSON.stringify({ keys: [operation.key] }), + } + ) + return { status: response.status } + } + + const readResponse = await fetch('/api/environment') + if (readResponse.status !== 200) return { status: readResponse.status } + const payload = (await readResponse.json()) as { + data?: Record + } + const variables = payload.data ?? {} + + if (operation.kind === 'read-personal') { + const value = variables[operation.key]?.value + return { + status: readResponse.status, + present: value !== undefined, + ...(value ? { fingerprint: fingerprint(value) } : {}), + } + } + + delete variables[operation.key] + const preservedVariables = Object.fromEntries( + Object.entries(variables).map(([key, variable]) => [key, variable.value]) + ) + const writeResponse = await fetch('/api/environment', { + method: 'POST', + headers, + body: JSON.stringify({ variables: preservedVariables }), + }) + return { status: writeResponse.status, readStatus: readResponse.status } + + function generateSecret(): string { + const bytes = new Uint8Array(24) + crypto.getRandomValues(bytes) + const token = btoa(String.fromCharCode(...bytes)) + .replaceAll('+', '-') + .replaceAll('/', '_') + .replaceAll('=', '') + return `E2E_RUNTIME_SECRET_V1_${token}` + } + + function fingerprint(input: string): string { + const bytes = new TextEncoder().encode(input) + return [0xcbf29ce484222325n, 0x84222325cbf29cen] + .map((seed) => { + let hash = seed + for (const byte of bytes) { + hash ^= BigInt(byte) + hash = BigInt.asUintN(64, hash * 0x100000001b3n) + } + return hash.toString(16).padStart(16, '0') + }) + .join('') + } + }, command) +} + +export async function findEnvironmentCredential( + page: Page, + workspaceId: string, + key: string, + type: 'env_personal' | 'env_workspace' = 'env_workspace' +): Promise { + return page.evaluate( + async ({ workspaceId, key, type }) => { + const query = new URLSearchParams({ workspaceId, type }) + const response = await fetch(`/api/credentials?${query}`) + if (response.status !== 200) { + return { status: response.status, id: null, role: null } + } + const payload = (await response.json()) as { + credentials?: Array<{ id: string; envKey?: string | null; role?: string | null }> + } + const match = payload.credentials?.find(({ envKey }) => envKey === key) + return { + status: response.status, + id: match?.id ?? null, + role: match?.role ?? null, + } + }, + { workspaceId, key, type } + ) +} + +export async function listApiKeys( + page: Page, + scope: 'personal' | 'workspace', + targetWorkspaceId?: string +): Promise { + return page.evaluate( + async ({ scope, workspaceId }) => { + const path = + scope === 'personal' + ? '/api/users/me/api-keys' + : `/api/workspaces/${encodeURIComponent(workspaceId ?? '')}/api-keys` + const response = await fetch(path) + if (response.status !== 200) { + return { status: response.status, containsPlaintextField: false, keys: [] } + } + const payload = (await response.json()) as { + keys?: Array> + } + const keys = payload.keys ?? [] + return { + status: response.status, + containsPlaintextField: keys.some((key) => Object.hasOwn(key, 'key')), + keys: keys.map((key) => ({ + id: String(key.id ?? ''), + name: String(key.name ?? ''), + displayKey: String(key.displayKey ?? ''), + })), + } + }, + { scope, workspaceId: targetWorkspaceId } + ) +} + +export async function deleteApiKeyByName( + page: Page, + scope: 'personal' | 'workspace', + name: string, + targetWorkspaceId?: string +): Promise { + const listed = await listApiKeys(page, scope, targetWorkspaceId) + if (listed.status !== 200) return listed.status + const match = listed.keys.find((key) => key.name === name) + if (!match) return 200 + return page.evaluate( + async ({ scope, workspaceId, id }) => { + const path = + scope === 'personal' + ? `/api/users/me/api-keys/${encodeURIComponent(id)}` + : `/api/workspaces/${encodeURIComponent(workspaceId ?? '')}/api-keys/${encodeURIComponent(id)}` + return (await fetch(path, { method: 'DELETE' })).status + }, + { scope, workspaceId: targetWorkspaceId, id: match.id } + ) +} + +export async function workspacePersonalKeyPolicy( + page: Page, + targetWorkspaceId: string +): Promise<{ status: number; allowed?: boolean }> { + return page.evaluate(async (workspaceId) => { + const response = await fetch(`/api/workspaces/${encodeURIComponent(workspaceId)}`) + if (response.status !== 200) return { status: response.status } + const payload = (await response.json()) as { + workspace?: { allowPersonalApiKeys?: boolean } + } + const allowed = payload.workspace?.allowPersonalApiKeys + return typeof allowed === 'boolean' + ? { status: response.status, allowed } + : { status: response.status } + }, targetWorkspaceId) +} + +export async function setWorkspacePersonalKeyPolicy( + page: Page, + targetWorkspaceId: string, + allowed: boolean +): Promise { + return page.evaluate( + async ({ workspaceId, allowed }) => + ( + await fetch(`/api/workspaces/${encodeURIComponent(workspaceId)}`, { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ allowPersonalApiKeys: allowed }), + }) + ).status, + { workspaceId: targetWorkspaceId, allowed } + ) +} + +export async function attemptWorkspaceApiKeyCreate( + page: Page, + targetWorkspaceId: string, + name: string +): Promise { + return page.evaluate( + async ({ workspaceId, name }) => + ( + await fetch(`/api/workspaces/${encodeURIComponent(workspaceId)}/api-keys`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name, source: 'settings' }), + }) + ).status, + { workspaceId: targetWorkspaceId, name } + ) +} + +export async function attemptWorkspaceApiKeyDelete( + page: Page, + targetWorkspaceId: string, + keyId: string +): Promise { + return page.evaluate( + async ({ workspaceId, keyId }) => + ( + await fetch( + `/api/workspaces/${encodeURIComponent(workspaceId)}/api-keys/${encodeURIComponent(keyId)}`, + { method: 'DELETE' } + ) + ).status, + { workspaceId: targetWorkspaceId, keyId } + ) +} + +export function waitForSameOriginResponse( + page: Page, + method: string, + pathname: string +): Promise { + const origin = new URL(absoluteE2eUrl('/')).origin + return page.waitForResponse((response) => { + const url = new URL(response.url()) + return ( + url.origin === origin && url.pathname === pathname && response.request().method() === method + ) + }) +} diff --git a/apps/sim/e2e/settings/credentials/secrets.spec.ts b/apps/sim/e2e/settings/credentials/secrets.spec.ts new file mode 100644 index 00000000000..8e7f75c5293 --- /dev/null +++ b/apps/sim/e2e/settings/credentials/secrets.spec.ts @@ -0,0 +1,465 @@ +import { absoluteE2eUrl } from '../navigation/contract-resolver' +import { expect, test } from './credential-test' +import { + expectWorkspaceSettingsReady, + findEnvironmentCredential, + newPersonaPage, + runSecretApi, + sensitiveInputFingerprint, + setSensitiveInput, + uniqueEnvironmentKey, + waitForSameOriginResponse, + workspaceId, +} from './helpers' + +test('read member manages a personal secret without workspace mutation rights', async ({ + contextForPersona, + credentialCleanup, + personaManifest, +}) => { + const page = await newPersonaPage(contextForPersona, 'workspaceReadMember', credentialCleanup) + const targetWorkspaceId = workspaceId(personaManifest) + const key = uniqueEnvironmentKey('PERSONAL_READ_MEMBER') + credentialCleanup.register('personal secret exact-key cleanup', async () => { + assertStatus(await runSecretApi(page, { kind: 'remove-personal', key }), 200) + }) + + await page.goto(absoluteE2eUrl(`/workspace/${targetWorkspaceId}/settings/secrets`)) + await expectWorkspaceSettingsReady(page) + await expect(page.getByRole('textbox', { name: 'Search secrets...', exact: true })).toBeVisible() + + const name = page.getByRole('textbox', { name: 'New personal secret name', exact: true }).first() + await name.click() + await name.fill(key) + const fingerprint = await setSensitiveInput( + page.getByRole('textbox', { name: `Personal secret value ${key}`, exact: true }) + ) + const save = page.getByRole('button', { name: 'Save', exact: true }) + await expect(save).toBeEnabled() + await save.click() + await expect + .poll(() => runSecretApi(page, { kind: 'read-personal', key })) + .toEqual({ + status: 200, + present: true, + fingerprint, + }) + + await page.getByRole('button', { name: `Secret actions for ${key}`, exact: true }).click() + await page.getByRole('menuitem', { name: 'Delete', exact: true }).click() + await expect(save).toBeEnabled() + await save.click() + await expect + .poll(() => runSecretApi(page, { kind: 'read-personal', key })) + .toEqual({ + status: 200, + present: false, + }) +}) + +test('write member completes the workspace secret lifecycle', async ({ + contextForPersona, + credentialCleanup, + personaManifest, +}) => { + const page = await newPersonaPage(contextForPersona, 'workspaceWriteMember', credentialCleanup) + const targetWorkspaceId = workspaceId(personaManifest) + const key = uniqueEnvironmentKey('WORKSPACE_LIFECYCLE') + credentialCleanup.register('workspace secret exact-key cleanup', async () => { + assertStatus( + await runSecretApi(page, { kind: 'delete-workspace', workspaceId: targetWorkspaceId, key }), + 200 + ) + }) + + await page.goto(absoluteE2eUrl(`/workspace/${targetWorkspaceId}/settings/secrets`)) + await expectWorkspaceSettingsReady(page) + + const newName = page + .getByRole('textbox', { + name: 'New workspace secret name', + exact: true, + }) + .first() + await newName.click() + await newName.fill(key) + await setSensitiveInput( + page.getByRole('textbox', { name: 'New workspace secret value', exact: true }).first() + ) + await page.getByRole('button', { name: 'Discard', exact: true }).click() + expect( + await runSecretApi(page, { + kind: 'read-workspace', + workspaceId: targetWorkspaceId, + key, + }) + ).toEqual({ status: 200, present: false, withheld: false }) + + await newName.click() + await newName.fill(key) + const originalFingerprint = await setSensitiveInput( + page.getByRole('textbox', { name: 'New workspace secret value', exact: true }).first() + ) + const createResponse = waitForSameOriginResponse( + page, + 'PUT', + `/api/workspaces/${targetWorkspaceId}/environment` + ) + await page.getByRole('button', { name: 'Save', exact: true }).click() + expect((await createResponse).status()).toBe(200) + expect( + await runSecretApi(page, { + kind: 'read-workspace', + workspaceId: targetWorkspaceId, + key, + }) + ).toEqual({ + status: 200, + present: true, + withheld: false, + fingerprint: originalFingerprint, + }) + await expect + .poll(() => findEnvironmentCredential(page, targetWorkspaceId, key)) + .toMatchObject({ status: 200, id: expect.any(String), role: 'admin' }) + + const existingValue = page.getByRole('textbox', { + name: `Workspace secret value ${key}`, + exact: true, + }) + await setSensitiveInput(existingValue) + await page.getByRole('button', { name: 'Discard', exact: true }).click() + await page.reload() + await expectWorkspaceSettingsReady(page) + expect( + await runSecretApi(page, { + kind: 'read-workspace', + workspaceId: targetWorkspaceId, + key, + }) + ).toMatchObject({ status: 200, fingerprint: originalFingerprint }) + + const updatedFingerprint = await setSensitiveInput( + page.getByRole('textbox', { name: `Workspace secret value ${key}`, exact: true }) + ) + const updateResponse = waitForSameOriginResponse( + page, + 'PUT', + `/api/workspaces/${targetWorkspaceId}/environment` + ) + await page.getByRole('button', { name: 'Save', exact: true }).click() + expect((await updateResponse).status()).toBe(200) + expect(updatedFingerprint).not.toBe(originalFingerprint) + expect( + await runSecretApi(page, { + kind: 'read-workspace', + workspaceId: targetWorkspaceId, + key, + }) + ).toMatchObject({ status: 200, fingerprint: updatedFingerprint }) + + await page.getByRole('button', { name: `Secret actions for ${key}`, exact: true }).click() + await page.getByRole('menuitem', { name: 'Delete', exact: true }).click() + const deleteResponse = waitForSameOriginResponse( + page, + 'DELETE', + `/api/workspaces/${targetWorkspaceId}/environment` + ) + await page.getByRole('button', { name: 'Save', exact: true }).click() + expect((await deleteResponse).status()).toBe(200) + expect( + await runSecretApi(page, { + kind: 'read-workspace', + workspaceId: targetWorkspaceId, + key, + }) + ).toMatchObject({ status: 200, present: false }) + await expect + .poll(() => findEnvironmentCredential(page, targetWorkspaceId, key)) + .toEqual({ + status: 200, + id: null, + role: null, + }) +}) + +test('workspace admin edits a secret from its detail route', async ({ + contextForPersona, + credentialCleanup, + personaManifest, +}) => { + const page = await newPersonaPage(contextForPersona, 'workspaceAdminMember', credentialCleanup) + const targetWorkspaceId = workspaceId(personaManifest) + const key = uniqueEnvironmentKey('ADMIN_DETAIL_SAVE') + credentialCleanup.register('admin detail secret cleanup', async () => { + assertStatus( + await runSecretApi(page, { kind: 'delete-workspace', workspaceId: targetWorkspaceId, key }), + 200 + ) + }) + const arranged = await runSecretApi(page, { + kind: 'upsert-workspace', + workspaceId: targetWorkspaceId, + key, + }) + assertStatus(arranged, 200) + const credential = await findEnvironmentCredential(page, targetWorkspaceId, key) + expect(credential).toMatchObject({ status: 200, id: expect.any(String), role: 'admin' }) + + await page.goto( + absoluteE2eUrl( + `/workspace/${targetWorkspaceId}/settings/secrets/${encodeURIComponent(credential.id ?? '')}` + ) + ) + await expect(page.getByText('Workspace secret', { exact: true })).toBeVisible() + const updatedFingerprint = await setSensitiveInput( + page.getByRole('textbox', { name: 'Secret value', exact: true }) + ) + const saveResponse = waitForSameOriginResponse( + page, + 'PUT', + `/api/workspaces/${targetWorkspaceId}/environment` + ) + await page.getByRole('button', { name: 'Save', exact: true }).click() + expect((await saveResponse).status()).toBe(200) + expect( + await runSecretApi(page, { + kind: 'read-workspace', + workspaceId: targetWorkspaceId, + key, + }) + ).toMatchObject({ status: 200, fingerprint: updatedFingerprint }) +}) + +for (const navigationKind of ['in-app Back', 'browser popstate'] as const) { + test(`secret detail protects dirty state for ${navigationKind}`, async ({ + contextForPersona, + credentialCleanup, + personaManifest, + }) => { + const page = await newPersonaPage(contextForPersona, 'workspaceAdminMember', credentialCleanup) + const targetWorkspaceId = workspaceId(personaManifest) + const key = uniqueEnvironmentKey(`DETAIL_GUARD_${navigationKind}`) + credentialCleanup.register('detail guard secret cleanup', async () => { + assertStatus( + await runSecretApi(page, { kind: 'delete-workspace', workspaceId: targetWorkspaceId, key }), + 200 + ) + }) + const arranged = await runSecretApi(page, { + kind: 'upsert-workspace', + workspaceId: targetWorkspaceId, + key, + }) + assertStatus(arranged, 200) + const credential = await findEnvironmentCredential(page, targetWorkspaceId, key) + expect(credential.id).toEqual(expect.any(String)) + const listPath = `/workspace/${targetWorkspaceId}/settings/secrets` + const detailPath = `${listPath}/${encodeURIComponent(credential.id ?? '')}` + + await page.goto(absoluteE2eUrl(listPath)) + await expectWorkspaceSettingsReady(page) + await page.getByRole('button', { name: `Secret actions for ${key}`, exact: true }).click() + await page.getByRole('menuitem', { name: 'View details', exact: true }).click() + await expect(page).toHaveURL(absoluteE2eUrl(detailPath)) + + const value = page.getByRole('textbox', { name: 'Secret value', exact: true }) + const dirtyFingerprint = await setSensitiveInput(value) + const attemptNavigation = async () => { + if (navigationKind === 'in-app Back') { + await page.getByRole('link', { name: 'Secrets', exact: true }).click() + } else { + await page.evaluate(() => window.history.back()) + } + } + + await attemptNavigation() + const dialog = page.getByRole('dialog', { name: 'Unsaved Changes', exact: true }) + await expect(dialog).toBeVisible() + await dialog.getByRole('button', { name: 'Keep editing', exact: true }).click() + await expect(page).toHaveURL(absoluteE2eUrl(detailPath)) + expect(await sensitiveInputFingerprint(value)).toBe(dirtyFingerprint) + + await attemptNavigation() + await expect(dialog).toBeVisible() + await dialog.getByRole('button', { name: 'Discard Changes', exact: true }).click() + await expect(page).toHaveURL(absoluteE2eUrl(listPath)) + expect( + await runSecretApi(page, { + kind: 'read-workspace', + workspaceId: targetWorkspaceId, + key, + }) + ).toMatchObject({ status: 200, fingerprint: arranged.fingerprint }) + }) +} + +test('read member sees masked workspace secret and cannot mutate it', async ({ + contextForPersona, + credentialCleanup, + personaManifest, +}) => { + const adminPage = await newPersonaPage( + contextForPersona, + 'workspaceAdminMember', + credentialCleanup + ) + const readPage = await newPersonaPage(contextForPersona, 'workspaceReadMember', credentialCleanup) + const targetWorkspaceId = workspaceId(personaManifest) + const key = uniqueEnvironmentKey('READ_DENIAL') + credentialCleanup.register('read denial secret cleanup', async () => { + assertStatus( + await runSecretApi(adminPage, { + kind: 'delete-workspace', + workspaceId: targetWorkspaceId, + key, + }), + 200 + ) + }) + assertStatus( + await runSecretApi(adminPage, { + kind: 'upsert-workspace', + workspaceId: targetWorkspaceId, + key, + }), + 200 + ) + const credential = await findEnvironmentCredential(adminPage, targetWorkspaceId, key) + expect(credential.id).toEqual(expect.any(String)) + + await readPage.goto(absoluteE2eUrl(`/workspace/${targetWorkspaceId}/settings/secrets`)) + await expectWorkspaceSettingsReady(readPage) + const value = readPage.getByRole('textbox', { + name: `Workspace secret value ${key}`, + exact: true, + }) + await expect(value).toHaveValue('••••••••••') + await expect(value).toHaveJSProperty('readOnly', true) + await readPage.getByRole('button', { name: `Secret actions for ${key}`, exact: true }).click() + await expect(readPage.getByRole('menuitem', { name: 'Delete', exact: true })).toHaveCount(0) + await readPage.keyboard.press('Escape') + await expect(readPage.getByRole('button', { name: 'Save', exact: true })).toHaveCount(0) + + const detailResponse = await readPage.goto( + absoluteE2eUrl( + `/workspace/${targetWorkspaceId}/settings/secrets/${encodeURIComponent(credential.id ?? '')}` + ) + ) + expect(detailResponse?.status()).toBe(200) + await expect(readPage.getByText('Workspace secret', { exact: true })).toBeVisible() + const detailValue = readPage.getByRole('textbox', { name: 'Secret value', exact: true }) + await expect(detailValue).toHaveValue('••••••••••') + await expect(detailValue).toHaveJSProperty('readOnly', true) + await expect(readPage.getByRole('button', { name: 'Save', exact: true })).toHaveCount(0) + + expect( + await runSecretApi(readPage, { + kind: 'read-workspace', + workspaceId: targetWorkspaceId, + key, + }) + ).toEqual({ status: 200, present: true, withheld: true }) + expect( + await runSecretApi(readPage, { + kind: 'upsert-workspace', + workspaceId: targetWorkspaceId, + key, + }) + ).toMatchObject({ status: 403 }) + expect( + await runSecretApi(readPage, { + kind: 'delete-workspace', + workspaceId: targetWorkspaceId, + key, + }) + ).toEqual({ status: 403 }) +}) + +test('nested secret route rejects cross-workspace credential binding', async ({ + contextForPersona, + credentialCleanup, + personaManifest, +}) => { + const page = await newPersonaPage(contextForPersona, 'paidOrganizationOwner', credentialCleanup) + const urlWorkspaceId = workspaceId(personaManifest, 'team-workspace') + const credentialWorkspaceId = workspaceId(personaManifest, 'team-invitation-workspace') + const key = uniqueEnvironmentKey('CROSS_WORKSPACE') + credentialCleanup.register('cross-workspace secret cleanup', async () => { + assertStatus( + await runSecretApi(page, { + kind: 'delete-workspace', + workspaceId: credentialWorkspaceId, + key, + }), + 200 + ) + }) + assertStatus( + await runSecretApi(page, { + kind: 'upsert-workspace', + workspaceId: credentialWorkspaceId, + key, + }), + 200 + ) + const credential = await findEnvironmentCredential(page, credentialWorkspaceId, key) + expect(credential.id).toEqual(expect.any(String)) + + const response = await page.goto( + absoluteE2eUrl( + `/workspace/${urlWorkspaceId}/settings/secrets/${encodeURIComponent(credential.id ?? '')}` + ) + ) + expect(response?.status()).toBe(404) +}) + +test('permission-group restriction rejects nested secret detail', async ({ + contextForPersona, + credentialCleanup, + personaManifest, +}) => { + const adminPage = await newPersonaPage( + contextForPersona, + 'enterpriseOrganizationAdmin', + credentialCleanup + ) + const restrictedPage = await newPersonaPage( + contextForPersona, + 'permissionGroupRestricted', + credentialCleanup + ) + const targetWorkspaceId = workspaceId(personaManifest, 'enterprise-workspace') + const key = uniqueEnvironmentKey('PERMISSION_GROUP_DETAIL') + credentialCleanup.register('permission-group secret cleanup', async () => { + assertStatus( + await runSecretApi(adminPage, { + kind: 'delete-workspace', + workspaceId: targetWorkspaceId, + key, + }), + 200 + ) + }) + assertStatus( + await runSecretApi(adminPage, { + kind: 'upsert-workspace', + workspaceId: targetWorkspaceId, + key, + }), + 200 + ) + const credential = await findEnvironmentCredential(adminPage, targetWorkspaceId, key) + expect(credential.id).toEqual(expect.any(String)) + + const response = await restrictedPage.goto( + absoluteE2eUrl( + `/workspace/${targetWorkspaceId}/settings/secrets/${encodeURIComponent(credential.id ?? '')}` + ) + ) + expect(response?.status()).toBe(404) +}) + +function assertStatus(result: { status: number }, expected: number): void { + expect(result.status).toBe(expected) +} diff --git a/apps/sim/e2e/support/leak-canary.ts b/apps/sim/e2e/support/leak-canary.ts index 4c4f48c1864..de1d06d99ed 100644 --- a/apps/sim/e2e/support/leak-canary.ts +++ b/apps/sim/e2e/support/leak-canary.ts @@ -4,6 +4,27 @@ import JSZip from 'jszip' import { writeJsonAtomic } from '../fixtures/e2e-world' const BINARY_EXTENSIONS = new Set(['.gif', '.jpeg', '.jpg', '.mp4', '.png', '.webp']) +const BASE64URL_TOKEN = '[A-Za-z0-9_-]{32}' +// Keep these patterns aligned with lib/testing/credential-diagnostic-redaction.ts. +const FORBIDDEN_CREDENTIAL_PATTERNS = [ + { + kind: 'current-api-key', + pattern: new RegExp(`sk-sim-${BASE64URL_TOKEN}(?![A-Za-z0-9_-])`), + }, + { + kind: 'legacy-api-key', + pattern: new RegExp(`sim_(?!e2e_)${BASE64URL_TOKEN}(?![A-Za-z0-9_-])`), + }, + { + kind: 'runtime-secret', + pattern: new RegExp(`E2E_RUNTIME_SECRET_V1_${BASE64URL_TOKEN}(?![A-Za-z0-9_-])`), + }, +] as const + +interface ArtifactEntry { + path: string + kind: 'directory' | 'file' | 'other' +} interface SyntheticSecretCanary { schemaVersion: 1 @@ -64,16 +85,24 @@ export async function assertNoSyntheticSecretLeaks(options: { const excluded = new Set((options.excludedPaths ?? []).map((value) => path.resolve(value))) const violations: string[] = [] - for (const root of options.roots) { + for (const [rootIndex, root] of options.roots.entries()) { if (!existsSync(root)) continue - for (const filePath of listFiles(path.resolve(root), excluded)) { - if (BINARY_EXTENSIONS.has(path.extname(filePath).toLowerCase())) continue - const contents = - path.extname(filePath).toLowerCase() === '.zip' - ? await readZipContents(filePath) - : readFileSync(filePath) - if (secrets.some((secret) => contents.includes(Buffer.from(secret)))) { - violations.push(filePath) + const resolvedRoot = path.resolve(root) + for (const [entryIndex, entry] of listArtifactEntries(resolvedRoot, excluded).entries()) { + const relativeName = path.relative(resolvedRoot, entry.path) || path.basename(entry.path) + const records: Buffer[] = [Buffer.from(relativeName)] + if (entry.kind === 'file' && !BINARY_EXTENSIONS.has(path.extname(entry.path).toLowerCase())) { + records.push( + ...(path.extname(entry.path).toLowerCase() === '.zip' + ? await readZipRecords(entry.path) + : [readFileSync(entry.path)]) + ) + } + const leakKind = records + .map((contents) => credentialLeakKind(contents, secrets)) + .find((kind): kind is string => Boolean(kind)) + if (leakKind) { + violations.push(`artifact-${rootIndex + 1}-${entryIndex + 1}:${leakKind}`) } } } @@ -81,7 +110,7 @@ export async function assertNoSyntheticSecretLeaks(options: { if (violations.length > 0) { throw new Error( `Synthetic E2E secret leaked outside private artifacts:\n${violations - .map((filePath) => `- ${filePath}`) + .map((identifier) => `- ${identifier}`) .join('\n')}` ) } @@ -101,15 +130,24 @@ function readSyntheticSecretCanary(filePath: string): SyntheticSecretCanary { return parsed as SyntheticSecretCanary } -function listFiles(currentPath: string, excluded: ReadonlySet = new Set()): string[] { +function listArtifactEntries( + currentPath: string, + excluded: ReadonlySet = new Set(), + includeCurrent = false +): ArtifactEntry[] { if (excluded.has(currentPath)) return [] const stats = lstatSync(currentPath) - if (stats.isSymbolicLink()) return [] - if (stats.isFile()) return [currentPath] - if (!stats.isDirectory()) return [] - return readdirSync(currentPath).flatMap((name) => - listFiles(path.join(currentPath, name), excluded) - ) + const kind = stats.isFile() ? 'file' : stats.isDirectory() ? 'directory' : 'other' + const current = includeCurrent ? [{ path: currentPath, kind } satisfies ArtifactEntry] : [] + if (stats.isSymbolicLink() || !stats.isDirectory()) { + return includeCurrent ? current : [{ path: currentPath, kind }] + } + return [ + ...current, + ...readdirSync(currentPath).flatMap((name) => + listArtifactEntries(path.join(currentPath, name), excluded, true) + ), + ] } function rmPath(filePath: string): void { @@ -117,16 +155,23 @@ function rmPath(filePath: string): void { if (existsSync(filePath)) throw new Error(`Unscannable E2E artifact still exists: ${filePath}`) } -async function readZipContents(filePath: string): Promise { +function credentialLeakKind(contents: Buffer, secrets: string[]): string | null { + if (secrets.some((secret) => contents.includes(Buffer.from(secret)))) return 'exact-canary' + const text = contents.toString('utf8') + return FORBIDDEN_CREDENTIAL_PATTERNS.find(({ pattern }) => pattern.test(text))?.kind ?? null +} + +async function readZipRecords(filePath: string): Promise { try { const archive = await JSZip.loadAsync(readFileSync(filePath)) + const entries = Object.values(archive.files) const contents = await Promise.all( - Object.values(archive.files) + entries .filter((entry) => !entry.dir) .map(async (entry) => Buffer.from(await entry.async('uint8array'))) ) - return Buffer.concat(contents) + return [...entries.map((entry) => Buffer.from(entry.name)), ...contents] } catch { - throw new Error(`Unable to inspect E2E diagnostic archive: ${filePath}`) + throw new Error('Unable to inspect an E2E diagnostic archive') } } diff --git a/apps/sim/lib/testing/credential-diagnostic-redaction.test.ts b/apps/sim/lib/testing/credential-diagnostic-redaction.test.ts new file mode 100644 index 00000000000..ccd1c68de45 --- /dev/null +++ b/apps/sim/lib/testing/credential-diagnostic-redaction.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { redactCredentialDiagnostic } from './credential-diagnostic-redaction' + +describe('redactCredentialDiagnostic', () => { + it('redacts complete credential values without removing surrounding diagnostics', () => { + const token = Array.from({ length: 32 }, (_, index) => + String.fromCharCode('A'.charCodeAt(0) + (index % 26)) + ).join('') + const diagnostic = [ + `input value: ${['sk', 'sim'].join('-')}-${token}`, + `legacy value: ${['sim', ''].join('_')}${token}`, + `runtime value: ${['E2E', 'RUNTIME', 'SECRET', 'V1'].join('_')}_${token}`, + ].join('\n') + + expect(redactCredentialDiagnostic(diagnostic)).toBe( + [ + 'input value: [credential-redacted]', + 'legacy value: [credential-redacted]', + 'runtime value: [credential-redacted]', + ].join('\n') + ) + }) + + it('does not redact masked, partial, or bounded non-credentials', () => { + const token = 'A'.repeat(32) + const diagnostic = [ + 'sk-sim-••••••••', + `sk-sim-${token.slice(0, 31)}`, + `sk-sim-${token}A`, + `sim_e2e_${token}`, + ].join('\n') + expect(redactCredentialDiagnostic(diagnostic)).toBe(diagnostic) + expect(redactCredentialDiagnostic(undefined)).toBeUndefined() + }) +}) diff --git a/apps/sim/lib/testing/credential-diagnostic-redaction.ts b/apps/sim/lib/testing/credential-diagnostic-redaction.ts new file mode 100644 index 00000000000..908773b73ad --- /dev/null +++ b/apps/sim/lib/testing/credential-diagnostic-redaction.ts @@ -0,0 +1,14 @@ +// Keep these patterns aligned with e2e/support/leak-canary.ts. +const CREDENTIAL_PATTERNS = [ + /sk-sim-[A-Za-z0-9_-]{32}(?![A-Za-z0-9_-])/g, + /sim_(?!e2e_)[A-Za-z0-9_-]{32}(?![A-Za-z0-9_-])/g, + /E2E_RUNTIME_SECRET_V1_[A-Za-z0-9_-]{32}(?![A-Za-z0-9_-])/g, +] as const + +export function redactCredentialDiagnostic(value: string | undefined): string | undefined { + if (value === undefined) return undefined + return CREDENTIAL_PATTERNS.reduce( + (redacted, pattern) => redacted.replace(pattern, '[credential-redacted]'), + value + ) +} diff --git a/apps/sim/playwright.config.ts b/apps/sim/playwright.config.ts index 9432b4c9063..a47e69cd9bd 100644 --- a/apps/sim/playwright.config.ts +++ b/apps/sim/playwright.config.ts @@ -54,10 +54,22 @@ export default defineConfig({ dependencies: ['hosted-billing-chromium-navigation'], workers: 2, }, + { + name: 'hosted-billing-chromium-credentials', + testMatch: '**/settings/credentials/**/*.spec.ts', + dependencies: ['hosted-billing-chromium-authorization'], + fullyParallel: false, + workers: 1, + use: { + trace: 'off', + screenshot: 'off', + video: 'off', + }, + }, { name: 'hosted-billing-chromium-workflows', testMatch: ['**/settings/smoke/authenticated.spec.ts', '**/settings/workflows/**/*.spec.ts'], - dependencies: ['hosted-billing-chromium-authorization'], + dependencies: ['hosted-billing-chromium-credentials'], fullyParallel: false, workers: 1, }, From 3fe4d947b25b65b0d020a60ccfafc33ec5100228 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Wed, 22 Jul 2026 05:27:23 -0700 Subject: [PATCH 2/4] fix(e2e): address credential review findings Co-authored-by: Cursor --- .../secrets-manager/secrets-manager.tsx | 2 +- apps/sim/e2e/settings/credentials/helpers.ts | 55 ++++++++++++------- apps/sim/lib/credentials/access.test.ts | 35 +++++++++++- apps/sim/lib/credentials/access.ts | 15 +++-- 4 files changed, 80 insertions(+), 27 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx index fd84f6c14ef..4abd8860b15 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx @@ -900,7 +900,7 @@ export function SecretsManager() { /> {hasContent ? ( copyName(envVar.key)} onDelete={() => removeEnvVar(originalIndex)} /> diff --git a/apps/sim/e2e/settings/credentials/helpers.ts b/apps/sim/e2e/settings/credentials/helpers.ts index 12dcec27778..f3d3a1fbcc2 100644 --- a/apps/sim/e2e/settings/credentials/helpers.ts +++ b/apps/sim/e2e/settings/credentials/helpers.ts @@ -151,6 +151,7 @@ export async function runSecretApi( const headers = { 'content-type': 'application/json' } if (operation.kind === 'read-workspace') { + // boundary-raw-fetch: E2E browser-resident credential operation prevents plaintext crossing into Playwright const response = await fetch( `/api/workspaces/${encodeURIComponent(operation.workspaceId)}/environment` ) @@ -169,6 +170,7 @@ export async function runSecretApi( if (operation.kind === 'upsert-workspace') { const value = generateSecret() + // boundary-raw-fetch: E2E browser-resident credential operation prevents plaintext crossing into Playwright const response = await fetch( `/api/workspaces/${encodeURIComponent(operation.workspaceId)}/environment`, { @@ -181,6 +183,7 @@ export async function runSecretApi( } if (operation.kind === 'delete-workspace') { + // boundary-raw-fetch: E2E browser-resident credential operation prevents plaintext crossing into Playwright const response = await fetch( `/api/workspaces/${encodeURIComponent(operation.workspaceId)}/environment`, { @@ -192,6 +195,7 @@ export async function runSecretApi( return { status: response.status } } + // boundary-raw-fetch: E2E browser-resident credential operation prevents plaintext crossing into Playwright const readResponse = await fetch('/api/environment') if (readResponse.status !== 200) return { status: readResponse.status } const payload = (await readResponse.json()) as { @@ -212,6 +216,7 @@ export async function runSecretApi( const preservedVariables = Object.fromEntries( Object.entries(variables).map(([key, variable]) => [key, variable.value]) ) + // boundary-raw-fetch: E2E browser-resident credential operation prevents plaintext crossing into Playwright const writeResponse = await fetch('/api/environment', { method: 'POST', headers, @@ -254,6 +259,7 @@ export async function findEnvironmentCredential( return page.evaluate( async ({ workspaceId, key, type }) => { const query = new URLSearchParams({ workspaceId, type }) + // boundary-raw-fetch: E2E browser-resident credential operation prevents plaintext crossing into Playwright const response = await fetch(`/api/credentials?${query}`) if (response.status !== 200) { return { status: response.status, id: null, role: null } @@ -283,6 +289,7 @@ export async function listApiKeys( scope === 'personal' ? '/api/users/me/api-keys' : `/api/workspaces/${encodeURIComponent(workspaceId ?? '')}/api-keys` + // boundary-raw-fetch: E2E browser-resident credential operation prevents plaintext crossing into Playwright const response = await fetch(path) if (response.status !== 200) { return { status: response.status, containsPlaintextField: false, keys: [] } @@ -321,6 +328,7 @@ export async function deleteApiKeyByName( scope === 'personal' ? `/api/users/me/api-keys/${encodeURIComponent(id)}` : `/api/workspaces/${encodeURIComponent(workspaceId ?? '')}/api-keys/${encodeURIComponent(id)}` + // boundary-raw-fetch: E2E browser-resident credential operation prevents plaintext crossing into Playwright return (await fetch(path, { method: 'DELETE' })).status }, { scope, workspaceId: targetWorkspaceId, id: match.id } @@ -332,6 +340,7 @@ export async function workspacePersonalKeyPolicy( targetWorkspaceId: string ): Promise<{ status: number; allowed?: boolean }> { return page.evaluate(async (workspaceId) => { + // boundary-raw-fetch: E2E browser-resident credential operation prevents plaintext crossing into Playwright const response = await fetch(`/api/workspaces/${encodeURIComponent(workspaceId)}`) if (response.status !== 200) return { status: response.status } const payload = (await response.json()) as { @@ -350,14 +359,15 @@ export async function setWorkspacePersonalKeyPolicy( allowed: boolean ): Promise { return page.evaluate( - async ({ workspaceId, allowed }) => - ( - await fetch(`/api/workspaces/${encodeURIComponent(workspaceId)}`, { - method: 'PATCH', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ allowPersonalApiKeys: allowed }), - }) - ).status, + async ({ workspaceId, allowed }) => { + // boundary-raw-fetch: E2E browser-resident credential operation prevents plaintext crossing into Playwright + const response = await fetch(`/api/workspaces/${encodeURIComponent(workspaceId)}`, { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ allowPersonalApiKeys: allowed }), + }) + return response.status + }, { workspaceId: targetWorkspaceId, allowed } ) } @@ -368,14 +378,18 @@ export async function attemptWorkspaceApiKeyCreate( name: string ): Promise { return page.evaluate( - async ({ workspaceId, name }) => - ( - await fetch(`/api/workspaces/${encodeURIComponent(workspaceId)}/api-keys`, { + async ({ workspaceId, name }) => { + // boundary-raw-fetch: E2E browser-resident credential operation prevents plaintext crossing into Playwright + const response = await fetch( + `/api/workspaces/${encodeURIComponent(workspaceId)}/api-keys`, + { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name, source: 'settings' }), - }) - ).status, + } + ) + return response.status + }, { workspaceId: targetWorkspaceId, name } ) } @@ -386,13 +400,14 @@ export async function attemptWorkspaceApiKeyDelete( keyId: string ): Promise { return page.evaluate( - async ({ workspaceId, keyId }) => - ( - await fetch( - `/api/workspaces/${encodeURIComponent(workspaceId)}/api-keys/${encodeURIComponent(keyId)}`, - { method: 'DELETE' } - ) - ).status, + async ({ workspaceId, keyId }) => { + // boundary-raw-fetch: E2E browser-resident credential operation prevents plaintext crossing into Playwright + const response = await fetch( + `/api/workspaces/${encodeURIComponent(workspaceId)}/api-keys/${encodeURIComponent(keyId)}`, + { method: 'DELETE' } + ) + return response.status + }, { workspaceId: targetWorkspaceId, keyId } ) } diff --git a/apps/sim/lib/credentials/access.test.ts b/apps/sim/lib/credentials/access.test.ts index fcba37053f8..284bc8cf3f2 100644 --- a/apps/sim/lib/credentials/access.test.ts +++ b/apps/sim/lib/credentials/access.test.ts @@ -74,7 +74,17 @@ describe('getCredentialActorContext', () => { }) it('does not derive credential admin on personal env credentials', async () => { - dbState.results = [[{ id: 'c1', workspaceId: 'ws', type: 'env_personal' }], []] + dbState.results = [ + [ + { + id: 'c1', + workspaceId: 'ws', + type: 'env_personal', + envOwnerUserId: 'owner-user', + }, + ], + [], + ] mockCheckWorkspaceAccess.mockResolvedValue(workspaceAdminAccess) const ctx = await getCredentialActorContext('c1', 'admin-user') @@ -82,6 +92,29 @@ describe('getCredentialActorContext', () => { expect(ctx.isAdmin).toBe(false) }) + it('treats a personal env credential owner as admin without a membership row', async () => { + dbState.results = [ + [ + { + id: 'c1', + workspaceId: 'ws', + type: 'env_personal', + envOwnerUserId: 'owner-user', + }, + ], + [], + ] + mockCheckWorkspaceAccess.mockResolvedValue({ + hasAccess: true, + canWrite: false, + canAdmin: false, + }) + + const ctx = await getCredentialActorContext('c1', 'owner-user') + + expect(ctx.isAdmin).toBe(true) + }) + it('is not admin for a non-admin without membership', async () => { dbState.results = [[{ id: 'c1', workspaceId: 'ws', type: 'oauth' }], []] mockCheckWorkspaceAccess.mockResolvedValue({ diff --git a/apps/sim/lib/credentials/access.ts b/apps/sim/lib/credentials/access.ts index 1fd5728982a..ee184716d03 100644 --- a/apps/sim/lib/credentials/access.ts +++ b/apps/sim/lib/credentials/access.ts @@ -91,11 +91,16 @@ export async function getCredentialActorContext( ) .limit(1) - const isAdmin = deriveCredentialAdmin({ - credentialType: credentialRow.type, - memberRole: memberRow?.role, - workspaceCanAdmin: workspaceAccess.canAdmin, - }) + // Ownership remains authoritative if a personal credential's membership row is missing. + const isPersonalOwner = + credentialRow.type === 'env_personal' && credentialRow.envOwnerUserId === userId + const isAdmin = + isPersonalOwner || + deriveCredentialAdmin({ + credentialType: credentialRow.type, + memberRole: memberRow?.role, + workspaceCanAdmin: workspaceAccess.canAdmin, + }) return { credential: credentialRow, From bef4ac94edb4a172a7ee2ca14a354476ea0a3f51 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Wed, 22 Jul 2026 05:33:18 -0700 Subject: [PATCH 3/4] style(e2e): format credential helper Co-authored-by: Cursor --- apps/sim/e2e/settings/credentials/helpers.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/apps/sim/e2e/settings/credentials/helpers.ts b/apps/sim/e2e/settings/credentials/helpers.ts index f3d3a1fbcc2..65400636af9 100644 --- a/apps/sim/e2e/settings/credentials/helpers.ts +++ b/apps/sim/e2e/settings/credentials/helpers.ts @@ -380,14 +380,11 @@ export async function attemptWorkspaceApiKeyCreate( return page.evaluate( async ({ workspaceId, name }) => { // boundary-raw-fetch: E2E browser-resident credential operation prevents plaintext crossing into Playwright - const response = await fetch( - `/api/workspaces/${encodeURIComponent(workspaceId)}/api-keys`, - { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ name, source: 'settings' }), - } - ) + const response = await fetch(`/api/workspaces/${encodeURIComponent(workspaceId)}/api-keys`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name, source: 'settings' }), + }) return response.status }, { workspaceId: targetWorkspaceId, name } From 7387452a94af9f320217733fe355259fc8443d68 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Wed, 22 Jul 2026 05:40:25 -0700 Subject: [PATCH 4/4] fix(settings): prioritize API key load errors Co-authored-by: Cursor --- .../settings/components/api-keys/api-keys.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx index 8065d397f8d..c757d89ff8f 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx @@ -104,12 +104,13 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) { const workspaceKeys = apiKeysData?.workspaceKeys ?? EMPTY_KEYS const personalKeys = apiKeysData?.personalKeys ?? EMPTY_KEYS const conflicts = apiKeysData?.conflicts ?? EMPTY_KEY_NAMES - const isLoading = - isLoadingKeys || - isKeysPlaceholderData || - (showsWorkspaceKeys && (isLoadingSettings || isSettingsPlaceholderData)) const hasLoadError = isKeysError || (showsWorkspaceKeys && isSettingsError) - const dataState = isLoading ? 'loading' : hasLoadError ? 'error' : 'ready' + const isLoading = + !hasLoadError && + (isLoadingKeys || + isKeysPlaceholderData || + (showsWorkspaceKeys && (isLoadingSettings || isSettingsPlaceholderData))) + const dataState = hasLoadError ? 'error' : isLoading ? 'loading' : 'ready' const allowPersonalApiKeys = workspaceSettingsData?.settings?.workspace?.allowPersonalApiKeys ?? true