OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-proto-peer / test / hosting-cli.test.mjs

96 lines · javascript ref:
// The invite ceremony's terminal half — the parts a web UI will reuse verbatim.
//
// The network half (getHead / submitUpdate) is PlcDirectory's and is not re-tested here. What is tested
// is everything a person or a UI actually decides on: what the preview says, and how the recovery key is
// obtained. Both are pure, which is why they can be shared with a bridge web UI later instead of being
// reimplemented in it — the preview someone approves must be computed once, not twice.
//
// Run: bare test/hosting-cli.test.mjs
import '../lib/bare-globals.js'
import test from 'brittle'
import { createSwarmIdentity, deriveAtprotoKey, ATPROTO_RECOVERY_PATH } from 'atproto-identity'
import { attachHosting, detachHosting } from 'atproto-identity/lib/hosting.js'
import { sealVault } from 'atproto-identity/lib/vault.js'
import { describeChange, resolveSigner, formatPreview } from '../lib/hosting-cli.js'

const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
const HANDLE = 'flaky.7rnx.net'
const PDS = 'https://pds.7rnx.net'
const recovery = () => deriveAtprotoKey(MNEMONIC, ATPROTO_RECOVERY_PATH)

test('the preview names exactly what changes', (t) => {
  const id = createSwarmIdentity(MNEMONIC) // P2P-only
  const op = attachHosting({ prevSignedOp: id.signedOp, handle: HANDLE, pdsUrl: PDS, signerPrivateKey: recovery().privateKey })
  const p = describeChange(id.signedOp, op)

  const byField = Object.fromEntries(p.changes.map((c) => [c.field, c]))
  t.is(byField.handle.from, '(none)', 'was handleless')
  t.is(byField.handle.to, 'at://' + HANDLE, 'claims the handle')
  t.is(byField.pds.from, '(none)', 'named no PDS')
  t.is(byField.pds.to, PDS, 'names one now')
})

test('⛔ the preview ASSERTS that keys are carried forward', (t) => {
  // A PLC op replaces the whole document, so "unchanged" is a property of the op, not a law. Silently
  // dropping a rotation key is the one mistake nobody recovers from, so the preview states it.
  const id = createSwarmIdentity(MNEMONIC)
  const op = attachHosting({ prevSignedOp: id.signedOp, handle: HANDLE, pdsUrl: PDS, signerPrivateKey: recovery().privateKey })
  const p = describeChange(id.signedOp, op)
  t.ok(p.kept.rotationKeys, 'rotation keys reported unchanged')
  t.ok(p.kept.verificationMethods, 'signing key reported unchanged')

  // And it must NOTICE when they do change, or the assertion is decoration.
  const tampered = { ...op, rotationKeys: [] }
  t.absent(describeChange(id.signedOp, tampered).kept.rotationKeys, 'a dropped rotation key is reported as CHANGED')
})

test('detaching previews as a release, not a claim', (t) => {
  const id = createSwarmIdentity(MNEMONIC)
  const joined = attachHosting({ prevSignedOp: id.signedOp, handle: HANDLE, pdsUrl: PDS, signerPrivateKey: recovery().privateKey })
  const left = detachHosting({ prevSignedOp: joined, signerPrivateKey: recovery().privateKey })
  const byField = Object.fromEntries(describeChange(joined, left).changes.map((c) => [c.field, c]))
  t.is(byField.handle.to, '(none)', 'the handle goes back to none')
  t.is(byField.pds.to, '(none)', 'and so does the PDS')
})

test('the recovery key comes from a mnemonic file', (t) => {
  const s = resolveSigner({ mnemonic: MNEMONIC })
  t.is(s.didKey, recovery().didKey, 'derives the RECOVERY key, not the signing key')
})

test('...or from the sealed vault the phone already writes', (t) => {
  // The path that matters: mint seals the mnemonic on the handset, so the secret reaches another device
  // without ever being typed.
  const id = createSwarmIdentity(MNEMONIC)
  const envelope = sealVault({ secret: MNEMONIC, passphrase: 'correct horse battery staple', did: id.did })
  const s = resolveSigner({ vault: envelope, passphrase: 'correct horse battery staple', did: id.did })
  t.is(s.didKey, recovery().didKey, 'the vault yields the same recovery key')
})

test('⛔ a vault will not open without its passphrase, or for another identity', (t) => {
  const id = createSwarmIdentity(MNEMONIC)
  const envelope = sealVault({ secret: MNEMONIC, passphrase: 'right', did: id.did })
  t.exception(() => resolveSigner({ vault: envelope, passphrase: 'wrong', did: id.did }), 'a wrong passphrase fails')
  t.exception(() => resolveSigner({ vault: envelope, passphrase: 'right', did: 'did:plc:someoneelse' }), 'and so does the wrong identity')
  t.exception(() => resolveSigner({ vault: envelope, did: id.did }), /passphrase/, 'and a missing passphrase is named, not guessed at')
})

test('⛔ with no source at all it refuses rather than inventing a key', (t) => {
  t.exception(() => resolveSigner({}), /mnemonic-file|vault/, 'and tells you the two ways to supply one')
})

test('the printed preview says PERMANENT before it has happened, and not after', (t) => {
  const id = createSwarmIdentity(MNEMONIC)
  const op = attachHosting({ prevSignedOp: id.signedOp, handle: HANDLE, pdsUrl: PDS, signerPrivateKey: recovery().privateKey })
  const preview = describeChange(id.signedOp, op)

  const dry = formatPreview({ did: id.did, preview, submitted: false })
  t.ok(/WOULD append/.test(dry), 'a dry run says WOULD')
  t.ok(/PERMANENT and PUBLIC/.test(dry), 'and warns before, which is the only time a warning helps')
  t.ok(/--yes/.test(dry), 'and says how to proceed')

  const done = formatPreview({ did: id.did, preview, submitted: true })
  t.ok(/^APPENDED/.test(done), 'a real run reports what it did')
  t.absent(/--yes/.test(done), 'and does not tell you to do it again')
})