From a5efc134094c1ba09bfb8bf38c29c67f90bfe546 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 4 Aug 2026 14:57:54 +0200 Subject: [PATCH] feat(remix)!: Do not double gate action form data capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `captureActionFormDataKeys` previously only took effect when `dataCollection.httpBodies` also included `'incomingRequest'`. It now opts in on its own, and takes precedence over `dataCollection`. Setting only `httpBodies` captures all fields. Values with a sensitive-looking field name (`password`, `token`, …) are replaced with `[Filtered]`, including explicitly allowlisted ones. Fixes #21295 Co-Authored-By: Claude Opus 5 (1M context) --- MIGRATION.md | 23 ++++ .../create-remix-app-express/instrument.mjs | 1 - packages/remix/src/server/errors.ts | 14 +-- .../server/integrations/RemixIntegration.ts | 10 +- .../server/integrations/tracing-channel.ts | 30 ++--- packages/remix/src/utils/formData.ts | 64 ++++++++++ packages/remix/src/utils/remixOptions.ts | 13 +- packages/remix/src/utils/utils.ts | 19 +-- packages/remix/test/server/errors.test.ts | 47 ++++++-- .../test/server/remix-integration.test.ts | 24 +++- .../test/server/tracing-channel-test-utils.ts | 4 +- packages/remix/test/utils/formData.test.ts | 114 ++++++++++++++++++ 12 files changed, 288 insertions(+), 75 deletions(-) create mode 100644 packages/remix/src/utils/formData.ts create mode 100644 packages/remix/test/utils/formData.test.ts diff --git a/MIGRATION.md b/MIGRATION.md index 907efa0cc944..8aebe33de904 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -166,6 +166,29 @@ User IP address inference, which was previously gated on `sendDefaultPii`, is no `dataCollection.userInfo`. An explicit `requestDataIntegration({ include: { ip: true } })` overrides `dataCollection.userInfo: false` for data collected by that integration. +#### Remix action form data + +`captureActionFormDataKeys` is an integration-level override, so it no longer requires +`dataCollection.httpBodies` to also include `'incomingRequest'`: + +```js +// v10 — both were required +Sentry.init({ + captureActionFormDataKeys: { username: true }, + dataCollection: { httpBodies: ['incomingRequest'] }, +}); + +// v11 — the option opts in on its own +Sentry.init({ + captureActionFormDataKeys: { username: true }, +}); +``` + +If `captureActionFormDataKeys` is not set, all form fields are captured when +`dataCollection.httpBodies` includes `'incomingRequest'` (the v11 default). Values whose field name +looks sensitive (`password`, `token`, …) are replaced with `[Filtered]`, including explicitly +allowlisted ones. + ### Channel-based instrumentation is the default Affected SDKs: `@sentry/node` and all dependents. diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express/instrument.mjs b/dev-packages/e2e-tests/test-applications/create-remix-app-express/instrument.mjs index 478e8561e29d..7078a725d4eb 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-express/instrument.mjs +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express/instrument.mjs @@ -7,7 +7,6 @@ Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.E2E_TEST_DSN, tunnel: 'http://localhost:3031/', // proxy server - dataCollection: { httpBodies: ['incomingRequest'] }, // Testing the FormData captureActionFormDataKeys: { file: true, text: true, diff --git a/packages/remix/src/server/errors.ts b/packages/remix/src/server/errors.ts index 10d8b3a3222b..6cfaaefb0b78 100644 --- a/packages/remix/src/server/errors.ts +++ b/packages/remix/src/server/errors.ts @@ -11,7 +11,7 @@ import { winterCGRequestToRequestData, } from '@sentry/core'; import { DEBUG_BUILD } from '../utils/debug-build'; -import type { RemixOptions } from '../utils/remixOptions'; +import { resolveFormDataCapture } from '../utils/formData'; import { storeFormDataKeys } from '../utils/utils'; import { extractData, isResponse } from '../utils/vendor/response'; @@ -93,14 +93,10 @@ export async function errorHandleDataFunction( return handleCallbackErrors( async () => { if (name === 'action' && span) { - const client = getClient(); - const options = client?.getOptions() as RemixOptions | undefined; - - if ( - client?.getDataCollectionOptions().httpBodies.includes('incomingRequest') && - options?.captureActionFormDataKeys - ) { - await storeFormDataKeys(args, span, options.captureActionFormDataKeys); + const formDataCapture = resolveFormDataCapture(getClient()); + + if (formDataCapture) { + await storeFormDataKeys(args, span, formDataCapture); } } diff --git a/packages/remix/src/server/integrations/RemixIntegration.ts b/packages/remix/src/server/integrations/RemixIntegration.ts index 7593c4ea5153..bcb244874707 100644 --- a/packages/remix/src/server/integrations/RemixIntegration.ts +++ b/packages/remix/src/server/integrations/RemixIntegration.ts @@ -1,7 +1,7 @@ import type { IntegrationFn } from '@sentry/core'; import { defineIntegration, getClient } from '@sentry/core'; import { instrumentRemix } from './tracing-channel'; -import type { RemixOptions } from '../../utils/remixOptions'; +import { resolveFormDataCapture } from '../../utils/formData'; const INTEGRATION_NAME = 'Remix' as const; @@ -9,13 +9,7 @@ const _remixIntegration = (() => { return { name: INTEGRATION_NAME, setupOnce() { - const client = getClient(); - const options = client?.getOptions() as RemixOptions | undefined; - const actionFormDataAttributes = client?.getDataCollectionOptions().httpBodies.includes('incomingRequest') - ? options?.captureActionFormDataKeys - : undefined; - - instrumentRemix(actionFormDataAttributes); + instrumentRemix(resolveFormDataCapture(getClient())); }, }; }) satisfies IntegrationFn; diff --git a/packages/remix/src/server/integrations/tracing-channel.ts b/packages/remix/src/server/integrations/tracing-channel.ts index 403332f87cee..d5cc0bfeadfc 100644 --- a/packages/remix/src/server/integrations/tracing-channel.ts +++ b/packages/remix/src/server/integrations/tracing-channel.ts @@ -23,6 +23,8 @@ import { SENTRY_KIND, } from '@sentry/conventions/attributes'; import { remixChannels } from '@sentry/server-utils/orchestrion'; +import type { FormDataCapture } from '../../utils/formData'; +import { applyFormDataAttributes } from '../../utils/formData'; const ORIGIN = 'auto.http.remix'; @@ -191,7 +193,7 @@ function subscribeCallRouteLoader(): void { ); } -function subscribeCallRouteAction(actionFormDataAttributes: Record | undefined): void { +function subscribeCallRouteAction(formDataCapture: FormDataCapture | undefined): void { bindTracingChannelToSpan( diagnosticsChannel.tracingChannel(remixChannels.REMIX_CALL_ROUTE_ACTION), data => { @@ -201,7 +203,7 @@ function subscribeCallRouteAction(actionFormDataAttributes: Record undefined); @@ -226,12 +228,12 @@ function subscribeCallRouteAction(actionFormDataAttributes: Record { const formData = data._sentryFormData; - if (!actionFormDataAttributes || !formData || 'error' in data) { + if (!formDataCapture || !formData || 'error' in data) { return false; } formData - .then(resolved => applyFormDataAttributes(span, resolved, actionFormDataAttributes)) + .then(resolved => applyFormDataAttributes(span, resolved, formDataCapture, 'formData.')) // Silently continue on any error. Typically happens because the action body cannot be // processed into FormData, in which case we should just continue. .catch(() => undefined) @@ -243,21 +245,7 @@ function subscribeCallRouteAction(actionFormDataAttributes: Record, -): void { - formData.forEach((value, key) => { - const mapped = actionFormDataAttributes[key]; - if (mapped && typeof value === 'string') { - const keyName = mapped === true ? key : mapped; - span.setAttribute(`formData.${keyName}`, value); - } - }); -} - -export function instrumentRemix(actionFormDataAttributes: Record | undefined): void { +export function instrumentRemix(formDataCapture: FormDataCapture | undefined): void { // `tracingChannel` is unavailable before Node 18.19, so do nothing in that case. if (!diagnosticsChannel.tracingChannel) { return; @@ -267,8 +255,8 @@ export function instrumentRemix(actionFormDataAttributes: Record | undefined; +} + +/** + * Resolves whether (and which) `action` form data fields to capture. + * + * `captureActionFormDataKeys` is an integration-level option, so it takes precedence over + * `dataCollection.httpBodies` rather than being gated by it. Without it, `httpBodies` decides, + * in which case there is no configured key list and all fields are captured. + */ +export function resolveFormDataCapture(client: Client | undefined): FormDataCapture | undefined { + if (!client) { + return undefined; + } + + const keys = (client.getOptions() as RemixOptions).captureActionFormDataKeys; + if (keys) { + return { keys }; + } + + return client.getDataCollectionOptions().httpBodies.includes('incomingRequest') ? { keys: undefined } : undefined; +} + +/** + * Sets form-data span attributes under `attributePrefix`, honoring the configured key list and + * renames. Values are run through the shared sensitive-key filter, so an allowlisted `password` + * still reports as `[Filtered]` rather than in the clear. + */ +export function applyFormDataAttributes( + span: Span, + formData: FormData, + { keys }: FormDataCapture, + attributePrefix: string, +): void { + const collected: Record = {}; + + formData.forEach((value, key) => { + const mapped = keys ? keys[key] : true; + if (!mapped) { + return; + } + + // Renames apply to the reported attribute name, but filtering must run against the original + // field name — otherwise renaming `password` to `pw` would defeat the denylist. + const attributeName = typeof mapped === 'string' ? mapped : key; + // File uploads report the filename rather than the contents. + const reported = typeof value === 'string' ? value : value.name || '[non-string value]'; + const filtered = _INTERNAL_filterKeyValueData({ [key]: reported }, true); + + collected[attributeName] = filtered[key] as string; + }); + + for (const [key, value] of Object.entries(collected)) { + span.setAttribute(`${attributePrefix}${key}`, value); + } +} diff --git a/packages/remix/src/utils/remixOptions.ts b/packages/remix/src/utils/remixOptions.ts index 7e08d30e3eea..2737b09a4d52 100644 --- a/packages/remix/src/utils/remixOptions.ts +++ b/packages/remix/src/utils/remixOptions.ts @@ -4,18 +4,21 @@ import type { BrowserOptions } from '@sentry/react'; export type RemixOptions = (Options | BrowserOptions | NodeOptions) & { /** - * Controls which `action` form data fields are captured and attached to spans/errors. + * Controls which `action` form data fields are captured and attached to spans/errors, optionally + * renaming them (`{ username: 'user' }` reports `username` as `user`). * - * This option only takes effect when incoming request bodies are collected, i.e. when - * `dataCollection.httpBodies` includes `'incomingRequest'`: + * Setting this option is enough to opt into capturing the configured fields, and it takes + * precedence over `dataCollection.httpBodies`: * * ```js * Sentry.init({ * captureActionFormDataKeys: { username: true }, - * dataCollection: { httpBodies: ['incomingRequest'] }, * }); * ``` + * + * When this option is not set, all form fields are captured if `dataCollection.httpBodies` + * includes `'incomingRequest'` (the default). Either way, values whose field name looks + * sensitive (`password`, `token`, …) are replaced with `[Filtered]`. */ captureActionFormDataKeys?: Record; - // TODO(v11): Remove the requirement to also set `dataCollection.httpBodies`. Setting `captureActionFormDataKeys` should be enough to opt into capturing the configured form values }; diff --git a/packages/remix/src/utils/utils.ts b/packages/remix/src/utils/utils.ts index 458c1b19cfcd..b86a02fe28ca 100644 --- a/packages/remix/src/utils/utils.ts +++ b/packages/remix/src/utils/utils.ts @@ -3,6 +3,8 @@ import type { AgnosticRouteObject } from '@remix-run/router'; import type { Span, TransactionSource } from '@sentry/core'; import { debug } from '@sentry/core'; import { DEBUG_BUILD } from './debug-build'; +import type { FormDataCapture } from './formData'; +import { applyFormDataAttributes } from './formData'; import { matchServerRoutes } from './vendor/response'; type ServerRouteManifest = ServerBuild['routes']; @@ -13,7 +15,7 @@ type ServerRouteManifest = ServerBuild['routes']; export async function storeFormDataKeys( args: LoaderFunctionArgs | ActionFunctionArgs, span: Span, - formDataKeys?: Record, + formDataCapture: FormDataCapture, ): Promise { try { // We clone the request for Remix be able to read the FormData later. @@ -24,20 +26,7 @@ export async function storeFormDataKeys( // https://remix.run/docs/en/main/utils/parse-multipart-form-data#unstable_parsemultipartformdata const formData = await clonedRequest.formData(); - formData.forEach((value, key) => { - let attrKey = key; - - if (formDataKeys?.[key]) { - if (typeof formDataKeys[key] === 'string') { - attrKey = formDataKeys[key]; - } - - span.setAttribute( - `remix.action_form_data.${attrKey}`, - typeof value === 'string' ? value : '[non-string value]', - ); - } - }); + applyFormDataAttributes(span, formData, formDataCapture, 'remix.action_form_data.'); } catch (e) { DEBUG_BUILD && debug.warn('Failed to read FormData from request', e); } diff --git a/packages/remix/test/server/errors.test.ts b/packages/remix/test/server/errors.test.ts index 6288d4df8238..ee50d3d16902 100644 --- a/packages/remix/test/server/errors.test.ts +++ b/packages/remix/test/server/errors.test.ts @@ -9,7 +9,10 @@ vi.mock('../../src/utils/utils', () => ({ import { storeFormDataKeys } from '../../src/utils/utils'; import { errorHandleDataFunction } from '../../src/server/errors'; -function createMockClient(httpBodies: string[] = []): Client { +function createMockClient( + captureActionFormDataKeys: Record | undefined, + httpBodies: string[] = [], +): Client { return { getDataCollectionOptions: () => ({ userInfo: false, @@ -23,9 +26,7 @@ function createMockClient(httpBodies: string[] = []): Client { stackFrameVariables: true, frameContextLines: 5, }), - getOptions: () => ({ - captureActionFormDataKeys: { username: true }, - }), + getOptions: () => ({ captureActionFormDataKeys }), } as unknown as Client; } @@ -38,8 +39,34 @@ describe('errorHandleDataFunction', () => { vi.restoreAllMocks(); }); - it('captures form data when httpBodies includes incomingRequest', async () => { - vi.spyOn(core, 'getClient').mockReturnValue(createMockClient(['incomingRequest'])); + it('captures the configured keys when captureActionFormDataKeys is set', async () => { + vi.spyOn(core, 'getClient').mockReturnValue(createMockClient({ username: true }, ['incomingRequest'])); + vi.spyOn(core, 'handleCallbackErrors').mockImplementation(async fn => fn()); + + const mockSpan = { setAttribute: vi.fn() } as any; + const mockArgs = { request: new Request('http://localhost', { method: 'POST' }) } as any; + const origFn = vi.fn().mockResolvedValue(new Response()); + + await errorHandleDataFunction.call(null, origFn, 'action', mockArgs, mockSpan); + + expect(storeFormDataKeys).toHaveBeenCalledWith(mockArgs, mockSpan, { keys: { username: true } }); + }); + + it('captures the configured keys even when httpBodies excludes incomingRequest', async () => { + vi.spyOn(core, 'getClient').mockReturnValue(createMockClient({ username: true }, [])); + vi.spyOn(core, 'handleCallbackErrors').mockImplementation(async fn => fn()); + + const mockSpan = { setAttribute: vi.fn() } as any; + const mockArgs = { request: new Request('http://localhost', { method: 'POST' }) } as any; + const origFn = vi.fn().mockResolvedValue(new Response()); + + await errorHandleDataFunction.call(null, origFn, 'action', mockArgs, mockSpan); + + expect(storeFormDataKeys).toHaveBeenCalledWith(mockArgs, mockSpan, { keys: { username: true } }); + }); + + it('captures all fields when only httpBodies opts in', async () => { + vi.spyOn(core, 'getClient').mockReturnValue(createMockClient(undefined, ['incomingRequest'])); vi.spyOn(core, 'handleCallbackErrors').mockImplementation(async fn => fn()); const mockSpan = { setAttribute: vi.fn() } as any; @@ -48,11 +75,11 @@ describe('errorHandleDataFunction', () => { await errorHandleDataFunction.call(null, origFn, 'action', mockArgs, mockSpan); - expect(storeFormDataKeys).toHaveBeenCalledWith(mockArgs, mockSpan, { username: true }); + expect(storeFormDataKeys).toHaveBeenCalledWith(mockArgs, mockSpan, { keys: undefined }); }); - it('does NOT capture form data when httpBodies is empty', async () => { - vi.spyOn(core, 'getClient').mockReturnValue(createMockClient([])); + it('does NOT capture form data when neither option opts in', async () => { + vi.spyOn(core, 'getClient').mockReturnValue(createMockClient(undefined, [])); vi.spyOn(core, 'handleCallbackErrors').mockImplementation(async fn => fn()); const mockSpan = { setAttribute: vi.fn() } as any; @@ -65,7 +92,7 @@ describe('errorHandleDataFunction', () => { }); it('does NOT capture form data for loader functions', async () => { - vi.spyOn(core, 'getClient').mockReturnValue(createMockClient(['incomingRequest'])); + vi.spyOn(core, 'getClient').mockReturnValue(createMockClient({ username: true }, ['incomingRequest'])); vi.spyOn(core, 'handleCallbackErrors').mockImplementation(async fn => fn()); const mockSpan = { setAttribute: vi.fn() } as any; diff --git a/packages/remix/test/server/remix-integration.test.ts b/packages/remix/test/server/remix-integration.test.ts index c08d9283b15e..6589c15af537 100644 --- a/packages/remix/test/server/remix-integration.test.ts +++ b/packages/remix/test/server/remix-integration.test.ts @@ -25,20 +25,36 @@ describe('remixIntegration', () => { vi.restoreAllMocks(); }); - it('passes the opted-in form-data keys through to the channel instrumentation', () => { + it('passes the configured form-data keys through to the channel instrumentation', () => { mockClient({ username: true }, ['incomingRequest']); remixIntegration().setupOnce?.(); - expect(instrumentRemix).toHaveBeenCalledWith({ username: true }); + expect(instrumentRemix).toHaveBeenCalledWith({ keys: { username: true } }); }); - it('passes undefined attributes when form-data capture is not opted into', () => { - // `httpBodies` without `incomingRequest` means capture is off, regardless of the configured keys. + it('passes the configured keys through even when `httpBodies` excludes `incomingRequest`', () => { + // `captureActionFormDataKeys` is an integration-level option, so it wins over `dataCollection`. mockClient({ username: true }, []); remixIntegration().setupOnce?.(); + expect(instrumentRemix).toHaveBeenCalledWith({ keys: { username: true } }); + }); + + it('captures all fields when only `httpBodies` opts in', () => { + mockClient(undefined, ['incomingRequest']); + + remixIntegration().setupOnce?.(); + + expect(instrumentRemix).toHaveBeenCalledWith({ keys: undefined }); + }); + + it('captures nothing when neither option opts in', () => { + mockClient(undefined, []); + + remixIntegration().setupOnce?.(); + expect(instrumentRemix).toHaveBeenCalledWith(undefined); }); }); diff --git a/packages/remix/test/server/tracing-channel-test-utils.ts b/packages/remix/test/server/tracing-channel-test-utils.ts index a2325823ab5e..b3abf279f1e9 100644 --- a/packages/remix/test/server/tracing-channel-test-utils.ts +++ b/packages/remix/test/server/tracing-channel-test-utils.ts @@ -104,8 +104,8 @@ export function setupRemixInstrumentation(captureActionFormDataKeys?: Record ({ captureActionFormDataKeys }), - getDataCollectionOptions: () => ({ httpBodies: captureActionFormDataKeys ? ['incomingRequest'] : [] }), + getDataCollectionOptions: () => ({ httpBodies: [] }), } as unknown as NodeClient); - instrumentRemix(captureActionFormDataKeys); + instrumentRemix(captureActionFormDataKeys ? { keys: captureActionFormDataKeys } : undefined); } diff --git a/packages/remix/test/utils/formData.test.ts b/packages/remix/test/utils/formData.test.ts new file mode 100644 index 000000000000..73f49563eccb --- /dev/null +++ b/packages/remix/test/utils/formData.test.ts @@ -0,0 +1,114 @@ +import type { Client, Span } from '@sentry/core'; +import { describe, expect, it } from 'vitest'; +import { applyFormDataAttributes, resolveFormDataCapture } from '../../src/utils/formData'; + +function mockClient( + captureActionFormDataKeys: Record | undefined, + httpBodies: string[], +): Client { + return { + getOptions: () => ({ captureActionFormDataKeys }), + getDataCollectionOptions: () => ({ httpBodies }), + } as unknown as Client; +} + +function formDataOf(entries: Record): FormData { + const formData = new FormData(); + for (const [key, value] of Object.entries(entries)) { + formData.append(key, value); + } + return formData; +} + +function applyTo(formData: FormData, keys: Record | undefined): Record { + const attributes: Record = {}; + const span = { setAttribute: (key: string, value: unknown) => void (attributes[key] = value) } as unknown as Span; + + applyFormDataAttributes(span, formData, { keys }, 'formData.'); + + return attributes; +} + +describe('resolveFormDataCapture', () => { + it('returns the configured keys when set', () => { + expect(resolveFormDataCapture(mockClient({ username: true }, ['incomingRequest']))).toEqual({ + keys: { username: true }, + }); + }); + + it('prefers the configured keys over dataCollection', () => { + expect(resolveFormDataCapture(mockClient({ username: true }, []))).toEqual({ keys: { username: true } }); + }); + + it('captures all fields when only httpBodies opts in', () => { + expect(resolveFormDataCapture(mockClient(undefined, ['incomingRequest']))).toEqual({ keys: undefined }); + }); + + it('captures nothing when neither option opts in', () => { + expect(resolveFormDataCapture(mockClient(undefined, []))).toBeUndefined(); + }); + + it('captures nothing without a client', () => { + expect(resolveFormDataCapture(undefined)).toBeUndefined(); + }); +}); + +describe('applyFormDataAttributes', () => { + it('sets only allowlisted fields', () => { + const attributes = applyTo(formDataOf({ username: 'alice', bio: 'ignored' }), { username: true }); + + expect(attributes).toEqual({ 'formData.username': 'alice' }); + }); + + it('applies renames', () => { + const attributes = applyTo(formDataOf({ username: 'alice' }), { username: 'user' }); + + expect(attributes).toEqual({ 'formData.user': 'alice' }); + }); + + it('sets every field when no keys are configured', () => { + const attributes = applyTo(formDataOf({ username: 'alice', bio: 'hello' }), undefined); + + expect(attributes).toEqual({ 'formData.username': 'alice', 'formData.bio': 'hello' }); + }); + + it('filters sensitive values when capturing all fields', () => { + const attributes = applyTo(formDataOf({ username: 'alice', password: 'hunter2' }), undefined); + + expect(attributes).toEqual({ 'formData.username': 'alice', 'formData.password': '[Filtered]' }); + }); + + it('filters sensitive values even when explicitly allowlisted', () => { + const attributes = applyTo(formDataOf({ password: 'hunter2' }), { password: true }); + + expect(attributes).toEqual({ 'formData.password': '[Filtered]' }); + }); + + it('still filters a sensitive field renamed after rename', () => { + // The denylist matches on the key it is given, and `pw` matches nothing in it. Filtering must + // therefore run against the original `password` key, before the rename is applied, or the + // value ships in the clear. + const attributes = applyTo(formDataOf({ password: 'hunter2' }), { password: 'pw' }); + + expect(attributes).toEqual({ 'formData.pw': '[Filtered]' }); + }); + + it('reports the filename for file uploads, not the contents', () => { + const formData = new FormData(); + formData.append('avatar', new Blob(['file contents']), 'avatar.png'); + + expect(applyTo(formData, undefined)).toEqual({ 'formData.avatar': 'avatar.png' }); + }); + + it('reports a placeholder for unnamed non-string values', () => { + const formData = new FormData(); + // An appended Blob with no filename reports as `blob` in undici; force the empty-name case. + formData.append('avatar', new Blob(['x']), ''); + + expect(applyTo(formData, undefined)).toEqual({ 'formData.avatar': '[non-string value]' }); + }); + + it('sets nothing for an empty form', () => { + expect(applyTo(new FormData(), undefined)).toEqual({}); + }); +});