// A recording stand-in for the third-party APIs the backend calls on its own: // Telegram, Meta Graph (WhatsApp / Messenger / Instagram), Slack, LINE, SMS // providers, HubSpot, Pipedrive, Jira, Shopify. The backend reaches it through the *_API_BASE overrides in // .env.e2e (see backend/app/core/external_urls.py); each provider is a path prefix. // // Every request is recorded (method, path, query, headers, body, the answer) and // answered like the provider would: // - the smallest success the adapter accepts, by default; // - the provider's own rejection when a payload breaks its published limits // (limits.ts), so a backend that drifts past a real limit fails here too; // - a scripted answer — a failure, or a reply for a provider with no built-in // answers — while one the test registered matches (PUT /__faults). // Tests then assert on what the backend sent ("the bot replied on Telegram with …") // and read values the provider would have learned, such as the webhook secret // Telegram receives in setWebhook. // // Where a provider tells the backend who the account is, the answer is derived // from what the test sent, so every test gets its own account: // Meta tokens `owns:[,…]:` own those nodes (pages, numbers, IG // accounts). Graph refuses such a token a // node another token owns, and debug_token // reports its first id as the page // Meta tokens `…:app=.:…` were issued by the customer's own Meta // app: GET /app answers that id and checks // appsecret_proof against that secret (any // other token is ReplyPier's app) // Meta POST / {webhook_configuration} verifies the override callback the way // Meta does — a GET with hub.verify_token // that must echo hub.challenge — before // accepting it // Meta GET / id = // LINE GET /info userId derived from the channel access token // Slack oauth.v2.access team id and bot token derived from the OAuth code // HubSpot oauth/v1/token an access token derived from the code; introspecting it // (oauth/v1/access-tokens/) gives a hub id from it // Pipedrive oauth/token tokens derived from the code, and this fake as the // company's api_domain; users/me gives a company id // derived from the token // Jira authorize consent granted at once: redirects to redirect_uri with a // fresh code and the state; the token is derived from the // code and accessible-resources gives a site derived from it // Shopify orders the customer's order with the number they searched for // (#1001 if none), shipped with a tracking number // // A call's `action` is the provider's method (Telegram, Slack) or else the last // non-empty path segment (`/Account//Message/` → `Message`). // // Control API (src/fakes/externals.ts): // GET /__calls?provider=&action=&contains=&since= recorded calls, oldest first; // contains = substring of path or body // DELETE /__calls forget every call // PUT /__faults/:id { provider, action?, contains?, status, body?, times? } // DELETE /__faults/:id // // Run: node services/fake-externals/server.ts --port 14020 import { createHash, createHmac } from 'node:crypto' import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' import { rejection } from './limits.ts' export interface RecordedCall { at: string provider: string /** Provider-level operation: Telegram/Slack method name, else the last path segment. */ action: string method: string path: string query: Record /** Lower-cased request headers — e.g. authorization, to tell which account sent it. */ headers: Record body: unknown /** HTTP status the fake answered with. */ status: number /** What the fake answered — e.g. the message_id Telegram assigned. */ response: unknown } export interface Fault { provider: string action?: string /** Only calls whose path or body contains this — scope a fault to one test's account or customer. */ contains?: string status: number body?: unknown /** How many matching calls fail; omitted = until the fault is deleted. */ times?: number } const MAX_CALLS = 5000 const calls: RecordedCall[] = [] const faults = new Map() /** Every node some `owns:` token has claimed — what other tokens are refused. */ const claimed = new Set() let counter = 0 class HttpError extends Error { status: number constructor(status: number, message: string) { super(message) this.status = status } } function send(res: ServerResponse, status: number, body: unknown, headers: Record = {}) { if (status === 204) { res.writeHead(status, headers) res.end() return } res.writeHead(status, { 'content-type': 'application/json', ...headers }) res.end(JSON.stringify(body)) } async function readRaw(req: IncomingMessage): Promise { const chunks: Buffer[] = [] for await (const chunk of req) chunks.push(chunk as Buffer) return Buffer.concat(chunks).toString('utf8') } function parseBody(raw: string, contentType: string): unknown { if (!raw) return null if (contentType.includes('application/x-www-form-urlencoded')) return Object.fromEntries(new URLSearchParams(raw)) try { return JSON.parse(raw) } catch { return raw } } function digest(value: string): string { return createHash('sha256').update(value).digest('hex') } /** A stable numeric id per bot token, so reconnecting the same token finds the same bot. */ function botIdFor(token: string): number { return parseInt(digest(token).slice(0, 10), 16) } function bearer(headers: Record): string { return (headers.authorization ?? '').replace(/^Bearer\s+/i, '') } // ---------- per-provider answers ---------- interface Call { provider: string /** Where the backend reached this fake, e.g. http://127.0.0.1:14020. */ origin: string action: string method: string url: URL headers: Record body: Record | null } interface Answer { status: number body: unknown headers?: Record } const ok = (body: unknown): Answer => ({ status: 200, body }) function telegram(action: string, token: string, body: Record | null): Answer { switch (action) { case 'getMe': { const id = botIdFor(token) return ok({ ok: true, result: { id, is_bot: true, first_name: 'E2E Bot', username: `e2e_bot_${id}` } }) } case 'sendMessage': return ok({ ok: true, result: { message_id: counter, date: Math.floor(Date.now() / 1000), chat: { id: body?.chat_id }, text: body?.text } }) default: // setWebhook, deleteWebhook, sendChatAction, answerCallbackQuery, editMessageReplyMarkup, … return ok({ ok: true, result: true }) } } /** The node ids a Meta token owns, or null for a token that makes no claim. */ function ownedNodes(token: string): string[] | null { const match = /^owns:([^:]+):/.exec(token) if (!match) return null const nodes = match[1].split(',') for (const node of nodes) claimed.add(node) return nodes } function graphError(message: string, code = 100): Answer { return { status: 400, body: { error: { message, type: 'OAuthException', code, fbtrace_id: 'e2e' } } } } /** ReplyPier's own Meta app; its secret mirrors META_APP_SECRET in .env.e2e. */ const REPLYPIER_APP = { id: 'e2e-replypier-app', secret: 'e2e-meta-app-secret' } /** The Meta app that issued a token. */ function appOf(token: string): { id: string; secret: string } { const match = /:app=([^.:]+)\.([^:]+)/.exec(token) return match ? { id: match[1], secret: match[2] } : REPLYPIER_APP } /** Meta checks an override callback before taking it: the GET must echo the challenge. */ async function overrideCallback(config: Record): Promise { const uri = String(config.override_callback_uri ?? '') if (!uri) return ok({ success: true }) // back to the app's own callback const challenge = `e2e-challenge-${counter}` const query = new URLSearchParams({ 'hub.mode': 'subscribe', 'hub.verify_token': String(config.verify_token ?? ''), 'hub.challenge': challenge }) const check = await fetch(`${uri}?${query}`, { signal: AbortSignal.timeout(5_000) }).catch(() => null) if (!check || (await check.text()) !== challenge) { return graphError('The callback URL or verify token couldn\'t be validated. Please verify the provided information or try again later.', 2200) } return ok({ success: true }) } async function graph({ action, method, url, headers, body }: Call): Promise { if (action === 'app' && method === 'GET') { const token = bearer(headers) const app = appOf(token) const proof = url.searchParams.get('appsecret_proof') if (proof !== null && proof !== createHmac('sha256', app.secret).update(token).digest('hex')) { return graphError('Invalid appsecret_proof provided in the API argument') } return ok({ id: app.id, name: `E2E app ${app.id}` }) } if (action === 'debug_token') { const inspected = ownedNodes(url.searchParams.get('input_token') ?? '') return ok({ data: { is_valid: Boolean(inspected), type: 'PAGE', profile_id: inspected?.[0] ?? '' } }) } // `//v21.0/[/]`. A token can't reach a page, number or IG // account another token owns; anything unclaimed (a customer's PSID, `me`) is fine. const owned = ownedNodes(bearer(headers)) const node = url.pathname.split('/').filter(Boolean)[2] ?? '' if (owned && claimed.has(node) && !owned.includes(node)) { return graphError(`Unsupported request - object with ID '${node}' does not exist, cannot be loaded due to missing permissions, or does not support this operation`) } const webhookConfiguration = body?.webhook_configuration if (method === 'POST' && webhookConfiguration && typeof webhookConfiguration === 'object') { return overrideCallback(webhookConfiguration as Record) } if (method !== 'GET') { return ok({ messages: [{ id: `wamid.e2e.${counter}` }], message_id: `m_e2e_${counter}`, recipient_id: body?.recipient ?? null, success: true }) } return ok({ id: action, name: `E2E ${action}`, username: `e2e_${action}`, data: [] }) } function answer(call: Call): Answer | Promise { const { provider, action, headers, body } = call switch (provider) { case 'graph': case 'graph-instagram': return graph(call) case 'slack': if (action === 'oauth.v2.access') { const code = String(body?.code ?? '') return ok({ ok: true, access_token: `xoxb-e2e-${digest(code).slice(0, 12)}`, bot_user_id: 'U0E2EBOT', team: { id: `T${digest(code).slice(0, 10).toUpperCase()}`, name: 'E2E Workspace' } }) } return ok({ ok: true, ts: `${Math.floor(Date.now() / 1000)}.${String(counter).padStart(6, '0')}`, channel: body?.channel ?? 'C0E2E' }) case 'line': // GET /info is the bot's own profile; everything else (push, webhook endpoint) answers an empty 200. return ok(action === 'info' ? { userId: `U${digest(bearer(headers)).slice(0, 32)}`, displayName: 'E2E LINE Bot', basicId: '@e2e' } : {}) case 'twilio': return ok({ sid: `SM${String(counter).padStart(32, '0')}`, status: 'queued' }) case 'plivo': return ok({ message_uuid: [`e2e-${counter}`], api_id: `e2e-${counter}` }) case 'hubspot': return hubspot(call) case 'pipedrive-oauth': return pipedriveOauth(call) case 'pipedrive': return pipedrive(call) case 'jira-auth': return jiraAuth(call) case 'jira': return jira(call) case 'shopify': return shopify(call) default: return ok({}) } } function hubspot({ method, url, body }: Call): Answer { const path = url.pathname if (path.endsWith('/oauth/v1/token')) { const seed = String(body?.code ?? body?.refresh_token ?? '') return ok({ access_token: `hs-${digest(seed).slice(0, 16)}`, refresh_token: `hs-refresh-${digest(seed).slice(0, 8)}`, expires_in: 1800, token_type: 'bearer' }) } const introspected = /\/oauth\/v1\/access-tokens\/([^/]+)$/.exec(path) if (introspected) return ok({ hub_id: parseInt(digest(introspected[1]).slice(0, 7), 16), hub_domain: 'e2e.hubspot.com', token: introspected[1] }) if (path.endsWith('/contacts/batch/upsert')) return ok({ status: 'COMPLETE', results: [{ id: String(counter), new: true }] }) return ok(method === 'GET' ? { results: [] } : { id: String(counter) }) } /** Insert-ordered, so the oldest entry goes first once a map outgrows `max`. */ function remember(map: Map, key: string, value: V, max = 1000) { map.set(key, value) if (map.size > max) map.delete(map.keys().next().value as string) } // Pipedrive: OAuth on pipedrive-oauth (oauth.pipedrive.com); the API on the // company's own host, which the token response names — here, this fake. /** Consent granted at once: back to the app with a fresh code and the state. */ function consentRedirect(url: URL): Answer { const redirect = url.searchParams.get('redirect_uri') if (!redirect) return { status: 400, body: { error: 'invalid_request', error_description: 'redirect_uri is required' } } const back = new URL(redirect) back.searchParams.set('code', `code-${counter}`) back.searchParams.set('state', url.searchParams.get('state') ?? '') return { status: 302, body: {}, headers: { location: back.toString() } } } function pipedriveOauth({ action, url, origin, body }: Call): Answer { if (action === 'authorize') return consentRedirect(url) if (action === 'revoke') return ok({}) const seed = String(body?.code ?? body?.refresh_token ?? '') return ok({ access_token: `pd-${digest(seed).slice(0, 16)}`, refresh_token: `pd-refresh-${digest(seed).slice(0, 8)}`, token_type: 'Bearer', expires_in: 3600, scope: 'base,contacts:full,leads:full', api_domain: `${origin}/pipedrive`, }) } function pipedrive({ method, url, headers, body }: Call): Answer { const path = url.pathname.replace(/^\/pipedrive/, '') if (path === '/api/v1/users/me') { return ok({ success: true, data: { id: 1, company_id: parseInt(digest(bearer(headers)).slice(0, 7), 16), company_name: 'E2E Pipedrive Co' } }) } // Nothing exists yet: searches find nobody, and every create gets a fresh id. if (path.endsWith('/search')) return ok({ success: true, data: { items: [] } }) if (path === '/api/v1/leads' && method === 'GET') return ok({ success: true, data: [] }) if (method === 'POST' && path === '/api/v1/leads') return ok({ success: true, data: { id: `00000000-0000-4000-8000-${String(counter).padStart(12, '0')}`, title: body?.title } }) if (method === 'POST' || method === 'PATCH') return ok({ success: true, data: { id: counter, ...(body ?? {}) } }) return { status: 404, body: { success: false, error: `e2e fake has no answer for ${method} ${path}` } } } // Jira Cloud: OAuth on jira-auth (auth.atlassian.com), the REST API on jira (api.atlassian.com). function jiraAuth({ action, url, body }: Call): Answer { if (action === 'authorize') return consentRedirect(url) // oauth/token, for both the code grant and a refresh. const seed = String(body?.code ?? body?.refresh_token ?? '') return ok({ access_token: `jira-${digest(seed).slice(0, 16)}`, refresh_token: `jira-refresh-${digest(seed).slice(0, 8)}`, token_type: 'Bearer', expires_in: 3600, scope: 'read:jira-work write:jira-work read:jira-user offline_access' }) } const JIRA_PRIORITIES = ['Highest', 'High', 'Medium', 'Low', 'Lowest'].map((name, i) => ({ id: String(i + 1), name })) const JIRA_ISSUE_TYPES = [ { id: '10001', name: 'Task', subtask: false, description: 'A small, distinct piece of work.' }, { id: '10002', name: 'Sub-task', subtask: true, description: 'A part of a task.' }, ] const jiraIssues = new Map }>() function jiraNotFound(message: string): Answer { return { status: 404, body: { errorMessages: [message], errors: {} } } } function jira({ method, url, headers, body }: Call): Answer { if (url.pathname.endsWith('/oauth/token/accessible-resources')) { const site = digest(bearer(headers)).slice(0, 12) return ok([{ id: `cloud-${site}`, url: `https://e2e-${site}.atlassian.net`, name: `e2e-${site}`, scopes: ['read:jira-work', 'write:jira-work'], avatarUrl: '' }]) } // /jira/ex/jira//rest/api/3/ const resource = /\/ex\/jira\/[^/]+\/rest\/api\/3\/(.+)$/.exec(url.pathname)?.[1] ?? '' if (resource === 'project') return ok([{ id: '10000', key: 'SUP', name: 'Support' }]) if (resource.startsWith('project/')) return ok({ id: '10000', key: resource.slice('project/'.length), name: 'Support', issueTypes: JIRA_ISSUE_TYPES }) if (resource === 'priority') return ok(JIRA_PRIORITIES) if (resource === 'issue/createmeta') { return ok({ projects: [{ key: url.searchParams.get('projectKeys'), issuetypes: [{ id: url.searchParams.get('issuetypeIds'), fields: { summary: {}, description: {}, priority: {} } }] }] }) } if (resource === 'issue' && method === 'POST') { const fields = (body?.fields ?? {}) as { project?: { key?: string } } const key = `${fields.project?.key ?? 'SUP'}-${counter}` remember(jiraIssues, key, { key, fields: { ...fields, status: { name: 'To Do' } } }) return { status: 201, body: { id: String(counter), key, self: `${url.origin}${url.pathname}/${counter}` } } } const issueKey = /^issue\/([^/]+)$/.exec(resource)?.[1] if (issueKey) { const issue = jiraIssues.get(issueKey) if (!issue) return jiraNotFound('Issue does not exist or you do not have permission to see it.') if (method === 'PUT') { Object.assign(issue.fields, (body?.fields ?? {}) as Record) return { status: 204, body: null } } return ok({ id: issueKey, ...issue }) } return jiraNotFound(`e2e fake has no answer for ${method} ${resource}`) } // Shopify Admin API: /shopify/.myshopify.com/admin/... interface ShopifyOrder { id: string; number: string; email: string } const shopifyOrders = new Map() function money(amount: string) { return { shopMoney: { amount, currencyCode: 'USD' } } } function shopifyOrderNode(order: ShopifyOrder) { return { id: `gid://shopify/Order/${order.id}`, name: `#${order.number}`, email: order.email, phone: null, createdAt: '2026-10-01T10:00:00Z', processedAt: '2026-10-01T10:00:00Z', updatedAt: '2026-10-02T10:00:00Z', cancelledAt: null, cancelReason: null, displayFinancialStatus: 'PAID', displayFulfillmentStatus: 'FULFILLED', subtotalLineItemsQuantity: 1, currentSubtotalPriceSet: money('40.00'), currentTotalPriceSet: money('45.00'), originalTotalPriceSet: money('45.00'), customer: { id: `gid://shopify/Customer/${order.id}`, firstName: 'E2E', lastName: 'Customer', email: order.email, phone: null }, shippingAddress: { address1: '1 Test Street', address2: null, city: 'Testville', provinceCode: 'CA', zip: '94000', country: 'United States', phone: null, name: 'E2E Customer' }, fulfillments: [{ status: 'SUCCESS', trackingInfo: [{ company: 'UPS', number: `1ZE2E${order.id}`, url: `https://tracking.e2e.test/1ZE2E${order.id}` }] }], lineItems: { edges: [{ node: { id: `gid://shopify/LineItem/${order.id}`, title: 'E2E Mug', name: 'E2E Mug', quantity: 1, originalUnitPriceSet: money('40.00'), variant: { id: 'gid://shopify/ProductVariant/1', title: 'Default', sku: 'E2E-MUG', image: null } } }] }, } } function shopify({ action, body }: Call): Answer { if (action === 'access_token') { const seed = String(body?.subject_token ?? body?.code ?? '') return ok({ access_token: `shpat_${digest(seed).slice(0, 24)}`, scope: 'read_orders,read_products' }) } const query = String(body?.query ?? '') const variables = (body?.variables ?? {}) as Record if (query.includes('query SearchOrders')) { // `email:[ AND name:]`, as the backend builds it. const search = String(variables.query ?? '') const email = /email:(\S+)/.exec(search)?.[1] ?? '' const number = /name:(\S+)/.exec(search)?.[1] ?? '1001' const order = { id: String(parseInt(digest(`${email}#${number}`).slice(0, 8), 16)), number, email } remember(shopifyOrders, order.id, order) return ok({ data: { orders: { edges: email ? [{ node: shopifyOrderNode(order) }] : [], pageInfo: { hasNextPage: false, endCursor: null } } } }) } if (query.includes('query GetOrder')) { const order = shopifyOrders.get(String(variables.id ?? '').split('/').at(-1) ?? '') return ok({ data: { order: order ? shopifyOrderNode(order) : null } }) } return ok({ errors: [{ message: 'e2e fake has no answer for this query' }] }) } /** How each provider says "bad request", so the backend's error handling sees the real shape. */ function providerRejection(provider: string, message: string): Answer { switch (provider) { case 'graph': case 'graph-instagram': return graphError(message) case 'telegram': return { status: 400, body: { ok: false, error_code: 400, description: `Bad Request: ${message}` } } case 'slack': return ok({ ok: false, error: 'msg_too_long', detail: message }) case 'twilio': return { status: 400, body: { code: 21617, message, status: 400 } } default: return { status: 400, body: { message, error: message } } } } // ---------- faults ---------- function matchingFault(provider: string, action: string, haystack: string): Fault | undefined { for (const [id, fault] of faults) { if (fault.provider !== provider) continue if (fault.action && fault.action !== action) continue if (fault.contains && !haystack.includes(fault.contains)) continue if (fault.times !== undefined) { fault.times -= 1 if (fault.times <= 0) faults.delete(id) } return fault } return undefined } // ---------- http ---------- async function route(req: IncomingMessage, res: ServerResponse) { const url = new URL(req.url ?? '/', 'http://fake-externals') const path = url.pathname if (path === '/health') return send(res, 200, { status: 'ok', calls: calls.length, faults: faults.size }) if (path === '/__calls' && req.method === 'GET') { const q = url.searchParams const since = q.get('since') const contains = q.get('contains') return send(res, 200, calls.filter((c) => (!q.get('provider') || c.provider === q.get('provider')) && (!q.get('action') || c.action === q.get('action')) && (!since || c.at >= since) && (!contains || `${c.path}\n${JSON.stringify(c.body)}`.includes(contains)))) } if (path === '/__calls' && req.method === 'DELETE') { calls.length = 0 return send(res, 200, { ok: true }) } const faultPath = /^\/__faults\/([\w.-]+)$/.exec(path) if (faultPath && req.method === 'PUT') { const fault = parseBody(await readRaw(req), 'application/json') as Fault | null if (!fault?.provider || !fault.status) throw new HttpError(400, 'a fault needs provider and status') faults.set(faultPath[1], fault) return send(res, 200, { ok: true }) } if (faultPath && req.method === 'DELETE') { faults.delete(faultPath[1]) return send(res, 200, { ok: true }) } const [, provider = '', ...rest] = path.split('/') const raw = await readRaw(req) const headers = Object.fromEntries(Object.entries(req.headers).map(([k, v]) => [k, Array.isArray(v) ? v.join(', ') : v ?? ''])) const body = parseBody(raw, headers['content-type'] ?? '') as Record | null const method = req.method ?? 'GET' counter += 1 let action = rest.filter(Boolean).at(-1) ?? '' let token = '' if (provider === 'telegram') { // /telegram/bot/ token = (rest[0] ?? '').replace(/^bot/, '') action = rest[1] ?? '' } const fault = matchingFault(provider, action, `${path}\n${raw}`) const rejected = fault ? null : rejection(provider, action, method, body) let result: Answer if (fault) result = { status: fault.status, body: fault.body ?? {} } else if (rejected) result = providerRejection(provider, rejected) else if (provider === 'telegram') result = telegram(action, token, body) else result = await answer({ provider, origin: `http://${headers.host ?? '127.0.0.1'}`, action, method, url, headers, body }) calls.push({ at: new Date().toISOString(), provider, action, method, path, query: Object.fromEntries(url.searchParams), headers, body, status: result.status, response: result.body, }) if (calls.length > MAX_CALLS) calls.splice(0, calls.length - MAX_CALLS) return send(res, result.status, result.body, result.headers) } const portArg = process.argv.indexOf('--port') const port = Number(portArg > -1 ? process.argv[portArg + 1] : 14020) createServer((req, res) => { route(req, res).catch((err: unknown) => { const status = err instanceof HttpError ? err.status : 500 const message = err instanceof Error ? err.message : String(err) console.error(`[fake-externals] ${req.method} ${req.url} -> ${status}: ${message}`) send(res, status, { error: message }) }) }).listen(port, '127.0.0.1', () => console.log(`[fake-externals] listening on http://127.0.0.1:${port}`))