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
13 changes: 10 additions & 3 deletions src/services/bridge/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,16 +369,19 @@ 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<T> {
const url = `${this.baseUrl}${path}`
const headers: Record<string, string> = {
"Api-Key": this.apiKey,
"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 {
Expand Down Expand Up @@ -555,10 +558,14 @@ export class BridgeClient {
linkToken: string,
publicToken: string,
): Promise<PlaidExchangePublicTokenResponse> {
// 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<PlaidExchangePublicTokenResponse>(
"POST",
`/plaid_exchange_public_token/${encodeURIComponent(linkToken)}`,
{ public_token: publicToken },
null,
)
}

Expand Down
44 changes: 44 additions & 0 deletions test/flash/unit/services/bridge/client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
Loading