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