From 8069136b2c87988ffcb6cc88d5a47ff7d75baa73 Mon Sep 17 00:00:00 2001 From: euniceamoni Date: Mon, 27 Jul 2026 09:20:53 +0000 Subject: [PATCH 1/4] fix(gdpr): send export email in POST body, not GET query string PII (email address) was being sent as a URL query parameter on GET /api/v1/newsletter/gdpr/export, exposing it to server access logs, browser history, and proxy logs. Changes: - frontend: newsletterGdprExport now uses POST with body: { email } instead of GET with params: { email } (client.ts) - backend: added NewsletterExportBody struct, changed handler extractor from Query to Json, updated utoipa path attribute to post, updated route registration from get() to post() (handlers.rs, main.rs) - tests: updated it.each entry to expect POST; added new test in 'GDPR export (#1156)' describe block asserting email is in the request body and absent from the URL Fixes #1156 --- frontend/src/lib/api/__tests__/client.test.ts | 23 ++++++++++++++++++- frontend/src/lib/api/client.ts | 4 ++-- services/api/src/handlers.rs | 13 +++++++---- services/api/src/main.rs | 2 +- 4 files changed, 34 insertions(+), 8 deletions(-) diff --git a/frontend/src/lib/api/__tests__/client.test.ts b/frontend/src/lib/api/__tests__/client.test.ts index 5dcf3d6c..fafa5673 100644 --- a/frontend/src/lib/api/__tests__/client.test.ts +++ b/frontend/src/lib/api/__tests__/client.test.ts @@ -35,7 +35,7 @@ describe('API Client', () => { ['getTransactionStatus', () => api.getTransactionStatus('0xdead'), 'GET', '/api/blockchain/tx/0xdead'], ['newsletterConfirm', () => api.newsletterConfirm('tok'), 'GET', '/api/v1/newsletter/confirm'], ['newsletterUnsubscribe', () => api.newsletterUnsubscribe('a@b.com'), 'DELETE', '/api/v1/newsletter/unsubscribe'], - ['newsletterGdprExport', () => api.newsletterGdprExport('a@b.com'), 'GET', '/api/v1/newsletter/gdpr/export'], + ['newsletterGdprExport', () => api.newsletterGdprExport('a@b.com'), 'POST', '/api/v1/newsletter/gdpr/export'], ['newsletterGdprDelete', () => api.newsletterGdprDelete('a@b.com'), 'DELETE', '/api/v1/newsletter/gdpr/delete'], ['resolveMarket', () => api.resolveMarket(3), 'POST', '/api/markets/3/resolve'], ['emailPreview', () => api.emailPreview('welcome'), 'GET', '/api/v1/email/preview/welcome'], @@ -551,6 +551,27 @@ describe('API Client', () => { }); }); + describe('GDPR export (#1156)', () => { + it('sends email in the POST request body, not as a URL query parameter', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + text: async () => JSON.stringify({ success: true, data: {} }), + }); + + await api.newsletterGdprExport('user@example.com'); + + const [calledUrl, calledInit] = (global.fetch as jest.Mock).mock.calls[0]; + + // Email must NOT appear in the URL to prevent logging PII in access logs. + expect(calledUrl).not.toContain('user@example.com'); + expect(calledUrl).not.toContain('email='); + + // Email MUST be in the JSON body. + expect(calledInit.method).toBe('POST'); + expect(JSON.parse(calledInit.body as string)).toEqual({ email: 'user@example.com' }); + }); + }); + describe('DELETE requests', () => { it('should handle DELETE requests with body', async () => { const mockResponse = { success: true }; diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index 7c904c5c..3b99bb61 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -448,9 +448,9 @@ export const api = { newsletterGdprExport: (email: string, signal?: AbortSignal) => request<{ success: boolean; data: Record }>( - "GET", + "POST", "/api/v1/newsletter/gdpr/export", - { params: { email }, cacheTags: [CacheTag.NEWSLETTER], signal } + { body: { email }, cacheTags: [CacheTag.NEWSLETTER], signal } ), newsletterGdprDelete: (email: string, signal?: AbortSignal) => diff --git a/services/api/src/handlers.rs b/services/api/src/handlers.rs index a5894dc9..244bcc28 100644 --- a/services/api/src/handlers.rs +++ b/services/api/src/handlers.rs @@ -366,6 +366,11 @@ pub struct NewsletterExportQuery { pub email: String, } +#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] +pub struct NewsletterExportBody { + pub email: String, +} + #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct NewsletterResponse { pub success: bool, @@ -634,10 +639,10 @@ pub async fn newsletter_unsubscribe( } #[utoipa::path( - get, + post, path = "/api/v1/newsletter/gdpr/export", tag = "newsletter", - params(NewsletterExportQuery), + request_body = NewsletterExportBody, responses( (status = 200, description = "GDPR data export", body = NewsletterExportResponse), (status = 400, description = "Invalid email", body = NewsletterResponse), @@ -649,7 +654,7 @@ pub async fn newsletter_gdpr_export( State(state): State>, headers: HeaderMap, connect_info: Option>, - Query(query): Query, + Json(body): Json, ) -> Result { use crate::security::extract_client_ip_cidrs; let ip = extract_client_ip_cidrs( @@ -677,7 +682,7 @@ pub async fn newsletter_gdpr_export( .into_response()); } - let Some(email) = normalized_email(&query.email) else { + let Some(email) = normalized_email(&body.email) else { return Ok(( StatusCode::BAD_REQUEST, Json(NewsletterResponse { diff --git a/services/api/src/main.rs b/services/api/src/main.rs index ca907d6f..354e8f5b 100644 --- a/services/api/src/main.rs +++ b/services/api/src/main.rs @@ -388,7 +388,7 @@ async fn main() -> anyhow::Result<()> { .route("/api/v1/newsletter/subscribe", post(handlers::newsletter_subscribe)) .route("/api/v1/newsletter/confirm", get(handlers::newsletter_confirm)) .route("/api/v1/newsletter/unsubscribe", get(handlers::newsletter_unsubscribe)) - .route("/api/v1/newsletter/gdpr/export", get(handlers::newsletter_gdpr_export)) + .route("/api/v1/newsletter/gdpr/export", post(handlers::newsletter_gdpr_export)) .route("/api/v1/newsletter/gdpr/delete", axum::routing::delete(handlers::newsletter_gdpr_delete)) .layer(middleware::from_fn(correlation::correlation_id_middleware)) .layer(TraceLayer::new_for_http()) From 40ee71d5196b338c25c11567128fbd142e7ae677 Mon Sep 17 00:00:00 2001 From: euniceamoni Date: Mon, 27 Jul 2026 09:34:41 +0000 Subject: [PATCH 2/4] fix(openapi): update GDPR export spec and contract test to POST Update openapi.yaml /api/v1/newsletter/gdpr/export from GET with query parameter to POST with requestBody (EmailRequest schema), matching the handler change in handlers.rs. Update SPEC_ROUTES in openapi_contract_test.rs from GET to POST to keep the contract test in sync with the spec and runtime router. --- services/api/openapi.yaml | 15 +++++++-------- services/api/tests/openapi_contract_test.rs | 2 +- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/services/api/openapi.yaml b/services/api/openapi.yaml index 93c0f032..967e3da5 100644 --- a/services/api/openapi.yaml +++ b/services/api/openapi.yaml @@ -387,17 +387,16 @@ paths: $ref: "#/components/responses/ApiError" /api/v1/newsletter/gdpr/export: - get: + post: tags: [newsletter] operationId: newsletterGdprExport summary: GDPR data export for a subscriber - parameters: - - name: email - in: query - required: true - schema: - type: string - format: email + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/EmailRequest" responses: "200": description: Subscriber data diff --git a/services/api/tests/openapi_contract_test.rs b/services/api/tests/openapi_contract_test.rs index 16e9bae9..cc4b6b85 100644 --- a/services/api/tests/openapi_contract_test.rs +++ b/services/api/tests/openapi_contract_test.rs @@ -27,7 +27,7 @@ mod tests { ("POST", "/api/v1/newsletter/subscribe"), ("GET", "/api/v1/newsletter/confirm"), ("DELETE", "/api/v1/newsletter/unsubscribe"), - ("GET", "/api/v1/newsletter/gdpr/export"), + ("POST", "/api/v1/newsletter/gdpr/export"), ("DELETE", "/api/v1/newsletter/gdpr/delete"), ("GET", "/api/v1/email/preview/{template_name}"), ("POST", "/api/v1/email/test"), From 4078e8bf5c0eca2bd06bdb797041b909b3fb6584 Mon Sep 17 00:00:00 2001 From: euniceamoni Date: Mon, 27 Jul 2026 10:03:09 +0000 Subject: [PATCH 3/4] fix: wrap path parameters with encodeURIComponent in client.ts --- frontend/src/lib/api/__tests__/client.test.ts | 28 ++++++++++++++++++- frontend/src/lib/api/client.ts | 12 ++++---- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/frontend/src/lib/api/__tests__/client.test.ts b/frontend/src/lib/api/__tests__/client.test.ts index fafa5673..c40e8d2b 100644 --- a/frontend/src/lib/api/__tests__/client.test.ts +++ b/frontend/src/lib/api/__tests__/client.test.ts @@ -52,7 +52,33 @@ describe('API Client', () => { }); }); - describe('Successful responses', () => { + describe('Path parameter URI encoding', () => { + const mockOk = (data: unknown = { ok: true }) => + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + text: async () => JSON.stringify(data), + }); + + it('encodes path parameters containing special characters (/, ?, #)', async () => { + mockOk(); + await api.getBlockchainMarket('foo/bar'); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('/api/blockchain/markets/foo%2Fbar'), + expect.any(Object), + ); + }); + + it('encodes user path parameter containing a slash', async () => { + mockOk(); + await api.getUserBets('user/name'); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('/api/blockchain/users/user%2Fname/bets'), + expect.any(Object), + ); + }); + }); + + describe('Successful responses', () => { it('should handle successful GET requests', async () => { const mockData = { status: 'ok' }; (global.fetch as jest.Mock).mockResolvedValueOnce({ diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index 3b99bb61..3c13e992 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -389,7 +389,7 @@ export const api = { }), getBlockchainMarket: (marketId: number | string, signal?: AbortSignal) => - request>("GET", `/api/blockchain/markets/${marketId}`, { + request>("GET", `/api/blockchain/markets/${encodeURIComponent(marketId)}`, { cacheTtl: CACHE_TTL.MEDIUM, cacheTags: [CacheTag.BLOCKCHAIN, CacheTag.MARKETS], signal, @@ -403,7 +403,7 @@ export const api = { }), getUserBets: (user: string, params?: { page?: number; page_size?: number }, signal?: AbortSignal) => - request>("GET", `/api/blockchain/users/${user}/bets`, { + request>("GET", `/api/blockchain/users/${encodeURIComponent(user)}/bets`, { params, cacheTtl: CACHE_TTL.MEDIUM, cacheTags: [CacheTag.BLOCKCHAIN], @@ -411,14 +411,14 @@ export const api = { }), getOracleResult: (marketId: number | string, signal?: AbortSignal) => - request>("GET", `/api/blockchain/oracle/${marketId}`, { + request>("GET", `/api/blockchain/oracle/${encodeURIComponent(marketId)}`, { cacheTtl: CACHE_TTL.LONG, cacheTags: [CacheTag.BLOCKCHAIN], signal, }), getTransactionStatus: (txHash: string, signal?: AbortSignal) => - request>("GET", `/api/blockchain/tx/${txHash}`, { + request>("GET", `/api/blockchain/tx/${encodeURIComponent(txHash)}`, { cacheTtl: CACHE_TTL.LONG, cacheTags: [CacheTag.BLOCKCHAIN], signal, @@ -462,13 +462,13 @@ export const api = { // Admin / email resolveMarket: (marketId: number | string, signal?: AbortSignal) => - request<{ invalidated_keys: number }>("POST", `/api/markets/${marketId}/resolve`, { + request<{ invalidated_keys: number }>("POST", `/api/markets/${encodeURIComponent(marketId)}/resolve`, { cacheTags: [CacheTag.MARKETS, CacheTag.BLOCKCHAIN, CacheTag.STATISTICS], signal, }), emailPreview: (templateName: string, signal?: AbortSignal) => - request>("GET", `/api/v1/email/preview/${templateName}`, { + request>("GET", `/api/v1/email/preview/${encodeURIComponent(templateName)}`, { cacheTtl: CACHE_TTL.LONG, cacheTags: [CacheTag.EMAIL], signal, From 6e1c4254d1f1d5f6089bd56d03310b7adc90d236 Mon Sep 17 00:00:00 2001 From: euniceamoni Date: Mon, 27 Jul 2026 17:17:46 +0000 Subject: [PATCH 4/4] fix: URI-encode path parameters in API client request URLs (#1157) Wrap all path-segment interpolations with encodeURIComponent() to prevent path corruption and request-smuggling when values contain special characters (/, ?, #). Affected endpoints: getBlockchainMarket, getUserBets, getOracleResult, getTransactionStatus, resolveMarket, emailPreview. Add URI encoding test block (7 tests) covering all 6 endpoints and asserting that slash-containing values produce correctly encoded paths. --- frontend/package-lock.json | 229 ------------------ frontend/src/lib/api/__tests__/client.test.ts | 73 ++++++ 2 files changed, 73 insertions(+), 229 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9b09afcd..503e69c4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -7541,188 +7541,6 @@ } } }, - "node_modules/lighthouse/node_modules/agent-base": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", - "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 20" - } - }, - "node_modules/lighthouse/node_modules/data-uri-to-buffer": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-8.0.0.tgz", - "integrity": "sha512-6UHfyCux51b8PTGDgveqtz1tvphBku5DrMKKJbFAZAJOI2zsjDpDoYE1+QGj7FOMS4BdTFNJsJiR3zEB0xH0yQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 20" - } - }, - "node_modules/lighthouse/node_modules/degenerator": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-7.0.1.tgz", - "integrity": "sha512-ABErK0IefDSyHjlPH7WUEenIAX2rPPnrDcDM+TS3z3+zu9TfyKKi07BQM+8rmxpdE2y1v5fjjdoAS/x4D2U60w==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "ast-types": "^0.13.4", - "escodegen": "^2.1.0", - "esprima": "^4.0.1" - }, - "engines": { - "node": ">= 20" - }, - "peerDependencies": { - "quickjs-wasi": "^2.2.0" - } - }, - "node_modules/lighthouse/node_modules/get-uri": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-8.0.1.tgz", - "integrity": "sha512-/5N/P4Lrh0p/mDwlDRi7Y1+P2o/OyzZI3l6Iz1Ov6XXwwm1y3RlZLuo3gVgML99djrEDtV980bBxSuOeHLk8ww==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "basic-ftp": "^5.3.1", - "data-uri-to-buffer": "8.0.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/lighthouse/node_modules/http-proxy-agent": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-9.1.0.tgz", - "integrity": "sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "agent-base": "9.0.0", - "debug": "^4.3.4", - "proxy-agent-negotiate": "1.1.0" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/lighthouse/node_modules/https-proxy-agent": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz", - "integrity": "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "agent-base": "9.0.0", - "debug": "^4.3.4", - "proxy-agent-negotiate": "1.1.0" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/lighthouse/node_modules/lru-cache": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/lighthouse/node_modules/pac-proxy-agent": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-9.1.0.tgz", - "integrity": "sha512-1aU+1mpj3DrQPfo3gh+3Gap3G5x+axnMx1P/y0ZF2ch7kb2meyOCAH8K2k9d27ROsTE7TnAerzxqF9aon2jqnA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "agent-base": "9.0.0", - "debug": "^4.3.4", - "get-uri": "8.0.1", - "http-proxy-agent": "9.1.0", - "https-proxy-agent": "9.1.0", - "pac-resolver": "9.0.1", - "quickjs-wasi": "^2.2.0", - "socks-proxy-agent": "10.1.0" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/lighthouse/node_modules/pac-resolver": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-9.0.1.tgz", - "integrity": "sha512-lJbS008tmkj08VhoM8Hzuv/VE5tK9MS0OIQ/7+s0lIF+BYhiQWFYzkSpML7lXs9iBu2jfmzBTLzhe9n6BX+dYw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "degenerator": "7.0.1", - "netmask": "^2.0.2" - }, - "engines": { - "node": ">= 20" - }, - "peerDependencies": { - "quickjs-wasi": "^2.2.0" - } - }, - "node_modules/lighthouse/node_modules/proxy-agent": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-8.0.2.tgz", - "integrity": "sha512-idLLRewuemWd7GH/BDJzGiB0dWGfT2SQs3jy6NtZtGWU9uPTTSdeC1/cdbqLwgzhfv027daGFuXX426e2Eg20A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "agent-base": "9.0.0", - "debug": "^4.3.4", - "http-proxy-agent": "9.1.0", - "https-proxy-agent": "9.1.0", - "lru-cache": "^7.14.1", - "pac-proxy-agent": "9.1.0", - "proxy-from-env": "^2.0.0", - "socks-proxy-agent": "10.1.0" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/lighthouse/node_modules/proxy-from-env": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", - "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=10" - } - }, "node_modules/lighthouse/node_modules/puppeteer-core": { "version": "25.1.0", "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.1.0.tgz", @@ -7787,23 +7605,6 @@ } } }, - "node_modules/lighthouse/node_modules/socks-proxy-agent": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-10.1.0.tgz", - "integrity": "sha512-WlMj/67cEJ6MDI1OcsnjuYKDNDoyPCCYZ249kuuXPiMDw9F8PXkVaQ7YWu3siTydfQ/4BEZcvGzu+aYvz7dDCQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "agent-base": "9.0.0", - "debug": "^4.3.4", - "socks": "^2.8.3" - }, - "engines": { - "node": ">= 20" - } - }, "node_modules/lighthouse/node_modules/webdriver-bidi-protocol": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.2.tgz", @@ -8763,7 +8564,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -8913,26 +8713,6 @@ "node": ">= 14" } }, - "node_modules/proxy-agent-negotiate": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-agent-negotiate/-/proxy-agent-negotiate-1.1.0.tgz", - "integrity": "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 20" - }, - "peerDependencies": { - "kerberos": "^2.0.0" - }, - "peerDependenciesMeta": { - "kerberos": { - "optional": true - } - } - }, "node_modules/proxy-agent/node_modules/lru-cache": { "version": "7.18.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", @@ -9043,15 +8823,6 @@ ], "license": "MIT" }, - "node_modules/quickjs-wasi": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/quickjs-wasi/-/quickjs-wasi-2.2.0.tgz", - "integrity": "sha512-zQxXmQMrEoD3S+jQdYsloq4qAuaxKFHZj6hHqOYGwB2iQZH+q9e/lf5zQPXCKOk0WJuAjzRFbO4KwHIp2D05Iw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/react": { "version": "19.2.7", "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", diff --git a/frontend/src/lib/api/__tests__/client.test.ts b/frontend/src/lib/api/__tests__/client.test.ts index c40e8d2b..1e0c8564 100644 --- a/frontend/src/lib/api/__tests__/client.test.ts +++ b/frontend/src/lib/api/__tests__/client.test.ts @@ -598,6 +598,79 @@ describe('API Client', () => { }); }); + describe('URI encoding of path parameters (#1157)', () => { + const mockOk = (data: unknown = { ok: true }) => + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + text: async () => JSON.stringify(data), + }); + + const base = 'http://localhost:3001'; + + it('getBlockchainMarket encodes a slash in marketId', async () => { + mockOk(); + await api.getBlockchainMarket('foo/bar'); + expect(global.fetch).toHaveBeenCalledWith( + `${base}/api/blockchain/markets/foo%2Fbar`, + expect.any(Object), + ); + }); + + it('getUserBets encodes a slash in user address', async () => { + mockOk(); + await api.getUserBets('GA/BC'); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('/api/blockchain/users/GA%2FBC/bets'), + expect.any(Object), + ); + }); + + it('getOracleResult encodes special characters in marketId', async () => { + mockOk(); + await api.getOracleResult('a/b?c#d'); + expect(global.fetch).toHaveBeenCalledWith( + `${base}/api/blockchain/oracle/a%2Fb%3Fc%23d`, + expect.any(Object), + ); + }); + + it('getTransactionStatus encodes a slash in txHash', async () => { + mockOk(); + await api.getTransactionStatus('0x/dead'); + expect(global.fetch).toHaveBeenCalledWith( + `${base}/api/blockchain/tx/0x%2Fdead`, + expect.any(Object), + ); + }); + + it('resolveMarket encodes a slash in marketId', async () => { + mockOk(); + await api.resolveMarket('10/20'); + expect(global.fetch).toHaveBeenCalledWith( + `${base}/api/markets/10%2F20/resolve`, + expect.any(Object), + ); + }); + + it('emailPreview encodes a slash in templateName', async () => { + mockOk(); + await api.emailPreview('welcome/v2'); + expect(global.fetch).toHaveBeenCalledWith( + `${base}/api/v1/email/preview/welcome%2Fv2`, + expect.any(Object), + ); + }); + + it('plain values without special characters are unchanged', async () => { + mockOk(); + await api.getBlockchainMarket(42); + expect(global.fetch).toHaveBeenCalledWith( + `${base}/api/blockchain/markets/42`, + expect.any(Object), + ); + }); + }); + describe('DELETE requests', () => { it('should handle DELETE requests with body', async () => { const mockResponse = { success: true };