// Issue + comment authorship must come from the caller's canonical
// userId (resolved from their session), not a free-form input. The
// rendered display name comes from a render-time lookup of the User
// record so a later name change propagates retroactively across all
// past issues authored by that user.
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)
}
}, (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 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)
}
// The issues page renders the author via displayAuthor() (issues.js:54), which
// wraps a resolvable name in a profile link: `by <a href="/profile/…">Name</a>`.
// A literal `includes('by Name')` therefore only matches the UNLINKED fallback —
// it used to pass here purely because a malformed cookie made the request
// anonymous. Match both forms so the assertion tests attribution, not markup.
function byAuthor (body, name) {
return new RegExp('by\\s*(?:<a\\b[^>]*>\\s*)?' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).test(body)
}
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) throw new Error('login failed: ' + r.status)
// set-cookie is a STRING under Bare, an ARRAY under Node — normalise before
// indexing, or [0] takes the first character. See 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 }
}
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)
})
}
test('create-issue + comment-issue store canonical userId; display updates on rename', async (t) => {
const node = await makeNode(t)
const port = node.svc.http.port
const author = await tofuLogin(port)
// Seed a repo so create-issue has a scope.
const client = await Client.discover({ uds: node.svc.uds.path })
t.teardown(async () => client.close())
await client.call('create-org', { name: 'demo', type: 'group' })
// Initial display name for the author.
await client.call('update-profile', { userId: author.userId, name: 'Initial Name' })
// Create issue via HTTP envelope — no `author` param. The handler MUST
// resolve from the session pubkey.
const createRes = await envelopeCall(port, 'create-issue', {
org: 'demo', title: 'first issue', body: 'hi'
}, author.kp)
t.is(createRes.status, 200, 'create-issue accepted')
const issueId = createRes.body.result.id
t.is(createRes.body.result.author, author.userId,
'stored author IS the canonical userId, not a free-form string')
// Comment via HTTP envelope, no author param.
const commentRes = await envelopeCall(port, 'comment-issue', {
org: 'demo', id: issueId, body: 'reply'
}, author.kp)
t.is(commentRes.status, 200, 'comment-issue accepted')
// Read back through UDS and confirm comment also has userId attribution.
const listResp = await client.call('list-issues', { org: 'demo' })
const issue = (listResp.issues || []).find(i => i.id === issueId)
t.ok(issue, 'issue exists')
t.is(issue.author, author.userId, 'issue.author is userId')
// Pull comments via UDS — need a get-issue with comments or a get-comments
// RPC. Easiest path: load the issue detail page and assert the rendered
// display name comes from the User record at render time.
const beforePage = await fetchText(port, '/orgs/demo/issues/' + issueId, author.cookie)
t.is(beforePage.status, 200, 'issue detail renders')
t.ok(byAuthor(beforePage.body, 'Initial Name'),
'display name is rendered from the User record, not the raw author field')
// Rename the user. The stored issue/comment author (userId) is unchanged,
// but the rendered display name must update everywhere.
await client.call('update-profile', { userId: author.userId, name: 'Renamed' })
const afterPage = await fetchText(port, '/orgs/demo/issues/' + issueId, author.cookie)
t.is(afterPage.status, 200, 'issue detail renders post-rename')
t.ok(byAuthor(afterPage.body, 'Renamed'), 'rename propagates to issue display')
t.absent(byAuthor(afterPage.body, 'Initial Name'), 'old name is gone')
})
test('issue create form no longer ships an author input', async (t) => {
const node = await makeNode(t)
const port = node.svc.http.port
const owner = await tofuLogin(port)
// Seed a repo so /orgs/demo/issues renders.
const client = await Client.discover({ uds: node.svc.uds.path })
t.teardown(async () => client.close())
await client.call('create-org', { name: 'demo', type: 'group' })
const page = await fetchText(port, '/orgs/demo/issues', owner.cookie)
t.is(page.status, 200, 'issues index renders')
t.absent(page.body.includes('id="new-issue-author"'),
'no free-form author input on the create form')
t.absent(page.body.includes('name="author"'),
'no name="author" input anywhere on the issues page')
})