// 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')
})