OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge / test / favela-source.test.js

203 lines · javascript ref:
// ⛔ THE FEDIVERSE DERIVES FROM THE MARKDOWN, not from the atproto projection of it.
//
// Measured on the live actor: `content` is built from `record.text`, so `# Heading` and a list reach
// Mastodon as paragraphs of literal • characters. AS2 content is HTML and Mastodon renders <h2> and
// <ul>. The canonical record is markdown and has all of it.
//
// ⛔ AND NO DID IS INVOLVED. Nothing in this path names an atproto identifier, a PDS or an MST —
// which is what lets this bridge front an identity that has no atproto credential (identity.md §4.1).

import '../lib/bare-globals.js'
import test from 'brittle'
import { FavelaSource } from '../lib/favela-source.js'

const src = (reach, rows, roomType = 'public') => {
  const s = new FavelaSource({
    sock: '/tmp/x.sock', host: 'h', actorId: 'https://h/ap/actor', followersId: 'https://h/ap/followers', roomType
  })
  s.peer = { reach: async () => reach, ownPosts: async () => ({ feed: 'ab'.repeat(32), rows }) }
  return s
}
const row = (body) => ({ name: '1788822724600', body, createdAt: '2026-09-07T00:00:00Z' })

test('★★ a heading and a list survive as HTML', async (t) => {
  const s = src({ networks: ['fediverse'] }, [row('# Heading\n\n* one\n* two')])
  const { notes } = await s.notes()
  t.is(notes.length, 1)
  t.ok(/<h1|<h2/.test(notes[0].content), 'a real heading: ' + notes[0].content.slice(0, 50))
  t.ok(/<ul|<li/.test(notes[0].content), 'a real list')
  t.absent(notes[0].content.includes('•'), 'and no literal bullet characters')
})

test('⛔ source.content is what the AUTHOR typed', async (t) => {
  // A receiver offering an edit box uses `source`. Handing back HTML round-trips their markdown.
  const md = '# Heading\n\n* one'
  const { notes } = await src({ networks: ['fediverse'] }, [row(md)]).notes()
  t.is(notes[0].source.content, md)
})

test('⛔ consent gates it — off means nothing, and says why', async (t) => {
  const { notes, reason } = await src({ networks: [] }, [row('hello')]).notes()
  t.is(notes.length, 0)
  t.ok(/not enabled/.test(reason), reason)
})

test('⛔ a room that may not reach the fediverse projects nothing', async (t) => {
  // reach.js: a members room and a dm reach favela only. Consent cannot widen that.
  for (const roomType of ['members', 'dm']) {
    const { notes, reason } = await src({ networks: ['fediverse'] }, [row('secret')], roomType).notes()
    t.is(notes.length, 0, roomType + ' stays local')
    t.ok(/may not reach/.test(reason), roomType + ': ' + reason)
  }
  // ⚠ But a FOLLOWERS room does reach the fediverse — AP has addressing where atproto has none.
  const { notes } = await src({ networks: ['fediverse'] }, [row('hi')], 'followers').notes()
  t.is(notes.length, 1, 'a followers room projects here')
})

test('⛔ nothing in this path names atproto — INCLUDING what it imports', async (t) => {
  // ⛔ The first version of this test read only THIS file's text and passed while the module
  // imported `objectIdFor` from records.js, which imports atproto-did-peer. The whole point is that
  // an identity with no atproto credential can be fronted; a path that cannot LOAD without the
  // atproto stack has not achieved that, however clean its own source reads.
  const fs = (await import('bare-fs')).default
  const seen = new Set()
  // ⚠ 'atproto' is NOT in this list, and that is deliberate. as2-project.js declares the JSON-LD
  // term `atproto: https://atproto.com/ns#` for the optional `atproto:cid` dedup key — a field that
  // travels WHEN a DID exists and is absent when one does not. A vocabulary term is not a
  // dependency; what matters is that nothing here IMPORTS the stack, asserted separately below.
  const forbidden = ['did:', 'app.bsky', 'pdsUrl', 'MST']
  const walk = (rel) => {
    if (seen.has(rel)) return
    seen.add(rel)
    const path = new URL('../' + rel, import.meta.url).pathname
    let src
    try { src = fs.readFileSync(path, 'utf8') } catch { return }
    const code = src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*/g, '')
    for (const f of forbidden) t.absent(code.includes(f), rel + ': no ' + f)
    // Follow only LOCAL imports — a package name is a different question, asserted below.
    for (const m of code.matchAll(/from '(\.[^']+)'/g)) {
      walk(('lib/' + m[1].replace(/^\.\//, '')).replace(/lib\/\.\.\//, ''))
    }
  }
  walk('lib/favela-source.js')
  t.ok(seen.size >= 2, 'followed the graph, not just one file: ' + [...seen].join(', '))

  // And no atproto PACKAGE reaches it either.
  const src = fs.readFileSync(new URL('../lib/favela-source.js', import.meta.url).pathname, 'utf8')
  for (const m of src.matchAll(/from '([^'.][^']*)'/g)) {
    t.absent(/atproto/.test(m[1]), 'imports no atproto package: ' + m[1])
  }
})

// ── pictures ──────────────────────────────────────────────────────────────────────────────────
//
// ⛔ A post with a picture that federates its TEXT ONLY is worse than one that does not federate:
// the reader sees a sentence referring to something that is not there. The blob bytes are already
// in the shared overlay — one physical copy, per OverlayBlobStore — so this is resolution, not
// transport.

const OID = 'a1'.repeat(16)
const withBlob = (reach, mime = 'image/webp') => {
  const s = src(reach, [{ name: 'p1', createdAt: '2026-09-07T00:00:00Z',
    body: 'look at this\n\n![a cat on a chair](favela://blob/' + OID + ')' }])
  s.peer.call = async (cmd, args) => {
    if (cmd !== 'file-get') throw new Error('unexpected ' + cmd)
    return mime ? { path: '/tmp/x', mimeType: mime, size: 1234 } : { found: false }
  }
  return s
}

test('★★ a picture becomes an AS2 attachment', async (t) => {
  const { notes } = await withBlob({ networks: ['fediverse'] }).notes()
  t.is(notes.length, 1)
  const att = notes[0].attachment || []
  t.is(att.length, 1, 'one attachment')
  t.is(att[0].type, 'Document')
  t.is(att[0].mediaType, 'image/webp', 'the type comes from the bytes, not the filename')
  t.is(att[0].url, 'https://h/ap/blobs/' + OID, 'served by this bridge, from the overlay')
  // ⛔ Alt text is an accessibility obligation, not a nicety.
  t.is(att[0].name, 'a cat on a chair')
})

test('⛔ a blob the peer cannot describe is OMITTED, not guessed', async (t) => {
  // A wrong mediaType makes a receiver render nothing and say nothing. An absent attachment at
  // least matches what the reader can see.
  const { notes } = await withBlob({ networks: ['fediverse'] }, null).notes()
  t.alike(notes[0].attachment || [], [], 'no attachment invented')
  t.ok(notes[0].content.includes('look at this'), 'and the text still federates')
})

test('⚠ a post with no picture carries no attachment key', async (t) => {
  const { notes } = await src({ networks: ['fediverse'] }, [row('plain')]).notes()
  t.absent(notes[0].attachment && notes[0].attachment.length)
})

// ⛔ `image` IS THE BANNER and `icon` IS THE AVATAR — two AS2 fields, two different pictures.
// Nothing here emitted `image` because favela's profile record had no banner field until
// favela-peer 0.0.49, so every actor this bridge served advertised no header and Mastodon rendered
// its own placeholder (hyper-social #67 §7.6). buildActor has always passed `profile.image`
// through, so this was the only missing link.
test('getProfile projects a banner oid as AS2 image, independently of the avatar', async (t) => {
  const mk = (frontmatter) => new FavelaSource({
    peer: {
      async profile () { return { frontmatter, body: 'about me' } },
      async reach () { return { networks: ['fediverse'] } },
      async ownPosts () { return { rows: [] } }
    },
    host: 'ap.7rnx.net',
    base: 'https://ap.7rnx.net/ap/users/ashatron',
    actorId: 'https://ap.7rnx.net/ap/users/ashatron/actor',
    followersId: 'https://ap.7rnx.net/ap/users/ashatron/followers'
  })

  const both = await mk({ name: 'ash', avatar: 'a'.repeat(64), banner: 'b'.repeat(64) }).getProfile()
  t.ok(both.icon.url.endsWith('a'.repeat(64)), 'the avatar is the icon')
  t.ok(both.image.url.endsWith('b'.repeat(64)), '⛔ and the banner is the image, not a second icon')
  t.is(both.image.type, 'Image')
  // Both hang off THIS user's base, never the primary's flat path.
  t.ok(both.image.url.startsWith('https://ap.7rnx.net/ap/users/ashatron/blobs/'), 'under their own root')

  // Each is independent: a profile with only one must not invent the other.
  const onlyAvatar = await mk({ avatar: 'a'.repeat(64) }).getProfile()
  t.ok(onlyAvatar.icon, 'avatar alone still projects')
  t.absent(onlyAvatar.image, 'and no banner is invented')

  const onlyBanner = await mk({ banner: 'b'.repeat(64) }).getProfile()
  t.ok(onlyBanner.image, 'banner alone still projects')
  t.absent(onlyBanner.icon, 'and no avatar is invented')
})

// ⛔ AS2 `summary` is HTML; the bio is markdown.
test('the summary is rendered, not handed over raw', async (t) => {
  const src = new FavelaSource({
    peer: {
      async profile () { return { frontmatter: { name: 'ash' }, body: 'the **dev** box' } },
      async reach () { return { networks: ['fediverse'] } },
      async ownPosts () { return { rows: [] } }
    },
    host: 'ap.7rnx.net',
    base: 'https://ap.7rnx.net/ap/users/ashatron',
    actorId: 'https://ap.7rnx.net/ap/users/ashatron/actor',
    followersId: 'https://ap.7rnx.net/ap/users/ashatron/followers'
  })
  const p = await src.getProfile()
  t.absent(p.summary.includes('**'), '⛔ or the asterisks show on every follower’s screen')
  t.ok(/<strong>|<b>/.test(p.summary), 'markdown became HTML')
})

// ⛔ The source names each deleted post by the SAME object id it was delivered under — a Delete for
// a different id retracts nothing.
test('★★ listDeleted gives each deleted post\'s rkey and its object id', async (t) => {
  const s = src({ networks: ['fediverse'] }, [])
  s.peer.ownDeleted = async () => ({ post: ['01gone'], reply: [] })
  const { deleted } = await s.listDeleted()
  t.is(deleted.length, 1)
  t.is(deleted[0].rkey, '01gone')
  const note = await s.project({ name: '01gone', body: 'x', createdAt: '2026-09-07T00:00:00Z' })
  t.is(deleted[0].id, note.id, 'the id its Note was delivered under')
})

test('a peer with no ownDeleted reports nothing deleted', async (t) => {
  t.alike(await src({ networks: ['fediverse'] }, []).listDeleted(), { deleted: [] })
})