OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / test / admin-grant-permission.test.js

135 lines · javascript ref:
// Regression test for the admin "make writer" button storing 'writer'
// (display label) instead of 'write' (canonical perm value). The badge
// looked right on the admin page but requireWriter() rejected the
// user everywhere else because perms.includes('write') was false.
//
// Asserts that the make-writer form posts permissions=write, and that
// after submission the target user can perform a write-gated operation
// (update-profile is the smallest one — but profile-self-edit doesn't
// require write anymore, so we use create-issue which still needs it).

import test from 'brittle'
import { mkdtempSync } from '#node:fs'
import { join } from '#node:path'
import { tmpdir } from '#node:os'
import http from '#node:http'
import b4a from 'b4a'

import { Client } from 'hyper-svc'
import { startPearGit } from '../index.js'
import { signTestEnvelope } from '../lib/auth/envelope.js'

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

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

function fetchText (port, path, cookie) {
  return new Promise((resolve, reject) => {
    http.get({
      hostname: '127.0.0.1', port, path,
      headers: cookie ? { Cookie: cookie } : {}
    }, (r) => {
      const parts = []
      r.on('data', c => parts.push(c))
      r.on('end', () => resolve({ status: r.statusCode, body: b4a.concat(parts).toString('utf8') }))
    }).on('error', reject)
  })
}

function postJson (url, body) {
  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,
      headers: {
        'Content-Type': 'application/json',
        'Content-Length': b4a.byteLength(json)
      }
    }, (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 tofuLogin (port) {
  const { an } = await import('anproto')
  const kp = await an.gen()
  const env = await signTestEnvelope({ keypair: kp, method: 'whoami', params: {} })
  const r = await postJson('http://127.0.0.1:' + port + '/api/rpc', env)
  if (r.status !== 200) throw new Error('login failed: ' + r.status)
  // Bare's http returns set-cookie as a STRING; Node returns an ARRAY. Indexing
  // [0] unconditionally yields the first CHARACTER under Bare ('p'), producing a
  // junk cookie — the session then never resolves, ctx.identity is null, and the
  // admin page silently renders its "Owner access required" gate (still HTTP
  // 200), so user rows are absent. Same normalisation as basic.test.js:95.
  const raw = r.setCookie
  const first = Array.isArray(raw) ? raw[0] : raw
  const cookie = (first || '').split(';')[0]
  return { kp, identity: r.body.identity, userId: r.body.identity.userId, cookie }
}

test('admin "make writer" stores canonical "write" perm (not "writer" label)', async (t) => {
  const node = await makeNode(t)
  const port = node.svc.http.port

  const admin = await tofuLogin(port)
  const target = await tofuLogin(port)

  // Render the admin page as the admin and assert the form posts the
  // CANONICAL perm name. This catches the regression at the UI layer.
  const page = await fetchText(port, '/admin', admin.cookie)
  t.is(page.status, 200, 'admin page rendered')
  t.ok(page.body.includes('name="permissions" value="write"'),
    'make-writer form posts permissions=write (not "writer")')
  t.absent(page.body.includes('name="permissions" value="writer"'),
    'no form posts the display label as the perm value')

  // Actually promote via UDS (mirrors what the form POST does) and assert
  // the perm landed under the canonical name. UDS bypasses HTTP auth so
  // we don't need to simulate the form submit end-to-end here.
  const client = await Client.discover({ uds: node.svc.uds.path })
  t.teardown(async () => client.close())
  await client.call('grant-permission', { userId: target.userId, permissions: ['write'] })
  const updated = await client.call('get-user', { userId: target.userId })
  t.alike(updated.user.globalPermissions, ['write'], 'stored perm is "write"')

  // Re-render the admin page — the target user should now show the
  // "Writer" badge (which is keyed off perms.includes('write'), the
  // bug being that it used to check for 'writer').
  const page2 = await fetchText(port, '/admin', admin.cookie)
  t.is(page2.status, 200, 'admin page re-renders')
  // The promoted user's row should have a "demote" button (which renders
  // only when isWriter || isAdmin). If isWriter were still checking the
  // wrong string, demote wouldn't appear.
  const rowMatch = page2.body.match(new RegExp(`<tr[^>]*data-search="[^"]*${target.userId}[^"]*"[\\s\\S]*?</tr>`))
  t.ok(rowMatch, 'admin page contains a row for the target user')
  t.ok(rowMatch && rowMatch[0].includes('demote'),
    'promoted user shows the demote button (proves isWriter check matches the stored value)')
})