[object Object]

← 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

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 →