Skip to content
Merged
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
119 changes: 119 additions & 0 deletions tests/upload-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,125 @@ describe("UploadHandler", () => {
}
});

// Fires a chunked (WebSocket) auto-upload from the input named "doc" inside
// the given form markup and returns whatever console.warn was called with.
const chunkedUploadWarnings = async (formHTML: string): Promise<string[]> => {
const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
const chunkedHandler = new UploadHandler(mockSendMessage);

const form = document.createElement("form");
form.innerHTML = formHTML;
const input = form.querySelector('input[name="doc"]') as HTMLInputElement;

await chunkedHandler.startUpload(
"doc",
[createMockFile("scan.png", "imgdata", "image/png")],
input
);
await chunkedHandler.handleUploadStartResponse({
upload_name: "doc",
entries: [
{
entry_id: "entry-1",
client_name: "scan.png",
valid: true,
auto_upload: true,
},
],
});
await jest.runAllTimersAsync();

const messages = warn.mock.calls.map((c) => String(c[0]));
warn.mockRestore();
return messages;
};

it("warns that marked fields cannot travel on a chunked upload (#508)", async () => {
const warnings = await chunkedUploadWarnings(
'<input type="hidden" name="id" value="item-42" lvt-upload-with>' +
'<input type="file" name="doc" lvt-upload="doc">'
);

// The chunked transport carries no form fields, so the mark is silently
// inert — say so rather than leaving an empty value in the handler with
// nothing in the markup to explain it.
const relevant = warnings.filter((m) => m.includes("lvt-upload-with"));
expect(relevant).toHaveLength(1);
expect(relevant[0]).toContain("id");
expect(relevant[0]).toContain("chunked");
});

it("stays quiet on a chunked upload when no field is marked", async () => {
const warnings = await chunkedUploadWarnings(
'<input type="hidden" name="id" value="item-42">' +
'<input type="file" name="doc" lvt-upload="doc">'
);

// Nothing marked means nothing was promised, so there is nothing to warn
// about — a warning on every chunked upload would train people to ignore it.
expect(warnings.filter((m) => m.includes("lvt-upload-with"))).toHaveLength(0);
});

it("stays quiet on a proxied upload, where marked fields do travel", async () => {
const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
await proxiedUploadFormData(
'<input type="hidden" name="id" value="item-42" lvt-upload-with>' +
'<input type="file" name="doc" lvt-upload="doc">'
);

// Proxied is always multipart, so the fields arrive and the warning would
// be plain wrong.
const messages = warn.mock.calls.map((c) => String(c[0]));
warn.mockRestore();
expect(messages.filter((m) => m.includes("lvt-upload-with"))).toHaveLength(0);
});

it("warns on a Direct upload, which never carries fields on any path (#508)", async () => {
const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
const mockUploader = { upload: jest.fn().mockResolvedValue(undefined) };
const directHandler = new UploadHandler(mockSendMessage);
directHandler.registerUploader("mock", mockUploader);

const form = document.createElement("form");
form.innerHTML =
'<input type="hidden" name="id" value="item-42" lvt-upload-with>' +
'<input type="file" name="doc" lvt-upload="doc">';
const input = form.querySelector('input[name="doc"]') as HTMLInputElement;

await directHandler.startUpload(
"doc",
[createMockFile("scan.png", "imgdata", "image/png")],
input
);
await directHandler.handleUploadStartResponse({
upload_name: "doc",
entries: [
{
entry_id: "entry-1",
client_name: "scan.png",
valid: true,
auto_upload: true,
mode: "direct",
external: { uploader: "mock", url: "https://cdn.example/scan.png" },
},
],
});
await jest.runAllTimersAsync();

const messages = warn.mock.calls.map((c) => String(c[0]));
warn.mockRestore();

// Direct PUTs straight to storage and completes with a metadata-only
// message, so marked fields reach the handler on NO path — not even with
// the socket down, which is the multipart fallback Volume gets and Direct
// does not. The remedy has to differ accordingly.
const relevant = messages.filter((m) => m.includes("lvt-upload-with"));
expect(relevant).toHaveLength(1);
expect(relevant[0]).toContain("direct-to-storage");
expect(relevant[0]).toContain("controller state");
expect(relevant[0]).not.toContain("when the socket is down");
});

it("previews locally without uploading when mode is preview", async () => {
const createObjectURL = jest
.fn()
Expand Down
42 changes: 42 additions & 0 deletions upload/upload-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,8 @@ export class UploadHandler {
entry: UploadEntry,
meta: ExternalUploadMeta
): Promise<void> {
this.warnIfMarkedFieldsCannotTravel(entry, "direct-to-storage");

try {
const uploader = this.uploaders.get(meta.uploader);
if (!uploader) {
Expand Down Expand Up @@ -618,13 +620,53 @@ export class UploadHandler {
this.previewUrls.clear();
}

/**
* Warn when a form marks fields with lvt-upload-with but the upload is leaving
* by a transport that carries no form fields. Only the multipart POST carries
* them: the chunked WebSocket path sends bytes and entry ids, and the Direct
* path PUTs straight to storage and completes with a metadata-only message —
* in both cases the server builds the action's context from an empty map.
* Without this the fields simply never arrive and the handler sees empty
* values, with nothing in the markup to suggest why.
*
* transport names the path taken, since the remedy differs: a Volume upload
* does carry fields when it falls back to multipart, while Direct never does
* on any path. Tracked as livetemplate/livetemplate#508.
*/
private warnIfMarkedFieldsCannotTravel(
entry: UploadEntry,
transport: "chunked WebSocket" | "direct-to-storage"
): void {
const form = entry.sourceInput?.form;
if (!form) return;
const marked = Array.from(form.elements)
.filter((el) => el.hasAttribute("lvt-upload-with"))
.map((el) => (el as HTMLInputElement).name)
.filter(Boolean);
if (marked.length === 0) return;
const remedy =
transport === "chunked WebSocket"
? `Fields travel on the multipart path only — Proxied uploads, and this ` +
`field when the socket is down.`
: `Fields travel on the multipart path only, which Direct never uses; ` +
`read the context from controller state instead.`;
console.warn(
`Upload "${entry.uploadName}" is using the ${transport} transport, which ` +
`does not carry form fields — ${marked.join(", ")} marked lvt-upload-with ` +
`will not reach the upload_${entry.uploadName}_complete handler. ` +
`${remedy} See livetemplate/livetemplate#508.`
);
}

/**
* Upload file in chunks via WebSocket
*/
private async uploadChunked(entry: UploadEntry): Promise<void> {
const { file, id } = entry;
let offset = 0;

this.warnIfMarkedFieldsCannotTravel(entry, "chunked WebSocket");

// Create abort controller for cancellation
entry.abortController = new AbortController();

Expand Down
Loading