OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge / test / inbound-notifications.test.js

99 lines · javascript ref:
// inbound-notifications.test.js — projecting what the fediverse sent us into the shape favela
// already uses for notifications.
//
// ⛔ WHY THIS AND NOT "WRITE IT INTO THE PEER'S LOG". favela-peer has nowhere to put a record whose
// author has no feed: every record lives in its author's feed, `notifications()` is a SCAN of feeds
// this node holds (peer.js:640), and the doorbell is keyed by FEED KEY (peer.js:705) — which a
// Mastodon actor does not have. Putting an inbound Note in the log would mean the bridge authoring
// records as if it were the remote person, which is impersonation with extra steps.
//
// ★ But notifications are "a pure function from records to a view — derived on read, never stored,
// never replicated" (peer.js:623, SOCIAL-MODEL §3). A view deriving from TWO sources is still a
// projection. So the honest fix is to hand the view a second source in the same shape, and let the
// peer go on not knowing what a fediverse is.

import test from 'brittle'
import { toNotification, toNotifications } from '../lib/inbound-notifications.js'

const at = '2026-09-08T10:00:00Z'
const item = (over = {}) => ({
  id: 'https://prsm.space/activities/1',
  type: 'Create',
  actor: 'https://prsm.space/users/fleeky',
  objectId: 'https://prsm.space/notes/1',
  inReplyTo: null,
  content: '<p>hello</p>',
  url: 'https://prsm.space/notes/1',
  published: at,
  receivedAt: at,
  ...over
})

test('★★ the row is the shape favela-peer already emits', async (t) => {
  const n = toNotification(item({ inReplyTo: 'https://ap.7rnx.net/ap/objects/abc' }))
  // peer.js:646 — kind is one of reaction · repost · reply · mention · follow · knock.
  t.alike(Object.keys(n).sort(), ['at', 'author', 'body', 'kind', 'name', 'source', 'target', 'value'].sort())
  t.is(n.kind, 'reply')
  t.is(n.author, 'https://prsm.space/users/fleeky')
  t.is(n.at, at)
  t.is(n.body, '<p>hello</p>')
})

// ⛔ `author` here is an https actor URI. In a peer row it is a 64-char FEED KEY. A view that
// cannot tell them apart will try to resolve one as the other and render a broken profile link.
test('★★ every federated row says where it came from', async (t) => {
  t.is(toNotification(item()).source, 'fediverse')
})

test('★ a Create with inReplyTo is a reply; without one it is a mention', async (t) => {
  t.is(toNotification(item({ inReplyTo: 'https://ap.7rnx.net/ap/objects/abc' })).kind, 'reply')
  t.is(toNotification(item({ inReplyTo: null })).kind, 'mention')
})

test('★ Like is a reaction, Announce is a repost, Follow is a follow', async (t) => {
  t.is(toNotification(item({ type: 'Like' })).kind, 'reaction')
  t.is(toNotification(item({ type: 'Announce' })).kind, 'repost')
  t.is(toNotification(item({ type: 'Follow' })).kind, 'follow')
})

// A remote server can send anything. Rendering an unrecognised activity as some nearby kind tells
// the reader something that did not happen.
test('⛔ an unknown activity type is DROPPED, never guessed at', async (t) => {
  t.is(toNotification(item({ type: 'Flag' })), null)
  t.is(toNotification(item({ type: null })), null)
  t.is(toNotification(null), null)
  t.is(toNotifications([item({ type: 'Flag' }), item()]).length, 1, 'and it does not reach the list')
})

// A Like/Announce points at OUR post; a reply points at the post it answers. Either way the target
// is the thing on our side, which is what makes the row actionable.
test('★ the target is what it acted on', async (t) => {
  t.is(toNotification(item({ type: 'Like', objectId: 'https://ap.7rnx.net/ap/objects/xyz' })).target,
    'https://ap.7rnx.net/ap/objects/xyz')
  t.is(toNotification(item({ inReplyTo: 'https://ap.7rnx.net/ap/objects/abc' })).target,
    'https://ap.7rnx.net/ap/objects/abc')
  t.is(toNotification(item({ type: 'Follow' })).target, null, 'a follow acts on you, not on a post')
})

test('★ newest first, and limited', async (t) => {
  const rows = toNotifications([
    item({ id: 'a', published: '2026-09-01T00:00:00Z' }),
    item({ id: 'b', published: '2026-09-08T00:00:00Z' }),
    item({ id: 'c', published: '2026-09-04T00:00:00Z' })
  ])
  t.alike(rows.map((r) => r.at), ['2026-09-08T00:00:00Z', '2026-09-04T00:00:00Z', '2026-09-01T00:00:00Z'])
  t.is(toNotifications([item(), item({ id: 'z' })], { limit: 1 }).length, 1)
})

// inbound-store.js: "Content is HTML from a remote server… NEVER rendered as trusted markup".
// This projection must not quietly sanitise it either — that would let a caller believe the string
// is safe when the guarantee lives in the renderer.
test('⛔ the body is carried VERBATIM — this layer makes no safety claim', async (t) => {
  const nasty = '<p>hi</p><script>alert(1)</script>'
  t.is(toNotification(item({ content: nasty })).body, nasty, 'unchanged, so the renderer must escape it')
})

test('a missing timestamp falls back to when we received it', async (t) => {
  t.is(toNotification(item({ published: null, receivedAt: at })).at, at)
})