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