Files
archy/neode-ui/src/views/settings/__tests__/LightningCredentialsSection.test.ts
T
archipelagoandClaude Opus 5 51a4b59ce8 fix(settings): card the Lightning section, and stop it crying "not installed" mid-rotation
Two operator reports on the same screen.

The section had no card. Every other Settings section wraps itself in
`glass-card px-6 py-6 mb-6` — AccountSection, AIDataAccessSection,
NodeCertificateSection, BackupSection, the lot — and this one rendered as
bare text on the page. Reported twice, because the wrapper lives in the new
component and nothing about adding `<LightningCredentialsSection />` to
SystemSection.vue's list tells you it is missing. Heading moved to h2/text-xl
to match its siblings. A test now asserts the card, so a third report is not
needed.

And rotating told the operator Lightning did not exist. Rotation restarts
LND, so `status.installed` reads false for a moment — and the template read
that literally: "Lightning is not set up on this node yet, so there are no
credentials to rotate. Install the Lightning app first." Seconds after
rotating. On a node with a working wallet. It also replaced the progress they
had every reason to be watching, on the one action that invalidates every
credential their wallet holds.

A container briefly absent is what rotating LOOKS like, not evidence
Lightning was never there. The not-installed message is now gated on
`!rotationInFlight`, which covers both `running: true` and the awaitUntil
window between asking for a rotation and the node reporting one — `installed`
can already be false in that gap, so gating on `running` alone would have
left the same hole. Mid-rotation with no status yet says "Rotating
credentials — Lightning is restarting" instead of falling through to a
details block with empty fields.

awaitUntil became a ref so the computed re-evaluates rather than holding a
stale value until some other reactive dependency happens to change.

Three tests: the card exists; a running rotation does not claim Lightning is
missing; and — the half that matters just as much — a node with genuinely no
Lightning still gets told there is nothing to rotate, so the fix has not
simply hidden a true statement. 16/16, vue-tsc clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 13:11:18 -04:00

354 lines
13 KiB
TypeScript

import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import LightningCredentialsSection from '../LightningCredentialsSection.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
lndMacaroonStatus: vi.fn(),
lndRotateMacaroons: vi.fn(),
lndMacaroonRotationProgress: vi.fn(),
},
}))
const STEP_KEYS = ['preflight', 'backup', 'stop', 'remove', 'start', 'verify', 'btcpay'] as const
type StepState = 'pending' | 'running' | 'done' | 'failed' | 'skipped'
function steps(overrides: Partial<Record<string, { state: StepState; detail?: string }>> = {}) {
return STEP_KEYS.map((key) => ({
key,
label: `label-${key}`,
state: overrides[key]?.state ?? ('pending' as StepState),
detail: overrides[key]?.detail ?? null,
}))
}
function idleRotation() {
return {
running: false,
ok: null,
started_at: null,
finished_at: null,
error: null,
steps: steps(),
backup_path: null,
identity_pubkey: null,
channels_before: null,
channels_after: null,
new_admin_macaroon_sha256: null,
}
}
function status(overrides: Record<string, unknown> = {}) {
return {
installed: true,
admin_macaroon_sha256: 'a'.repeat(64),
issued_at: '2026-08-08 06:03:11',
identity_pubkey: '024a5fd7de13623aeec81095cf8776fedbc0c4109363022c3ec948196202130b92',
channels_open: 3,
channels_pending: 1,
lnd_error: null,
btcpay_uses_internal_lnd: true,
btcpay_credential_current: true,
rotation: idleRotation(),
...overrides,
}
}
function mountSection() {
return mount(LightningCredentialsSection, {
global: { stubs: { Teleport: true } },
})
}
describe('LightningCredentialsSection', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
})
it('shows what must survive before offering to rotate', async () => {
vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(status())
const wrapper = mountSection()
await flushPromises()
// The channel census is the reassurance an operator needs before clicking a
// button that invalidates every credential their wallet holds.
expect(wrapper.text()).toContain('3 open')
expect(wrapper.text()).toContain('1 pending')
expect(wrapper.text()).toContain('2026-08-08 06:03:11')
// A digest is fine to display; the token itself must never be fetched.
expect(wrapper.text()).toContain('aaaaaaaaaaaaaaaa…')
expect(wrapper.find('button').attributes('disabled')).toBeUndefined()
})
it('warns when BTCPay is stranded on a rotated-out credential', async () => {
vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(
status({ btcpay_credential_current: false }),
)
const wrapper = mountSection()
await flushPromises()
expect(wrapper.text()).toContain('BTCPay Server is holding an old Lightning credential')
})
it('stays quiet about BTCPay when there is no internal node to warn about', async () => {
// null means "not configured" — an absence, not a fault. Reporting it as a
// problem would train operators to ignore the warning that matters.
vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(
status({ btcpay_uses_internal_lnd: false, btcpay_credential_current: null }),
)
const wrapper = mountSection()
await flushPromises()
expect(wrapper.text()).not.toContain('holding an old Lightning credential')
})
it('blocks rotation while LND is not answering', async () => {
vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(
status({
lnd_error: 'LND is not answering on its REST port',
channels_open: null,
channels_pending: null,
identity_pubkey: null,
}),
)
const wrapper = mountSection()
await flushPromises()
expect(wrapper.text()).toContain('Lightning is not answering right now')
// Without a before-reading there is no way to prove the channels came back,
// so the button must be unavailable rather than merely discouraged.
expect(wrapper.find('button').attributes('disabled')).toBeDefined()
})
it('says Lightning is not installed instead of offering a no-op rotation', async () => {
vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(
status({ installed: false, admin_macaroon_sha256: null }),
)
const wrapper = mountSection()
await flushPromises()
expect(wrapper.text()).toContain('Lightning is not set up on this node yet')
expect(wrapper.findAll('button')).toHaveLength(0)
})
async function startRotation(wrapper: ReturnType<typeof mountSection>, pw = 'node-password') {
await wrapper.find('button').trigger('click')
await wrapper.find('input[type="password"]').setValue(pw)
await wrapper.find('form').trigger('submit')
await flushPromises()
}
it('sends the password and starts polling for progress', async () => {
vi.mocked(rpcClient.lndMacaroonStatus)
.mockResolvedValueOnce(status())
.mockResolvedValue(status({ rotation: { ...idleRotation(), running: true } }))
vi.mocked(rpcClient.lndRotateMacaroons).mockResolvedValue({ status: 'started' })
const wrapper = mountSection()
await flushPromises()
await startRotation(wrapper)
expect(rpcClient.lndRotateMacaroons).toHaveBeenCalledWith('node-password')
const callsBefore = vi.mocked(rpcClient.lndMacaroonStatus).mock.calls.length
vi.advanceTimersByTime(4000)
await flushPromises()
expect(vi.mocked(rpcClient.lndMacaroonStatus).mock.calls.length).toBeGreaterThan(callsBefore)
})
it('keeps polling when the first status after starting has not caught up yet', async () => {
// The node accepts the rotation and then answers a status request that was
// computed a moment earlier, still saying `running: false`. Cancelling the
// poll here would freeze the screen on the one action that most needs to show
// progress — the operator has just invalidated every credential their wallet
// holds and would be told nothing is happening.
vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(status())
vi.mocked(rpcClient.lndRotateMacaroons).mockResolvedValue({ status: 'started' })
const wrapper = mountSection()
await flushPromises()
await startRotation(wrapper)
const callsBefore = vi.mocked(rpcClient.lndMacaroonStatus).mock.calls.length
vi.advanceTimersByTime(4000)
await flushPromises()
expect(vi.mocked(rpcClient.lndMacaroonStatus).mock.calls.length).toBeGreaterThan(callsBefore)
})
it('gives up polling if the node never reports the rotation as running', async () => {
// Bounded, so a request that was accepted but never acted on stops polling
// instead of hammering the node forever.
vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(status())
vi.mocked(rpcClient.lndRotateMacaroons).mockResolvedValue({ status: 'started' })
const wrapper = mountSection()
await flushPromises()
await startRotation(wrapper)
vi.advanceTimersByTime(180_000)
await flushPromises()
const callsAfterGiveUp = vi.mocked(rpcClient.lndMacaroonStatus).mock.calls.length
vi.advanceTimersByTime(30_000)
await flushPromises()
expect(vi.mocked(rpcClient.lndMacaroonStatus).mock.calls.length).toBe(callsAfterGiveUp)
})
it('surfaces a rejected password without starting anything', async () => {
vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(status())
vi.mocked(rpcClient.lndRotateMacaroons).mockRejectedValue(
new Error('Password verification failed'),
)
const wrapper = mountSection()
await flushPromises()
await wrapper.find('button').trigger('click')
await wrapper.find('input[type="password"]').setValue('wrong')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(wrapper.text()).toContain('Password verification failed')
// The dialog stays open so the operator can correct the password.
expect(wrapper.find('input[type="password"]').exists()).toBe(true)
})
it('reports a finished rotation with the re-pair and backup-cleanup steps', async () => {
vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(
status({
rotation: {
...idleRotation(),
ok: true,
finished_at: '2026-08-08T12:00:00Z',
steps: steps({
preflight: { state: 'done' },
backup: { state: 'done' },
stop: { state: 'done' },
remove: { state: 'done' },
start: { state: 'done' },
verify: { state: 'done', detail: 'same node, same 3 channel(s)' },
btcpay: { state: 'done' },
}),
backup_path: '/var/lib/archipelago/lnd/macaroon-rotation-20260808T120000Z',
channels_before: 3,
channels_after: 3,
},
}),
)
const wrapper = mountSection()
await flushPromises()
expect(wrapper.text()).toContain('Rotation complete')
expect(wrapper.text()).toContain('same node, same 3 channel(s)')
expect(wrapper.text()).toContain('Re-pair anything that connects to this node')
// The backup holds the OLD root key, so telling the operator to delete it is
// part of the job, not a nicety.
expect(wrapper.text()).toContain('macaroon-rotation-20260808T120000Z')
})
it('reports a failed rotation as failed rather than silently idle', async () => {
vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(
status({
rotation: {
...idleRotation(),
ok: false,
finished_at: '2026-08-08T12:00:00Z',
error: 'backup incomplete — refusing to delete anything',
steps: steps({ preflight: { state: 'done' }, backup: { state: 'failed' } }),
},
}),
)
const wrapper = mountSection()
await flushPromises()
expect(wrapper.text()).toContain('Rotation failed')
expect(wrapper.text()).toContain('backup incomplete')
})
it('does not poll the node when nothing is running', async () => {
vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(status())
mountSection()
await flushPromises()
const callsAfterLoad = vi.mocked(rpcClient.lndMacaroonStatus).mock.calls.length
vi.advanceTimersByTime(30_000)
await flushPromises()
expect(vi.mocked(rpcClient.lndMacaroonStatus).mock.calls.length).toBe(callsAfterLoad)
})
it('renders inside a card, like every other Settings section', () => {
// Operator-reported twice: the section rendered as bare text on the
// Settings page. A new section carries its own wrapper, and nothing about
// adding it to SystemSection.vue's list reminds you it needs one.
// `wrapper.element` is not the div: the confirm modal is a second root
// node, so the component is a fragment. Assert on the first div.
const wrapper = mountSection()
expect(wrapper.find('div').classes()).toContain('glass-card')
})
it('does not claim Lightning is missing while a rotation is running', async () => {
// Rotation restarts LND, so `installed` goes false for a moment. The
// screen used to read that literally and tell the operator "Lightning is
// not set up on this node yet" — seconds after they rotated, on a node
// with a working wallet — replacing the progress they were watching.
vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(
status({ installed: false, rotation: { ...idleRotation(), running: true } }),
)
const wrapper = mountSection()
await flushPromises()
expect(wrapper.text()).not.toContain('Lightning is not set up on this node yet')
expect(wrapper.text()).toContain('Lightning is restarting')
})
it('still tells a node with no Lightning that there is nothing to rotate', async () => {
// The other half: the message must survive for its real audience, or the
// fix above has just hidden a true statement.
vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(status({ installed: false }))
const wrapper = mountSection()
await flushPromises()
expect(wrapper.text()).toContain('Lightning is not set up on this node yet')
})
it('does not claim Lightning is missing in the gap before the node reports the rotation', async () => {
// The window `awaitUntil` exists for: the rotate RPC has been accepted but
// the node has not yet reported `running: true`. `installed` can already be
// false there, so the guard has to cover the await window too, not just
// `running`.
vi.mocked(rpcClient.lndMacaroonStatus)
.mockResolvedValueOnce(status())
.mockResolvedValue(status({ installed: false }))
vi.mocked(rpcClient.lndRotateMacaroons).mockResolvedValue(undefined as never)
const wrapper = mountSection()
await flushPromises()
await wrapper.find('button').trigger('click')
await flushPromises()
const confirm = wrapper.findAll('button').find((b) => /rotate/i.test(b.text()))
if (confirm) {
await confirm.trigger('click')
await flushPromises()
}
expect(wrapper.text()).not.toContain('Lightning is not set up on this node yet')
})
})