Files
tauri-plugins-workspace/packages/api-e2e/test/specs/http.spec.ts
T
Christopher PruijsenandLucas Nogueira 0e17675653 fix(http): decode header values as ISO-8859-1 (#3586)
* fix(http): decode response header values as ISO-8859-1

Response header values were decoded with String::from_utf8 before being
sent over IPC. Raw UTF-8 bytes produced JS strings with code points
above 255, which new Headers() rejects as invalid ByteStrings
("Type error" on WebKit), and bytes that were not valid UTF-8 failed
the fetch outright. Decode as ISO-8859-1 instead so every byte maps to a
char 0-255, matching what browsers expose for the same response.

Fixes #2898

* fix(http): add tests for ISO-8859-1 response header decoding

Cover the #2898 Content-Disposition case and non-UTF-8 header bytes on
the same conversion fetch_send uses, so a revert to UTF-8 fails the suite.

* request headers, e2e test

---------

Co-authored-by: Lucas Nogueira <lucas@tauri.app>
2026-10-13 14:43:50 -03:00

185 lines
6.8 KiB
TypeScript

// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
import { expect } from '@wdio/globals'
import { tauri, tauriError, describePlugin } from '../helpers/index.js'
import { FIXTURE_SERVER_URL, RAW_HEADER_FILENAME } from '../helpers/server.js'
// The example spawns an echo server on this port: it replies with the request
// body and the request headers, and sets a `session-token` cookie on requests
// that do not carry one. It is also the only `http://` origin in the example's
// http scope.
const echoServer = 'http://localhost:3003'
describePlugin('http', () => {
it('fetch performs a GET and exposes status, url and headers', async () => {
const response = await tauri(async (api, url) => {
const response = await api.http.fetch(url, {
headers: { 'x-e2e-header': 'present' }
})
return {
ok: response.ok,
status: response.status,
url: response.url,
// the echo server mirrors the request headers back
echoedHeader: response.headers.get('x-e2e-header'),
body: await response.text()
}
}, echoServer)
expect(response.ok).toBe(true)
expect(response.status).toBe(200)
expect(response.url).toBe(`${echoServer}/`)
expect(response.echoedHeader).toBe('present')
expect(response.body).toBe('')
})
it('fetch sends a JSON body and parses the echoed response', async () => {
const payload = { message: 'hello from e2e', nested: { list: [1, 2, 3] } }
const response = await tauri(
async (api, url, payload) => {
const response = await api.http.fetch(`${url}/json`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload)
})
return {
status: response.status,
contentType: response.headers.get('content-type'),
body: (await response.json()) as unknown
}
},
echoServer,
payload
)
expect(response.status).toBe(200)
expect(response.contentType).toBe('application/json')
expect(response.body).toEqual(payload)
})
it('fetch sends binary bodies and reads them back as bytes', async () => {
const bytes = [0, 1, 2, 127, 128, 254, 255]
const echoed = await tauri(
async (api, url, bytes) => {
const response = await api.http.fetch(`${url}/bytes`, {
method: 'PUT',
body: new Uint8Array(bytes)
})
return Array.from(new Uint8Array(await response.arrayBuffer()))
},
echoServer,
bytes
)
expect(echoed).toEqual(bytes)
})
it('fetch sends multipart form data', async () => {
const body = await tauri(async (api, url) => {
const form = new FormData()
form.append('foo', 'baz')
form.append('bar', 'qux')
const response = await api.http.fetch(`${url}/form`, {
method: 'POST',
body: form
})
return {
contentType: response.headers.get('content-type'),
text: await response.text()
}
}, echoServer)
expect(body.contentType).toMatch(/^multipart\/form-data; boundary=/)
expect(body.text).toContain('name="foo"')
expect(body.text).toContain('baz')
expect(body.text).toContain('name="bar"')
expect(body.text).toContain('qux')
})
it('fetch exposes non-ASCII response header bytes as a ByteString', async () => {
// https://github.com/tauri-apps/plugins-workspace/issues/2898
const result = await tauri(async (api, url) => {
const response = await api.http.fetch(`${url}/raw-header`)
await response.text()
const value = response.headers.get('content-disposition') ?? ''
// A ByteString holds one byte per char, like a browser exposes it.
const bytes = Uint8Array.from(value, (c) => c.charCodeAt(0))
return new TextDecoder().decode(bytes)
}, FIXTURE_SERVER_URL)
expect(result).toBe(`attachment; filename="${RAW_HEADER_FILENAME}"`)
})
it('fetch sends ByteString request header values as raw bytes', async () => {
// Each char is one byte (0x80-0xFF) on the wire, as in a browser. The
// tail is the UTF-8 encoding of `中`, as when a response header is sent back.
const value = 'x\u00e4\u00ff\u00e4\u00b8\u00ad'
const echoed = await tauri(
async (api, url, value) => {
const response = await api.http.fetch(`${url}/echo`, {
headers: { 'x-e2e-raw': value }
})
const body = (await response.json()) as {
headers: Record<string, string>
}
return body.headers['x-e2e-raw']
},
FIXTURE_SERVER_URL,
value
)
// the fixture server decodes header values as ISO-8859-1
expect(echoed).toBe(value)
})
it('the cookie jar stores and replays cookies across requests', async () => {
const result = await tauri(async (api, url) => {
// The jar is persisted in the app data dir, so an earlier run (or the
// requests above) may already hold the cookie: the first request then
// replays it instead of being handed a new one.
const first = await api.http.fetch(`${url}/cookies`)
await first.text()
// Either way the jar attaches it to the next request, which the echo
// server mirrors back as a `cookie` header without setting a new one.
const second = await api.http.fetch(`${url}/cookies`)
await second.text()
return {
setCookie: first.headers.get('set-cookie'),
replayedFirst: first.headers.get('cookie'),
replayed: second.headers.get('cookie'),
setAgain: second.headers.get('set-cookie')
}
}, echoServer)
if (result.replayedFirst === null) {
expect(result.setCookie).toMatch(/^session-token=test-value/)
} else {
expect(result.replayedFirst).toContain('session-token=test-value')
}
expect(result.replayed).toContain('session-token=test-value')
expect(result.setAgain).toBeNull()
})
it('fetch can be aborted', async () => {
const message = await tauriError(async (api, url) => {
const controller = new AbortController()
controller.abort()
await api.http.fetch(`${url}/aborted`, { signal: controller.signal })
}, echoServer)
expect(message).toMatch(/abort|cancel/i)
})
it('rejects URLs outside the configured scope', async () => {
const message = await tauriError((api) =>
api.http.fetch('http://localhost:3999/not-in-scope')
)
expect(message).toMatch(/url not allowed on the configured scope/)
})
it('network failures reject', async () => {
// Every in-scope origin is reachable, so route the request through a proxy
// nothing listens on to force a connection error.
const message = await tauriError(
(api, url) =>
api.http.fetch(url, { proxy: { all: 'http://127.0.0.1:1' } }),
echoServer
)
expect(message).toMatch(/error sending request/)
})
})