OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge / test / public-timeline.test.js

177 lines · javascript ref:
// A page with no login in front of it, rendering what strangers sent us.
//
// ⛔ THE FILTER IS THE SAFETY PROPERTY. Mastodon delivers DIRECT MESSAGES to an inbox exactly as it
// delivers replies — same handler, same store — so a public page over that store publishes somebody's
// DM unless addressing is checked. normalize() drops `to`/`cc`, and the remote object is only
// refetchable while its server still serves it, so the answer has to be recorded AT ARRIVAL.

import '../lib/bare-globals.js'
import test from 'brittle'
import { normalize, isPublicActivity, isInteraction } from '../lib/inbound-store.js'
import { publicTimeline } from '../lib/public-timeline.js'
import { buildFeed } from '../lib/feed.js'

const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public'
const note = (extra = {}, objExtra = {}) => ({
  id: 'https://m.example/a/1',
  type: 'Create',
  actor: 'https://m.example/users/someone',
  object: { id: 'https://m.example/o/1', type: 'Note', content: '<p>hi</p>', ...objExtra },
  ...extra
})

test('★★★ a direct message is NOT public', (t) => {
  // Addressed to one actor and nobody else — this is a DM, and it arrives at the same inbox.
  const dm = note({ to: ['https://ap.7rnx.net/ap/users/fleeky/actor'] })
  t.absent(isPublicActivity(dm), 'a DM is not public')
  t.is(normalize(dm, 1).public, false, 'and the store records that at arrival')
})

test('★★★ followers-only is NOT public', (t) => {
  const followers = note({ to: ['https://m.example/users/someone/followers'] })
  t.absent(isPublicActivity(followers))
  t.is(normalize(followers, 1).public, false)
})

test('★★★ public is recognised in all three spellings seen in the wild', (t) => {
  // ⚠ The full URI is canonical, `as:Public` is the compacted JSON-LD form, bare `Public` comes from
  // older implementations. Matching only the first fails safely but hides half the fediverse.
  for (const form of [PUBLIC, 'as:Public', 'Public']) {
    t.ok(isPublicActivity(note({ to: [form] })), form + ' in to')
    t.ok(isPublicActivity(note({ cc: [form] })), form + ' in cc')
  }
  // And on the OBJECT rather than the activity, which is where Mastodon often puts it.
  t.ok(isPublicActivity(note({}, { to: [PUBLIC] })), 'public on the object counts')
})

test('★★★ an item with no addressing at all is withheld', (t) => {
  // ⛔ FAIL CLOSED. Records stored before `public` existed have `undefined`, and the route requires
  // `=== true`. An operator sees a shorter page after upgrading; the alternative is publishing a DM
  // because the record predates the question being asked.
  t.absent(isPublicActivity(note({})), 'no to/cc means not public')
  const legacy = { type: 'Create', actor: 'https://m.example/u/1', content: 'old' }
  t.not(legacy.public, true, 'a legacy record is not `=== true`, which is what the route tests')
})

test('★★★ remote markup never reaches the page intact', (t) => {
  const rows = buildFeed({
    self: {},
    inbound: [{ id: 'x', actor: 'https://m.example/u/1', type: 'Create', inReplyTo: 'https://ap.7rnx.net/ap/objects/1', public: true, receivedAt: 1788800000000, content: '<img src=x onerror=alert(1)>hello <b>there</b>' }]
  })
  const body = publicTimeline({ host: 'ap.7rnx.net', total: 1, rows })
  t.absent(/<img/.test(body), 'no remote tag survives')
  t.absent(/onerror/.test(body), 'no remote attribute survives')
  t.ok(/hello there/.test(body), 'the words do')
})

test('★★ it says what it does not show, and asks not to be indexed', (t) => {
  const body = publicTimeline({ host: 'ap.7rnx.net', rows: [], total: 0 })
  t.ok(/Direct and followers-only messages are never shown/.test(body),
    'a reader should not have to infer the policy from an absence')
  // ⚠ Other people's words republished on our domain — letting a crawler take a second permanent copy
  // is a decision nobody here has made.
  t.ok(/noindex/.test(body))
})

test('★★ paging offers an older link only when there is more', (t) => {
  const rows = Array.from({ length: 50 }, (_, i) => ({ kind: 'post', author: { handle: '@a@b' }, at: String(i), text: 'x', source: 'own' }))
  t.ok(/50 older/.test(publicTimeline({ rows, total: 120, offset: 0 })), 'more to come')
  t.absent(/older/.test(publicTimeline({ rows, total: 50, offset: 0 })), 'nothing more, no link')
  t.ok(/newer/.test(publicTimeline({ rows, total: 120, offset: 50 })), 'and a way back')
})

test('★★★ an interaction is visible in words, which is why this page exists', (t) => {
  const rows = buildFeed({
    self: {},
    inbound: [
      { id: 'l', type: 'Like', actor: 'https://m.example/users/alice', objectId: 'https://ap.7rnx.net/ap/objects/1', public: true, published: '2026-09-15T10:00:00Z' },
      { id: 'b', type: 'Announce', actor: 'https://m.example/users/bob', objectId: 'https://ap.7rnx.net/ap/objects/1', public: true, published: '2026-09-15T10:01:00Z' }
    ]
  })
  const body = publicTimeline({ host: 'ap.7rnx.net', rows, total: 2 })
  t.ok(/liked your post/.test(body), 'a like reads as a like, not as the string "Like"')
  t.ok(/boosted your post/.test(body), 'and a boost as a boost')
  t.ok(/@alice@m.example/.test(body), 'attributed to a handle a fediverse user recognises')
  t.absent(/https:\/\/m\.example\/users\/alice<\/span>/.test(body), 'not to a raw URL')
})

test('★★ our own picture renders; a remote one is LINKED, not hotlinked', (t) => {
  const rows = buildFeed({
    self: { handle: '@p2p@ap.7rnx.net' },
    notes: [{ id: 'https://ap.7rnx.net/ap/objects/1', type: 'Note', content: '<p>look</p>', published: '2026-09-15T09:00:00Z', attachment: [{ type: 'Document', mediaType: 'image/png', url: 'https://ap.7rnx.net/ap/blobs/bafk1', name: 'a screenshot' }] }],
    inbound: [{ id: 'r', type: 'Create', actor: 'https://m.example/users/alice', inReplyTo: 'https://ap.7rnx.net/ap/objects/1', public: true, published: '2026-09-15T10:00:00Z', content: '<p>nice</p>', attachment: [{ type: 'Document', mediaType: 'image/jpeg', url: 'https://m.example/media/1.jpg', name: 'their photo' }] }]
  })
  const body = publicTimeline({ host: 'ap.7rnx.net', rows, total: 2 })
  t.ok(/<img src="https:\/\/ap.7rnx.net\/ap\/blobs\/bafk1"/.test(body), 'ours is shown — we serve those bytes')
  t.ok(/alt="a screenshot"/.test(body), 'with its alt text')
  // ⛔ A public page with no login hotlinking a remote image hands every viewer's IP and page view to
  // a server we do not run. Mastodon proxies media for exactly this reason; we link instead.
  t.absent(/<img src="https:\/\/m.example/.test(body), "a stranger's server does not get our readers' IPs")
  t.ok(/their photo/.test(body), 'but the reader is told a picture is there, and can open it')
})

// ── interactions carry no audience, and absence is not evidence of privacy ─────────────────────────
//
// ⛔ THE RULE THAT WAS WRONG. `isPublicActivity` looks for to/cc on the activity or its object. A Like
// has neither — its `object` is a bare URI string and Mastodon sends no audience at all — so "no
// addressing" read as "not public" and EVERY inbound like, boost and follow was invisible by
// construction. Measured on the live bridge: one Like from @fleeky@prsm.space, stored `public: false`,
// withheld from a page built to show exactly that.
//
// ⚠ THE FIX IS NARROW. For CONTENT the old rule is right and unchanged — a Create with no addressing
// is still withheld, because a DM arrives at the same inbox through the same handler. What changes is
// that an interaction is judged by the thing it acted on, which on this bridge is a public post.

test('★★★ a Create with no addressing is STILL withheld — the DM guard is untouched', (t) => {
  t.absent(isPublicActivity(note({})), 'content with no audience stays private')
  t.is(normalize(note({}), 1).public, false)
  t.is(normalize(note({ to: ['https://ap.7rnx.net/ap/users/fleeky/actor'] }), 1).public, false, 'and a DM stays a DM')
})

test('★★★ a Like is showable — it has no audience because the protocol supplies none', (t) => {
  const like = { id: 'l', type: 'Like', actor: 'https://m.example/users/a', object: 'https://ap.7rnx.net/ap/objects/1' }
  t.ok(isInteraction(like), 'a Like is an interaction, not content')
  t.is(normalize(like, 1).public, true, 'so it is recorded as showable, matching Mastodon')
})

test('★★ a Follow and a Boost too, and an explicitly private Boost is still refused', (t) => {
  t.is(normalize({ type: 'Follow', actor: 'https://m.example/users/a', object: 'https://ap.7rnx.net/ap/actor' }, 1).public, true)
  t.is(normalize({ type: 'Announce', actor: 'https://m.example/users/a', object: 'https://ap.7rnx.net/ap/objects/1' }, 1).public, true)
  // ⚠ An Announce CAN carry addressing, unlike a Like. When it does, it is honoured.
  const privateBoost = { type: 'Announce', actor: 'https://m.example/users/a', object: 'x', to: ['https://m.example/users/a/followers'] }
  t.is(normalize(privateBoost, 1).public, false, 'stated privacy beats the default')
})

test('★★ the feed now shows a like that arrived', (t) => {
  const rows = buildFeed({
    self: {},
    inbound: [normalize({ type: 'Like', actor: 'https://prsm.space/users/fleeky', object: 'https://ap.7rnx.net/ap/objects/1' }, Date.parse('2026-09-14T00:00:00Z'))]
  })
  t.is(rows.length, 1, 'the one measured on the live bridge would appear')
  t.is(rows[0].verb, 'liked your post')
})

test('★★ a handle is a link — ours to their page here, a stranger\'s to their own server', (t) => {
  const rows = buildFeed({
    self: { handle: '@fleeky@ap.7rnx.net' },
    notes: [{ id: 'https://ap.7rnx.net/ap/users/fleeky/objects/1', type: 'Note', content: '<p>mine</p>', published: '2026-09-15T09:00:00Z' }],
    inbound: [{ id: 'r', type: 'Create', actor: 'https://m.example/users/alice', inReplyTo: 'https://ap.7rnx.net/ap/users/fleeky/objects/1', public: true, published: '2026-09-15T10:00:00Z', content: '<p>hi</p>' }]
  })
  const body = publicTimeline({ host: 'ap.7rnx.net', rows, total: 2 })
  // ⛔ A COMBINED PAGE WITH NO WAY INTO A PEER'S OWN IS HALF A FEATURE. You had to already know the
  // URL to read one account, which is the same as it not being there.
  t.ok(/href="\/ap\/users\/fleeky\/timeline"/.test(body), 'ours goes to their page on this bridge')
  t.ok(/href="https:\/\/m.example\/users\/alice"/.test(body), "a stranger's goes to their own server")
  t.ok(/rel="nofollow noopener ugc"/.test(body), 'and a link we did not author is marked as such')
})

test('⛔ a handle on another host is never rewritten into a local path', (t) => {
  const rows = buildFeed({
    self: {},
    inbound: [{ id: 'x', type: 'Like', actor: 'https://evil.example/users/ap.7rnx.net', objectId: 'https://ap.7rnx.net/ap/objects/1', public: true, published: 'z' }]
  })
  const body = publicTimeline({ host: 'ap.7rnx.net', rows, total: 1 })
  t.absent(/href="\/ap\/users\/ap.7rnx.net\/timeline"/.test(body),
    'a name that merely looks like ours does not get one of our pages')
})