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>
354 lines
13 KiB
TypeScript
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')
|
|
})
|
|
})
|