OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge / test / per-user-actor.test.js

118 lines · javascript ref:
// per-user-actor.test.js — an actor must never wear somebody else's identity.
//
// ⛔ THE LIVE DEFECT. ashatron registered, /ap/users/ashatron/actor served 200 — with the
// INCUMBENT's display name ("tabertron"), the incumbent's summary, and the incumbent's avatar. The
// cause was not one bug but a shape: `actorDoc` reads `ctx.records.getProfile()`, FavelaSource did
// not implement it, `.catch(() => ({}))` swallowed that, and everything then fell through to the
// process-wide config — which describes the primary.
//
// So a missing method surfaced as THE WRONG PERSON, silently. The contract file stops the method
// being missing; this stops the fallback being another identity.

import test from 'brittle'
import { handleRequest } from '../lib/router.js'
import { Users } from '../lib/users.js'

const CONFIG = {
  host: 'ap.7rnx.net',
  username: 'p2p',
  displayName: 'tabertron',
  summary: 'the upstairs device of fleeky'
}

const get = (path, ctx) => handleRequest({ method: 'GET', path, query: {}, headers: { accept: 'application/activity+json' } }, ctx)

function ctxWith (userProfile) {
  const users = new Users([{ username: 'p2p', primary: true }], CONFIG.host)
  users.add({ username: 'ashatron' })
  return {
    config: CONFIG,
    users,
    publicKeyPem: '-----BEGIN PUBLIC KEY-----\nx\n-----END PUBLIC KEY-----',
    recordsFor: (u) => (u.username === 'ashatron'
      ? { async getProfile () { return userProfile }, async listNotes () { return { notes: [], cursor: null } } }
      : null),
    followersFor: () => ({ async list () { return [] }, async count () { return 0 } }),
    log: () => {}
  }
}

test('★★ a registered actor uses ITS OWN profile', async (t) => {
  const res = await get('/ap/users/ashatron/actor', ctxWith({ name: 'ashes', summary: 'the downstairs device' }))
  const doc = JSON.parse(res.body)

  t.is(doc.preferredUsername, 'ashatron')
  t.is(doc.name, 'ashes', 'their name')
  t.is(doc.summary, 'the downstairs device', 'their words')
})

// ⛔ THE ONE THAT MATTERS. An identity with no profile record yet must render as ITSELF with fields
// missing — never as the primary. Wearing somebody else's name is worse than having none, and it is
// the failure that actually shipped.
test('⛔⛔ an empty profile does NOT inherit the primary\'s identity', async (t) => {
  const res = await get('/ap/users/ashatron/actor', ctxWith({}))
  const doc = JSON.parse(res.body)

  t.is(doc.preferredUsername, 'ashatron')
  t.not(doc.name, 'tabertron', 'NOT the incumbent\'s display name')
  t.not(doc.summary, 'the upstairs device of fleeky', 'NOT the incumbent\'s bio')
  t.absent(doc.icon && doc.icon.url && String(doc.icon.url).includes('/ap/blobs/'), 'and not their avatar')
})

test('★ the primary still uses the config, exactly as before', async (t) => {
  const res = await get('/ap/actor', ctxWith({}))
  const doc = JSON.parse(res.body)
  t.is(doc.preferredUsername, 'p2p')
  t.is(doc.name, 'tabertron', 'the incumbent is unchanged — its config IS its profile')
})

// ⛔⛔ THE DUPLICATE. Mastodon indexes actors by publicKey.id. buildActor called keyId({host}),
// ignoring the actor it was building — so EVERY user advertised `https://host/ap/actor#main-key`,
// the PRIMARY's key id, with their own actor as `owner`. Mastodon resolved a registered user to the
// actor it already knew for that key: @ashatron@ap.7rnx.net came back as tabertron.
//
// Worse, publicKeyPem was process-wide too, so a second actor advertised the primary's actual key
// material. Two actors, one keypair.
test('⛔⛔ a registered actor advertises ITS OWN key id, not the primary\'s', async (t) => {
  const ctx = ctxWith({})
  ctx.publicKeyPem = '-----BEGIN PUBLIC KEY-----\nprimary\n-----END PUBLIC KEY-----'
  ctx.keysFor = (u) => (u.username === 'ashatron'
    ? { publicKeyPem: '-----BEGIN PUBLIC KEY-----\nashes\n-----END PUBLIC KEY-----' }
    : null)

  const doc = JSON.parse((await get('/ap/users/ashatron/actor', ctx)).body)
  t.is(doc.publicKey.id, 'https://ap.7rnx.net/ap/users/ashatron/actor#main-key',
    'their key id, under their actor')
  t.is(doc.publicKey.owner, doc.id, 'and owner agrees with id')
  t.ok(doc.publicKey.publicKeyPem.includes('ashes'), 'their OWN key material, not the primary\'s')

  const primary = JSON.parse((await get('/ap/actor', ctx)).body)
  t.is(primary.publicKey.id, 'https://ap.7rnx.net/ap/actor#main-key', 'the incumbent key id is unmoved')
  t.not(doc.publicKey.id, primary.publicKey.id, 'and the two are distinct — which is the whole bug')
})

// ⛔ `url` IS THE HUMAN-READABLE PROFILE, and it was `https://${host}/` for everyone — the bridge
// root, which is the PRIMARY's page. So Mastodon's "open original profile" on a registered user
// took you to the incumbent. Same class as the blob and object ids: derived from the host, so every
// user got the primary's.
test('★★ a registered user\'s url is their own page, not the bridge root', async (t) => {
  const doc = JSON.parse((await get('/ap/users/ashatron/actor', ctxWith({}))).body)
  t.is(doc.url, 'https://ap.7rnx.net/ap/users/ashatron', 'their page')
  t.not(doc.url, 'https://ap.7rnx.net/', 'NOT the incumbent\'s')
})

test('★ the primary keeps the bare root, which is already federated', async (t) => {
  const doc = JSON.parse((await get('/ap/actor', ctxWith({}))).body)
  t.is(doc.url, 'https://ap.7rnx.net/')
})

test('★ and that page actually serves', async (t) => {
  const res = await handleRequest(
    { method: 'GET', path: '/ap/users/ashatron', query: {}, headers: { accept: 'text/html' } },
    ctxWith({})
  )
  t.is(res.status, 200, 'a url that 404s is worse than none')
  t.ok(res.body.includes('@ashatron@ap.7rnx.net'), 'and it names the right person')
  t.absent(res.body.includes('@p2p@'), 'not the primary')
})