OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer / test / profile.test.js

359 lines · javascript ref:
// The profile — a display name and a picture, on the USER, not in a record.
//
// ⛔ `NAMING.md` §2: a name must never appear in a record. Names are reclaimable, so one baked into
// immutable bytes can later denote a different person. The picture follows the same reasoning — it
// changes, and a registry row is the thing that is allowed to change.
//
// So this is pear-git-v3's shape exactly: { name, profilePicture } on the user record, where
// profilePicture is a hyper-overlay content hash held as a plain string.

import test from 'brittle'
import Corestore from 'corestore'
import fs from '#node:fs'
import os from '#node:os'
import { join } from '#node:path'
import b4a from 'b4a'
import Corestore2 from 'corestore'
import { RecordStore } from '../lib/record-store.js'
import { Feed } from '../lib/feed-base.js'
import { Peer } from '../lib/peer.js'
import { attest, identity } from './helpers.js'
import { Avatars, sniff, MAX_BYTES } from '../lib/avatars.js'

const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'favela-profile-'))

// A 1x1 PNG. Real bytes, so the magic-byte sniff is exercised rather than mocked.
const PNG = b4a.from(
  'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
  'base64')

// ⛔ The profile lives in the FEED, not in the local registry. A registry does not replicate, so a
// profile kept there is invisible to everyone who reads your feed — no name, no picture, nothing to
// search. That is what these assert.
async function mkPeer () {
  const f = new Feed(new Corestore(tmp()), null, { store: new RecordStore(tmp()) })
  await f.ready()
  await f.addDevice(await attest(f))
  await f.update()
  const p = new Peer(f)
  await p.ready()
  return p
}

test('a fresh feed publishes no profile at all', async (t) => {
  const b = await mkPeer()
  t.teardown(() => b.close())
  t.alike(await b.profile(), { name: null, avatar: null, banner: null, bio: null }, 'nothing published yet')
  t.alike(await b.profiles(), {},
    'and ABSENT from the map — "has not published one" and "published an empty one" differ')
})

test('★ setting a name is what replaces 64 hex characters on every screen', async (t) => {
  const b = await mkPeer()
  t.teardown(() => b.close())

  await b.setProfile({ name: '  Ada Lovelace  ' })
  await b.update()
  t.is((await b.profile()).name, 'Ada Lovelace', 'trimmed')

  await b.setProfile({ name: 'Ada\nLovelace' })
  await b.update()
  t.is((await b.profile()).name, 'Ada Lovelace', 'a newline would break the line it renders on')

  await b.setProfile({ name: 'x'.repeat(200) })
  await b.update()
  t.is((await b.profile()).name.length, 64, 'and it is capped')
})

test('★ a profile TRAVELS — it is a record in the feed, not a local registry row', async (t) => {
  const author = await mkPeer()
  const reader = await mkPeer()
  t.teardown(() => Promise.all([author.close(), reader.close()]))

  await author.setProfile({ name: 'stortron', avatar: 'ab'.repeat(16) })
  await author.update()

  // The reader HOLDS the author's feed, exactly as a follower does.
  reader.feeds.set(Peer.nameFor(author.feed.key), author.feed)
  await reader.update()

  const seen = await reader.profileOf(author.feed.key)
  t.is(seen.name, 'stortron', 'the reader sees the name its author chose')
  t.is(seen.avatar, 'ab'.repeat(16), 'and the avatar oid, so the bytes can be fetched by hash')
})

test('★ search finds PEOPLE, not only text', async (t) => {
  const me = await mkPeer()
  const them = await mkPeer()
  t.teardown(() => Promise.all([me.close(), them.close()]))

  await them.setProfile({ name: 'stortron' })
  await them.update()
  me.feeds.set(Peer.nameFor(them.feed.key), them.feed)
  await me.update()

  const byName = await me.searchProfiles('stort')
  t.is(byName.length, 1, 'a partial name matches')
  t.is(byName[0].name, 'stortron')

  // Pasting a key is how you find someone who has published no name yet.
  const byKey = await me.searchProfiles(Peer.nameFor(them.feed.key).slice(0, 12))
  t.is(byKey.length, 1, 'and a key prefix matches too')

  t.alike(await me.searchProfiles('nobody'), [], 'and a miss is empty, not everything')
  t.alike(await me.searchProfiles(''), [], 'an empty query matches nothing rather than everyone')
})

test('an omitted field is untouched; a blank name clears', async (t) => {
  const b = await mkPeer()
  t.teardown(() => b.close())

  await b.setProfile({ name: 'Ada', avatar: 'deadbeef' })
  await b.update()
  await b.setProfile({ name: 'Grace' })
  await b.update()
  const p = await b.profile()
  t.is(p.name, 'Grace')
  // ⚠ A settings form showing two fields must not erase a third it never rendered.
  t.is(p.avatar, 'deadbeef', 'the field that was not mentioned survived')

  await b.setProfile({ name: '   ' })
  await b.update()
  t.is((await b.profile()).name, null, 'a blank name is a removal, not an error')
})

// ── avatars ────────────────────────────────────────────────────────────────────────────────────

test('an avatar is typed by its BYTES, not by what the uploader says', async (t) => {
  t.is(sniff(PNG), 'image/png')
  t.is(sniff(b4a.from('GIF89a' + '\0'.repeat(10))), 'image/gif')
  // ⛔ SVG is refused: it is a document that can carry script and fetch remote resources.
  t.is(sniff(b4a.from('<svg onload=alert(1)>              ')), null, 'svg is not an image here')
  t.is(sniff(b4a.from('hello world!!')), null)
  t.is(sniff(b4a.alloc(3)), null, 'too short to judge is not a yes')
})

// ⛔ The daemon takes a PATH, not bytes. Both ends of the control socket are on one machine and
// `registerFile` takes a path, so a file crossing that socket as base64 was an encode-and-decode
// round trip to hand a process a file it wrote straight back to disk. These tests hand over paths
// because that is what a surface does.
const at = (bytes, name) => {
  const p = join(tmp(), name)
  fs.writeFileSync(p, bytes)
  return p
}

test('★ storing an avatar returns an oid, and the same bytes collapse to one', async (t) => {
  const dir = tmp()
  const store = new Corestore(tmp())
  const { HyperOverlay } = await import('hyper-overlay')
  const overlay = new HyperOverlay(store.namespace('overlay'), {
    namespace: 'favela-records', destDir: join(dir, '.fetch')
  })
  await overlay.ready()
  t.teardown(async () => { await overlay.close(); await store.close() })

  const a = new Avatars(dir, overlay)
  const first = await a.put(at(PNG, 'a.png'))
  t.ok(/^[0-9a-f]{8,}$/i.test(first.oid), 'a content hash')
  t.is(first.mimeType, 'image/png')

  const again = await a.put(at(PNG, 'b.png'))
  t.is(again.oid, first.oid, 'identical bytes are one file — content addressing, not a copy per user')

  const got = await a.get(first.oid)
  // ⛔ A PATH back, so a surface streams the file rather than receiving a copy through a socket.
  t.is(got.mimeType, 'image/png')
  t.is(got.size, PNG.length)
  t.ok(b4a.equals(fs.readFileSync(got.path), PNG), 'and it reads back byte for byte')
})

test('⛔ a non-image and an oversized image are both refused, with reasons', async (t) => {
  const dir = tmp()
  const store = new Corestore(tmp())
  const { HyperOverlay } = await import('hyper-overlay')
  const overlay = new HyperOverlay(store.namespace('overlay'), {
    namespace: 'favela-records', destDir: join(dir, '.fetch')
  })
  await overlay.ready()
  t.teardown(async () => { await overlay.close(); await store.close() })

  const a = new Avatars(dir, overlay)
  await t.exception(a.put(at(b4a.from('<svg onload=alert(1)>            '), 'x.svg')), /png, jpeg, gif or webp/i)
  // ⚠ The size message states the actual size, because "not allowed" is a true and useless answer
  // to a photograph someone just picked.
  await t.exception(a.put(at(b4a.concat([PNG, b4a.alloc(MAX_BYTES)]), 'big.png')), /KiB and the cap is/)
  await t.exception(a.put(at(b4a.alloc(0), 'empty.png')), /empty/)
  await t.exception(a.put('/no/such/file'), /no file at/)
})

test('an avatar this node does not hold reads as absent, not as an error', async (t) => {
  const dir = tmp()
  const store = new Corestore(tmp())
  const { HyperOverlay } = await import('hyper-overlay')
  const overlay = new HyperOverlay(store.namespace('overlay'), {
    namespace: 'favela-records', destDir: join(dir, '.fetch')
  })
  await overlay.ready()
  t.teardown(async () => { await overlay.close(); await store.close() })

  const a = new Avatars(dir, overlay)
  // A picture that has not replicated yet is a missing image, not a broken page.
  t.is(await a.get('ab'.repeat(16)), null)
  t.is(await a.get('not-an-oid'), null)
  t.is(await a.get(null), null)
})

// ── the bio ───────────────────────────────────────────────────────────────────────────────────
//
// ⛔ The profile record has had a BODY slot since it was designed — hyper-social #62: "a profile is
// a record: `profile/self`, frontmatter {name, avatar}, body = a markdown bio". `setProfile`
// carefully PRESERVED that body on every write and nothing ever wrote it, so every screen that
// wanted to say who somebody is had a name, a picture and 64 hex characters.

test('★ a bio is the profile record\'s BODY, not another frontmatter field', async (t) => {
  const p = await mkPeer()
  t.teardown(() => p.close())

  await p.setProfile({ name: 'Alice', bio: 'builds **p2p** things' })
  await p.update()
  const got = await p.profile()
  t.is(got.name, 'Alice')
  t.is(got.bio, 'builds **p2p** things', 'markdown, kept as written')

  // It is the body of an ordinary record, so it is markdown like every other body and travels with
  // the feed like every other record.
  const rec = await p.feed.get('profile', 'self')
  t.is(rec.body.trim(), 'builds **p2p** things')
  t.absent('bio' in (rec.frontmatter || {}), 'not duplicated into frontmatter')
})

test('setting one field leaves the others alone', async (t) => {
  const p = await mkPeer()
  t.teardown(() => p.close())
  await p.setProfile({ name: 'Alice', bio: 'first' })
  await p.update()
  await p.setProfile({ name: 'Alice B' })
  await p.update()
  // A settings form that showed two fields must not erase a third it never rendered — the rule
  // `setProfile` already followed for name and avatar, now extended to the body.
  t.is((await p.profile()).bio, 'first', 'the bio survived a name-only save')
})

test('a blank bio REMOVES it rather than asserting an empty one', async (t) => {
  const p = await mkPeer()
  t.teardown(() => p.close())
  await p.setProfile({ bio: 'something' })
  await p.update()
  await p.setProfile({ bio: '   ' })
  await p.update()
  t.is((await p.profile()).bio, null, 'absence asserts nothing; "" would assert emptiness')
})

test('⚠ a bio is bounded, and control characters do not survive it', async (t) => {
  const p = await mkPeer()
  t.teardown(() => p.close())
  await p.setProfile({ bio: 'x'.repeat(2000) })
  await p.update()
  const bio = (await p.profile()).bio
  t.ok(bio.length <= 512, 'bounded — a profile is a description, not a post')
})

test('profiles() carries the bio, so a listing can say who someone is', async (t) => {
  const p = await mkPeer()
  t.teardown(() => p.close())
  await p.setProfile({ name: 'Alice', bio: 'here' })
  await p.update()
  const all = await p.profiles()
  t.is(all[p.feed.key.toString('hex')].bio, 'here')
})

test('★ profiles carry the identity ROOT — what a mention has to name', async (t) => {
  const author = await mkPeer()
  const reader = await mkPeer()
  t.teardown(() => Promise.all([author.close(), reader.close()]))
  await author.setProfile({ name: 'stortron' })
  await author.update()
  reader.feeds.set(Peer.nameFor(author.feed.key), author.feed)
  await reader.update()

  // ⛔ Without this there is no way to WRITE a mention from a UI: the target is an identity root
  // (markdown-strip §3.4), a bare @name is never a guess, and nothing else on a byline exposes one.
  // Mentions were readable and unwritable, which is half a feature.
  const all = await reader.profiles()
  const row = all[author.feed.key.toString('hex')]
  const founder = await author.feed.founder()
  t.is(row.root, founder.identityRoot, 'the root the feed was founded under')
  t.is(row.root.length, 64)
})

// ── the banner ────────────────────────────────────────────────────────────────────────────────
//
// ⛔ A BANNER IS A SECOND PICTURE, not a variant of the first. AS2 gives an actor `icon` (the
// avatar) and `image` (the header), and Mastodon renders both — so a profile that can only carry
// one of them is missing a field the destination has always had. `getProfile` could not emit
// `image` because nothing upstream ever stored one: hyper-social #67 §7.6, "the banner cannot work
// at all — it needs a new field end to end".
//
// It reuses the avatar's machinery exactly: an oid on the profile record, bytes in the overlay,
// typed from magic bytes. Nothing about a banner needs a second mechanism.
test('a banner is stored alongside the avatar and neither displaces the other', async (t) => {
  const p = await mkPeer()

  await p.setProfile({ name: 'ash', avatar: 'a'.repeat(64) })
  const withBanner = await p.setProfile({ banner: 'b'.repeat(64) })

  t.is(withBanner.avatar, 'a'.repeat(64), 'setting a banner leaves the avatar alone')
  t.is(withBanner.banner, 'b'.repeat(64), 'and the banner is returned')
  t.is(withBanner.name, 'ash', 'and the name survives both')

  // ⚠ Blank REMOVES, matching how name/avatar/bio already behave. Absence asserts nothing.
  const cleared = await p.setProfile({ banner: '' })
  t.absent(cleared.banner, 'a blank banner removes it')
  t.is(cleared.avatar, 'a'.repeat(64), 'and still does not touch the avatar')
})

test('the banner travels in the published record, not a local registry', async (t) => {
  const p = await mkPeer()
  await p.setProfile({ banner: 'c'.repeat(64) })
  const rec = await p.feed.get('profile', 'self')
  t.is(rec.frontmatter.banner, 'c'.repeat(64), '⛔ in the FEED — a registry does not replicate')
})

// ── the mip pointer ───────────────────────────────────────────────────────────────────────────
//
// ⛔ It rides the FEED, not a local index. A bridge choosing a level holds this feed and nothing
// else; a pointer kept only on the authoring node would be invisible to exactly the reader it
// exists for. Same reasoning that put the profile in the feed rather than a registry.
test('a blob pointer travels in the feed and reads back', async (t) => {
  const p = await mkPeer()
  t.teardown(() => p.close())

  const pointer = {
    mimeType: 'image/png',
    size: 900,
    levels: [
      { oid: 'a'.repeat(64), w: 2000, h: 1500, size: 900, by: 'author' },
      { oid: 'b'.repeat(64), w: 640, h: 480, size: 200, by: 'derived:ap-bridge' }
    ]
  }
  await p.putBlobPointer('a'.repeat(64), pointer)
  await p.update()

  const back = await p.blobPointer('a'.repeat(64))
  t.is(back.levels.length, 2, 'both levels survive the round trip')
  t.is(back.levels[0].by, 'author')
  t.is(back.levels[1].by, 'derived:ap-bridge', '⛔ who derived it survives too, or the rule is unenforceable')

  const rec = await p.feed.get('blob', 'a'.repeat(64))
  t.ok(rec, 'and it is a record in the feed, which is what makes it replicate')
})

// ⛔ record-vocabulary §3.5 calls today's avatar the single-level special case.
test('a blob written before levels existed reads as absent, not broken', async (t) => {
  const p = await mkPeer()
  t.teardown(() => p.close())
  t.is(await p.blobPointer('f'.repeat(64)), null,
    '⛔ null means one level — a caller must not read it as a missing asset')
})