Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 181 additions & 4 deletions packages/deploy/src/connect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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',
Expand All @@ -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')) {
Expand All @@ -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' });
}
Expand All @@ -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[] = [];
Expand Down
82 changes: 65 additions & 17 deletions packages/deploy/src/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
);
Expand Down Expand Up @@ -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 };
}
}
}
}

Expand Down Expand Up @@ -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<string, unknown>;
if (record.connectionMatched === false) {
return false;
}
const oauth = record.oauth;
if (oauth && typeof oauth === 'object' && !Array.isArray(oauth)) {
const oauthRecord = oauth as Record<string, unknown>;
if (oauthRecord.connected === true) {
return true;
}
}
return record.status === 'ready'
|| record.state === 'ready'
|| record.ready === true;
Expand Down
Loading