// 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')
})