diff --git a/packages/deploy/src/connect.test.ts b/packages/deploy/src/connect.test.ts index a329526..b08c31b 100644 --- a/packages/deploy/src/connect.test.ts +++ b/packages/deploy/src/connect.test.ts @@ -148,6 +148,79 @@ test('relayfileIntegrationResolver isConnected accepts ready runtime status as c ); }); +test('relayfileIntegrationResolver isConnected accepts canonical OAuth-connected status before initial sync is ready', async () => { + const resolver = relayfileIntegrationResolver({ + apiUrl: 'https://cloud.example.test', + workspaceId: 'ws-1', + workspaceToken: 'tok', + fetch: async () => okJson({ + provider: 'slack', + configKey: 'slack-relay', + ready: false, + state: 'pending', + connectionMatched: true, + currentConnectionId: 'conn-slack', + oauth: { connected: true } + }) + }); + assert.equal( + await resolver.isConnected({ + workspace: 'ws-1', + provider: 'slack', + expectedConfigKey: 'slack-relay' + }), + true + ); +}); + +test('relayfileIntegrationResolver isConnected rejects OAuth status for a mismatched requested connection', async () => { + const resolver = relayfileIntegrationResolver({ + apiUrl: 'https://cloud.example.test', + workspaceId: 'ws-1', + workspaceToken: 'tok', + fetch: async () => okJson({ + provider: 'slack', + configKey: 'slack-relay', + ready: false, + state: 'pending', + connectionMatched: false, + currentConnectionId: 'conn-other', + oauth: { connected: true } + }) + }); + assert.equal( + await resolver.isConnected({ + workspace: 'ws-1', + provider: 'slack', + expectedConfigKey: 'slack-relay' + }), + false + ); +}); + +test('relayfileIntegrationResolver isConnected ignores legacy connected fields without OAuth or ready status', async () => { + const resolver = relayfileIntegrationResolver({ + apiUrl: 'https://cloud.example.test', + workspaceId: 'ws-1', + workspaceToken: 'tok', + fetch: async () => okJson({ + provider: 'slack', + configKey: 'slack-relay', + connectionMatched: true, + connected: true, + active: true + }) + }); + assert.equal( + await resolver.isConnected({ + workspace: 'ws-1', + provider: 'slack', + expectedConfigKey: 'slack-relay' + }), + false + ); +}); + test('relayfileIntegrationResolver isConnected falls back to workspace scope for legacy default personas', async () => { const urls: string[] = []; const resolver = relayfileIntegrationResolver({ @@ -474,11 +547,65 @@ test('relayfileIntegrationResolver connect resolves when OAuth completes at work assert.deepEqual(opened, ['https://connect.example.test/slack']); assert.deepEqual(statusUrls, [ 'https://cloud.example.test/api/v1/workspaces/ws-runtime/integrations/slack/status?connectionId=conn-slack&scope=deployer_user', + 'https://cloud.example.test/api/v1/workspaces/ws-runtime/integrations/slack/status?scope=deployer_user', 'https://cloud.example.test/api/v1/workspaces/ws-runtime/integrations/slack/status?connectionId=conn-slack&scope=workspace' ]); }); -test('relayfileIntegrationResolver connect ignores fallback rows with a different connectionId', async () => { +test('relayfileIntegrationResolver connect reconciles canonical status when setup-session id differs', async () => { + const statusUrls: string[] = []; + const resolver = relayfileIntegrationResolver({ + apiUrl: 'https://cloud.example.test', + workspaceId: 'ws-1', + workspaceToken: 'tok', + pollIntervalMs: 0, + timeoutMs: 100, + openUrl: () => undefined, + sleep: async () => undefined, + fetch: async (input, init) => { + const url = input.toString(); + if (url.endsWith('/integrations/connect-session')) { + assert.equal(init?.method, 'POST'); + return okJson({ + sessionUrl: 'https://connect.example.test/slack', + connectionId: 'setup-session-id' + }); + } + statusUrls.push(url); + if (url.includes('connectionId=setup-session-id')) { + return okJson({ + provider: 'slack', + configKey: 'slack-relay', + ready: false, + state: 'pending', + connectionMatched: false, + currentConnectionId: 'conn-slack-final', + oauth: { connected: true } + }); + } + return okJson({ + provider: 'slack', + configKey: 'slack-relay', + ready: false, + state: 'pending', + connectionMatched: true, + currentConnectionId: 'conn-slack-final', + oauth: { connected: true } + }); + } + }); + + assert.deepEqual( + await resolver.connect({ workspace: 'ws-runtime', provider: 'slack' }), + { connectionId: 'conn-slack-final' } + ); + assert.deepEqual(statusUrls, [ + 'https://cloud.example.test/api/v1/workspaces/ws-runtime/integrations/slack/status?connectionId=setup-session-id&scope=deployer_user', + 'https://cloud.example.test/api/v1/workspaces/ws-runtime/integrations/slack/status?scope=deployer_user' + ]); +}); + +test('relayfileIntegrationResolver connect rejects canonical status with a different configKey', async () => { const resolver = relayfileIntegrationResolver({ apiUrl: 'https://cloud.example.test', workspaceId: 'ws-1', @@ -487,6 +614,49 @@ test('relayfileIntegrationResolver connect ignores fallback rows with a differen timeoutMs: 1, openUrl: () => undefined, sleep: async () => undefined, + fetch: async (input) => { + const url = input.toString(); + if (url.endsWith('/integrations/connect-session')) { + return okJson({ + sessionUrl: 'https://connect.example.test/slack', + connectionId: 'setup-session-id', + configKey: 'slack-relay' + }); + } + if (url.includes('connectionId=setup-session-id')) { + return okJson({ + provider: 'slack', + configKey: 'slack-relay', + connectionMatched: false, + oauth: { connected: true } + }); + } + return okJson({ + provider: 'slack', + configKey: 'slack-ricky', + connectionMatched: true, + currentConnectionId: 'conn-slack-ricky', + oauth: { connected: true } + }); + } + }); + + await assert.rejects( + resolver.connect({ workspace: 'ws-runtime', provider: 'slack' }), + /Timed out waiting for slack OAuth/ + ); +}); + +test('relayfileIntegrationResolver connect reconciles canonical fallback rows with a different connectionId', async () => { + const statusUrls: string[] = []; + const resolver = relayfileIntegrationResolver({ + apiUrl: 'https://cloud.example.test', + workspaceId: 'ws-1', + workspaceToken: 'tok', + pollIntervalMs: 0, + timeoutMs: 100, + openUrl: () => undefined, + sleep: async () => undefined, fetch: async (input, init) => { const url = input.toString(); if (url.endsWith('/integrations/connect-session')) { @@ -496,6 +666,7 @@ test('relayfileIntegrationResolver connect ignores fallback rows with a differen connectionId: 'conn-slack-new' }); } + statusUrls.push(url); if (url.includes('scope=deployer_user')) { return okJson({ provider: 'slack', status: 'pending' }); } @@ -507,14 +678,20 @@ test('relayfileIntegrationResolver connect ignores fallback rows with a differen } }); - await assert.rejects( - resolver.connect({ + assert.deepEqual( + await resolver.connect({ workspace: 'ws-runtime', provider: 'slack', allowWorkspaceFallback: true }), - /Timed out waiting for slack OAuth/ + { connectionId: 'conn-slack-other' } ); + assert.deepEqual(statusUrls, [ + 'https://cloud.example.test/api/v1/workspaces/ws-runtime/integrations/slack/status?connectionId=conn-slack-new&scope=deployer_user', + 'https://cloud.example.test/api/v1/workspaces/ws-runtime/integrations/slack/status?scope=deployer_user', + 'https://cloud.example.test/api/v1/workspaces/ws-runtime/integrations/slack/status?connectionId=conn-slack-new&scope=workspace', + 'https://cloud.example.test/api/v1/workspaces/ws-runtime/integrations/slack/status?scope=workspace' + ]); }); test('relayfileIntegrationResolver connect sends scope=workspace and scopes status polls (workspace source)', async () => { const bodies: unknown[] = []; diff --git a/packages/deploy/src/connect.ts b/packages/deploy/src/connect.ts index c65921f..e9ec130 100644 --- a/packages/deploy/src/connect.ts +++ b/packages/deploy/src/connect.ts @@ -242,15 +242,18 @@ const fallbackSource = workspaceFallbackSource( while (Date.now() < deadline) { await sleepImpl(opts.pollIntervalMs ?? 2_000); const pollToken = await resolveWorkspaceToken(opts.workspaceToken); - const status = await fetchIntegrationStatusForScope({ + const statusArgs = { fetchImpl, apiUrl, token: pollToken, workspaceId, provider, source: effectiveSource, - ...(sessionId ? { connectionId: sessionId } : {}), io + }; + const status = await fetchIntegrationStatusForScope({ + ...statusArgs, + ...(sessionId ? { connectionId: sessionId } : {}) }); if (statusIsConnectedForSource(status, provider, effectiveSource)) { const connectionId = readConnectionId(status) @@ -260,7 +263,23 @@ const fallbackSource = workspaceFallbackSource( return { connectionId }; } -const fallbackSource = workspaceFallbackSource( + if (sessionId) { + const canonicalStatus = await fetchIntegrationStatusForScope(statusArgs); + if (statusIsConnectedForSource( + canonicalStatus, + provider, + effectiveSource, + sessionConfigKey + )) { + const connectionId = readConnectionId(canonicalStatus) + ?? sessionId + ?? provider; + io?.info(`${provider} connected.`); + return { connectionId }; + } + } + + const fallbackSource = workspaceFallbackSource( effectiveSource, allowWorkspaceFallback === true ); @@ -290,6 +309,31 @@ const fallbackSource = workspaceFallbackSource( io?.info(`${provider} connected.`); return { connectionId }; } + if (sessionId) { + const canonicalFallbackStatus = await fetchIntegrationStatusForScope({ + fetchImpl, + apiUrl, + token: pollToken, + workspaceId, + provider, + source: fallbackSource, + io + }); + if ( + statusIsConnectedForSource( + canonicalFallbackStatus, + provider, + fallbackSource, + sessionConfigKey + ) + ) { + const connectionId = readConnectionId(canonicalFallbackStatus) + ?? sessionId + ?? provider; + io?.info(`${provider} connected.`); + return { connectionId }; + } + } } } @@ -966,25 +1010,29 @@ function statusMatchesExpectedConfigKey(value: unknown, expectedConfigKey?: stri } /** - * A provider counts as connected for deploy only when the cloud's - * runtime-visible status is ready. Pending/syncing rows mean the sandbox will - * not see a ready mounted provider yet, so deploy must prompt/reconnect - * instead of shipping a silently-dead proactive agent. + * A provider counts as connected for deploy when the cloud confirms the + * canonical credential row/backend connection exists. Top-level `ready` still + * means initial sync/writeback are healthy, but OAuth completion can precede + * that readiness and should not force deploy back through OAuth. * - * - `ready` — sync complete, writeback healthy. Fully usable. - * - `pending` — OAuth row exists but runtime-visible sync is not ready. - * - `syncing` — initial sync is still running. - * - `degraded` — sync/writeback is unhealthy enough not to trust deploy. - * - `error` — sync failed or writeback errored. Treat as not-connected - * so the user re-runs OAuth (or fixes the upstream cause). - * - * Legacy fields (`connected`, `active`, `state`, `ready: true`, `oauth.connected`) - * are intentionally narrowed here; deploy is checking runtime readiness, not - * merely OAuth existence. + * When the status route is asked about a specific setup-session id, it returns + * `connectionMatched:false` for a different final connection. Treat that as + * not connected for the exact poll; the caller also performs a canonical + * no-connectionId poll to reconcile successful OAuth completion. */ function isConnectedStatus(value: unknown): boolean { if (!value || typeof value !== 'object' || Array.isArray(value)) return false; const record = value as Record; + if (record.connectionMatched === false) { + return false; + } + const oauth = record.oauth; + if (oauth && typeof oauth === 'object' && !Array.isArray(oauth)) { + const oauthRecord = oauth as Record; + if (oauthRecord.connected === true) { + return true; + } + } return record.status === 'ready' || record.state === 'ready' || record.ready === true;