OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface / test / multipart.test.js

89 lines · javascript ref:
// multipart/form-data — the only way a browser posts a file without JavaScript.
//
// ⛔ The whole risk here is byte fidelity. An image that round-trips through a UTF-8 string comes
// back corrupted, and the symptom is a broken picture rather than an error — so these tests use
// bytes that DO NOT survive a UTF-8 round trip, on purpose.

import test from 'brittle'
import b4a from 'b4a'
import { parse } from '../lib/multipart.js'

const B = '----favelaBoundary'
const CT = 'multipart/form-data; boundary=' + B

// Assemble a body the way a browser does, from parts that may be binary.
const body = (parts) => {
  const chunks = []
  for (const p of parts) {
    chunks.push(b4a.from('--' + B + '\r\n'))
    chunks.push(b4a.from(p.headers + '\r\n\r\n'))
    chunks.push(typeof p.body === 'string' ? b4a.from(p.body) : p.body)
    chunks.push(b4a.from('\r\n'))
  }
  chunks.push(b4a.from('--' + B + '--\r\n'))
  return b4a.concat(chunks)
}

test('text fields decode; the parser is not confused by two of them', (t) => {
  const r = parse(body([
    { headers: 'Content-Disposition: form-data; name="name"', body: 'Ada Lovelace' },
    { headers: 'Content-Disposition: form-data; name="other"', body: 'x' }
  ]), CT)
  t.is(r.fields.name, 'Ada Lovelace')
  t.is(r.fields.other, 'x')
  t.alike(Object.keys(r.files), [])
})

test('★ a file survives BYTE FOR BYTE, including bytes that are not valid UTF-8', (t) => {
  // A real PNG header plus every byte value. If anything on the path decoded to a string, the
  // high bytes come back as U+FFFD and this fails.
  const raw = b4a.concat([
    b4a.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
    b4a.from(Array.from({ length: 256 }, (_, i) => i))
  ])
  const r = parse(body([
    { headers: 'Content-Disposition: form-data; name="name"', body: 'Ada' },
    { headers: 'Content-Disposition: form-data; name="avatar"; filename="a.png"\r\nContent-Type: image/png', body: raw }
  ]), CT)

  t.is(r.fields.name, 'Ada')
  t.is(r.files.avatar.filename, 'a.png')
  t.ok(b4a.equals(r.files.avatar.bytes, raw), 'every byte, unchanged')
  t.is(r.files.avatar.bytes.length, raw.length)
})

test('⛔ "no file chosen" is not a zero-byte upload', (t) => {
  // Browsers send the part regardless. Treating an empty filename as a file would clear the avatar
  // every single time the settings form was saved with the name alone.
  const r = parse(body([
    { headers: 'Content-Disposition: form-data; name="name"', body: 'Ada' },
    { headers: 'Content-Disposition: form-data; name="avatar"; filename=""\r\nContent-Type: application/octet-stream', body: '' }
  ]), CT)
  t.is(r.fields.name, 'Ada')
  t.absent(r.files.avatar, 'no file part at all, so the caller leaves the picture alone')
})

test('a body containing the boundary text inside a file does not split it', (t) => {
  // The delimiter is CRLF + "--" + boundary; the bare string is ordinary content.
  const raw = b4a.from('before ' + B + ' after')
  const r = parse(body([
    { headers: 'Content-Disposition: form-data; name="avatar"; filename="a.bin"', body: raw }
  ]), CT)
  t.ok(b4a.equals(r.files.avatar.bytes, raw), 'the file is whole')
})

test('the declared content-type is recorded but never authoritative', (t) => {
  const r = parse(body([
    { headers: 'Content-Disposition: form-data; name="avatar"; filename="x.png"\r\nContent-Type: image/png', body: b4a.from('<svg/>') }
  ]), CT)
  // ⛔ The daemon types the file from its magic bytes. This field only reports what was claimed.
  t.is(r.files.avatar.contentType, 'image/png')
  t.is(b4a.toString(r.files.avatar.bytes), '<svg/>', 'and the bytes are what actually arrived')
})

test('a non-multipart request is null, not an empty form', (t) => {
  // Null so the caller can say "expected a form upload" rather than silently saving nothing.
  t.is(parse(b4a.from('name=Ada'), 'application/x-www-form-urlencoded'), null)
  t.is(parse(b4a.from(''), 'multipart/form-data'), null, 'multipart with no boundary is unusable')
})