From e11e05657ba10036e7671e1c9548f0b85f9b55ce Mon Sep 17 00:00:00 2001 From: ClaydeCode Date: Wed, 22 Jul 2026 06:26:39 +0000 Subject: [PATCH 1/3] fix(uploads): clear JSON Content-Type on multipart upload requests The global $http axios instance sets a default Content-Type: application/json. Sending a FormData body under that default makes axios serialize the FormData to JSON, so the browser never sets the multipart/form-data boundary and the backend can't find the file part, returning 422. Clear the per-request Content-Type on the avatar upload (Public.vue) and the custom-app upload (Apps.vue) so the browser sets the multipart boundary itself. Apps.vue previously set 'multipart/form-data' with no boundary, which fails the same way. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/views/Apps.vue | 2 +- src/views/Public.vue | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/views/Apps.vue b/src/views/Apps.vue index e467c5e..497ca78 100644 --- a/src/views/Apps.vue +++ b/src/views/Apps.vue @@ -234,7 +234,7 @@ export default { try { await this.$http.post(`/core/protected/apps`, formData, { headers: { - 'Content-Type': 'multipart/form-data' + 'Content-Type': undefined } }); } catch (error) { diff --git a/src/views/Public.vue b/src/views/Public.vue index 5f95437..ceefda3 100644 --- a/src/views/Public.vue +++ b/src/views/Public.vue @@ -74,7 +74,9 @@ export default { async uploadAvatar(eventBody) { const formData = new FormData(); formData.append('file', eventBody.value); - await this.$http.put(`/core/protected/identities/default/avatar`, formData); + await this.$http.put(`/core/protected/identities/default/avatar`, formData, { + headers: { 'Content-Type': undefined } + }); await eventBody.doneCallback(); this.refreshAvatar(); }, From 3c60795717bb9b98a8fc08848dd02697d8163387 Mon Sep 17 00:00:00 2001 From: ClaydeCode Date: Wed, 22 Jul 2026 06:26:39 +0000 Subject: [PATCH 2/3] test(uploads): guard multipart Content-Type regression + document gotcha Add a unit test that drives a main.js-shaped axios instance and asserts the FormData body reaches the adapter intact when Content-Type is cleared; a control case proves the JSON default alone serializes it to a string. Note the requirement in agents.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- agents.md | 2 + tests/unit/upload-content-type.spec.js | 62 ++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 tests/unit/upload-content-type.spec.js diff --git a/agents.md b/agents.md index c1dad3a..7d51fd1 100644 --- a/agents.md +++ b/agents.md @@ -58,6 +58,8 @@ All API calls go to the shard_core backend. Two base paths: Dev proxy: `vue.config.js` proxies requests to a remote shard or `localhost:8080`. +The global `$http` instance (`main.js`) sets a default `Content-Type: application/json`. For `FormData`/file uploads this default must be cleared per request (`{ headers: { 'Content-Type': undefined } }`); otherwise axios serializes the FormData to JSON and the browser never sets the `multipart/form-data` boundary, so the backend rejects the upload with 422. + ### Authentication Flow 1. App loads → calls `/core/public/meta/whoami` 2. If anonymous → redirect to `/welcome` or `/pair` diff --git a/tests/unit/upload-content-type.spec.js b/tests/unit/upload-content-type.spec.js new file mode 100644 index 0000000..6d1af44 --- /dev/null +++ b/tests/unit/upload-content-type.spec.js @@ -0,0 +1,62 @@ +// Regression test for the multipart upload bug (issue #32). +// +// `$http` is an axios instance created in main.js with a default +// `Content-Type: application/json`. When a FormData body is sent under that +// default, axios' transformRequest serializes the FormData to a JSON string +// and the browser never sets a `multipart/form-data; boundary=...` header, so +// the backend sees no file part and returns 422. +// +// The upload calls override the per-request Content-Type to `undefined`, which +// drops the JSON default and lets axios keep the raw FormData (the browser then +// sets the multipart boundary). We assert on the body reaching the adapter: +// with the override it stays a FormData instance; without it, it would be a +// serialized string. +// Jest 26 ignores the package `exports` map and would load axios' ESM entry, +// which it cannot transform; require the CJS build explicitly. +import axios from 'axios/dist/node/axios.cjs'; + +// Mirror the instance configured in main.js. +function makeHttp(adapter) { + const http = axios.create({headers: {'Content-type': 'application/json'}}); + http.defaults.adapter = adapter; + return http; +} + +describe('multipart upload Content-Type override', () => { + let seen; + let http; + + beforeEach(() => { + seen = null; + http = makeHttp(async (config) => { + seen = config.data; + return {data: {}, status: 200, statusText: 'OK', headers: {}, config}; + }); + }); + + test('avatar PUT keeps FormData body when Content-Type is cleared', async () => { + const formData = new FormData(); + formData.append('file', 'avatar-bytes'); + await http.put('/core/protected/identities/default/avatar', formData, { + headers: {'Content-Type': undefined}, + }); + expect(seen).toBeInstanceOf(FormData); + }); + + test('custom app POST keeps FormData body when Content-Type is cleared', async () => { + const formData = new FormData(); + formData.append('file', 'app-bytes'); + await http.post('/core/protected/apps', formData, { + headers: {'Content-Type': undefined}, + }); + expect(seen).toBeInstanceOf(FormData); + }); + + test('regression: the JSON default alone serializes FormData to a string', async () => { + const formData = new FormData(); + formData.append('file', 'avatar-bytes'); + await http.put('/core/protected/identities/default/avatar', formData); + expect(typeof seen).toBe('string'); + expect(seen).not.toBeInstanceOf(FormData); + }); +}); From 7aa7e2462ada0011a947f91f5b7f5a42d0ae8261 Mon Sep 17 00:00:00 2001 From: ClaydeCode Date: Wed, 22 Jul 2026 06:37:23 +0000 Subject: [PATCH 3/3] test(uploads): drive real component methods so the override is guarded The first version reconstructed an inline axios instance and re-inlined the Content-Type override, so it verified axios behavior rather than the app: deleting the override from Public.vue/Apps.vue left every test green. Invoke the real uploadAvatar / uploadCustomApp methods with an injected $http mock and assert on the config they pass, so the override regression actually fails a test (confirmed by mutation). Keep one axios-level test pinning why 'undefined' is the correct value to send. Also note in agents.md that axios normalizes header case, which is the load-bearing reason the override cancels the differently-cased default. Co-Authored-By: Claude Opus 4.8 (1M context) --- agents.md | 2 +- tests/unit/upload-content-type.spec.js | 113 ++++++++++++++----------- 2 files changed, 66 insertions(+), 49 deletions(-) diff --git a/agents.md b/agents.md index 7d51fd1..d1226be 100644 --- a/agents.md +++ b/agents.md @@ -58,7 +58,7 @@ All API calls go to the shard_core backend. Two base paths: Dev proxy: `vue.config.js` proxies requests to a remote shard or `localhost:8080`. -The global `$http` instance (`main.js`) sets a default `Content-Type: application/json`. For `FormData`/file uploads this default must be cleared per request (`{ headers: { 'Content-Type': undefined } }`); otherwise axios serializes the FormData to JSON and the browser never sets the `multipart/form-data` boundary, so the backend rejects the upload with 422. +The global `$http` instance (`main.js`) sets a default `Content-Type: application/json`. For `FormData`/file uploads this default must be cleared per request (`{ headers: { 'Content-Type': undefined } }`); otherwise axios serializes the FormData to JSON and the browser never sets the `multipart/form-data` boundary, so the backend rejects the upload with 422. (axios normalizes header case, so `Content-Type: undefined` overrides the `Content-type` default.) ### Authentication Flow 1. App loads → calls `/core/public/meta/whoami` diff --git a/tests/unit/upload-content-type.spec.js b/tests/unit/upload-content-type.spec.js index 6d1af44..993b6ba 100644 --- a/tests/unit/upload-content-type.spec.js +++ b/tests/unit/upload-content-type.spec.js @@ -1,62 +1,79 @@ // Regression test for the multipart upload bug (issue #32). // // `$http` is an axios instance created in main.js with a default -// `Content-Type: application/json`. When a FormData body is sent under that -// default, axios' transformRequest serializes the FormData to a JSON string -// and the browser never sets a `multipart/form-data; boundary=...` header, so -// the backend sees no file part and returns 422. +// `Content-Type: application/json`. Sending a FormData body under that default +// makes axios serialize the FormData to a JSON string, so the browser never +// sets a `multipart/form-data; boundary=...` header, the backend sees no file +// part, and returns 422. // -// The upload calls override the per-request Content-Type to `undefined`, which -// drops the JSON default and lets axios keep the raw FormData (the browser then -// sets the multipart boundary). We assert on the body reaching the adapter: -// with the override it stays a FormData instance; without it, it would be a -// serialized string. -// Jest 26 ignores the package `exports` map and would load axios' ESM entry, -// which it cannot transform; require the CJS build explicitly. -import axios from 'axios/dist/node/axios.cjs'; - -// Mirror the instance configured in main.js. -function makeHttp(adapter) { - const http = axios.create({headers: {'Content-type': 'application/json'}}); - http.defaults.adapter = adapter; - return http; -} - -describe('multipart upload Content-Type override', () => { - let seen; - let http; - - beforeEach(() => { - seen = null; - http = makeHttp(async (config) => { - seen = config.data; - return {data: {}, status: 200, statusText: 'OK', headers: {}, config}; +// The upload methods clear the per-request Content-Type so axios keeps the raw +// FormData and the browser sets the multipart boundary itself. The first two +// tests drive the real component methods and assert on the config they hand to +// `$http`, so deleting the override from the source fails them. The third test +// pins the axios behavior that makes `undefined` the correct value to send. +import Public from '@/views/Public.vue'; +import Apps from '@/views/Apps.vue'; + +describe('multipart uploads clear the JSON Content-Type', () => { + test('Public.uploadAvatar sends FormData with Content-Type cleared', async () => { + const put = jest.fn().mockResolvedValue({}); + const ctx = {$http: {put}, refreshAvatar: jest.fn()}; + + await Public.methods.uploadAvatar.call(ctx, { + value: 'avatar-bytes', + doneCallback: jest.fn(), }); + + expect(put).toHaveBeenCalledTimes(1); + const [url, body, config] = put.mock.calls[0]; + expect(url).toBe('/core/protected/identities/default/avatar'); + expect(body).toBeInstanceOf(FormData); + expect(config).toBeDefined(); + expect(config.headers['Content-Type']).toBeUndefined(); }); - test('avatar PUT keeps FormData body when Content-Type is cleared', async () => { - const formData = new FormData(); - formData.append('file', 'avatar-bytes'); - await http.put('/core/protected/identities/default/avatar', formData, { - headers: {'Content-Type': undefined}, - }); - expect(seen).toBeInstanceOf(FormData); + test('Apps.uploadCustomApp posts each file with Content-Type cleared', async () => { + const post = jest.fn().mockResolvedValue({}); + const ctx = { + customAppFiles: ['app-a', 'app-b'], + $http: {post}, + $bvModal: {hide: jest.fn()}, + refresh: jest.fn(), + }; + + await Apps.methods.uploadCustomApp.call(ctx); + + expect(post).toHaveBeenCalledTimes(2); + for (const [url, body, config] of post.mock.calls) { + expect(url).toBe('/core/protected/apps'); + expect(body).toBeInstanceOf(FormData); + expect(config).toBeDefined(); + expect(config.headers['Content-Type']).toBeUndefined(); + } }); - test('custom app POST keeps FormData body when Content-Type is cleared', async () => { - const formData = new FormData(); - formData.append('file', 'app-bytes'); - await http.post('/core/protected/apps', formData, { - headers: {'Content-Type': undefined}, - }); + // Pins why `undefined` is the right value: run a FormData body through an + // instance shaped like main.js and confirm clearing the Content-Type leaves + // the body as FormData, whereas the JSON default alone serializes it to a + // string. Jest 26 ignores axios' `exports` map and would load its ESM entry, + // which it cannot transform, so require the CJS build explicitly. + test('clearing Content-Type keeps the FormData body; the default serializes it', async () => { + const axios = require('axios/dist/node/axios.cjs'); + let seen; + const http = axios.create({headers: {'Content-type': 'application/json'}}); + http.defaults.adapter = async (config) => { + seen = config.data; + return {data: {}, status: 200, statusText: 'OK', headers: {}, config}; + }; + + const cleared = new FormData(); + cleared.append('file', 'bytes'); + await http.put('/x', cleared, {headers: {'Content-Type': undefined}}); expect(seen).toBeInstanceOf(FormData); - }); - test('regression: the JSON default alone serializes FormData to a string', async () => { - const formData = new FormData(); - formData.append('file', 'avatar-bytes'); - await http.put('/core/protected/identities/default/avatar', formData); + const defaulted = new FormData(); + defaulted.append('file', 'bytes'); + await http.put('/x', defaulted); expect(typeof seen).toBe('string'); - expect(seen).not.toBeInstanceOf(FormData); }); });