diff --git a/src/services/bridge/client.ts b/src/services/bridge/client.ts index db4a23aa9..4e68f66c0 100644 --- a/src/services/bridge/client.ts +++ b/src/services/bridge/client.ts @@ -369,7 +369,8 @@ export class BridgeClient { method: string, path: string, body?: unknown, - idempotencyKey?: string, + // undefined → auto-generate; null → endpoint does not accept the header + idempotencyKey?: string | null, ): Promise { const url = `${this.baseUrl}${path}` const headers: Record = { @@ -377,8 +378,10 @@ export class BridgeClient { "Content-Type": "application/json", } - // Bridge rejects Idempotency-Key on GET and DELETE endpoints. - if (!["GET", "DELETE"].includes(method.toUpperCase())) { + // Bridge rejects Idempotency-Key on GET and DELETE endpoints, and on a + // handful of POST resources that disable it explicitly (callers pass + // null for those — see exchangePlaidPublicToken). + if (!["GET", "DELETE"].includes(method.toUpperCase()) && idempotencyKey !== null) { if (idempotencyKey) { headers["Idempotency-Key"] = idempotencyKey } else { @@ -555,10 +558,14 @@ export class BridgeClient { linkToken: string, publicToken: string, ): Promise { + // Bridge explicitly disables Idempotency-Key on this resource (the + // exchange is already idempotent per link token) and 400s when the + // header is present: "Cannot set Idempotency-Key on this request". return this.request( "POST", `/plaid_exchange_public_token/${encodeURIComponent(linkToken)}`, { public_token: publicToken }, + null, ) } diff --git a/test/flash/unit/services/bridge/client.spec.ts b/test/flash/unit/services/bridge/client.spec.ts index d32b4e8dd..a788a85de 100644 --- a/test/flash/unit/services/bridge/client.spec.ts +++ b/test/flash/unit/services/bridge/client.spec.ts @@ -216,3 +216,47 @@ describe("BridgeClient transfer deletion", () => { expect(init.headers["Idempotency-Key"]).toBeUndefined() }) }) + +describe("BridgeClient Plaid exchange idempotency", () => { + const originalFetch = global.fetch + + beforeEach(() => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ message: "ok" }), + } as Response) + }) + + afterEach(() => { + global.fetch = originalFetch + }) + + it("does not send an Idempotency-Key on the plaid public_token exchange", async () => { + // Bridge explicitly disables the header on this resource and 400s when it + // is present ("Cannot set Idempotency-Key on this request") — found live + // on the first production Plaid link (flash-mobile#668 device test). + const client = new BridgeClient() + + await client.exchangePlaidPublicToken("lt_abc", "public-token-1") + + const [url, init] = (global.fetch as jest.Mock).mock.calls[0] + expect(url).toContain("/plaid_exchange_public_token/lt_abc") + expect(init.method).toBe("POST") + expect(init.headers["Idempotency-Key"]).toBeUndefined() + }) + + it("still auto-generates an Idempotency-Key for plaid link-token requests", async () => { + ;(global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: async () => ({ link_token: "lt_abc", expires_at: "later" }), + } as Response) + const client = new BridgeClient() + + await client.createPlaidLinkRequest("cust_1" as never) + + const [, init] = (global.fetch as jest.Mock).mock.calls[0] + expect(init.method).toBe("POST") + expect(init.headers["Idempotency-Key"]).toEqual(expect.any(String)) + expect(init.headers["Idempotency-Key"]).not.toHaveLength(0) + }) +})