// applySetPassword — `hpp-onboard --set-password` must write the account the DAEMON actually reads.
//
// The bug: it wrote cfg.appPasswordHash (top level) on configs whose accounts live in cfg.dids[].
// bootMultiDevice spreads the per-DID entry (bootSwarmDevice({ storage, ...entry }),
// device-node.js:694), so sessions come from entry.appPasswordHash and a top-level field is dead.
// The command reported success and changed nothing. The first test below is the control that pins
// that exact shape, so a regression cannot pass by writing the wrong level again.
import test from 'brittle'
import { applySetPassword } from '../lib/set-password.js'
const multi = () => ({
swarm: true,
dids: [
{ did: 'did:plc:aaa', handle: 'a.example', appPasswordHash: 'OLD-A', jwtSecret: 'ja' },
{ did: 'did:plc:bbb', handle: 'b.example', appPasswordHash: 'OLD-B', jwtSecret: 'jb' },
{ did: 'did:plc:feed', handle: 'feed.example' } // a co-owned feed: no hash, no jwtSecret, on purpose
]
})
test('CONTROL: the per-DID entry is what changes — never a stray top-level field', (t) => {
const cfg = multi()
const r = applySetPassword(cfg, { did: 'did:plc:bbb', hash: 'NEW' })
t.is(cfg.dids[1].appPasswordHash, 'NEW', 'the NAMED account got the new hash')
t.is(cfg.dids[0].appPasswordHash, 'OLD-A', 'the other account is untouched')
t.absent('appPasswordHash' in cfg, 'NO top-level appPasswordHash — that field is never read in multi-DID mode')
t.is(r.mode, 'multi')
t.is(r.did, 'did:plc:bbb')
t.is(r.handle, 'b.example', 'reports the handle, so the operator can see which login changed')
t.absent(r.warning, 'an account with a jwtSecret warns about nothing')
})
test('refuses to GUESS when several accounts are hosted', (t) => {
const cfg = multi()
t.exception(() => applySetPassword(cfg, { hash: 'NEW' }), /MULTI-DID config with 3 accounts/,
'no --did on a multi-account config is a refusal, not a coin flip')
t.is(cfg.dids[0].appPasswordHash, 'OLD-A', 'and nothing was written on the way out')
t.exception(() => applySetPassword(cfg, { did: 'did:plc:nope', hash: 'NEW' }), /not a hosted DID/)
t.is(cfg.dids[1].appPasswordHash, 'OLD-B', 'an unknown DID writes nothing either')
})
test('a single hosted account is unambiguous, so --did is optional', (t) => {
const cfg = { dids: [{ did: 'did:plc:only', handle: 'only.example', jwtSecret: 'j' }] }
const r = applySetPassword(cfg, { hash: 'NEW' })
t.is(cfg.dids[0].appPasswordHash, 'NEW')
t.is(r.did, 'did:plc:only')
})
test('a feed DID (no jwtSecret) is warned about, not silently accepted', (t) => {
const cfg = multi()
const r = applySetPassword(cfg, { did: 'did:plc:feed', hash: 'NEW' })
t.is(cfg.dids[2].appPasswordHash, 'NEW', 'still written — a two-step setup is legitimate')
t.ok(r.warning && /jwtSecret/.test(r.warning), 'but it says the password alone will not enable login')
})
test('legacy single-DID config still writes the top level — that IS the account there', (t) => {
const cfg = { did: 'did:plc:legacy', handle: 'l.example', jwtSecret: 'j', appPasswordHash: 'OLD' }
const r = applySetPassword(cfg, { hash: 'NEW' })
t.is(cfg.appPasswordHash, 'NEW')
t.is(r.mode, 'single')
t.absent(cfg.dids, 'no dids[] was invented')
t.exception(() => applySetPassword(cfg, { did: 'did:plc:other', hash: 'X' }), /does not match/,
'a --did naming a different account is a refusal, not a silent overwrite')
})
test('empty dids[] falls through to the legacy path rather than throwing', (t) => {
const cfg = { did: 'did:plc:legacy', dids: [], jwtSecret: 'j' }
const r = applySetPassword(cfg, { hash: 'NEW' })
t.is(cfg.appPasswordHash, 'NEW')
t.is(r.mode, 'single')
})
test('bad input is refused', (t) => {
t.exception(() => applySetPassword(null, { hash: 'X' }), /config object required/)
t.exception(() => applySetPassword({}, {}), /hash required/)
})