| 1 | import { test } from 'node:test' |
| 2 | import assert from 'node:assert/strict' |
| 3 | |
| 4 | import { createUploader, checkFile, sha256Hex, refusal, UploadError, MAX_BYTES } from './uploads.mjs' |
| 5 | |
| 6 | // fakeFile is enough of a File for this module: a name, a type, a size, |
| 7 | // and bytes when something wants to hash them. |
| 8 | function fakeFile (name, type, bytes = new Uint8Array([1, 2, 3])) { |
| 9 | return { |
| 10 | name, |
| 11 | type, |
| 12 | size: bytes.length, |
| 13 | arrayBuffer: async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) |
| 14 | } |
| 15 | } |
| 16 | |
| 17 | // rig wires an uploader over fakes and records what each step was |
| 18 | // asked, so a test can assert on the ORDER as well as the arguments. |
| 19 | function rig (over = {}) { |
| 20 | const calls = [] |
| 21 | const states = [] |
| 22 | const uploader = createUploader({ |
| 23 | sign: async body => { calls.push(['sign', body]); return { ref: 'abc123', url: '/put/abc123', headers: { 'Content-Type': body.type } } }, |
| 24 | put: async (url, file, headers) => { calls.push(['put', url, file.name, headers]) }, |
| 25 | register: async body => { calls.push(['register', body]); return { id: 'a1', ref: 'abc123', name: body.name, width: body.width, height: body.height, alt: body.alt } }, |
| 26 | applyOp: async record => { calls.push(['op', record]) }, |
| 27 | measure: async () => ({ width: 40, height: 30 }), |
| 28 | hash: async () => 'abc123', |
| 29 | onState: (s, d) => states.push([s, d]), |
| 30 | ...over |
| 31 | }) |
| 32 | return { uploader, calls, states } |
| 33 | } |
| 34 | |
| 35 | test('the state machine runs choose → sign → put → register → op', async () => { |
| 36 | const { uploader, calls, states } = rig() |
| 37 | const record = await uploader.upload(fakeFile('chart.png', 'image/png'), { alt: 'A bar chart' }) |
| 38 | |
| 39 | assert.deepEqual(calls.map(c => c[0]), ['sign', 'put', 'register', 'op']) |
| 40 | assert.deepEqual(states.map(s => s[0]), ['sign', 'put', 'register', 'op', 'done']) |
| 41 | assert.deepEqual(calls[0][1], { name: 'chart.png', size: 3, type: 'image/png', hash: 'abc123' }) |
| 42 | assert.equal(calls[1][1], '/put/abc123') |
| 43 | assert.deepEqual(calls[2][1], { |
| 44 | ref: 'abc123', name: 'chart.png', width: 40, height: 30, alt: 'A bar chart', decorative: false |
| 45 | }) |
| 46 | // The op is applied with the SERVER's record, never with what the |
| 47 | // client thought it uploaded. |
| 48 | assert.deepEqual(calls[3][1], record) |
| 49 | assert.equal(record.id, 'a1') |
| 50 | }) |
| 51 | |
| 52 | test('a file the deck will not take never reaches the network', async () => { |
| 53 | const { uploader, calls } = rig() |
| 54 | await assert.rejects(() => uploader.upload(fakeFile('notes.pdf', 'application/pdf'), { alt: 'x' }), |
| 55 | err => err instanceof UploadError && err.code === 'type') |
| 56 | |
| 57 | const big = fakeFile('huge.png', 'image/png') |
| 58 | big.size = MAX_BYTES + 1 |
| 59 | await assert.rejects(() => uploader.upload(big, { alt: 'x' }), |
| 60 | err => err.code === 'toolarge') |
| 61 | |
| 62 | assert.deepEqual(calls, [], 'nothing was signed, put or registered') |
| 63 | }) |
| 64 | |
| 65 | test('alt text is required before the bytes move, unless the image is decorative', async () => { |
| 66 | const { uploader, calls } = rig() |
| 67 | await assert.rejects(() => uploader.upload(fakeFile('x.png', 'image/png'), { alt: ' ' }), |
| 68 | err => err.code === 'altfirst') |
| 69 | assert.deepEqual(calls, []) |
| 70 | |
| 71 | await uploader.upload(fakeFile('x.png', 'image/png'), { alt: '', decorative: true }) |
| 72 | assert.deepEqual(calls.map(c => c[0]), ['sign', 'put', 'register', 'op']) |
| 73 | assert.equal(calls[2][1].decorative, true) |
| 74 | assert.equal(calls[2][1].alt, '') |
| 75 | }) |
| 76 | |
| 77 | test('a step that fails stops the ones after it and reports its own code', async () => { |
| 78 | for (const [failing, code] of [['sign', 'no_store'], ['put', 'failed'], ['register', 'unsafe_svg']]) { |
| 79 | const { uploader, calls, states } = rig({ |
| 80 | [failing]: async () => { throw new UploadError(code, 'in words') } |
| 81 | }) |
| 82 | await assert.rejects(() => uploader.upload(fakeFile('x.png', 'image/png'), { alt: 'a' }), |
| 83 | err => err.code === code && err.serverMessage === 'in words') |
| 84 | assert.ok(!calls.some(c => c[0] === 'op'), `${failing}: the op ran anyway`) |
| 85 | assert.equal(states.at(-1)[0], 'error') |
| 86 | } |
| 87 | }) |
| 88 | |
| 89 | test('a failure that is not ours still becomes an UploadError', async () => { |
| 90 | const { uploader } = rig({ put: async () => { throw new TypeError('network down') } }) |
| 91 | await assert.rejects(() => uploader.upload(fakeFile('x.png', 'image/png'), { alt: 'a' }), |
| 92 | err => err instanceof UploadError && err.code === 'failed' && err.serverMessage === 'network down') |
| 93 | }) |
| 94 | |
| 95 | test('a second pick while one is in flight is refused, not raced', async () => { |
| 96 | // The first PUT hangs until the test lets it go; every later one |
| 97 | // resolves at once, so the "and then the next one works" half of this |
| 98 | // is not itself a hang. |
| 99 | const held = [] |
| 100 | const { uploader, calls } = rig({ |
| 101 | put: () => (held.length ? Promise.resolve() : new Promise(r => held.push(r))) |
| 102 | }) |
| 103 | const first = uploader.upload(fakeFile('a.png', 'image/png'), { alt: 'a' }) |
| 104 | await assert.rejects(() => uploader.upload(fakeFile('b.png', 'image/png'), { alt: 'b' }), |
| 105 | err => err.code === 'busy') |
| 106 | held[0]() |
| 107 | await first |
| 108 | await uploader.upload(fakeFile('c.png', 'image/png'), { alt: 'c' }) |
| 109 | assert.deepEqual(calls.filter(c => c[0] === 'op').length, 2, 'b.png never became an op') |
| 110 | }) |
| 111 | |
| 112 | test('an unreadable size is zero rather than a failure', async () => { |
| 113 | const { uploader, calls } = rig({ measure: async () => ({ width: 0, height: 0 }) }) |
| 114 | await uploader.upload(fakeFile('x.svg', 'image/svg+xml'), { alt: 'a' }) |
| 115 | assert.equal(calls[2][1].width, 0) |
| 116 | assert.equal(calls[2][1].height, 0) |
| 117 | }) |
| 118 | |
| 119 | test('a per-call applyOp wins, so an upload lands on the block it was chosen for', async () => { |
| 120 | const { uploader, calls } = rig() |
| 121 | const landed = [] |
| 122 | await uploader.upload(fakeFile('x.png', 'image/png'), |
| 123 | { alt: 'a', applyOp: record => landed.push(record.id) }) |
| 124 | assert.deepEqual(landed, ['a1']) |
| 125 | assert.ok(!calls.some(c => c[0] === 'op'), 'the constructor\'s applyOp ran as well') |
| 126 | }) |
| 127 | |
| 128 | test('checkFile takes exactly the four types', () => { |
| 129 | for (const type of ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml']) { |
| 130 | assert.doesNotThrow(() => checkFile(fakeFile('x', type))) |
| 131 | } |
| 132 | for (const type of ['image/gif', 'text/html', 'image/svg', '']) { |
| 133 | assert.throws(() => checkFile(fakeFile('x', type)), /type/) |
| 134 | } |
| 135 | assert.throws(() => checkFile(null), /nofile/) |
| 136 | }) |
| 137 | |
| 138 | test('sha256Hex is the hex digest of the bytes', async () => { |
| 139 | // sha256("abc"), the vector everybody knows. |
| 140 | const file = fakeFile('abc.png', 'image/png', new TextEncoder().encode('abc')) |
| 141 | assert.equal(await sha256Hex(file), |
| 142 | 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad') |
| 143 | }) |
| 144 | |
| 145 | test('refusal reads this app\'s JSON error shape, and survives one that is not', async () => { |
| 146 | const json = { status: 503, json: async () => ({ error: 'no_store', message: 'No object store.' }) } |
| 147 | const a = await refusal(json) |
| 148 | assert.equal(a.code, 'no_store') |
| 149 | assert.equal(a.serverMessage, 'No object store.') |
| 150 | |
| 151 | const html = { status: 502, json: async () => { throw new SyntaxError('not JSON') } } |
| 152 | const b = await refusal(html) |
| 153 | assert.equal(b.code, 'http_502') |
| 154 | assert.equal(b.serverMessage, '') |
| 155 | }) |
| 156 | |