diff --git a/services/cowork-service/e2e/ecosystem-integration.test.ts b/services/cowork-service/e2e/ecosystem-integration.test.ts new file mode 100644 index 00000000..a2823777 --- /dev/null +++ b/services/cowork-service/e2e/ecosystem-integration.test.ts @@ -0,0 +1,361 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { createServer, type Server } from 'node:http'; + +const runIntegration = Boolean(process.env.INTEGRATION_TESTS); + +const platformBaseUrl = process.env.PLATFORM_SERVICE_URL ?? 'http://localhost:4003/api'; +const coworkBaseUrl = process.env.COWORK_SERVICE_URL ?? 'http://localhost:4009'; +const integrationEmail = process.env.INTEGRATION_TEST_EMAIL; +const integrationPassword = process.env.INTEGRATION_TEST_PASSWORD; +const integrationProductId = 'clawcowork'; + +type JsonRecord = Record; + +function requireIntegrationCredentials(): { email: string; password: string } { + if (!integrationEmail || !integrationPassword) { + throw new Error( + 'Set INTEGRATION_TEST_EMAIL and INTEGRATION_TEST_PASSWORD before running integration tests' + ); + } + return { email: integrationEmail, password: integrationPassword }; +} + +async function requestJson( + url: string, + init: RequestInit = {}, + token?: string +): Promise<{ status: number; data: T }> { + const headers: Record = { + 'Content-Type': 'application/json', + ...(init.headers as Record | undefined), + }; + if (token) headers.authorization = `Bearer ${token}`; + if (!headers['x-product-id'] && url.startsWith(platformBaseUrl)) { + headers['x-product-id'] = integrationProductId; + } + + const response = await fetch(url, { ...init, headers }); + const text = await response.text(); + const data = text ? (JSON.parse(text) as T) : ({} as T); + return { status: response.status, data }; +} + +async function waitFor( + label: string, + callback: () => Promise, + timeoutMs = 45_000, + intervalMs = 2_500 +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await callback()) return; + await new Promise(resolve => setTimeout(resolve, intervalMs)); + } + throw new Error(`Timed out waiting for ${label}`); +} + +describe.runIf(runIntegration)('ecosystem integration (H.1-H.12)', () => { + let token = ''; + let webhookServer: Server | null = null; + const webhookDeliveries: JsonRecord[] = []; + let webhookUrl = ''; + let webhookSubscriptionId = ''; + let marketplaceListingId = ''; + let sandboxFlagInitialEnabled = true; + + beforeAll(async () => { + const creds = requireIntegrationCredentials(); + const login = await requestJson<{ + accessToken: string; + user: { id: string; email: string }; + }>(`${platformBaseUrl}/auth/login`, { + method: 'POST', + body: JSON.stringify({ + email: creds.email, + password: creds.password, + productId: integrationProductId, + }), + }); + + expect(login.status).toBe(200); + expect(login.data.accessToken).toBeTruthy(); + token = login.data.accessToken; + + webhookServer = createServer((req, res) => { + const chunks: Buffer[] = []; + req.on('data', chunk => chunks.push(Buffer.from(chunk))); + req.on('end', () => { + try { + webhookDeliveries.push(JSON.parse(Buffer.concat(chunks).toString('utf8'))); + } catch { + webhookDeliveries.push({ raw: Buffer.concat(chunks).toString('utf8') }); + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); + }); + + await new Promise((resolve, reject) => { + webhookServer!.listen(0, '127.0.0.1', err => (err ? reject(err) : resolve())); + }); + const address = webhookServer.address(); + if (!address || typeof address === 'string') { + throw new Error('Failed to bind webhook test server'); + } + webhookUrl = `http://127.0.0.1:${address.port}/webhook`; + }); + + afterAll(async () => { + if (webhookSubscriptionId) { + await requestJson( + `${platformBaseUrl}/webhooks/subscriptions/${webhookSubscriptionId}`, + { + method: 'DELETE', + }, + token + ).catch(() => undefined); + } + + if (!sandboxFlagInitialEnabled) { + await requestJson( + `${platformBaseUrl}/flags/sandbox_enabled/toggle`, + { + method: 'POST', + }, + token + ).catch(() => undefined); + } + + if (webhookServer) { + await new Promise((resolve, reject) => + webhookServer!.close(err => (err ? reject(err) : resolve())) + ); + } + }); + + it('H.1: product registry contains clawcowork and flags are seeded', async () => { + const products = await requestJson<{ products: Array<{ productId: string }> }>( + `${platformBaseUrl}/products` + ); + expect(products.status).toBe(200); + expect(products.data.products.some(product => product.productId === integrationProductId)).toBe( + true + ); + + const flags = await requestJson<{ flags: Array<{ key: string }> }>( + `${platformBaseUrl}/flags`, + {}, + token + ); + expect(flags.status).toBe(200); + expect(flags.data.flags.length).toBeGreaterThanOrEqual(13); + }); + + it('H.3: login obtains JWT and cowork-service accepts task submission', async () => { + const task = await requestJson<{ id: string }>( + `${coworkBaseUrl}/api/tasks`, + { + method: 'POST', + body: JSON.stringify({ + goal: 'Integration smoke test task', + folder: process.cwd(), + model: 'claude-sonnet-4-20250514', + }), + }, + token + ); + + expect(task.status).toBe(201); + expect(task.data.id).toBeTruthy(); + }); + + it('H.3: enterprise SSO lookup endpoint is available', async () => { + const email = process.env.INTEGRATION_SSO_EMAIL ?? integrationEmail!; + const lookup = await requestJson<{ found: boolean; idp: { protocol?: string } | null }>( + `${platformBaseUrl}/auth/enterprise/lookup?email=${encodeURIComponent(email)}` + ); + + expect(lookup.status).toBe(200); + expect(typeof lookup.data.found).toBe('boolean'); + if (lookup.data.found) { + expect(['saml', 'oidc']).toContain(lookup.data.idp?.protocol); + } + }); + + it('H.4: sandbox_enabled propagates through platform flag polling', async () => { + const before = await requestJson<{ flags: Record }>( + `${platformBaseUrl}/flags/poll`, + {}, + token + ); + expect(before.status).toBe(200); + sandboxFlagInitialEnabled = Boolean(before.data.flags.sandbox_enabled); + + if (sandboxFlagInitialEnabled) { + const toggle = await requestJson( + `${platformBaseUrl}/flags/sandbox_enabled/toggle`, + { + method: 'POST', + }, + token + ); + expect(toggle.status).toBe(200); + } + + await waitFor('sandbox_enabled=false', async () => { + const polled = await requestJson<{ flags: Record }>( + `${platformBaseUrl}/flags/poll`, + {}, + token + ); + return polled.status === 200 && polled.data.flags.sandbox_enabled === false; + }); + }); + + it('H.5: audit entries appear after task submission flush', async () => { + const task = await requestJson<{ id: string }>( + `${coworkBaseUrl}/api/tasks`, + { + method: 'POST', + body: JSON.stringify({ + goal: 'Trigger audit flush integration test', + folder: process.cwd(), + model: 'claude-sonnet-4-20250514', + }), + }, + token + ); + expect(task.status).toBe(201); + + await waitFor( + 'audit entry for clawcowork', + async () => { + const audit = await requestJson<{ records?: JsonRecord[]; entries?: JsonRecord[] }>( + `${platformBaseUrl}/audit?days=7&limit=25`, + {}, + token + ); + const entries = audit.data.records ?? audit.data.entries ?? []; + return audit.status === 200 && entries.length > 0; + }, + 50_000, + 5_000 + ); + }); + + it('H.6: usage limit check returns a verdict for a small daily budget', async () => { + const verdict = await requestJson( + `${coworkBaseUrl}/api/usage/check-limits`, + { + method: 'POST', + body: JSON.stringify({ plan: 'free' }), + }, + token + ); + + expect(verdict.status).toBe(200); + expect('allowed' in verdict.data || 'withinLimits' in verdict.data).toBe(true); + }); + + it('H.8: telemetry metrics are queryable after submitting activity', async () => { + await requestJson( + `${coworkBaseUrl}/api/tasks`, + { + method: 'POST', + body: JSON.stringify({ + goal: 'Trigger telemetry integration test', + folder: process.cwd(), + model: 'claude-sonnet-4-20250514', + }), + }, + token + ); + + await waitFor( + 'telemetry metrics', + async () => { + const metrics = await requestJson( + `${platformBaseUrl}/telemetry/metrics`, + {}, + token + ); + return metrics.status === 200; + }, + 50_000, + 5_000 + ); + }); + + it('H.9: webhook subscription receives a test delivery', async () => { + const created = await requestJson<{ id: string }>( + `${coworkBaseUrl}/api/webhooks`, + { + method: 'POST', + body: JSON.stringify({ + url: webhookUrl, + events: ['user.created', 'task.completed'], + }), + }, + token + ); + expect(created.status).toBe(201); + webhookSubscriptionId = created.data.id; + + const testDelivery = await requestJson( + `${platformBaseUrl}/webhooks/subscriptions/${webhookSubscriptionId}/test`, + { method: 'POST' }, + token + ); + expect(testDelivery.status).toBe(200); + + await waitFor('webhook delivery', async () => webhookDeliveries.length > 0, 20_000, 1_000); + expect(webhookDeliveries.length).toBeGreaterThan(0); + }); + + it('H.10: cowork-service proxies extraction requests', async () => { + const models = await requestJson<{ models: string[] }>(`${coworkBaseUrl}/api/extract/models`); + expect(models.status).toBe(200); + expect(models.data.models.length).toBeGreaterThan(0); + + const extraction = await requestJson(`${coworkBaseUrl}/api/extract`, { + method: 'POST', + body: JSON.stringify({ + text: + process.env.INTEGRATION_EXTRACTION_TEXT ?? + 'Sample extraction payload for Claw Cowork integration testing.', + task: 'entity-extraction', + }), + }); + + expect(extraction.status).toBe(200); + expect(extraction.data).toBeTruthy(); + }); + + it('H.12: marketplace listing install is recorded', async () => { + const listings = await requestJson<{ + items?: Array<{ id: string }>; + listings?: Array<{ id: string }>; + }>(`${coworkBaseUrl}/api/marketplace?limit=10`, {}, token); + expect(listings.status).toBe(200); + + const items = listings.data.items ?? listings.data.listings ?? []; + expect(items.length).toBeGreaterThan(0); + marketplaceListingId = items[0].id; + + const install = await requestJson( + `${coworkBaseUrl}/api/marketplace/${marketplaceListingId}/install`, + { method: 'POST' }, + token + ); + expect([200, 201]).toContain(install.status); + + await waitFor('marketplace install record', async () => { + const installs = await requestJson<{ + items?: Array<{ listingId: string }>; + installs?: Array<{ listingId: string }>; + }>(`${coworkBaseUrl}/api/marketplace/installs`, {}, token); + const records = installs.data.items ?? installs.data.installs ?? []; + return records.some(record => record.listingId === marketplaceListingId); + }); + }); +});