Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
2 changes: 1 addition & 1 deletion src/views/Apps.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
4 changes: 3 additions & 1 deletion src/views/Public.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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();
},
Expand Down
79 changes: 79 additions & 0 deletions tests/unit/upload-content-type.spec.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading