From 2b692c096693400fb60b9001b0a6eaa4a165ecc2 Mon Sep 17 00:00:00 2001 From: Rohith Date: Thu, 24 Sep 2026 03:14:12 +0530 Subject: [PATCH 1/5] Add Connections and Action Consent launch gate --- .github/workflows/ci.yml | 6 +- .../test/connections-launch-fixture.ts | 246 ++++++++++ .../test/connections-launch-provider.ts | 88 ++++ .../test/connections-launch.e2e-spec.ts | 457 ++++++++++++++++++ package.json | 2 + scripts/connections-launch-gate.mjs | 79 +++ scripts/connections-provider-smoke.mjs | 49 ++ 7 files changed, 924 insertions(+), 3 deletions(-) create mode 100644 apps/platform-api/test/connections-launch-fixture.ts create mode 100644 apps/platform-api/test/connections-launch-provider.ts create mode 100644 apps/platform-api/test/connections-launch.e2e-spec.ts create mode 100644 scripts/connections-launch-gate.mjs create mode 100644 scripts/connections-provider-smoke.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 50336f9f..4bde9eb6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -151,7 +151,7 @@ jobs: run: pnpm test launch-gate: - name: First-launch approval gate + name: Connections and Action Consent launch gate runs-on: ubuntu-latest services: postgres: @@ -195,8 +195,8 @@ jobs: - name: Run database migrations run: pnpm db:migrate - - name: Run first-launch approval gate - run: pnpm test:launch + - name: Run Connections and Action Consent launch gate + run: pnpm test:connections-launch build: name: Build diff --git a/apps/platform-api/test/connections-launch-fixture.ts b/apps/platform-api/test/connections-launch-fixture.ts new file mode 100644 index 00000000..09e0bc71 --- /dev/null +++ b/apps/platform-api/test/connections-launch-fixture.ts @@ -0,0 +1,246 @@ +import { createHash, randomUUID } from 'node:crypto' +import { googleAuthorizationScopes } from '@linea/connectors' +import { db, repositories, schema } from '@linea/db' +import { + calculateJwkThumbprint, + exportJWK, + generateKeyPair, + SignJWT, + type JWK, + type KeyLike, +} from 'jose-v5' +import { generateApplicationKey } from '../src/auth/api-key.util' + +type ProofKey = { privateKey: KeyLike; publicJwk: JWK } + +export type LaunchSession = { + externalSubjectId: string + accessToken: string + nonce: string + key: ProofKey +} + +export type LaunchFixture = { + workspaceId: string + applicationId: string + applicationKey: string + googleWorkflowId: string + githubWorkflowId: string + primary: LaunchSession + secondDevice: LaunchSession + otherSubject: LaunchSession +} + +function hash(value: string, encoding: 'hex' | 'base64url'): string { + return createHash('sha256').update(value).digest(encoding) +} + +async function session(input: { + workspaceId: string + applicationId: string + externalSubjectId: string +}): Promise { + const { privateKey, publicKey } = await generateKeyPair('ES256') + const key = { privateKey, publicJwk: await exportJWK(publicKey) } + const accessToken = `lnu_${randomUUID().replaceAll('-', '')}` + const nonce = randomUUID() + await db.insert(schema.endUserSessions).values({ + ...input, + tokenHash: hash(accessToken, 'hex'), + proofJkt: await calculateJwkThumbprint(key.publicJwk, 'sha256'), + nonceHash: hash(nonce, 'hex'), + expiresAt: new Date(Date.now() + 10 * 60_000), + }) + return { externalSubjectId: input.externalSubjectId, accessToken, nonce, key } +} + +async function subject(input: { workspaceId: string; applicationId: string }) { + const [created] = await db + .insert(schema.externalSubjects) + .values({ + workspaceId: input.workspaceId, + issuer: 'https://identity.example.com', + issuerSubject: randomUUID(), + status: 'verified', + verifiedAt: new Date(), + }) + .returning() + await db.insert(schema.externalSubjectApplications).values({ + ...input, + externalSubjectId: created.id, + }) + return created.id +} + +export async function createConnectionsLaunchFixture(): Promise { + const suffix = randomUUID() + const [workspace] = await db + .insert(schema.organizations) + .values({ + name: 'Connections launch gate', + slug: `connections-launch-${suffix}`, + createdAt: new Date(), + }) + .returning() + const [application] = await db + .insert(schema.applications) + .values({ + workspaceId: workspace.id, + environment: 'dev', + displayName: 'Connections launch application', + allowedBrowserOrigins: ['http://127.0.0.1:4173'], + allowedRedirectOrigins: ['http://127.0.0.1:4173'], + oidcIssuer: 'https://identity.example.com', + oidcClientId: `connections-launch-${suffix}`, + oidcAudience: `connections-launch-${suffix}`, + oidcJwksUrl: 'https://identity.example.com/jwks', + connectorAccessPolicy: { + providers: [ + { + provider: 'google', + actionFamilies: ['gmail_read'], + maxScopes: [...googleAuthorizationScopes(['gmail_read'])], + }, + { + provider: 'github', + actionFamilies: ['issues'], + maxScopes: ['read:user', 'repo'], + }, + ], + }, + }) + .returning() + const workflows = new Map() + for (const [provider, operation] of [ + ['google', 'google.gmail.list_messages'], + ['github', 'github.issues.create'], + ]) { + const workflow = await repositories.workflow.createWorkflow(db, { + workspaceId: workspace.id, + name: `${provider} launch workflow`, + slug: `${provider}-launch-${suffix}`, + }) + const contract = + await repositories.workflowContract.createWorkflowContractRevision( + db, + workspace.id, + workflow.id, + { + inputSchema: { + type: 'object', + properties: { + connectionId: { type: 'string' }, + input: { type: 'object' }, + }, + required: ['connectionId', 'input'], + additionalProperties: false, + }, + outputSchema: { type: 'object' }, + }, + ) + if (contract.outcome !== 'created') + throw new Error('Contract creation failed') + const version = await repositories.workflow.createWorkflowVersion(db, { + workflowId: workflow.id, + graph: { + version: 1, + trigger: { type: 'api' }, + entryNodeId: 'action', + nodes: [ + { id: 'action', type: 'connector', config: { operation } }, + { id: 'end', type: 'end', config: {} }, + ], + edges: [{ from: 'action', to: 'end' }], + }, + contentHash: `${provider}-${suffix}`, + workflowContractRevisionId: contract.revision.id, + }) + await repositories.workflow.publishWorkflowVersion( + db, + workflow.id, + version.id, + ) + const binding = + await repositories.applicationWorkflowBinding.putApplicationWorkflowBinding( + db, + workspace.id, + application.id, + workflow.id, + { + workflowContractRevisionId: contract.revision.id, + allowBackendStart: false, + allowEndUserStart: true, + enabled: true, + }, + ) + if (binding.outcome !== 'updated') + throw new Error('Workflow binding failed') + workflows.set(provider, workflow.id) + } + const primarySubjectId = await subject({ + workspaceId: workspace.id, + applicationId: application.id, + }) + const otherSubjectId = await subject({ + workspaceId: workspace.id, + applicationId: application.id, + }) + const generatedKey = generateApplicationKey() + await db.insert(schema.applicationKeys).values({ + workspaceId: workspace.id, + applicationId: application.id, + name: 'Connections launch gate', + scopes: ['audit:read', 'executions:read', 'executions:cancel'], + hashedKey: generatedKey.hashedKey, + keyPrefix: generatedKey.keyPrefix, + }) + const googleWorkflowId = workflows.get('google') + const githubWorkflowId = workflows.get('github') + if (!googleWorkflowId || !githubWorkflowId) + throw new Error('Workflows missing') + return { + workspaceId: workspace.id, + applicationId: application.id, + applicationKey: generatedKey.rawKey, + googleWorkflowId, + githubWorkflowId, + primary: await session({ + workspaceId: workspace.id, + applicationId: application.id, + externalSubjectId: primarySubjectId, + }), + secondDevice: await session({ + workspaceId: workspace.id, + applicationId: application.id, + externalSubjectId: primarySubjectId, + }), + otherSubject: await session({ + workspaceId: workspace.id, + applicationId: application.id, + externalSubjectId: otherSubjectId, + }), + } +} + +export async function sessionHeaders( + baseUrl: string, + session: LaunchSession, + method: string, + path: string, +): Promise> { + const proof = await new SignJWT({ + jti: randomUUID(), + htm: method, + htu: `${baseUrl}${path}`, + iat: Math.floor(Date.now() / 1_000), + nonce: session.nonce, + ath: hash(session.accessToken, 'base64url'), + }) + .setProtectedHeader({ + typ: 'dpop+jwt', + alg: 'ES256', + jwk: session.key.publicJwk, + }) + .sign(session.key.privateKey) + return { Authorization: `DPoP ${session.accessToken}`, DPoP: proof } +} diff --git a/apps/platform-api/test/connections-launch-provider.ts b/apps/platform-api/test/connections-launch-provider.ts new file mode 100644 index 00000000..b5a106ff --- /dev/null +++ b/apps/platform-api/test/connections-launch-provider.ts @@ -0,0 +1,88 @@ +import { createServer, type IncomingMessage } from 'node:http' + +type ProviderRequest = { + authorization: string | undefined + method: string | undefined + path: string + body: string +} + +async function body(request: IncomingMessage): Promise { + const chunks: Uint8Array[] = [] + for await (const chunk of request) { + if (!(chunk instanceof Uint8Array)) throw new Error('Invalid provider body') + chunks.push(chunk) + } + return Buffer.concat(chunks).toString('utf8') +} + +export async function startConnectorApiProvider() { + const requests: ProviderRequest[] = [] + const server = createServer((request, response) => { + void (async () => { + const path = new URL(request.url ?? '/', 'http://127.0.0.1').pathname + const requestBody = await body(request) + requests.push({ + authorization: request.headers.authorization, + method: request.method, + path, + body: requestBody, + }) + if ( + path === '/gmail/v1/users/me/messages' && + request.method === 'GET' && + request.headers.authorization?.startsWith('Bearer google-access-') + ) { + response.writeHead(200, { 'content-type': 'application/json' }).end( + JSON.stringify({ + messages: [{ id: 'message-one', threadId: 'thread-one' }], + resultSizeEstimate: 1, + rawProviderSecret: request.headers.authorization, + }), + ) + return + } + if ( + path === '/repos/octo/demo/issues' && + request.method === 'POST' && + request.headers.authorization?.startsWith('Bearer gho_') + ) { + const input: unknown = JSON.parse(requestBody) + if (!input || typeof input !== 'object' || !('title' in input)) { + response.writeHead(400).end() + return + } + response.writeHead(201, { 'content-type': 'application/json' }).end( + JSON.stringify({ + id: 42, + number: 7, + title: input.title, + state: 'open', + html_url: 'https://github.com/octo/demo/issues/7', + rawProviderSecret: request.headers.authorization, + }), + ) + return + } + response.writeHead(404).end() + })().catch((error: unknown) => { + response.destroy( + error instanceof Error ? error : new Error(String(error)), + ) + }) + }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + const address = server.address() + if (!address || typeof address === 'string') throw new Error('No API address') + return { + baseUrl: `http://127.0.0.1:${address.port}`, + requests, + close: () => + new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())) + }), + } +} diff --git a/apps/platform-api/test/connections-launch.e2e-spec.ts b/apps/platform-api/test/connections-launch.e2e-spec.ts new file mode 100644 index 00000000..d739bb58 --- /dev/null +++ b/apps/platform-api/test/connections-launch.e2e-spec.ts @@ -0,0 +1,457 @@ +import '@linea/config/env' +import { spawn, type ChildProcess } from 'node:child_process' +import { join } from 'node:path' +import { randomUUID } from 'node:crypto' +import type { INestApplication } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { pool } from '@linea/db' +import { + approvalDecisionSchema, + connectionUseSchema, + connectionAuthorizationResponseSchema, + connectionSchema, + connectionsResponseSchema, + endUserConnectorAuditEventSchema, + operatorConnectorAuditEventSchema, + pendingActionIntentSchema, + publicExecutionSchema, +} from '@linea/protocol/resources' +import { paginatedResponseSchema } from '@linea/protocol/shared' +import request from 'supertest' +import type { App } from 'supertest/types' +import { CONNECTION_OAUTH_PROVIDERS } from '../src/connections/connection-oauth-provider' +import { ConnectionsModule } from '../src/connections/connections.module' +import { startTestGithubOAuthProvider } from '../src/connections/test-github-oauth-provider' +import { startTestGoogleProvider } from '../src/connections/test-google-provider' +import { PublicRuntimeModule } from '../src/public-runtime/public-runtime.module' +import { + createConnectionsLaunchFixture, + sessionHeaders, + type LaunchFixture, + type LaunchSession, +} from './connections-launch-fixture' +import { startConnectorApiProvider } from './connections-launch-provider' + +jest.setTimeout(60_000) + +type Worker = { process: ChildProcess; output: string[] } +type RateLimitSnapshot = { + key: string + requestCount: number + expiresAt: Date +} + +function startWorker(name: 'background-worker' | 'execution-worker'): Worker { + const worker = spawn( + process.execPath, + [ + join( + __dirname, + `../../${name}/dist/${name === 'background-worker' ? 'src/' : ''}main.js`, + ), + ], + { cwd: join(__dirname, '../../..'), env: process.env, stdio: 'pipe' }, + ) + const output: string[] = [] + worker.stdout?.on('data', (chunk: Buffer) => output.push(chunk.toString())) + worker.stderr?.on('data', (chunk: Buffer) => output.push(chunk.toString())) + return { process: worker, output } +} + +async function stopWorker(worker: Worker | undefined): Promise { + if ( + !worker || + worker.process.exitCode !== null || + worker.process.signalCode !== null + ) + return + await new Promise((resolve) => { + worker.process.once('exit', () => resolve()) + worker.process.kill() + }) +} + +function workerFailure(workers: Worker[]): string { + return workers + .map((worker) => worker.output.join('').slice(-4_000)) + .join('\n') +} + +describe('Connections and Action Consent launch tracer', () => { + let app: INestApplication + let baseUrl: string + let fixture: LaunchFixture + let google: Awaited> + let github: Awaited> + let apiProvider: Awaited> + let backgroundWorker: Worker | undefined + let executionWorker: Worker | undefined + let googleConnectionId: string + let githubConnectionId: string + let rateLimitBaseline: Map + + beforeAll(async () => { + google = await startTestGoogleProvider() + github = await startTestGithubOAuthProvider() + apiProvider = await startConnectorApiProvider() + process.env.CONNECTION_CREDENTIAL_ACTIVE_KEY = 'connections-launch-v1' + process.env.CONNECTION_CREDENTIAL_KEYS = JSON.stringify({ + 'connections-launch-v1': Buffer.alloc(32, 11).toString('base64'), + }) + process.env.GOOGLE_CONNECTOR_API_BASE_URL = apiProvider.baseUrl + process.env.GITHUB_API_BASE_URL = apiProvider.baseUrl + const rateLimits = await pool.query( + 'SELECT key, request_count AS "requestCount", expires_at AS "expiresAt" FROM end_user_authorization_rate_limits', + ) + rateLimitBaseline = new Map(rateLimits.rows.map((row) => [row.key, row])) + fixture = await createConnectionsLaunchFixture() + const moduleRef = await Test.createTestingModule({ + imports: [ConnectionsModule, PublicRuntimeModule], + }) + .overrideProvider(CONNECTION_OAUTH_PROVIDERS) + .useValue([google.adapter, github.adapter]) + .compile() + app = moduleRef.createNestApplication() + app.setGlobalPrefix('v1') + await app.listen(0) + baseUrl = await app.getUrl() + process.env.CONNECTION_OAUTH_CALLBACK_BASE_URL = baseUrl + }) + + afterAll(async () => { + await stopWorker(backgroundWorker) + await stopWorker(executionWorker) + if (app) await app.close() + if (google) await google.close() + if (github) await github.close() + if (apiProvider) await apiProvider.close() + if (rateLimitBaseline) { + const rateLimits = await pool.query( + 'SELECT key, request_count AS "requestCount", expires_at AS "expiresAt" FROM end_user_authorization_rate_limits', + ) + for (const current of rateLimits.rows) { + const baseline = rateLimitBaseline.get(current.key) + if ( + baseline?.requestCount === current.requestCount && + baseline.expiresAt.getTime() === current.expiresAt.getTime() + ) + continue + if (baseline) { + await pool.query( + 'UPDATE end_user_authorization_rate_limits SET request_count = $2, expires_at = $3 WHERE key = $1', + [baseline.key, baseline.requestCount, baseline.expiresAt], + ) + } else { + await pool.query( + 'DELETE FROM end_user_authorization_rate_limits WHERE key = $1', + [current.key], + ) + } + } + } + if (fixture) { + await pool.query( + 'DELETE FROM approval_requests WHERE workspace_id = $1', + [fixture.workspaceId], + ) + await pool.query('DELETE FROM organizations WHERE id = $1', [ + fixture.workspaceId, + ]) + } + await pool.end() + delete process.env.CONNECTION_CREDENTIAL_ACTIVE_KEY + delete process.env.CONNECTION_CREDENTIAL_KEYS + delete process.env.GOOGLE_CONNECTOR_API_BASE_URL + delete process.env.GITHUB_API_BASE_URL + delete process.env.CONNECTION_OAUTH_CALLBACK_BASE_URL + }) + + async function authorize(provider: 'google' | 'github'): Promise { + const path = '/v1/user/connections/authorizations' + const started = await request(baseUrl) + .post(path) + .set(await sessionHeaders(baseUrl, fixture.primary, 'POST', path)) + .send({ + provider, + returnUri: 'http://127.0.0.1:4173/connections/callback', + }) + .expect(201) + const authorization = connectionAuthorizationResponseSchema.parse( + started.body, + ) + const providerResponse = await fetch(authorization.authorizationUrl, { + redirect: 'manual', + }) + const callback = providerResponse.headers.get('location') + if (!callback) throw new Error(`${provider} omitted its callback`) + const completed = await fetch(callback, { redirect: 'manual' }) + const returnLocation = completed.headers.get('location') + if (!returnLocation) + throw new Error(`${provider} omitted its return location`) + expect(new URL(returnLocation).searchParams.get('status')).toBe('connected') + const listed = await request(baseUrl) + .get('/v1/user/connections') + .set( + await sessionHeaders( + baseUrl, + fixture.primary, + 'GET', + '/v1/user/connections', + ), + ) + .expect(200) + const connection = connectionsResponseSchema + .parse(listed.body) + .data.find((item) => item.provider === provider) + if (!connection) throw new Error(`${provider} Connection was not listed`) + return connection.id + } + + async function startExecution( + session: LaunchSession, + workflowId: string, + connectionId: string, + input: Record, + ) { + const path = '/v1/user/executions' + const response = await request(baseUrl) + .post(path) + .set(await sessionHeaders(baseUrl, session, 'POST', path)) + .set('Idempotency-Key', randomUUID()) + .send({ workflowId, input: { connectionId, input } }) + .expect(202) + return publicExecutionSchema.parse(response.body) + } + + async function waitForExecution( + session: LaunchSession, + executionId: string, + expectedStatus: 'paused' | 'succeeded' | 'failed', + ): Promise { + const path = `/v1/user/executions/${executionId}` + const deadline = Date.now() + 30_000 + while (Date.now() < deadline) { + const response = await request(baseUrl) + .get(path) + .set(await sessionHeaders(baseUrl, session, 'GET', path)) + .expect(200) + const execution = publicExecutionSchema.parse(response.body) + if (execution.status === expectedStatus) return + if (execution.status === 'failed' || execution.status === 'cancelled') { + throw new Error( + `Execution reached ${execution.status}: ${workerFailure([backgroundWorker!, executionWorker!])}`, + ) + } + if ( + backgroundWorker?.process.exitCode !== null || + executionWorker?.process.exitCode !== null + ) { + throw new Error( + `Worker exited: ${workerFailure([backgroundWorker!, executionWorker!])}`, + ) + } + await new Promise((resolve) => setTimeout(resolve, 250)) + } + throw new Error( + `Execution did not reach ${expectedStatus}: ${workerFailure([backgroundWorker!, executionWorker!])}`, + ) + } + + async function expectPublishedOutbox(executionId: string): Promise { + const deadline = Date.now() + 10_000 + while (Date.now() < deadline) { + const messages = await pool.query<{ kind: string; status: string }>( + "SELECT kind, status FROM outbox_messages WHERE workspace_id = $1 AND payload->>'executionId' = $2 AND (kind = 'workflow_execution' OR event_type = 'action_intent.executed')", + [fixture.workspaceId, executionId], + ) + if ( + messages.rows.some( + (message) => + message.kind === 'workflow_execution' && + message.status === 'published', + ) && + messages.rows.some( + (message) => + message.kind === 'public_event' && message.status === 'published', + ) + ) + return + await new Promise((resolve) => setTimeout(resolve, 250)) + } + throw new Error( + 'Transactional execution and public-event outbox did not publish', + ) + } + + it('connects Google and GitHub over OAuth HTTP and isolates the DPoP subject', async () => { + googleConnectionId = await authorize('google') + githubConnectionId = await authorize('github') + const path = '/v1/user/connections' + const hidden = await request(baseUrl) + .get(path) + .set(await sessionHeaders(baseUrl, fixture.otherSubject, 'GET', path)) + .expect(200) + expect(connectionsResponseSchema.parse(hidden.body).data).toEqual([]) + const primary = await request(baseUrl) + .get(path) + .set(await sessionHeaders(baseUrl, fixture.primary, 'GET', path)) + .expect(200) + expect(JSON.stringify(primary.body)).not.toMatch(/google-access-|gho_/) + }) + + it('runs the queued Google read and consent-bound GitHub write through the public API', async () => { + backgroundWorker = startWorker('background-worker') + executionWorker = startWorker('execution-worker') + const googleExecution = await startExecution( + fixture.primary, + fixture.googleWorkflowId, + googleConnectionId, + { maxResults: 1 }, + ) + await waitForExecution(fixture.primary, googleExecution.id, 'succeeded') + expect( + apiProvider.requests.filter( + (item) => item.path === '/gmail/v1/users/me/messages', + ), + ).toHaveLength(1) + const githubExecution = await startExecution( + fixture.primary, + fixture.githubWorkflowId, + githubConnectionId, + { + owner: 'octo', + repository: 'demo', + title: 'Launch gate issue', + body: 'Approved exact content', + labels: [], + assignees: [], + }, + ) + await waitForExecution(fixture.primary, githubExecution.id, 'paused') + const pendingPath = '/v1/user/action-intents' + const pending = await request(baseUrl) + .get(pendingPath) + .set(await sessionHeaders(baseUrl, fixture.primary, 'GET', pendingPath)) + .expect(200) + const [intent] = paginatedResponseSchema(pendingActionIntentSchema) + .parse(pending.body) + .data.filter((item) => item.executionId === githubExecution.id) + if (!intent) throw new Error('Pending GitHub Action Intent was not listed') + expect(JSON.stringify(intent)).not.toMatch(/gho_|rawProviderSecret/) + expect( + apiProvider.requests.filter((item) => item.method === 'POST'), + ).toHaveLength(0) + const hidden = await request(baseUrl) + .get(pendingPath) + .set( + await sessionHeaders(baseUrl, fixture.otherSubject, 'GET', pendingPath), + ) + .expect(200) + expect( + paginatedResponseSchema(pendingActionIntentSchema).parse(hidden.body) + .data, + ).toEqual([]) + const decisionPath = `/v1/user/approval-requests/${intent.approvalRequest.id}/decisions` + const decision = await request(baseUrl) + .post(decisionPath) + .set( + await sessionHeaders( + baseUrl, + fixture.secondDevice, + 'POST', + decisionPath, + ), + ) + .set('Idempotency-Key', randomUUID()) + .send({ decision: 'approved' }) + .expect(201) + expect(approvalDecisionSchema.parse(decision.body).outcome).toBe('approved') + await waitForExecution(fixture.primary, githubExecution.id, 'succeeded') + expect( + apiProvider.requests.filter((item) => item.method === 'POST'), + ).toHaveLength(1) + await expectPublishedOutbox(githubExecution.id) + const executionPath = `/v1/user/executions/${githubExecution.id}` + const result = await request(baseUrl) + .get(executionPath) + .set(await sessionHeaders(baseUrl, fixture.primary, 'GET', executionPath)) + .expect(200) + expect(JSON.stringify(result.body)).not.toMatch(/gho_|rawProviderSecret/) + }) + + it('projects recent use and audit by audience without provider credentials', async () => { + const usesPath = `/v1/user/connections/${githubConnectionId}/uses` + const uses = await request(baseUrl) + .get(usesPath) + .set(await sessionHeaders(baseUrl, fixture.primary, 'GET', usesPath)) + .expect(200) + expect( + paginatedResponseSchema(connectionUseSchema).parse(uses.body).data.length, + ).toBeGreaterThan(0) + expect(JSON.stringify(uses.body)).not.toMatch(/gho_|rawProviderSecret/) + const hidden = await request(baseUrl) + .get(usesPath) + .set(await sessionHeaders(baseUrl, fixture.otherSubject, 'GET', usesPath)) + expect(hidden.status).toBe(404) + const userPath = '/v1/user/audit-events' + const userAudit = await request(baseUrl) + .get(userPath) + .set(await sessionHeaders(baseUrl, fixture.primary, 'GET', userPath)) + .expect(200) + const otherAudit = await request(baseUrl) + .get(userPath) + .set(await sessionHeaders(baseUrl, fixture.otherSubject, 'GET', userPath)) + .expect(200) + expect( + paginatedResponseSchema(endUserConnectorAuditEventSchema).parse( + userAudit.body, + ).data.length, + ).toBeGreaterThan(0) + expect( + paginatedResponseSchema(endUserConnectorAuditEventSchema).parse( + otherAudit.body, + ).data, + ).toEqual([]) + const operatorPath = `/v1/applications/${fixture.applicationId}/audit-events` + const operatorAudit = await request(baseUrl) + .get(operatorPath) + .set('Authorization', `Bearer ${fixture.applicationKey}`) + .expect(200) + expect( + paginatedResponseSchema(operatorConnectorAuditEventSchema).parse( + operatorAudit.body, + ).data.length, + ).toBeGreaterThan(0) + for (const response of [userAudit, operatorAudit]) { + expect(JSON.stringify(response.body)).not.toMatch( + /google-access-|gho_|rawProviderSecret/, + ) + } + }) + + it('revokes the GitHub Connection before another queued side effect can run', async () => { + const path = `/v1/user/connections/${githubConnectionId}` + const revoked = await request(baseUrl) + .delete(path) + .set(await sessionHeaders(baseUrl, fixture.primary, 'DELETE', path)) + .expect(200) + expect(connectionSchema.parse(revoked.body).status).toBe('revoked') + const execution = await startExecution( + fixture.primary, + fixture.githubWorkflowId, + githubConnectionId, + { + owner: 'octo', + repository: 'demo', + title: 'Blocked after revocation', + body: 'Must never reach GitHub', + labels: [], + assignees: [], + }, + ) + await waitForExecution(fixture.primary, execution.id, 'failed') + expect( + apiProvider.requests.filter((item) => item.method === 'POST'), + ).toHaveLength(1) + }) +}) diff --git a/package.json b/package.json index b8e1ac30..4b64f034 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ "typecheck": "turbo typecheck", "test": "turbo test", "test:launch": "node scripts/first-launch-gate.mjs", + "test:connections-launch": "node scripts/connections-launch-gate.mjs", + "test:connections-smoke": "node scripts/connections-provider-smoke.mjs", "db:up": "pnpm --filter @linea/db db:up", "db:down": "pnpm --filter @linea/db db:down", "db:logs": "pnpm --filter @linea/db db:logs", diff --git a/scripts/connections-launch-gate.mjs b/scripts/connections-launch-gate.mjs new file mode 100644 index 00000000..251744ad --- /dev/null +++ b/scripts/connections-launch-gate.mjs @@ -0,0 +1,79 @@ +import { spawnSync } from "node:child_process" +import { existsSync } from "node:fs" + +if (existsSync(".env") && (!process.env.DATABASE_URL || !process.env.REDIS_URL)) + process.loadEnvFile(".env") + +const pnpmCli = process.env.npm_execpath +const pnpmCommand = pnpmCli?.endsWith(".exe") ? pnpmCli : process.execPath +const pnpmArguments = (arguments_) => + pnpmCli?.endsWith(".exe") ? arguments_ : [pnpmCli, ...arguments_] +const checks = [ + ["format", ["format:check"]], + ["lint", ["exec", "turbo", "lint", "--force"]], + ["typecheck", ["exec", "turbo", "typecheck", "--force"]], + ["build", ["exec", "turbo", "build", "--force"]], + ["public contracts", ["check:contracts"]], + ["database repositories", ["--filter", "@linea/db", "test"]], + ["public protocol", ["--filter", "@linea/protocol", "test"]], + ["connector operations", ["--filter", "@linea/connectors", "test"]], + ["queue", ["--filter", "@linea/queue", "test"]], + ["runtime", ["--filter", "@linea/runtime", "test"]], + ["authentication", ["--filter", "@linea/auth", "test"]], + ["browser SDK", ["--filter", "@linea/sdk", "test"]], + ["React SDK", ["--filter", "@linea/sdk-react", "test"]], + [ + "Platform API", + ["--filter", "@linea/platform-api", "exec", "jest", "--runInBand"], + ], + [ + "execution worker", + ["--filter", "@linea/execution-worker", "exec", "jest", "--runInBand"], + ], + [ + "background worker", + ["--filter", "@linea/background-worker", "exec", "jest", "--runInBand"], + ], + [ + "queued Google and GitHub public-boundary tracer", + [ + "--filter", + "@linea/platform-api", + "exec", + "jest", + "--config", + "./test/jest-e2e.json", + "--runInBand", + "connections-launch.e2e-spec.ts", + ], + ], + ["first-launch public boundary", ["test:launch"]], + ["packed browser and server SDK", ["--filter", "@linea/sdk", "test:pack"]], + ["packed React SDK", ["--filter", "@linea/sdk-react", "test:pack"]], +] + +const missing = [ + ...(pnpmCli ? [] : ["pnpm"]), + ...(process.env.DATABASE_URL ? [] : ["DATABASE_URL"]), + ...(process.env.REDIS_URL ? [] : ["REDIS_URL"]), +] +if (missing.length) { + process.stderr.write( + `CONNECTIONS AND ACTION CONSENT LAUNCH GATE: FAIL (missing ${missing.join(", ")})\n` + ) + process.exit(1) +} +for (const [name, arguments_] of checks) { + process.stdout.write(`\nConnections launch gate: ${name}\n`) + const result = spawnSync(pnpmCommand, pnpmArguments(arguments_), { + stdio: "inherit", + env: process.env, + }) + if (result.error || result.status !== 0) { + process.stderr.write( + `\nCONNECTIONS AND ACTION CONSENT LAUNCH GATE: FAIL (${name})\n` + ) + process.exit(result.status ?? 1) + } +} +process.stdout.write("\nCONNECTIONS AND ACTION CONSENT LAUNCH GATE: PASS\n") diff --git a/scripts/connections-provider-smoke.mjs b/scripts/connections-provider-smoke.mjs new file mode 100644 index 00000000..7777fdfd --- /dev/null +++ b/scripts/connections-provider-smoke.mjs @@ -0,0 +1,49 @@ +import { spawnSync } from "node:child_process" + +const pnpmCli = process.env.npm_execpath +const pnpmCommand = pnpmCli?.endsWith(".exe") ? pnpmCli : process.execPath +const pnpmArguments = (arguments_) => + pnpmCli?.endsWith(".exe") ? arguments_ : [pnpmCli, ...arguments_] +const providers = [ + [ + "Google", + "test:google-live", + [ + "GOOGLE_LIVE_ACCESS_TOKEN", + "GOOGLE_LIVE_GMAIL_RECIPIENT", + "GOOGLE_LIVE_CALENDAR_ID", + ], + ], + [ + "GitHub", + "test:github:live", + ["GITHUB_LIVE_TOKEN", "GITHUB_LIVE_REPOSITORY"], + ], +] + +if (!pnpmCli) { + process.stderr.write("CREDENTIALED PROVIDER SMOKE: FAIL (pnpm is required)\n") + process.exit(1) +} +let failed = false +for (const [provider, script, credentials] of providers) { + const missing = credentials.filter((name) => !process.env[name]) + if (missing.length) { + process.stderr.write(`${provider}: FAIL (missing ${missing.join(", ")})\n`) + failed = true + continue + } + const result = spawnSync( + pnpmCommand, + pnpmArguments(["--filter", "@linea/connectors", script]), + { stdio: "inherit", env: process.env } + ) + if (result.error || result.status !== 0) { + process.stderr.write(`${provider}: FAIL\n`) + failed = true + } else process.stdout.write(`${provider}: PASS\n`) +} +process[failed ? "stderr" : "stdout"].write( + `CREDENTIALED PROVIDER SMOKE: ${failed ? "FAIL" : "PASS"}\n` +) +if (failed) process.exit(1) From 98b83fb06bb4b8cdcfce5844e59897dc433712ac Mon Sep 17 00:00:00 2001 From: Rohith Date: Thu, 24 Sep 2026 03:19:06 +0530 Subject: [PATCH 2/5] Reduce launch tracer fixture duplication --- .../test/connections-launch.e2e-spec.ts | 50 +++++-------------- 1 file changed, 12 insertions(+), 38 deletions(-) diff --git a/apps/platform-api/test/connections-launch.e2e-spec.ts b/apps/platform-api/test/connections-launch.e2e-spec.ts index d739bb58..145060d3 100644 --- a/apps/platform-api/test/connections-launch.e2e-spec.ts +++ b/apps/platform-api/test/connections-launch.e2e-spec.ts @@ -35,11 +35,6 @@ import { startConnectorApiProvider } from './connections-launch-provider' jest.setTimeout(60_000) type Worker = { process: ChildProcess; output: string[] } -type RateLimitSnapshot = { - key: string - requestCount: number - expiresAt: Date -} function startWorker(name: 'background-worker' | 'execution-worker'): Worker { const worker = spawn( @@ -59,11 +54,8 @@ function startWorker(name: 'background-worker' | 'execution-worker'): Worker { } async function stopWorker(worker: Worker | undefined): Promise { - if ( - !worker || - worker.process.exitCode !== null || - worker.process.signalCode !== null - ) + if (!worker) return + if (worker.process.exitCode !== null || worker.process.signalCode !== null) return await new Promise((resolve) => { worker.process.once('exit', () => resolve()) @@ -88,7 +80,7 @@ describe('Connections and Action Consent launch tracer', () => { let executionWorker: Worker | undefined let googleConnectionId: string let githubConnectionId: string - let rateLimitBaseline: Map + let rateLimitKeys: string[] beforeAll(async () => { google = await startTestGoogleProvider() @@ -100,10 +92,10 @@ describe('Connections and Action Consent launch tracer', () => { }) process.env.GOOGLE_CONNECTOR_API_BASE_URL = apiProvider.baseUrl process.env.GITHUB_API_BASE_URL = apiProvider.baseUrl - const rateLimits = await pool.query( - 'SELECT key, request_count AS "requestCount", expires_at AS "expiresAt" FROM end_user_authorization_rate_limits', + const rateLimits = await pool.query<{ key: string }>( + 'SELECT key FROM end_user_authorization_rate_limits', ) - rateLimitBaseline = new Map(rateLimits.rows.map((row) => [row.key, row])) + rateLimitKeys = rateLimits.rows.map((row) => row.key) fixture = await createConnectionsLaunchFixture() const moduleRef = await Test.createTestingModule({ imports: [ConnectionsModule, PublicRuntimeModule], @@ -125,29 +117,11 @@ describe('Connections and Action Consent launch tracer', () => { if (google) await google.close() if (github) await github.close() if (apiProvider) await apiProvider.close() - if (rateLimitBaseline) { - const rateLimits = await pool.query( - 'SELECT key, request_count AS "requestCount", expires_at AS "expiresAt" FROM end_user_authorization_rate_limits', + if (rateLimitKeys) { + await pool.query( + 'DELETE FROM end_user_authorization_rate_limits WHERE NOT (key = ANY($1::text[]))', + [rateLimitKeys], ) - for (const current of rateLimits.rows) { - const baseline = rateLimitBaseline.get(current.key) - if ( - baseline?.requestCount === current.requestCount && - baseline.expiresAt.getTime() === current.expiresAt.getTime() - ) - continue - if (baseline) { - await pool.query( - 'UPDATE end_user_authorization_rate_limits SET request_count = $2, expires_at = $3 WHERE key = $1', - [baseline.key, baseline.requestCount, baseline.expiresAt], - ) - } else { - await pool.query( - 'DELETE FROM end_user_authorization_rate_limits WHERE key = $1', - [current.key], - ) - } - } } if (fixture) { await pool.query( @@ -333,9 +307,9 @@ describe('Connections and Action Consent launch tracer', () => { .get(pendingPath) .set(await sessionHeaders(baseUrl, fixture.primary, 'GET', pendingPath)) .expect(200) - const [intent] = paginatedResponseSchema(pendingActionIntentSchema) + const intent = paginatedResponseSchema(pendingActionIntentSchema) .parse(pending.body) - .data.filter((item) => item.executionId === githubExecution.id) + .data.find((item) => item.executionId === githubExecution.id) if (!intent) throw new Error('Pending GitHub Action Intent was not listed') expect(JSON.stringify(intent)).not.toMatch(/gho_|rawProviderSecret/) expect( From 5217193c20f310de015cba24a31260aad0d234e8 Mon Sep 17 00:00:00 2001 From: Rohith Date: Thu, 24 Sep 2026 03:58:12 +0530 Subject: [PATCH 3/5] Fix launch runner compatibility across Node and pnpm installs --- package.json | 1 + packages/sdk-react/test/packed-react.mjs | 5 +++-- packages/sdk/test/packed-browser.mjs | 5 +++-- packages/sdk/test/packed-server.mjs | 5 +++-- pnpm-lock.yaml | 3 +++ scripts/connections-launch-gate.mjs | 10 +++++----- scripts/connections-provider-smoke.mjs | 5 +++-- scripts/first-launch-gate.mjs | 5 +++-- 8 files changed, 24 insertions(+), 15 deletions(-) diff --git a/package.json b/package.json index 4b64f034..2982ec77 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "demo": "pnpm --filter @linea/platform-api demo" }, "devDependencies": { + "dotenv": "17.4.2", "prettier": "^3.8.3", "prettier-plugin-tailwindcss": "^0.8.0", "turbo": "^2.9.18", diff --git a/packages/sdk-react/test/packed-react.mjs b/packages/sdk-react/test/packed-react.mjs index 5578bbd0..064693ce 100644 --- a/packages/sdk-react/test/packed-react.mjs +++ b/packages/sdk-react/test/packed-react.mjs @@ -15,9 +15,10 @@ const repository = resolve(import.meta.dirname, "../../..") const temporary = mkdtempSync(join(tmpdir(), "linea-sdk-react-")) const pnpmCli = process.env.npm_execpath if (!pnpmCli) throw new Error("pnpm executable path is unavailable") -const pnpmCommand = pnpmCli.endsWith(".exe") ? pnpmCli : process.execPath +const pnpmIsJavaScript = /\.(?:c|m)?js$/.test(pnpmCli) +const pnpmCommand = pnpmIsJavaScript ? process.execPath : pnpmCli const pnpmArguments = (arguments_) => - pnpmCli.endsWith(".exe") ? arguments_ : [pnpmCli, ...arguments_] + pnpmIsJavaScript ? [pnpmCli, ...arguments_] : arguments_ const consumers = [ { directory: "react", diff --git a/packages/sdk/test/packed-browser.mjs b/packages/sdk/test/packed-browser.mjs index f47585b5..904a1570 100644 --- a/packages/sdk/test/packed-browser.mjs +++ b/packages/sdk/test/packed-browser.mjs @@ -15,9 +15,10 @@ const repository = resolve(import.meta.dirname, "../../..") const temporary = mkdtempSync(join(tmpdir(), "linea-sdk-browser-")) const pnpmCli = process.env.npm_execpath if (!pnpmCli) throw new Error("pnpm executable path is unavailable") -const pnpmCommand = pnpmCli.endsWith(".exe") ? pnpmCli : process.execPath +const pnpmIsJavaScript = /\.(?:c|m)?js$/.test(pnpmCli) +const pnpmCommand = pnpmIsJavaScript ? process.execPath : pnpmCli const pnpmArguments = (arguments_) => - pnpmCli.endsWith(".exe") ? arguments_ : [pnpmCli, ...arguments_] + pnpmIsJavaScript ? [pnpmCli, ...arguments_] : arguments_ try { execFileSync( pnpmCommand, diff --git a/packages/sdk/test/packed-server.mjs b/packages/sdk/test/packed-server.mjs index fb4c5533..55d34fe8 100644 --- a/packages/sdk/test/packed-server.mjs +++ b/packages/sdk/test/packed-server.mjs @@ -7,9 +7,10 @@ const repository = resolve(import.meta.dirname, "../../..") const temporary = mkdtempSync(join(tmpdir(), "linea-sdk-server-")) const pnpmCli = process.env.npm_execpath if (!pnpmCli) throw new Error("pnpm executable path is unavailable") -const pnpmCommand = pnpmCli.endsWith(".exe") ? pnpmCli : process.execPath +const pnpmIsJavaScript = /\.(?:c|m)?js$/.test(pnpmCli) +const pnpmCommand = pnpmIsJavaScript ? process.execPath : pnpmCli const pnpmArguments = (arguments_) => - pnpmCli.endsWith(".exe") ? arguments_ : [pnpmCli, ...arguments_] + pnpmIsJavaScript ? [pnpmCli, ...arguments_] : arguments_ try { for (const packageName of ["@linea/protocol", "@linea/sdk"]) { execFileSync( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b38b6fe..74017f6d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,9 @@ importers: .: devDependencies: + dotenv: + specifier: 17.4.2 + version: 17.4.2 prettier: specifier: ^3.8.3 version: 3.8.3 diff --git a/scripts/connections-launch-gate.mjs b/scripts/connections-launch-gate.mjs index 251744ad..d43b5fce 100644 --- a/scripts/connections-launch-gate.mjs +++ b/scripts/connections-launch-gate.mjs @@ -1,13 +1,13 @@ import { spawnSync } from "node:child_process" -import { existsSync } from "node:fs" +import { config as loadEnv } from "dotenv" -if (existsSync(".env") && (!process.env.DATABASE_URL || !process.env.REDIS_URL)) - process.loadEnvFile(".env") +loadEnv({ quiet: true }) const pnpmCli = process.env.npm_execpath -const pnpmCommand = pnpmCli?.endsWith(".exe") ? pnpmCli : process.execPath +const pnpmIsJavaScript = /\.(?:c|m)?js$/.test(pnpmCli ?? "") +const pnpmCommand = pnpmIsJavaScript ? process.execPath : pnpmCli const pnpmArguments = (arguments_) => - pnpmCli?.endsWith(".exe") ? arguments_ : [pnpmCli, ...arguments_] + pnpmIsJavaScript ? [pnpmCli, ...arguments_] : arguments_ const checks = [ ["format", ["format:check"]], ["lint", ["exec", "turbo", "lint", "--force"]], diff --git a/scripts/connections-provider-smoke.mjs b/scripts/connections-provider-smoke.mjs index 7777fdfd..c4fc5d3c 100644 --- a/scripts/connections-provider-smoke.mjs +++ b/scripts/connections-provider-smoke.mjs @@ -1,9 +1,10 @@ import { spawnSync } from "node:child_process" const pnpmCli = process.env.npm_execpath -const pnpmCommand = pnpmCli?.endsWith(".exe") ? pnpmCli : process.execPath +const pnpmIsJavaScript = /\.(?:c|m)?js$/.test(pnpmCli ?? "") +const pnpmCommand = pnpmIsJavaScript ? process.execPath : pnpmCli const pnpmArguments = (arguments_) => - pnpmCli?.endsWith(".exe") ? arguments_ : [pnpmCli, ...arguments_] + pnpmIsJavaScript ? [pnpmCli, ...arguments_] : arguments_ const providers = [ [ "Google", diff --git a/scripts/first-launch-gate.mjs b/scripts/first-launch-gate.mjs index bdb2ee01..8d92dcc4 100644 --- a/scripts/first-launch-gate.mjs +++ b/scripts/first-launch-gate.mjs @@ -2,9 +2,10 @@ import { spawnSync } from "node:child_process" const pnpmCli = process.env.npm_execpath if (!pnpmCli) throw new Error("pnpm executable path is unavailable") -const pnpmCommand = pnpmCli.endsWith(".exe") ? pnpmCli : process.execPath +const pnpmIsJavaScript = /\.(?:c|m)?js$/.test(pnpmCli) +const pnpmCommand = pnpmIsJavaScript ? process.execPath : pnpmCli const pnpmArguments = (arguments_) => - pnpmCli.endsWith(".exe") ? arguments_ : [pnpmCli, ...arguments_] + pnpmIsJavaScript ? [pnpmCli, ...arguments_] : arguments_ const jestTest = (packageName, testPath) => [ "--filter", packageName, From fe678a65be377bb0c6a7e6851db823af47c2d0b7 Mon Sep 17 00:00:00 2001 From: Rohith Date: Thu, 24 Sep 2026 04:06:19 +0530 Subject: [PATCH 4/5] Limit launch fixture rate-limit cleanup to its subject --- .../test/connections-launch.e2e-spec.ts | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/apps/platform-api/test/connections-launch.e2e-spec.ts b/apps/platform-api/test/connections-launch.e2e-spec.ts index 145060d3..802a0e98 100644 --- a/apps/platform-api/test/connections-launch.e2e-spec.ts +++ b/apps/platform-api/test/connections-launch.e2e-spec.ts @@ -1,7 +1,7 @@ import '@linea/config/env' import { spawn, type ChildProcess } from 'node:child_process' import { join } from 'node:path' -import { randomUUID } from 'node:crypto' +import { createHash, randomUUID } from 'node:crypto' import type { INestApplication } from '@nestjs/common' import { Test } from '@nestjs/testing' import { pool } from '@linea/db' @@ -80,7 +80,7 @@ describe('Connections and Action Consent launch tracer', () => { let executionWorker: Worker | undefined let googleConnectionId: string let githubConnectionId: string - let rateLimitKeys: string[] + let rateLimitStartBucket: number beforeAll(async () => { google = await startTestGoogleProvider() @@ -92,10 +92,7 @@ describe('Connections and Action Consent launch tracer', () => { }) process.env.GOOGLE_CONNECTOR_API_BASE_URL = apiProvider.baseUrl process.env.GITHUB_API_BASE_URL = apiProvider.baseUrl - const rateLimits = await pool.query<{ key: string }>( - 'SELECT key FROM end_user_authorization_rate_limits', - ) - rateLimitKeys = rateLimits.rows.map((row) => row.key) + rateLimitStartBucket = Math.floor(Date.now() / 60_000) fixture = await createConnectionsLaunchFixture() const moduleRef = await Test.createTestingModule({ imports: [ConnectionsModule, PublicRuntimeModule], @@ -117,13 +114,21 @@ describe('Connections and Action Consent launch tracer', () => { if (google) await google.close() if (github) await github.close() if (apiProvider) await apiProvider.close() - if (rateLimitKeys) { + if (fixture) { + const lastBucket = Math.floor(Date.now() / 60_000) + const rateLimitKeys = Array.from( + { length: lastBucket - rateLimitStartBucket + 1 }, + (_, offset) => + createHash('sha256') + .update( + `runtime:execution:${fixture.primary.externalSubjectId}:${rateLimitStartBucket + offset}`, + ) + .digest('hex'), + ) await pool.query( - 'DELETE FROM end_user_authorization_rate_limits WHERE NOT (key = ANY($1::text[]))', + 'DELETE FROM end_user_authorization_rate_limits WHERE key = ANY($1::text[])', [rateLimitKeys], ) - } - if (fixture) { await pool.query( 'DELETE FROM approval_requests WHERE workspace_id = $1', [fixture.workspaceId], From baa9a5bfebc204fe130315f4721bb55b1cbf30ff Mon Sep 17 00:00:00 2001 From: Rohith Date: Thu, 24 Sep 2026 04:08:28 +0530 Subject: [PATCH 5/5] Assert revoked connection credential deletion --- apps/platform-api/test/connections-launch.e2e-spec.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/platform-api/test/connections-launch.e2e-spec.ts b/apps/platform-api/test/connections-launch.e2e-spec.ts index 802a0e98..92101fd1 100644 --- a/apps/platform-api/test/connections-launch.e2e-spec.ts +++ b/apps/platform-api/test/connections-launch.e2e-spec.ts @@ -415,6 +415,12 @@ describe('Connections and Action Consent launch tracer', () => { .set(await sessionHeaders(baseUrl, fixture.primary, 'DELETE', path)) .expect(200) expect(connectionSchema.parse(revoked.body).status).toBe('revoked') + const stored = await pool.query<{ credential_encrypted: string | null }>( + 'SELECT credential_encrypted FROM connections WHERE id = $1', + [githubConnectionId], + ) + expect(stored.rows).toHaveLength(1) + expect(stored.rows[0]?.credential_encrypted).toBeNull() const execution = await startExecution( fixture.primary, fixture.githubWorkflowId,