import test from 'brittle'
import {
parseAcctResource,
matchesActor,
buildWebfinger,
buildActor,
buildCollection,
wantsActivityJson,
actorId,
keyId,
ACTIVITY_JSON
} from '../lib/actor.js'
import { TEST_PUBLIC_KEY } from './fixtures/keys.js'
const CFG = { host: 'p2p.7rnx.net', username: 'p2p', publicKeyPem: TEST_PUBLIC_KEY }
test('parseAcctResource — accepts acct:, rejects everything else', (t) => {
t.alike(parseAcctResource('acct:alice@example.com'), { user: 'alice', host: 'example.com' })
t.alike(parseAcctResource(' acct:Alice@Example.COM '), { user: 'alice', host: 'example.com' })
// RFC 7033 defines acct: — a bare address is not a valid resource and must not be guessed at
t.is(parseAcctResource('alice@example.com'), null)
t.is(parseAcctResource('https://example.com/users/alice'), null)
t.is(parseAcctResource('acct:alice'), null)
t.is(parseAcctResource('acct:alice@ex@ample.com'), null)
t.is(parseAcctResource('acct:alice@example.com/x'), null)
t.is(parseAcctResource(''), null)
t.is(parseAcctResource(undefined), null)
})
// A WebFinger endpoint that answers for any resource claims to host accounts it doesn't have,
// which breaks discovery for whoever actually does.
test('matchesActor — only our own account', (t) => {
t.ok(matchesActor('acct:p2p@p2p.7rnx.net', CFG))
t.ok(matchesActor('acct:P2P@P2P.7RNX.NET', CFG))
t.absent(matchesActor('acct:someoneelse@p2p.7rnx.net', CFG))
t.absent(matchesActor('acct:p2p@mastodon.social', CFG))
t.absent(matchesActor('garbage', CFG))
})
test('webfinger — shape Mastodon actually reads', (t) => {
const jrd = buildWebfinger({ ...CFG, profileUrl: 'https://p2p.7rnx.net/' })
t.is(jrd.subject, 'acct:p2p@p2p.7rnx.net')
t.alike(jrd.aliases, ['https://p2p.7rnx.net/ap/actor'])
const self = jrd.links.find((l) => l.rel === 'self')
t.is(self.type, ACTIVITY_JSON, 'rel=self MUST be activity+json or remote servers ignore it')
t.is(self.href, actorId(CFG))
t.absent(buildWebfinger(CFG).links.find((l) => l.rel.includes('profile-page')),
'no profile-page link when no profileUrl given')
})
test('actor — required fields and endpoint URLs', (t) => {
const a = buildActor({ ...CFG, displayName: 'P2P', summary: 'test actor', published: '2026-07-31T00:00:00.000Z' })
t.is(a.id, 'https://p2p.7rnx.net/ap/actor')
t.is(a.type, 'Person')
t.is(a.preferredUsername, 'p2p')
t.is(a.name, 'P2P')
t.is(a.inbox, 'https://p2p.7rnx.net/ap/inbox')
t.is(a.outbox, 'https://p2p.7rnx.net/ap/outbox')
t.is(a.followers, 'https://p2p.7rnx.net/ap/followers')
t.is(a.following, 'https://p2p.7rnx.net/ap/following')
t.is(a.published, '2026-07-31T00:00:00.000Z')
t.ok(a['@context'].includes('https://www.w3.org/ns/activitystreams'))
t.ok(a['@context'].includes('https://w3id.org/security/v1'), 'security context is required for publicKey')
})
test('actor — publicKey block is what HTTP Signature verification resolves', (t) => {
const a = buildActor(CFG)
t.is(a.publicKey.id, keyId(CFG))
t.is(a.publicKey.id, 'https://p2p.7rnx.net/ap/actor#main-key')
t.is(a.publicKey.owner, a.id)
t.ok(a.publicKey.publicKeyPem.startsWith('-----BEGIN PUBLIC KEY-----'))
t.ok(a.publicKey.publicKeyPem.endsWith('-----END PUBLIC KEY-----\n'), 'trailing newline, no stray whitespace')
})
test('actor — name falls back to username', (t) => {
t.is(buildActor(CFG).name, 'p2p')
})
test('actor — refuses to build without a key', (t) => {
t.exception(() => buildActor({ host: 'a', username: 'b' }), /publicKeyPem is required/)
t.exception(() => buildActor({ username: 'b', publicKeyPem: 'x' }), /host is required/)
t.exception(() => buildActor({ host: 'a', publicKeyPem: 'x' }), /username is required/)
})
// Content negotiation is a silent failure mode: get it wrong and remote servers receive HTML
// where they expected JSON, which surfaces as an unhelpful generic error on their side.
test('wantsActivityJson — the Accept headers real implementations send', (t) => {
t.ok(wantsActivityJson('application/activity+json'))
t.ok(wantsActivityJson('application/ld+json; profile="https://www.w3.org/ns/activitystreams"'))
t.ok(wantsActivityJson('application/activity+json, application/ld+json'))
t.ok(wantsActivityJson('application/json'))
t.absent(wantsActivityJson('text/html'))
t.absent(wantsActivityJson('text/html,application/xhtml+xml'))
t.absent(wantsActivityJson(''))
t.absent(wantsActivityJson(undefined))
// ld+json WITHOUT the AS2 profile is not an ActivityPub request
t.absent(wantsActivityJson('application/ld+json'))
})
test('collection — empty and populated', (t) => {
const empty = buildCollection({ id: 'https://p2p.7rnx.net/ap/followers' })
t.is(empty.type, 'OrderedCollection')
t.is(empty.totalItems, 0)
t.absent(empty.orderedItems)
const one = buildCollection({ id: 'x', totalItems: 1, items: ['https://mastodon.social/users/a'] })
t.is(one.totalItems, 1)
t.alike(one.orderedItems, ['https://mastodon.social/users/a'])
})
// ★ The cross-network identity link. One-sided for now — the DID doc asserting back needs a PLC
// op — so this proves we STATE the claim, not that it is mutually confirmed.
test('actor — alsoKnownAs carries the cross-network identity link', (t) => {
const did = 'did:plc:wj62ebbusln6gxo5azo6ogzo'
const a = buildActor({ ...CFG, alsoKnownAs: [did] })
t.alike(a.alsoKnownAs, [did])
// Absent rather than empty when unset — an empty array is a claim of "no other identities",
// which is not what we mean.
t.absent('alsoKnownAs' in buildActor(CFG))
})
test('actor — avatar/banner/displayName projected from the profile record', (t) => {
const a = buildActor({
...CFG,
profile: {
name: 'tabertron',
summary: 'the upstairs device of fleeky',
icon: { type: 'Image', mediaType: 'image/jpeg', url: 'https://p2p.7rnx.net/ap/blobs/bafkreiavatar' },
image: { type: 'Image', mediaType: 'image/webp', url: 'https://p2p.7rnx.net/ap/blobs/bafkreibanner' }
}
})
t.is(a.name, 'tabertron', 'profile displayName wins over config')
t.is(a.summary, 'the upstairs device of fleeky')
t.is(a.icon.url, 'https://p2p.7rnx.net/ap/blobs/bafkreiavatar')
t.is(a.image.mediaType, 'image/webp')
// Absent, not null — an empty icon renders as a broken image on some clients.
const bare = buildActor(CFG)
t.absent('icon' in bare)
t.absent('image' in bare)
})
test('⛔ favelaSocket and did are mutually exclusive', async (t) => {
// Two sources for one actor means two object ids per post, and whichever ran second reads as a
// stranger reposting the first. Refused at startup rather than discovered in somebody's timeline.
const { loadConfig } = await import('../lib/config.js')
const cfg = loadConfig({ AP_FAVELA_SOCKET: '/tmp/p.sock', AP_DID: 'did:plc:x', AP_HOST: 'h' })
t.is(cfg.favelaSocket, '/tmp/p.sock')
t.is(cfg.did, 'did:plc:x')
// lib/bridge.js (index.js's main() until 0.10.15) throws on this pair; the config layer's job is
// only to carry both so it can be seen.
const src = (await import('bare-fs')).default.readFileSync(new URL('../lib/bridge.js', import.meta.url).pathname, 'utf8')
t.ok(/favelaSocket OR did, not both/.test(src), 'and the refusal names the reason')
})