OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer / test / rpc-namespace.test.js

227 lines · javascript ref:
// The namespace's DOORS.
//
// ⛔ Every capability this project has lost was a method with no way to reach it: `browser-auth`
// written with no route (#63 §8), a name that could be claimed by the daemon and by no person
// (#64 §2), `avatar-put` converted to take a path while the CLI kept sending base64, and
// `favela pair --cancel` calling a `has()` that was never defined. Unit tests on the method cannot
// see any of that — they call the method.
//
// So these drive `dispatch`, the thing the socket actually calls.

import test from 'brittle'
import process from '#node:process'
import fs from '#node:fs'
import os from '#node:os'
import { join } from '#node:path'
import b4a from 'b4a'
import { FavelaNode } from '../lib/node.js'
import { dispatch } from '../lib/rpc.js'
import { saveCredential } from '../lib/device.js'
import { identity } from './helpers.js'

const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'favela-rpcns-'))
const stubTransport = () => ({ async join () {}, async leave () {}, async destroy () {} })

async function node () {
  // ⚠ FOUNDS ITS OWN. A fresh node now defaults to the fleet namespace, so a suite about founding,
  // admins and carrying must say so. Set HERE rather than at module scope: env is process-wide and
  // brittle loads every file before running, so a top-level assignment is racy with other files.
  process.env.FAVELA_NAMESPACE = 'own'
  const storage = tmp()
  const id = await identity()
  const cred = await id.device()
  saveCredential(storage, cred)
  const n = new FavelaNode(storage, { transport: stubTransport() })
  await n.ready()
  // Admitted to its own feed, or every mutating command is refused at the door by design.
  await n.link(cred)
  return { n, cred, root: id.rootHex }
}

test('`namespace` reports whether this node CARRIES the log, not just which one it is', async (t) => {
  const { n, root } = await node()
  t.teardown(() => n.close())

  const ns = await dispatch(n, 'namespace', {})
  t.is(ns.key.length, 64)
  t.ok(ns.founded, 'founded at first boot')
  t.is(ns.founder, root)
  t.alike(ns.admins, [root])
  // ⛔ "I resolve handles here" and "I carry this log" are different facts with different fixes,
  // and neither is visible from a key. A reader is not broken; it just cannot admit anyone.
  t.is(ns.carrying, true, 'the founder carries its own namespace')
  t.alike(ns.requests, [])
})

test('★ the whole join → request → admit ceremony is reachable over the socket', async (t) => {
  const a = await node()
  const b = await node()
  t.teardown(async () => { await a.n.close(); await b.n.close() })

  const theirs = b4a.toString(a.n.names.key, 'hex')
  await dispatch(b.n, 'namespace-join', { key: theirs })
  t.is(b4a.toString(b.n.names.key, 'hex'), theirs, 'B resolves handles in A\'s namespace now')

  await dispatch(a.n, 'namespace-grant', { root: b.root })
  await dispatch(b.n, 'namespace-request', {})
  await b.n.names.update()

  // ⛔ B cannot answer its own ask: the grant would ride B's own weightless block and be capped to
  // zero, silently. The door says so rather than appending something inert.
  await t.exception(dispatch(b.n, 'namespace-admit',
    { writer: b4a.toString(b.n.names.base.local.key, 'hex') }), /weight/)
})

test('⛔ a malformed namespace key is refused at the door, not at apply', async (t) => {
  const { n } = await node()
  t.teardown(() => n.close())
  const before = b4a.toString(n.names.key, 'hex')
  await t.exception(dispatch(n, 'namespace-join', { key: 'nope' }), /32 bytes of hex/)
  t.is(b4a.toString(n.names.key, 'hex'), before)
})

test('⚠ namespace-join needs no credential; the signing ops do', async (t) => {
  const storage = tmp()
  process.env.FAVELA_NAMESPACE = 'own'
  const unlinked = new FavelaNode(storage, { transport: stubTransport() })
  await unlinked.ready()
  t.teardown(() => unlinked.close())

  // A node that cannot sign must still be able to LOOK — otherwise an unlinked machine cannot even
  // be pointed at the registry it is about to join.
  const ns = await dispatch(unlinked, 'namespace', {})
  t.is(ns.founded, false, 'unlinked, so unfounded — reported rather than hidden')
  await t.exception(dispatch(unlinked, 'namespace-request', {}), /not admitted|favela link/)
})

// ── peek ──────────────────────────────────────────────────────────────────────────────────────

test('★ peek is reachable, writes nothing, and reports what it did', async (t) => {
  const { n } = await node()
  t.teardown(() => n.close())
  const stranger = 'ab'.repeat(32)

  const before = await dispatch(n, 'following', {})
  const r = await dispatch(n, 'peek', { feed: stranger })
  t.is(r.feed, stranger)
  // ⛔ It ANSWERS before it has finished. Opening a feed we have never seen is a swarm join, and
  // doing that inside the request is indistinguishable from a hung daemon.
  t.is(r.status, 'opening')
  t.alike(await dispatch(n, 'following', {}), before, 'and my own feed is untouched')

  await n.opening(stranger)
  const h = await dispatch(n, 'holding', { feed: stranger })
  t.alike(h, { held: true, peeked: true, following: false, opening: false, blocked: false },
    'four states, because "not held" and "still arriving" need different pages')
})

test('⚠ peek needs no credential — reading is not writing', async (t) => {
  const { n } = await node()
  t.teardown(() => n.close())
  // It is not in MUTATIONS, so an unlinked device that cannot write to its own feed can still read
  // a stranger's page. Refusing that would gate reading behind being able to post.
  t.ok((await dispatch(n, 'peek', { feed: 'cd'.repeat(32) })).feed)
})

// ── rooms over the socket ─────────────────────────────────────────────────────────────────────

test('★ the whole room ceremony is reachable', async (t) => {
  const { n } = await node()
  t.teardown(() => n.close())

  const { key } = await dispatch(n, 'room-create', {})
  const before = await dispatch(n, 'room', { key })
  t.is(before.role, 'admin', 'the creator is its admin, by identity')

  await dispatch(n, 'room-config', { key, field: 'title', value: 'Berlin P2P' })
  await dispatch(n, 'room-post', { key, body: 'first message' })
  const after = await dispatch(n, 'room', { key })
  t.is(after.config.title, 'Berlin P2P')
  t.alike(after.messages.map((m) => m.body), ['first message'])
})

test('⛔ a room this node is not in says WHICH thing is wrong', async (t) => {
  const { n } = await node()
  t.teardown(() => n.close())
  // "not in that room" and "no such room" look identical from outside and are fixed by completely
  // different things — one by a key, the other by nothing.
  await t.exception(dispatch(n, 'room-post', { key: 'ab'.repeat(32), body: 'x' }), /not in room/)
})

test('★ a DM is a room with two members — there is no DM type', async (t) => {
  const { n } = await node()
  t.teardown(() => n.close())
  const other = 'cd'.repeat(32)
  const { key } = await dispatch(n, 'dm', { member: other })
  const r = await dispatch(n, 'room', { key })
  t.is(r.members.length, 2, 'me and them')
  t.ok(r.members.some((m) => m.root === other))
  // SOCIAL-MODEL §4: a two-member room IS a direct message. One primitive, used — not a second
  // thing that behaves almost the same.
  t.is(r.config.title, 'DM')
})

const until = async (fn, ms = 10000) => {
  const end = Date.now() + ms
  while (Date.now() < end) { const v = await fn(); if (v) return v; await new Promise((resolve) => setTimeout(resolve, 40)) }
  return null
}

test('★ ROOM-CONTRACT v2 over the socket: invite → join on the joiner\'s own core → accept', async (t) => {
  const a = await node()
  const b = await node()
  t.teardown(async () => { await a.n.close(); await b.n.close() })
  const { key } = await dispatch(a.n, 'room-create', {})
  // The transport is stubbed, so the two rooms are piped by hand — what the swarm does in the daemon.
  await dispatch(b.n, 'room-join', { key })
  const s1 = a.n.room(key).replicate(true); const s2 = b.n.room(key).replicate(false)
  s1.pipe(s2).pipe(s1)
  t.teardown(() => { s1.destroy(); s2.destroy() })
  t.ok(await until(async () => (await dispatch(b.n, 'room', { key })).members.length === 1), 'the joiner reads the room')

  const before = await dispatch(b.n, 'room', { key })
  t.is(before.role, null, 'holding the key is not membership')
  t.is(before.bound, false, 'and no core of its is bound')

  const inv = await dispatch(a.n, 'room-invite', { key, requiresConfirmation: true })
  t.ok(/^[0-9a-f]+$/.test(inv.token), 'the invitation comes back as a token to hand over')
  t.ok(await until(async () => { await b.n.room(key).update(); return (await b.n.room(key).invites()).length === 1 }), 'the joiner sees the invitation')

  await dispatch(b.n, 'room-join-invite', { key, token: inv.token })
  const req = await until(async () => {
    await dispatch(a.n, 'room-config', { key, field: 'title', value: 'anchor' })
    const r = await dispatch(a.n, 'room', { key })
    return r.requests.length === 1 && r.requests[0]
  })
  t.ok(req, 'a confirmation-required join waits as a request the admin sees')
  t.is(req.root, b.root)

  await dispatch(a.n, 'room-accept', { key, writer: req.writer })
  t.ok(await until(async () => (await dispatch(b.n, 'room', { key })).role === 'peer'), 'accepted: a peer, on the joiner\'s node too')
  t.is((await dispatch(b.n, 'room', { key })).bound, true, 'with its own core bound')

  await dispatch(b.n, 'room-post', { key, body: 'hello from b' })
  t.ok(await until(async () => (await dispatch(a.n, 'room', { key })).messages.some((m) => m.body === 'hello from b' && m.author === b.root)),
    'the joiner writes, attributed to its identity')
})

test('⛔ the v1 admission verbs refuse with the way forward', async (t) => {
  const { n } = await node()
  t.teardown(() => n.close())
  const { key } = await dispatch(n, 'room-create', {})
  for (const verb of ['room-add', 'room-request', 'room-admit']) {
    await t.exception(dispatch(n, verb, { key }), /room-invite/, verb + ' names its replacement')
  }
})

test('rooms are listed with what a person needs to pick one', async (t) => {
  const { n } = await node()
  t.teardown(() => n.close())
  const { key } = await dispatch(n, 'room-create', {})
  await dispatch(n, 'room-config', { key, field: 'title', value: 'Berlin' })
  const { rooms } = await dispatch(n, 'rooms', {})
  t.is(rooms.length, 1)
  t.is(rooms[0].config.title, 'Berlin')
  t.is(rooms[0].members, 1, 'and how many people are in it')
})