← back to NationalPaperHangers
fix(admin/template): drag-drop upload was 403'ing on CSRF — multer parses multipart AFTER csrf mw runs, so _csrf in FormData was invisible. Switch XHR to X-CSRF-Token header. e2e: 13/13 pass
efd79906453ddb29905923065ada80b65b1b921a · 2026-05-06 17:04:17 -0700 · Steve
Files touched
M tests/e2e-template-chooser.jsM views/admin/template.ejs
Diff
commit efd79906453ddb29905923065ada80b65b1b921a
Author: Steve <steve@designerwallcoverings.com>
Date: Wed May 6 17:04:17 2026 -0700
fix(admin/template): drag-drop upload was 403'ing on CSRF — multer parses multipart AFTER csrf mw runs, so _csrf in FormData was invisible. Switch XHR to X-CSRF-Token header. e2e: 13/13 pass
---
tests/e2e-template-chooser.js | 34 ++++++++++++++++++++++++++++++++++
views/admin/template.ejs | 5 ++++-
2 files changed, 38 insertions(+), 1 deletion(-)
diff --git a/tests/e2e-template-chooser.js b/tests/e2e-template-chooser.js
index 18f3510..63fe6c0 100644
--- a/tests/e2e-template-chooser.js
+++ b/tests/e2e-template-chooser.js
@@ -94,10 +94,44 @@ function findChromium() {
`body has class tpl-${TARGET_TEMPLATE} (got "${bodyClass}")`
);
+ // ─── STEP 6 · drag-drop upload ────────────────────────────────
+ console.log('\n[step 6] hero image upload');
+ const fixturePath = process.env.UPLOAD_FIXTURE || '/tmp/nph-test-pixel.png';
+ if (!require('fs').existsSync(fixturePath)) {
+ console.log(` ⊘ skipped — fixture missing at ${fixturePath}`);
+ } else {
+ await page.goto(`${BASE}/admin/template`, { waitUntil: 'domcontentloaded' });
+ // The hero file input is created dynamically by JS, inserted right after #heroDrop.
+ // Click the dropzone so the hidden input exists, then set its files.
+ await page.click('#heroDrop');
+ // Find the file input that was just inserted (sibling of dropzone).
+ const fileInput = await page.$('#heroDrop ~ input[type="file"], input[type="file"]');
+ assert(!!fileInput, 'hidden file input exists after dropzone click');
+ const uploadResp = page.waitForResponse(r => r.url().endsWith('/admin/uploads') && r.request().method() === 'POST', { timeout: 8000 });
+ await fileInput.setInputFiles(fixturePath);
+ const resp = await uploadResp;
+ assert(resp.status() === 200, `POST /admin/uploads → 200 (got ${resp.status()})`);
+ const json = await resp.json();
+ assert(json.ok === true, 'response body { ok: true }');
+ assert(/^\/uploads\/\d+\/hero-[a-f0-9]+\.png$/.test(json.url), `URL shape correct (got ${json.url})`);
+ // Hidden hero_url field should now be set
+ await page.waitForFunction(() => document.getElementById('hero_url').value !== '', { timeout: 4000 });
+ const heroUrlVal = await page.$eval('#hero_url', el => el.value);
+ assert(heroUrlVal === json.url, `hero_url hidden input updated`);
+ // Verify file is on disk
+ const fs = require('fs');
+ const diskPath = path.join(__dirname, '..', 'public', json.url);
+ assert(fs.existsSync(diskPath), `file written to disk at ${diskPath.replace(__dirname + '/..', '')}`);
+ // Cleanup the test file
+ try { fs.unlinkSync(diskPath); } catch {}
+ }
+
// ─── CLEANUP · revert to editorial ────────────────────────────
console.log('\n[cleanup] reverting tpl_slug → editorial');
await page.goto(`${BASE}/admin/template`);
await page.click('.tpl-card[data-tpl="editorial"]');
+ // Clear the hero_url so the cleanup save doesn't keep the (now-deleted) image
+ await page.$eval('#hero_url', el => el.value = '');
await Promise.all([
page.waitForURL(/\/admin\/template/, { timeout: 8000 }),
page.click('#tplForm button[type="submit"]'),
diff --git a/views/admin/template.ejs b/views/admin/template.ejs
index ec6220e..be2a7e8 100644
--- a/views/admin/template.ejs
+++ b/views/admin/template.ejs
@@ -162,9 +162,12 @@
var fd = new FormData();
fd.append('file', f);
fd.append('role', role);
- fd.append('_csrf', csrf);
var xhr = new XMLHttpRequest();
xhr.open('POST', '/admin/uploads', true);
+ // CSRF middleware runs BEFORE multer parses multipart, so a _csrf field
+ // in FormData would be invisible to it. Send via header instead — the
+ // middleware also accepts X-CSRF-Token. (caught by e2e: 403 → 200)
+ xhr.setRequestHeader('X-CSRF-Token', csrf);
xhr.upload.onprogress = function(e){
if (e.lengthComputable) {
var pct = Math.round(((done + e.loaded / e.total) / arr.length) * 100);
← 6546f0f fix: /admin/template flash double-clear (route was reading n
·
back to NationalPaperHangers
·
tests: e2e buyer wizard — 5 steps, conditional brand reveal, 7c77afb →