OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / test / profile-self-edit.test.js

148 lines · javascript ref:
// Read-only users must be able to edit their own profile.
//
// Before this fix, `update-profile` called `requireWriter`, which 403'd
// every TOFU-registered non-first user (they get globalPermissions:
// ['read']). The handler already pins userId to the caller's session,
// so a writer-perm gate on self-edit was both wrong and unnecessary.

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 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),
        Accept: 'application/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 })
      })
      res.on('error', reject)
    })
    req.on('error', reject)
    req.write(json)
    req.end()
  })
}

async function envelopeCall (port, method, params, kp) {
  const env = await signTestEnvelope({ keypair: kp, method, params: params || {} })
  return postJson('http://127.0.0.1:' + port + '/api/rpc', env)
}

async function tofuLogin (port) {
  const { an } = await import('anproto')
  const kp = await an.gen()
  const r = await envelopeCall(port, 'whoami', {}, kp)
  if (r.status !== 200 || !r.body || !r.body.ok) {
    throw new Error('login failed: ' + r.status + ' ' + JSON.stringify(r.body))
  }
  return { kp, identity: r.body.identity }
}

test('read-only user can edit their OWN profile (update-profile self-edit)', async (t) => {
  const node = await makeNode(t)
  const port = node.svc.http.port

  // First TOFU user → owner.
  const owner = await tofuLogin(port)
  t.ok(owner.identity.becameOwner, 'first user is owner')

  // Second TOFU user → read-only. Pre-fix this user got 403 on update-profile.
  const reader = await tofuLogin(port)
  t.absent(reader.identity.becameOwner, 'second user is not owner')

  // Confirm via UDS that the second user really has read-only perms
  // (so this test stays meaningful if TOFU defaults ever change).
  const client = await Client.discover({ uds: node.svc.uds.path })
  t.teardown(async () => client.close())
  const users = await client.call('list-users', {})
  const readerRow = users.find(u => (u.identities || []).some(i =>
    i === reader.identity.publicKeyHex || i.publicKey === reader.identity.publicKeyHex
  ))
  t.ok(readerRow, 'second user is in the registry')
  const perms = (readerRow && readerRow.globalPermissions) || []
  t.absent(perms.includes('write'), 'second user has no write perm')
  t.absent(perms.includes('admin'), 'second user has no admin perm')

  // The fix: second user can update their own name.
  const upd = await envelopeCall(port, 'update-profile', { name: 'Alice the Reader' }, reader.kp)
  t.is(upd.status, 200, 'read-only user gets 200 on self-edit')
  t.ok(upd.body && upd.body.ok, 'envelope reports ok')
  t.is(upd.body.result.user.name, 'Alice the Reader', 'name returned')

  // Reload via get-profile, asserting persistence not just response shape.
  const fresh = await envelopeCall(port, 'get-profile', {}, reader.kp)
  t.is(fresh.status, 200, 'get-profile ok')
  t.is(fresh.body.result.user.name, 'Alice the Reader', 'name persisted')
})

test('update-profile ignores params.userId over HTTP (no cross-user write)', async (t) => {
  const node = await makeNode(t)
  const port = node.svc.http.port

  const owner = await tofuLogin(port)
  const reader = await tofuLogin(port)

  // Reader attempts to set the OWNER's name by passing the owner's userId
  // in params. The handler must ignore it and update the reader's own
  // record instead.
  const client = await Client.discover({ uds: node.svc.uds.path })
  t.teardown(async () => client.close())
  const users = await client.call('list-users', {})
  const ownerRow = users.find(u => (u.identities || []).some(i =>
    i === owner.identity.publicKeyHex || i.publicKey === owner.identity.publicKeyHex
  ))
  t.ok(ownerRow, 'owner row resolved')
  const ownerOriginalName = ownerRow.name

  const upd = await envelopeCall(port, 'update-profile', {
    userId: ownerRow.userId,
    name: 'HIJACKED'
  }, reader.kp)
  t.is(upd.status, 200, 'update succeeded (against reader, not owner)')
  t.not(upd.body.result.user.userId, ownerRow.userId, 'updated record is NOT the owner')

  // Owner's record unchanged.
  const usersAfter = await client.call('list-users', {})
  const ownerAfter = usersAfter.find(u => u.userId === ownerRow.userId)
  t.is(ownerAfter.name, ownerOriginalName, 'owner name not altered by reader')
})