diff --git a/scripts/__tests__/pipes-generation.spec.mjs b/scripts/__tests__/pipes-generation.spec.mjs new file mode 100644 index 0000000..ee022d3 --- /dev/null +++ b/scripts/__tests__/pipes-generation.spec.mjs @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = fileURLToPath(new URL('../../', import.meta.url)); +const oagen = (...args) => execFileSync('npx', ['--no-install', 'oagen', ...args], { + cwd: repoRoot, + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + timeout: 120_000, +}); + +test('organization and user data providers mount onto Pipes using the real policy', () => { + const operations = JSON.parse(oagen('resolve', '--spec', 'spec/open-api-spec.yaml', '--format', 'json')); + for (const service of ['OrganizationsDataProviders', 'UserManagementDataProviders']) { + const mounted = operations.filter((operation) => operation.service === service); + assert.ok(mounted.length > 0, `expected operations for ${service}`); + for (const operation of mounted) { + assert.equal(operation.mountOn, 'Pipes', `${operation.method} ${operation.path}`); + } + } +}); + +test('Pipes-scoped Node generation puts organization methods and models under src/pipes', (t) => { + const output = mkdtempSync(join(tmpdir(), 'openapi-pipes-generation-')); + t.after(() => rmSync(output, { recursive: true, force: true })); + oagen('generate', '--spec', 'spec/open-api-spec.yaml', '--lang', 'node', + '--namespace', 'workos', '--services', 'Pipes', '--output', output); + + const pipes = readFileSync(join(output, 'src/pipes/pipes.ts'), 'utf8'); + for (const method of [ + 'getOrganizationConnectedAccount', + 'createOrganizationConnectedAccount', + 'updateOrganizationConnectedAccount', + 'deleteOrganizationConnectedAccount', + 'listOrganizationDataProviders', + 'getUserConnectedAccount', + ]) { + assert.match(pipes, new RegExp(`async ${method}\\(`)); + const file = method.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`); + assert.ok(existsSync(join(output, `src/pipes/interfaces/${file}-options.interface.ts`)), method); + } + for (const model of ['connected-account', 'connected-account-input', 'data-integrations-list-response']) { + for (const [folder, suffix] of [['interfaces', 'interface'], ['serializers', 'serializer']]) { + const file = `src/pipes/${folder}/${model}.${suffix}.ts`; + assert.ok(existsSync(join(output, file)), `expected ${file}`); + } + } + assert.doesNotMatch(pipes, /\.\.\/(organizations-data-providers|user-management-data-providers)\//); + assert.equal(existsSync(join(output, 'src/organizations-data-providers')), false); + assert.equal(existsSync(join(output, 'src/user-management-data-providers')), false); +}); diff --git a/scripts/__tests__/sdk-release-metadata.spec.mjs b/scripts/__tests__/sdk-release-metadata.spec.mjs index 2a0dd19..e6ce6bd 100644 --- a/scripts/__tests__/sdk-release-metadata.spec.mjs +++ b/scripts/__tests__/sdk-release-metadata.spec.mjs @@ -7,6 +7,7 @@ import { factsFromCompat, factsFromDiff, groupFacts, + publicScopeFromService, renderChangelogMarkdown, scopesForServices, } from '../sdk-release-metadata.mjs'; @@ -84,6 +85,79 @@ test('scopesForServices maps staged post-mount names to changelog scope keys', ( assert.deepEqual(scopesForServices('AgentsRegistrations,AgentsBlueprintsTokens'), new Set(['agents'])); }); +test('organization and user data providers share the Pipes release scope', () => { + for (const service of ['OrganizationsDataProviders', 'UserManagementDataProviders']) { + assert.equal(publicScopeFromService(service), 'pipes'); + const facts = factsFromDiff({ changes: [{ kind: 'service-added', name: service }] }, EMPTY_INDEXES); + assert.equal(facts[0].scope, 'pipes'); + assert.ok(scopesForServices('Pipes').has(facts[0].scope)); + } +}); + +for (const [name, scope] of [ + ['ConnectedAccount', 'pipes'], + ['ConnectedAccountInput', 'pipes'], + ['ConnectedAccountAuthMethod', 'pipes'], + ['ConnectedAccountConnectionRole', 'pipes'], + ['GetOrganizationConnectedAccountOptions', 'pipes'], + ['Connection', 'sso'], + ['ConnectionType', 'sso'], + ['ConnectApplication', 'connect'], + ['ApplicationCredentials', 'connect'], +]) { + test(`connected-account classification keeps ${name} in ${scope}`, () => { + const [fact] = factsFromDiff({ changes: [{ kind: 'model-added', name }] }, EMPTY_INDEXES); + assert.equal(fact.scope, scope); + const compat = factsFromCompat(compatBreak(`${name}.from_dict`), [], EMPTY_INDEXES); + assert.equal(compat[0].scope, scope); + }); +} + +test('ConnectedAccount model and enum release metadata agree with organization Pipes ownership', () => { + const indexes = buildIndexes([{ + services: [{ + name: 'OrganizationsDataProviders', + operations: [{ name: 'getOrganizationConnectedAccount', response: { kind: 'model', name: 'ConnectedAccount' } }], + }], + models: [{ + name: 'ConnectedAccount', + fields: [{ name: 'auth_method', type: { kind: 'enum', name: 'ConnectedAccountAuthMethod' } }], + }], + enums: [{ name: 'ConnectedAccountAuthMethod', values: [] }], + }]); + const facts = factsFromDiff({ changes: [ + { kind: 'model-added', name: 'ConnectedAccount' }, + { kind: 'enum-added', name: 'ConnectedAccountAuthMethod' }, + ] }, indexes); + for (const fact of facts) { + assert.equal(fact.scope, 'pipes'); + assert.equal(fact.scope_source, 'name_and_ir'); + assert.deepEqual(fact.scope_candidates, ['pipes']); + } + const entries = entriesFromGroups(groupFacts(facts), []); + assert.equal(entries.length, 1); + assert.equal(entries[0].scope, 'pipes'); + assert.equal(entries[0].docs_url, 'https://workos.com/docs/reference/pipes'); +}); + +test('connected-account files are attributed to Pipes rather than Connect', () => { + // Unrelated symbols leave attribution to the file classifier alone. + const facts = factsFromDiff({ changes: [ + { kind: 'model-added', name: 'DataIntegration' }, + { kind: 'model-added', name: 'ConnectApplication' }, + ] }, EMPTY_INDEXES); + const pipesFiles = [ + 'workos/types/connected_account.py', + 'workos/types/connected_account_auth_method.py', + 'Models/ConnectedAccount.cs', + 'src/pipes/interfaces/get-organization-connected-account-options.interface.ts', + ]; + const connectFiles = ['src/connect/interfaces/application.interface.ts']; + const entries = entriesFromGroups(groupFacts(facts), [...pipesFiles, ...connectFiles]); + assert.deepEqual(entries.find((entry) => entry.scope === 'pipes').file_paths, pipesFiles); + assert.deepEqual(entries.find((entry) => entry.scope === 'connect').file_paths, connectFiles); +}); + test('scopesForServices returns null for an empty/absent selection (full generation keeps every scope)', () => { assert.equal(scopesForServices(undefined), null); assert.equal(scopesForServices(''), null); diff --git a/scripts/sdk-release-metadata.mjs b/scripts/sdk-release-metadata.mjs index 7d79bf9..cb1c66c 100644 --- a/scripts/sdk-release-metadata.mjs +++ b/scripts/sdk-release-metadata.mjs @@ -72,6 +72,7 @@ const SERVICE_SCOPE_OVERRIDES = new Map( FeatureFlagsTargets: 'feature_flags', MultiFactorAuthChallenges: 'multi_factor_auth', OrganizationsApiKeys: 'api_keys', + OrganizationsDataProviders: 'pipes', OrganizationsFeatureFlags: 'feature_flags', Permissions: 'authorization', PipesProvider: 'pipes', @@ -417,7 +418,8 @@ export function scopesForServices(servicesArg) { function scopeFromName(name) { if (!name) return 'sdk'; - if (/DataIntegration|Pipe/.test(name)) return 'pipes'; + // Connected accounts belong to Pipes, not the broader Connect* family below. + if (/ConnectedAccount|DataIntegration|Pipe/.test(name)) return 'pipes'; if (/SessionAuthenticate/.test(name)) return 'user_management'; if (/WebhookEndpointEvents|Webhook/.test(name)) return 'webhooks'; if (/^(ApiKey|ExpireApiKey|OrganizationApiKey|UserApiKey)/.test(name)) return 'api_keys'; @@ -494,6 +496,7 @@ function scopeFromFile(path) { if (/directorysync|directory_sync|dsync/.test(normalized)) return 'directory_sync'; if (/radar/.test(normalized)) return 'radar'; if (/vault|vaultobject/.test(normalized)) return 'vault'; + if (/connectedaccount|dataintegration|pipes/.test(normalized)) return 'pipes'; if (/connect|applicationcredential|externalauth|userobject/.test(normalized)) return 'connect'; if (/featureflag|featureflags|feature_flags/.test(normalized)) return 'feature_flags'; if (/multifactorauth|multi_factor_auth|mfa/.test(normalized)) return 'multi_factor_auth'; diff --git a/src/policy/mount-rules.ts b/src/policy/mount-rules.ts index 25276ac..fc693af 100644 --- a/src/policy/mount-rules.ts +++ b/src/policy/mount-rules.ts @@ -77,6 +77,7 @@ export const mountRules: Record = { 'UserManagementOrganizationMembership*': 'OrganizationMembership', // Pipes / Data Providers -> Pipes + OrganizationsDataProviders: 'Pipes', UserManagementDataProviders: 'Pipes', // User Management MFA -> MultiFactorAuth