diff --git a/agents.md b/agents.md index c1dad3a..d1226be 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. (axios normalizes header case, so `Content-Type: undefined` overrides the `Content-type` default.) + ### Authentication Flow 1. App loads → calls `/core/public/meta/whoami` 2. If anonymous → redirect to `/welcome` or `/pair` 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(); }, diff --git a/tests/unit/upload-content-type.spec.js b/tests/unit/upload-content-type.spec.js new file mode 100644 index 0000000..993b6ba --- /dev/null +++ b/tests/unit/upload-content-type.spec.js @@ -0,0 +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`. 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 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('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(); + } + }); + + // 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); + + const defaulted = new FormData(); + defaulted.append('file', 'bytes'); + await http.put('/x', defaulted); + expect(typeof seen).toBe('string'); + }); +});