From e8a218d8e362924f71ee9caa7e951e23162d45f1 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 5 Oct 2026 12:55:16 -0700 Subject: [PATCH] fix(credentials): keep the field-less GitHub App installation out of v2 provider discovery --- .../application/list-credential-providers.ts | 9 +- .../provider-catalog-contract.test.ts | 103 ++++++++++++++++++ .../sim-cli/src/commands/credentials.test.ts | 55 ++++++++++ 3 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 apps/sim/lib/credentials/application/provider-catalog-contract.test.ts diff --git a/apps/sim/lib/credentials/application/list-credential-providers.ts b/apps/sim/lib/credentials/application/list-credential-providers.ts index ded16457d4d..c7ca08e6db3 100644 --- a/apps/sim/lib/credentials/application/list-credential-providers.ts +++ b/apps/sim/lib/credentials/application/list-credential-providers.ts @@ -6,6 +6,7 @@ import { type CredentialProviderCatalogEntry, listCredentialProviderCatalog, } from '@/lib/credentials/application/provider-catalog' +import { GITHUB_INSTALLATION_PROVIDER_ID } from '@/lib/oauth/github-installation-types' import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' export interface ListCredentialProvidersInput { @@ -31,7 +32,13 @@ export const listCredentialProviders = defineAuthorizedWorkspaceUseCase({ throw new OrchestrationError('validation', 'search cannot be empty') } - const providers = await listCredentialProviderCatalog(principal, context) + // A GitHub App installation is connected through Search integrations, never credential + // creation, so it has no create fields and stays out of public discovery. + const providers = (await listCredentialProviderCatalog(principal, context)).filter( + (provider) => + provider.type !== 'service_account' || + provider.providerId !== GITHUB_INSTALLATION_PROVIDER_ID + ) return { providers: search ? providers.filter((provider) => provider.name.toLowerCase().includes(search)) diff --git a/apps/sim/lib/credentials/application/provider-catalog-contract.test.ts b/apps/sim/lib/credentials/application/provider-catalog-contract.test.ts new file mode 100644 index 00000000000..4eaed355f21 --- /dev/null +++ b/apps/sim/lib/credentials/application/provider-catalog-contract.test.ts @@ -0,0 +1,103 @@ +import { createSessionPrincipal } from '@sim/testing/factories/principal.factory' +import { blockVisibilityMock } from '@sim/testing/mocks/block-visibility.mock' +import { workspaceAuthzMock, workspaceAuthzMockFns } from '@sim/testing/mocks/workspace-authz.mock' +import { + workspaceContextMock, + workspaceContextMockFns, +} from '@sim/testing/mocks/workspace-context.mock' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const hoisted = vi.hoisted(() => ({ + allowedIntegrationTypes: vi.fn(), +})) + +vi.mock('@/lib/core/config/block-visibility', () => blockVisibilityMock) +vi.mock('@/lib/workspaces/application/workspace-context', () => workspaceContextMock) +vi.mock('@sim/platform-authz/workspace', () => workspaceAuthzMock) +vi.mock('@/lib/integrations/principal-scope.server', () => ({ + principalUserId: () => 'user-1', + allowedIntegrationTypes: hoisted.allowedIntegrationTypes, + allowedOrganizationIntegrationTypes: hoisted.allowedIntegrationTypes, +})) + +import { v2ListCredentialProvidersContract } from '@/lib/api/contracts/v2/credentials' +import { listCredentialProviders } from '@/lib/credentials/application/list-credential-providers' +import { listCredentialProviderCatalog } from '@/lib/credentials/application/provider-catalog' + +const CLAUDE_PROVIDER_ID = 'claude-platform-service-account' +const GITHUB_INSTALLATION_PROVIDER_ID = 'github-app-installation' +const context = { workspaceId: 'workspace-1', workspaceOrganizationId: null } +const responseSchema = v2ListCredentialProvidersContract.response.schema + +async function listProviders(search?: string) { + const { providers } = await listCredentialProviders.execute({ + principal: createSessionPrincipal(), + input: { workspaceId: 'workspace-1', ...(search ? { search } : {}) }, + }) + return providers +} + +describe('v2 credential provider catalog contract', () => { + beforeEach(() => { + hoisted.allowedIntegrationTypes.mockResolvedValue(null) + workspaceContextMockFns.mockLoadActiveWorkspaceApplicationContext.mockResolvedValue({ + ...context, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + }) + workspaceAuthzMockFns.mockResolveEffectiveWorkspacePermission.mockResolvedValue('read') + }) + + it('presents the full unfiltered catalog as a valid response', async () => { + const providers = await listProviders() + + const parsed = responseSchema.safeParse({ data: providers, nextCursor: null }) + expect(parsed.success ? [] : parsed.error.issues).toEqual([]) + expect( + providers.some( + (provider) => + provider.type === 'service_account' && + provider.providerId === GITHUB_INSTALLATION_PROVIDER_ID + ) + ).toBe(false) + }) + + it('keeps the field-less GitHub installation in the internal catalog for existing credentials', async () => { + const catalog = await listCredentialProviderCatalog(createSessionPrincipal(), context) + const installation = catalog.find( + (provider) => + provider.type === 'service_account' && + provider.providerId === GITHUB_INSTALLATION_PROVIDER_ID + ) + + expect(installation?.fields).toEqual([]) + expect(responseSchema.safeParse({ data: catalog, nextCursor: null }).success).toBe(false) + }) + + it('lists the native Claude Platform provider when filtered', async () => { + const providers = await listProviders('claude') + + expect(responseSchema.safeParse({ data: providers, nextCursor: null }).success).toBe(true) + expect(providers).toContainEqual( + expect.objectContaining({ + type: 'service_account', + serviceId: CLAUDE_PROVIDER_ID, + providerId: CLAUDE_PROVIDER_ID, + available: true, + requiresClientGeneratedCredentialId: false, + fields: [expect.objectContaining({ id: 'apiToken', required: true, secret: true })], + }) + ) + }) + + it('reports Claude as unavailable, not missing, when integration policy disables it', async () => { + hoisted.allowedIntegrationTypes.mockResolvedValue(new Set(['slack'])) + + const providers = await listProviders() + + expect(responseSchema.safeParse({ data: providers, nextCursor: null }).success).toBe(true) + expect(providers).toContainEqual( + expect.objectContaining({ providerId: CLAUDE_PROVIDER_ID, available: false }) + ) + }) +}) diff --git a/packages/sim-cli/src/commands/credentials.test.ts b/packages/sim-cli/src/commands/credentials.test.ts index 562194f9d03..9d40f11cf0c 100644 --- a/packages/sim-cli/src/commands/credentials.test.ts +++ b/packages/sim-cli/src/commands/credentials.test.ts @@ -112,6 +112,61 @@ describe('credential connection commands', () => { ).rejects.toThrow('unsupported field "extra" for zoom-service-account') expect(mockRequest).toHaveBeenCalledTimes(1) }) + + describe('native Claude Platform service account', () => { + const claude = { + type: 'service_account', + serviceId: 'claude-platform-service-account', + providerId: 'claude-platform-service-account', + available: true, + requiresClientGeneratedCredentialId: false, + fields: [{ id: 'apiToken', required: true, secret: true }], + } + const createArgs = [ + 'node', + 'sim', + 'credentials', + 'create', + 'claude-platform-service-account', + '--name', + 'Code Fixes', + '--credentials', + '{"apiToken":"test-api-token"}', + ] + + it('creates from the unfiltered catalog without requiring a client credential id', async () => { + mockRequest + .mockReset() + .mockResolvedValueOnce({ data: [claude], nextCursor: null }) + .mockResolvedValueOnce({ data: { id: 'credential-1' } }) + + await program().parseAsync(createArgs) + + expect(mockRequest).toHaveBeenNthCalledWith(1, '/api/v2/credentials/providers', { + method: 'GET', + query: { workspaceId: 'ws_local' }, + }) + const [, createRequest] = mockRequest.mock.calls[1] + expect(createRequest.body).toEqual({ + workspaceId: 'ws_local', + type: 'service_account', + providerId: 'claude-platform-service-account', + displayName: 'Code Fixes', + credentials: '{"apiToken":"test-api-token"}', + }) + }) + + it('refuses before creation when workspace policy disables the provider', async () => { + mockRequest + .mockReset() + .mockResolvedValueOnce({ data: [{ ...claude, available: false }], nextCursor: null }) + + await expect(program().parseAsync(createArgs)).rejects.toThrow( + 'Service-account provider "claude-platform-service-account" is not available.' + ) + expect(mockRequest).toHaveBeenCalledTimes(1) + }) + }) }) describe('credentials update --name', () => {