Skip to content

Commit 11ea7bc

Browse files
authored
🐛 (device-management-kit) [DSDK-1136]: Fix Android Manager API requests rejected with HTTP 400 due to trailing slash in query params (#1484)
2 parents 05b5045 + b06170e commit 11ea7bc

5 files changed

Lines changed: 68 additions & 41 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@ledgerhq/device-management-kit": patch
3+
---
4+
5+
Fix React Native Manager API requests failing with HTTP 400 because the last query parameter value was suffixed with a stray `/` (e.g. `provider=1/`). `DmkNetworkClient` now builds and sends the request URL as a plain string instead of round-tripping through `URL`/`URL.toString()`, which corrupted URLs with a query string on affected React Native versions (facebook/react-native#54242).

packages/device-management-kit/src/api/network/DmkNetworkClient.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,23 @@ describe("DmkNetworkClient", () => {
6060
expect(url.searchParams.has("skip")).toBe(false);
6161
expect(url.searchParams.has("alsoSkip")).toBe(false);
6262
});
63+
64+
it("should pass a plain string URL to fetch with no trailing slash after the query (facebook/react-native#54242)", async () => {
65+
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ ok: true }));
66+
const client = new DmkNetworkClient({ fetch: fetchMock });
67+
68+
await client.get(
69+
"https://manager.api.live.ledger.com/api/get_device_version",
70+
{ params: { target_id: 858783748, provider: 1 } },
71+
);
72+
73+
const [calledUrl] = fetchMock.mock.calls[0]!;
74+
expect(typeof calledUrl).toBe("string");
75+
expect(calledUrl).toBe(
76+
"https://manager.api.live.ledger.com/api/get_device_version?target_id=858783748&provider=1",
77+
);
78+
expect((calledUrl as string).endsWith("/")).toBe(false);
79+
});
6380
});
6481

6582
describe("headers", () => {

packages/device-management-kit/src/api/network/DmkNetworkClient.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ export class DmkNetworkClient {
187187

188188
let response: Response;
189189
try {
190-
response = await this.getFetch()(url.toString(), {
190+
response = await this.getFetch()(url, {
191191
method: config.method,
192192
headers,
193193
body,

packages/device-management-kit/src/api/network/DmkNetworkClientHelpers.test.ts

Lines changed: 36 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -45,23 +45,23 @@ describe("DmkNetworkClientHelpers", () => {
4545
describe("buildUrl", () => {
4646
it("should keep an absolute URL as-is", () => {
4747
const url = buildUrl({ url: "https://api.example.com/items" });
48-
expect(url.toString()).toBe("https://api.example.com/items");
48+
expect(url).toBe("https://api.example.com/items");
4949
});
5050

5151
it("should prepend the base URL to a relative path", () => {
5252
const url = buildUrl({
5353
url: "/items",
5454
baseUrl: "https://api.example.com/",
5555
});
56-
expect(url.toString()).toBe("https://api.example.com/items");
56+
expect(url).toBe("https://api.example.com/items");
5757
});
5858

5959
it("should ignore the base URL when the input URL is absolute", () => {
6060
const url = buildUrl({
6161
url: "https://other.example.com/items",
6262
baseUrl: "https://api.example.com",
6363
});
64-
expect(url.toString()).toBe("https://other.example.com/items");
64+
expect(url).toBe("https://other.example.com/items");
6565
});
6666

6767
it("should append query params and skip null/undefined entries", () => {
@@ -75,36 +75,56 @@ describe("DmkNetworkClientHelpers", () => {
7575
alsoSkip: undefined,
7676
},
7777
});
78-
expect(url.searchParams.get("chain")).toBe("1");
79-
expect(url.searchParams.get("contract")).toBe("0xabc");
80-
expect(url.searchParams.get("active")).toBe("true");
81-
expect(url.searchParams.has("skip")).toBe(false);
82-
expect(url.searchParams.has("alsoSkip")).toBe(false);
78+
expect(typeof url).toBe("string");
79+
const parsed = new URL(url);
80+
expect(parsed.searchParams.get("chain")).toBe("1");
81+
expect(parsed.searchParams.get("contract")).toBe("0xabc");
82+
expect(parsed.searchParams.get("active")).toBe("true");
83+
expect(parsed.searchParams.has("skip")).toBe(false);
84+
expect(parsed.searchParams.has("alsoSkip")).toBe(false);
8385
});
8486

8587
it("should preserve a pre-existing query string in the input URL", () => {
8688
const url = buildUrl({
8789
url: "https://api.example.com/items?keep=1",
8890
params: { chain: 2 },
8991
});
90-
expect(url.searchParams.get("keep")).toBe("1");
91-
expect(url.searchParams.get("chain")).toBe("2");
92+
const parsed = new URL(url);
93+
expect(parsed.searchParams.get("keep")).toBe("1");
94+
expect(parsed.searchParams.get("chain")).toBe("2");
9295
});
9396

9497
it("should percent-encode keys and values", () => {
9598
const url = buildUrl({
9699
url: "https://api.example.com/items",
97100
params: { "a key": "a value&b" },
98101
});
99-
expect(url.toString()).toBe(
100-
"https://api.example.com/items?a%20key=a%20value%26b",
102+
expect(url).toBe("https://api.example.com/items?a%20key=a%20value%26b");
103+
});
104+
105+
it("should not append a trailing slash after the last query value (facebook/react-native#54242)", () => {
106+
const url = buildUrl({
107+
url: "https://manager.api.live.ledger.com/api/get_device_version",
108+
params: { target_id: 858783748, provider: 1 },
109+
});
110+
expect(typeof url).toBe("string");
111+
expect(url).toBe(
112+
"https://manager.api.live.ledger.com/api/get_device_version?target_id=858783748&provider=1",
101113
);
114+
expect(url.endsWith("/")).toBe(false);
115+
});
116+
117+
it("should return a plain string (not a URL instance) so RN URL serialization is bypassed", () => {
118+
const url = buildUrl({
119+
url: "https://api.example.com/items",
120+
params: { chain: 1 },
121+
});
122+
expect(typeof url).toBe("string");
123+
expect(url).not.toBeInstanceOf(URL);
102124
});
103125

104126
it("should still build the URL when URLSearchParams.set is unavailable (React Native regression)", () => {
105127
const original = URLSearchParams.prototype.set;
106-
// Simulate a runtime (e.g. some React Native versions) where
107-
// URLSearchParams exists but `set` is not implemented.
108128
URLSearchParams.prototype.set = function notImplemented(): never {
109129
throw new Error("URLSearchParams.set is not implemented");
110130
};
@@ -113,10 +133,8 @@ describe("DmkNetworkClientHelpers", () => {
113133
url: "https://api.example.com/items",
114134
params: { chain: 1, contract: "0xabc" },
115135
});
116-
// Reading `searchParams.get` in jsdom does not call `set`, so it is
117-
// safe to assert against the parsed URL here.
118-
expect(url.toString()).toContain("chain=1");
119-
expect(url.toString()).toContain("contract=0xabc");
136+
expect(url).toContain("chain=1");
137+
expect(url).toContain("contract=0xabc");
120138
} finally {
121139
URLSearchParams.prototype.set = original;
122140
}

packages/device-management-kit/src/api/network/DmkNetworkClientHelpers.ts

Lines changed: 9 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -44,43 +44,30 @@ function serializeParams(params: DmkQueryParams): string {
4444
}
4545

4646
/**
47-
* Builds the final request URL from an input URL (absolute or relative), an
48-
* optional base URL, and optional query params. `null`/`undefined` param
49-
* values are skipped.
47+
* Builds the final request URL as a plain string. `null`/`undefined` params
48+
* are skipped; params are appended (not merged) when `url` already has a
49+
* query string; fragments are not supported alongside `params`.
5050
*
51-
* Query params are serialized manually (see {@link serializeParams}) instead
52-
* of via `URL.searchParams`, because some runtimes (notably React Native)
53-
* ship a `URLSearchParams` whose mutation methods (`set`, `append`, …) are
54-
* not implemented.
55-
*
56-
* @remarks
57-
* Fragments (`#...`) in the input URL are not supported when `params` is
58-
* provided: the query string is concatenated after the fragment, so it ends
59-
* up parsed as part of `.hash` rather than `.search`. Callers should strip
60-
* or avoid fragments on URLs passed to this function.
61-
*
62-
* @remarks
63-
* If `url` already contains a query string, `params` are appended to it.
64-
* Keys present in both the existing query string and `params` are not merged:
65-
* both occurrences will appear in the final URL. Callers relying on override
66-
* semantics should pre-merge their parameters.
51+
* Returns a string (not a `URL`) to avoid React Native's `URL.toString()`,
52+
* which appends a stray trailing `/` to URLs with a query string on affected
53+
* versions (facebook/react-native#54242).
6754
*/
6855
export function buildUrl(args: {
6956
url: string;
7057
params?: DmkQueryParams;
7158
baseUrl?: string;
72-
}): URL {
59+
}): string {
7360
const { url, params, baseUrl } = args;
7461
const isAbsolute = /^[a-z][a-z0-9+.-]*:\/\//i.test(url);
7562
const composed = isAbsolute ? url : baseUrl ? joinPath(baseUrl, url) : url;
7663

7764
const query = params ? serializeParams(params) : "";
7865
if (query.length === 0) {
79-
return new URL(composed);
66+
return composed;
8067
}
8168

8269
const separator = composed.includes("?") ? "&" : "?";
83-
return new URL(`${composed}${separator}${query}`);
70+
return `${composed}${separator}${query}`;
8471
}
8572

8673
/**

0 commit comments

Comments
 (0)