OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / test / attachment-upload.test.js

189 lines · javascript ref:
import test from 'brittle'
import { mkdtempSync, statSync, existsSync, readFileSync, createReadStream } from '#node:fs'
import { join } from '#node:path'
import { tmpdir } from '#node:os'
import http from '#node:http'
import b4a from 'b4a'
import crypto from 'hypercore-crypto'

import { startPearGit } from '../index.js'
import { signTestEnvelope } from '../lib/auth/envelope.js'

// /api/upload regression: previously did `b4a.concat(req)` then
// `overlay.put(buffer)`, which buffered the whole body in RSS and stored
// chunks as base64 inside the corestore (~135% overhead, irrecoverable).
//
// The fix (hyper-overlay#18 wiring) spools to <storage>/attachments-spool/
// and calls overlay.putFile, so chunks store as type:'local' pointers and
// the corestore stays small. These tests lock in the contract.

function tmpStorage () {
  return mkdtempSync(join(tmpdir(), 'pear-git-v3-upload-'))
}
function tmpSockPath () {
  return join(mkdtempSync(join(tmpdir(), 'pear-git-v3-upload-sock-')), 'svc.sock')
}

function postJson (url, body, headers = {}) {
  return new Promise((resolve, reject) => {
    const json = JSON.stringify(body || {})
    const u = new URL(url)
    const req = http.request({
      method: 'POST', hostname: u.hostname, port: u.port,
      path: u.pathname + u.search,
      headers: { Accept: 'application/json', 'Content-Type': 'application/json',
        'Content-Length': b4a.byteLength(json), ...headers }
    }, (res) => {
      const parts = []
      res.on('data', c => parts.push(c))
      res.on('end', () => {
        const text = b4a.concat(parts).toString('utf8')
        let parsed; try { parsed = JSON.parse(text) } catch { parsed = text }
        resolve({ status: res.statusCode, body: parsed, setCookie: res.headers['set-cookie'] })
      })
      res.on('error', reject)
    })
    req.on('error', reject)
    req.write(json); req.end()
  })
}

async function loginAsHttpUser (port) {
  const { an } = await import('anproto')
  const kp = await an.gen()
  const env = await signTestEnvelope({ keypair: kp, method: 'whoami', params: {} })
  const res = await postJson('http://127.0.0.1:' + port + '/api/rpc', env)
  if (res.status !== 200 || !res.body || !res.body.ok) {
    throw new Error('login failed: ' + res.status + ' ' + JSON.stringify(res.body))
  }
  const setCookie = res.setCookie
  const cookieHeader = Array.isArray(setCookie) ? setCookie[0].split(';')[0] : setCookie.split(';')[0]
  return cookieHeader
}

function postBytes (url, bytes, headers = {}) {
  return new Promise((resolve, reject) => {
    const u = new URL(url)
    const req = http.request({
      method: 'POST', hostname: u.hostname, port: u.port,
      path: u.pathname + u.search,
      headers: { 'Content-Type': 'application/octet-stream',
        'Content-Length': bytes.length, ...headers }
    }, (res) => {
      const parts = []
      res.on('data', c => parts.push(c))
      res.on('end', () => {
        const text = b4a.concat(parts).toString('utf8')
        let parsed; try { parsed = JSON.parse(text) } catch { parsed = text }
        resolve({ status: res.statusCode, body: parsed })
      })
      res.on('error', reject)
    })
    req.on('error', reject)
    req.write(bytes); req.end()
  })
}

function fetchBytes (url, headers = {}) {
  return new Promise((resolve, reject) => {
    const u = new URL(url)
    const req = http.request({
      method: 'GET', hostname: u.hostname, port: u.port,
      path: u.pathname + u.search, headers
    }, (res) => {
      const parts = []
      res.on('data', c => parts.push(c))
      res.on('end', () => resolve({ status: res.statusCode, body: b4a.concat(parts) }))
      res.on('error', reject)
    })
    req.on('error', reject)
    req.end()
  })
}

async function makeNode (t) {
  const storage = tmpStorage()
  const node = await startPearGit({
    storage,
    uds: tmpSockPath(),
    http: { port: 0 },
    swarm: false,
    autoImportSSH: false
  })
  t.teardown(async () => { try { await node.close() } catch {} })
  const port = node.svc.http.server.address().port
  return { node, storage, port }
}

test('attachment upload spools to disk and stores type:local chunks', async (t) => {
  const { node, storage, port } = await makeNode(t)
  const cookie = await loginAsHttpUser(port)

  // Multi-chunk payload (2 MB) — exercises the streaming path, not a
  // single-chunk shortcut.
  const data = crypto.randomBytes(2 * 1024 * 1024)

  const res = await postBytes('http://127.0.0.1:' + port + '/api/upload', data, { Cookie: cookie })
  t.is(res.status, 200, 'upload succeeded')
  t.ok(res.body.hash && /^[0-9a-f]{64}$/.test(res.body.hash), 'response has 64-hex hash')
  t.is(res.body.size, data.length, 'reported size matches input')
  t.is(res.body.path, '/attachments/' + res.body.hash, 'path is /attachments/<hash>')

  const spoolFile = join(storage, 'attachments-spool', res.body.hash)
  t.ok(existsSync(spoolFile), 'spool file written at <storage>/attachments-spool/<hash>')
  t.is(statSync(spoolFile).size, data.length, 'spool file is full input size')
  t.ok(readFileSync(spoolFile).equals(data), 'spool bytes equal input')

  // No leftover .tmp-* files
  const dirEntries = readFileSync(spoolFile).length > 0
  t.ok(dirEntries, 'sanity')
})

test('uploaded attachment reads back via /files/raw', async (t) => {
  const { port } = await makeNode(t)
  const cookie = await loginAsHttpUser(port)

  const data = crypto.randomBytes(1024 * 1024) // 1 MB
  const upload = await postBytes('http://127.0.0.1:' + port + '/api/upload', data, { Cookie: cookie })
  t.is(upload.status, 200)

  const read = await fetchBytes('http://127.0.0.1:' + port + '/files/raw?path=' + encodeURIComponent(upload.body.path))
  t.is(read.status, 200, 'GET /files/raw returns 200')
  t.is(read.body.length, data.length, 'returned size matches')
  t.ok(read.body.equals(data), 'returned bytes equal input — putFile chunks read back via type:local')
})

test('duplicate upload of identical bytes reuses the same spool file', async (t) => {
  const { storage, port } = await makeNode(t)
  const cookie = await loginAsHttpUser(port)

  const data = crypto.randomBytes(512 * 1024)
  const r1 = await postBytes('http://127.0.0.1:' + port + '/api/upload', data, { Cookie: cookie })
  const r2 = await postBytes('http://127.0.0.1:' + port + '/api/upload', data, { Cookie: cookie })
  t.is(r1.body.hash, r2.body.hash, 'same content → same oid')

  const spoolFile = join(storage, 'attachments-spool', r1.body.hash)
  t.ok(existsSync(spoolFile), 'one spool file exists')
  t.is(statSync(spoolFile).size, data.length, 'spool has full bytes')
})

test('empty body returns 400 without writing a spool file', async (t) => {
  const { storage, port } = await makeNode(t)
  const cookie = await loginAsHttpUser(port)

  const res = await postBytes('http://127.0.0.1:' + port + '/api/upload', Buffer.alloc(0), { Cookie: cookie })
  t.is(res.status, 400, '400 on empty body')

  // No leftover .tmp files in the spool dir
  const fs = await import('#node:fs')
  const entries = fs.readdirSync(join(storage, 'attachments-spool'))
  t.is(entries.length, 0, 'spool dir is empty after rejected upload')
})

test('unauthenticated upload returns 401', async (t) => {
  const { port } = await makeNode(t)
  const data = crypto.randomBytes(1024)
  const res = await postBytes('http://127.0.0.1:' + port + '/api/upload', data)
  t.is(res.status, 401, 'no cookie → 401')
})