OverviewFilesCommitsIssuesDocsReleasesPeers

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

183 lines · javascript ref:
// per-user-followers.test.js — a follow of a registered user must land on THAT user.
//
// ⛔ THE LIVE DEFECT, and it is the same shape as the `shared.followerStore` one a version earlier:
// a per-user value is resolved correctly and the consumer reads a process-wide one. Four of them
// sit on this single path, and together they meant a follow of @ashatron@ap.7rnx.net did nothing
// at all — no error, no log, 202 to the sender.
//
//   1. `withUser` set `followerStore`; every handler reads `ctx.followers`. So /followers served
//      the PRIMARY's list for everyone, and an accepted Follow was written to it.
//   2. `inbox` verified the signature against a hardcoded `/ap/inbox`. A remote server signs
//      `(request-target): post /ap/users/ashatron/inbox` — the signing strings differ, so every
//      signed Follow to a registered user's inbox was rejected 401.
//   3. The `Accept` carried `actor: actorId(ctx.config)` — always the primary's actor id.
//   4. It was signed with the primary's key, against the primary's key id.
//
// (3) and (4) are the `keyId({host})` duplicate bug again: an Accept that says it comes from
// tabertron does not match a Follow addressed to ashatron, so the follow stays pending for ever.
//
// ⚠ Live evidence for (1): /ap/users/ashatron/followers and /ap/followers both served
// {"totalItems":1,"orderedItems":["https://prsm.space/users/fleeky"]} while
// <dataDir>/users/ashatron/ did not exist — and the shared followers.json was last written in July.

import test from 'brittle'
import { handleRequest } from '../lib/router.js'
import { MemoryFollowerStore } from '../lib/followers.js'
import { Users } from '../lib/users.js'
import { signRequest } from '../lib/http-signature.js'
import { TEST_PRIVATE_KEY, TEST_PUBLIC_KEY } from './fixtures/keys.js'

const HOST = 'ap.7rnx.net'
const REMOTE = 'https://prsm.space/users/fleeky'
const REMOTE_KEY = `${REMOTE}#main-key`

function makeCtx () {
  const users = new Users([{ username: 'p2p', primary: true }], HOST)
  users.add({ username: 'ashatron' })

  const stores = {
    p2p: new MemoryFollowerStore(['https://mastodon.social/users/someone-else']),
    ashatron: new MemoryFollowerStore()
  }
  const signers = { p2p: null, ashatron: { keyId: `https://${HOST}/ap/users/ashatron/actor#main-key` } }
  const delivered = []

  const ctx = {
    config: { host: HOST, username: 'p2p', maxSkewSeconds: 300 },
    publicKeyPem: TEST_PUBLIC_KEY,
    users,
    // The process-wide store, which is what the primary keeps using.
    followers: stores.p2p,
    followersFor: (u) => stores[u.username] || null,
    signerFor: (u) => signers[u.username] || null,
    resolvePublicKey: async () => TEST_PUBLIC_KEY,
    deliver: async (d) => { delivered.push(d) },
    newId: () => 'test-id',
    log: () => {}
  }
  return { ctx, stores, delivered }
}

// Sign exactly as a remote server does: over the path it is actually POSTing to.
function signedFollow (path, object) {
  const payload = JSON.stringify({
    '@context': 'https://www.w3.org/ns/activitystreams',
    id: `${REMOTE}/follows/1`,
    type: 'Follow',
    actor: REMOTE,
    object
  })
  const headers = signRequest({
    method: 'POST',
    url: `https://${HOST}${path}`,
    body: payload,
    keyId: REMOTE_KEY,
    privateKey: TEST_PRIVATE_KEY
  })
  return { method: 'POST', path, query: {}, headers, body: payload }
}

test('⛔ a registered user\'s /followers reads THEIR list, not the primary\'s', async (t) => {
  const { ctx, stores } = makeCtx()
  await stores.ashatron.add('https://elsewhere.net/users/bob')

  const res = await handleRequest({ method: 'GET', path: '/ap/users/ashatron/followers', query: {}, headers: {} }, ctx)
  const doc = JSON.parse(res.body)

  t.is(doc.totalItems, 1, 'their one follower')
  t.alike(doc.orderedItems, ['https://elsewhere.net/users/bob'], 'and it is theirs')
  t.absent(doc.orderedItems.includes('https://mastodon.social/users/someone-else'), 'NOT the primary\'s')
})

test('★ the primary\'s /followers is unchanged', async (t) => {
  const { ctx } = makeCtx()
  const res = await handleRequest({ method: 'GET', path: '/ap/followers', query: {}, headers: {} }, ctx)
  const doc = JSON.parse(res.body)
  t.alike(doc.orderedItems, ['https://mastodon.social/users/someone-else'])
})

// ⛔⛔ THE ONE THAT SHIPPED. This is the whole live symptom in one assertion.
test('⛔⛔ a signed Follow to a registered user\'s inbox is ACCEPTED and lands on them', async (t) => {
  const { ctx, stores } = makeCtx()
  const req = signedFollow('/ap/users/ashatron/inbox', `https://${HOST}/ap/users/ashatron/actor`)

  const res = await handleRequest(req, ctx)
  t.is(res.status, 202, 'accepted — a 401 here is the signature being checked against the wrong path')

  t.alike(await stores.ashatron.list(), [REMOTE], 'recorded against ashatron')
  t.is(await stores.p2p.count(), 1, 'and the primary\'s list did not grow')
})

test('⛔ the Accept comes from the user who was followed, signed with their key', async (t) => {
  const { ctx, delivered } = makeCtx()
  await handleRequest(signedFollow('/ap/users/ashatron/inbox', `https://${HOST}/ap/users/ashatron/actor`), ctx)

  t.is(delivered.length, 1)
  const { activity, signer } = delivered[0]
  t.is(activity.type, 'Accept')
  t.is(activity.actor, `https://${HOST}/ap/users/ashatron/actor`,
    'THEIR actor — an Accept from the primary does not match a Follow addressed to ashatron')
  t.ok(activity.id.startsWith(`https://${HOST}/ap/users/ashatron/`), 'and their id space')
  t.ok(signer && signer.keyId.startsWith(`https://${HOST}/ap/users/ashatron/`),
    'signed with their key — the primary\'s signature against their key id is rejected by every receiver')
})

test('★ a Follow of the primary still works exactly as before', async (t) => {
  const { ctx, stores, delivered } = makeCtx()
  const res = await handleRequest(signedFollow('/ap/inbox', `https://${HOST}/ap/actor`), ctx)

  t.is(res.status, 202)
  t.ok((await stores.p2p.list()).includes(REMOTE), 'on the primary\'s list')
  t.is(await stores.ashatron.count(), 0, 'and nowhere else')
  t.is(delivered[0].activity.actor, `https://${HOST}/ap/actor`, 'the flat actor id, unmoved')
  t.absent(delivered[0].signer, 'and no per-delivery signer — it keeps the process signer it always had')
})

test('⛔ an Undo{Follow} removes from THEIR list', async (t) => {
  const { ctx, stores } = makeCtx()
  await handleRequest(signedFollow('/ap/users/ashatron/inbox', `https://${HOST}/ap/users/ashatron/actor`), ctx)
  t.is(await stores.ashatron.count(), 1)

  const payload = JSON.stringify({
    '@context': 'https://www.w3.org/ns/activitystreams',
    id: `${REMOTE}/undo/1`,
    type: 'Undo',
    actor: REMOTE,
    object: { type: 'Follow', actor: REMOTE, object: `https://${HOST}/ap/users/ashatron/actor` }
  })
  const path = '/ap/users/ashatron/inbox'
  const headers = signRequest({ method: 'POST', url: `https://${HOST}${path}`, body: payload, keyId: REMOTE_KEY, privateKey: TEST_PRIVATE_KEY })
  await handleRequest({ method: 'POST', path, query: {}, headers, body: payload }, ctx)

  t.is(await stores.ashatron.count(), 0, 'gone from theirs')
  t.is(await stores.p2p.count(), 1, 'the primary untouched')
})

// ⛔ A NEW FOLLOWER MUST DRAIN THAT USER'S HELD POSTS — and only that user's.
//
// Posts made while nobody was following are HELD rather than recorded as delivered (0.9.82), which
// only means anything if somebody eventually sends them. Without this they sit until an unrelated
// future post advances the feed.
test('⛔ an accepted Follow drains THAT user\'s publisher, not the primary\'s', async (t) => {
  const { ctx } = makeCtx()
  const drained = []
  ctx.drainFor = (u) => drained.push(u.username)
  ctx.onFollowed = () => drained.push('primary')

  await handleRequest(signedFollow('/ap/users/ashatron/inbox', `https://${HOST}/ap/users/ashatron/actor`), ctx)
  t.alike(drained, ['ashatron'], 'theirs')

  await handleRequest(signedFollow('/ap/inbox', `https://${HOST}/ap/actor`), ctx)
  t.alike(drained, ['ashatron', 'p2p'], 'and the primary resolves through the same per-user path')
})

test('★ a repeated Follow does not re-drain', async (t) => {
  const { ctx } = makeCtx()
  let drains = 0
  ctx.drainFor = () => { drains++ }
  const req = () => signedFollow('/ap/users/ashatron/inbox', `https://${HOST}/ap/users/ashatron/actor`)
  await handleRequest(req(), ctx)
  await handleRequest(req(), ctx)
  t.is(drains, 1, 'the second Follow adds nobody, so there is nothing new to send')
})