OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / test / issue-author-attribution.test.js

174 lines · javascript ref:
// 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')
})