OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface-android / test / boot.test.js

207 lines · javascript ref:
// ★ The worklet boot, exercised OFF-DEVICE.
//
// Everything here runs without bare-kit, without Android and without an emulator. That is the point
// of the split in `lib/node.js`: `serveSurface(node)` takes a node and stands up the real routes
// over real HTTP, so the wiring that decides whether a phone shows anything is testable on a laptop.
//
// ⛔ What this proves is the one architectural claim the port rests on: favela-surface can be served
// from favela-peer with NO SOCKET IN EXISTENCE. `favela-peer/lib/rpc.js` exports `dispatch` next to
// `createServer`; `dispatch` is the interface and the socket is one adapter over it. A pathname unix
// socket could not be created on Android at all (SELinux refuses an unprivileged app a `sock_file`),
// so if this did not work the port would not be possible in this shape.

import test from 'brittle'
import fs from 'bare-fs'
import os from 'bare-os'
import { join } from 'bare-path'
import http from 'bare-http1'
import b4a from 'b4a'
import Hyperswarm from 'hyperswarm'
import { FavelaNode } from 'favela-peer/lib/node.js'
import { Sessions } from 'favela-surface/lib/session.js'
import { serveSurface, inProcessClient, signIn } from '../lib/node.js'

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

function get (port, path, headers = {}) {
  return new Promise((resolve, reject) => {
    const req = http.request({ host: '127.0.0.1', port, method: 'GET', path, headers }, (res) => {
      let body = ''
      res.on('data', (c) => { body += c.toString() })
      res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body }))
    })
    req.on('error', reject)
    req.end()
  })
}

async function bootNode (t) {
  // ⚠ No swarm join: this test is about the surface-to-node wiring, and a DHT would add real network
  // timing to something that is not about the network.
  const swarm = new Hyperswarm()
  const node = new FavelaNode(tmp(), { transport: null, swarm })
  await node.ready()
  t.teardown(async () => {
    try { await node.close() } catch {}
    try { await swarm.destroy() } catch {}
  })
  return node
}

test('★ the surface serves from an in-process node, with NO socket', async (t) => {
  const node = await bootNode(t)
  const s = await serveSurface(node, { port: 0 })
  t.teardown(() => new Promise((r) => s.server.close(r)))

  t.ok(s.port > 0, 'the OS chose a port and we know which')
  t.is(s.url, 'http://127.0.0.1:' + s.port, 'loopback only — a phone must not answer on Wi-Fi')

  // The login page is the one route reachable without a session, so it proves the whole path:
  // HTTP -> route -> in-process client -> dispatch -> the real node.
  const r = await get(s.port, '/login')
  t.is(r.status, 200, 'a real page, rendered from a real node')
  t.ok(r.body.includes('<!doctype html'), 'and it is HTML')
})

test('the in-process client IS the socket client\'s shape', async (t) => {
  const node = await bootNode(t)
  const c = inProcessClient(node)
  t.is(typeof c.call, 'function', 'one method, same signature favela-surface\'s Client has')

  // ⛔ Reaching the daemon's own handler, not a reimplementation of it.
  const who = await c.call('whoami')
  t.is(who.feed, node.key.toString('hex'), 'and it answers with this node\'s own feed')

  const ns = await c.call('namespace')
  t.ok(/^[0-9a-f]{64}$/.test(ns.key), 'namespace too — the whole surface is reachable')
})

test('⛔ a daemon error surfaces as an error, not as an empty page', async (t) => {
  const node = await bootNode(t)
  const c = inProcessClient(node)
  // An unlinked node cannot claim a name, and must say so rather than appear to succeed.
  await t.exception(c.call('claim-name', { name: 'alice' }), /credential|linked/i)
})

test('every page is still gated — one process does not mean no session', async (t) => {
  const node = await bootNode(t)
  const s = await serveSurface(node, { port: 0 })
  t.teardown(() => new Promise((r) => s.server.close(r)))

  // ⚠ Running in one process is not an authorisation. A WebView is still a browser and the gate is
  // still the gate; the handset gets a session the same way anything else does.
  for (const p of ['/', '/settings', '/following']) {
    const r = await get(s.port, p)
    t.is(r.status, 303, p + ' redirects to login')
  }
})

test('★ a session minted directly by the worklet works — no /auth/handoff needed', async (t) => {
  const node = await bootNode(t)
  const sessions = new Sessions()
  const s = await serveSurface(node, { port: 0, sessions })
  t.teardown(() => new Promise((r) => s.server.close(r)))

  // ⛔ THIS is why `serveSurface` takes the session store. favela-android had to add an
  // `/auth/handoff` ROUTE to favela-web so a worklet could hand its WebView a cookie. Here the
  // worklet owns the `Sessions` object outright, so it mints one and there is no route to add,
  // no token to swap, and no second code path to keep honest.
  const key = 'ab'.repeat(32)
  const id = sessions.create(key)
  const r = await get(s.port, '/', { cookie: 'favela_session=' + id })

  // ⚠ The gate asks the DAEMON whether the key is still allowed, and this one was never registered
  // with it — so the correct answer is still "no". Minting a session is not the same as being
  // known, and the worklet must register the app's key before this becomes a 200.
  t.is(r.status, 303, 'a minted session is not a credential — the daemon is still asked')
  t.ok(decodeURIComponent(String(r.headers.location)).includes('revoked'),
    'and it says the browser is not known, which is true')
})

test('★ the app claims its own node on first run, then AUTHENTICATES on the next', async (t) => {
  const storage = tmp()
  const swarm = new Hyperswarm()
  const node = new FavelaNode(storage, { transport: null, swarm })
  await node.ready()
  t.teardown(async () => {
    try { await node.close() } catch {}
    try { await swarm.destroy() } catch {}
  })

  // ⛔ TOFU: with nobody registered, the first key to prove itself claims the instance. This is the
  // fresh-install case, and favela-peer refuses an unknown key otherwise (`autoRegister: false`).
  const first = await signIn(node, storage)
  t.ok(first.claimed, 'the first run claims')
  t.is(first.publicKey.length, 44, 'an anproto public key')

  // ⛔ And the SECOND run must not try to claim again — TOFU closes on the first claim and never
  // reopens, not even for the claimant. A key regenerated each boot would lock this app out of its
  // own node permanently, which is why the keypair is on disk.
  const second = await signIn(node, storage)
  t.absent(second.claimed, 'the second run authenticates instead')
  t.is(second.publicKey, first.publicKey, 'as the SAME key, read back from disk')

  // And the daemon now vouches for it — which is what the session gate asks on every request.
  const st = await inProcessClient(node).call('browser-status', { publicKey: first.publicKey })
  t.alike(st, { known: true, revoked: false }, 'known and not revoked')
})

test('★ end to end: a signed-in app loads a real PAGE, not a login redirect', async (t) => {
  const storage = tmp()
  const swarm = new Hyperswarm()
  const node = new FavelaNode(storage, { transport: null, swarm })
  await node.ready()
  t.teardown(async () => {
    try { await node.close() } catch {}
    try { await swarm.destroy() } catch {}
  })

  const who = await signIn(node, storage)
  const sessions = new Sessions()
  const s = await serveSurface(node, { port: 0, sessions })
  t.teardown(() => new Promise((r) => s.server.close(r)))

  // The handoff the shell performs: mint a session for the app's key, then a ONE-TIME token, then
  // let the redirect plant the HttpOnly cookie the WebView cannot be given any other way.
  const id = sessions.create(who.publicKey)
  const token = sessions.createHandoff(id)
  const hand = await get(s.port, '/auth/handoff?token=' + token + '&next=/')
  t.is(hand.status, 303, 'the handoff redirects')
  const cookie = String(hand.headers['set-cookie']).split(';')[0]

  // ⛔ A FRESH NODE IS UNLINKED, and that is now a state the surface has an answer for. This used to
  // assert 200 here and it was right to fail when onboarding landed: a node with no identity rendering
  // a timeline is exactly the defect the /welcome gate exists to remove. Signed in as a BROWSER and
  // being SOMEBODY are different questions.
  const gated = await get(s.port, '/', { cookie })
  t.is(gated.status, 303, 'a node with no identity is sent to onboarding')
  t.is(gated.headers.location, '/welcome', 'and onboarding is where it goes')

  const welcome = await get(s.port, '/welcome', { cookie })
  t.is(welcome.status, 200, '★ a real page — the app is signed in to its own node')
  t.ok(welcome.body.includes('/welcome/create'), 'offering to create an identity')
  t.ok(welcome.body.includes('/welcome/restore'), 'or restore one from a recovery phrase')

  // ★ MINT FOR REAL, against the live daemon rather than a stub — the whole point of this file. This
  // is the path a handset takes on first run and it could not be taken at all until now.
  const { mintIdentity } = await import('favela-peer/lib/mint.js')
  const r = await mintIdentity({ vaultPath: join(storage, 'identity', 'vault'), passphrase: 'pw' })
  t.is(r.mnemonic.split(' ').length, 24, 'a real 24-word phrase came back')
  await node.link({
    keyPair: {
      publicKey: b4a.from(r.credential.publicKey, 'hex'),
      secretKey: b4a.from(r.credential.secretKey, 'hex')
    },
    proof: r.credential.proof
  })

  const page = await get(s.port, '/', { cookie })
  t.is(page.status, 200, '★ and once it is somebody, the timeline loads')
  t.ok(page.body.includes('<!doctype html'), 'and it is the timeline, not a redirect')

  // ⛔ One-time really is one-time, on the path that matters.
  const replay = await get(s.port, '/auth/handoff?token=' + token)
  t.is(replay.status, 303)
  t.ok(decodeURIComponent(String(replay.headers.location)).includes('already used'))
})