diff --git a/neode-ui/src/views/settings/LightningCredentialsSection.vue b/neode-ui/src/views/settings/LightningCredentialsSection.vue index 01231d92..e2dbc320 100644 --- a/neode-ui/src/views/settings/LightningCredentialsSection.vue +++ b/neode-ui/src/views/settings/LightningCredentialsSection.vue @@ -25,6 +25,20 @@ const confirmError = ref('') let poll: ReturnType | null = null +/// Epoch ms until which we keep polling even though the node has not reported a +/// running rotation yet. +/// +/// Without this the screen can freeze on the one action that most needs to show +/// progress: `rotate()` starts polling, the `load()` right behind it observes a +/// status snapshot that does not yet carry `running: true`, and `syncPolling` +/// cancels the interval. The operator has just invalidated every credential +/// their wallet holds and the page tells them nothing is happening. +/// +/// Bounded rather than a plain flag, so a request the node accepted but never +/// acted on stops polling instead of hammering it forever. +let awaitUntil = 0 +const AWAIT_START_MS = 120_000 + const rotation = computed(() => status.value?.rotation ?? null) const isRunning = computed(() => rotation.value?.running === true) @@ -42,10 +56,7 @@ async function load() { try { status.value = await rpcClient.lndMacaroonStatus() loadError.value = '' - // Poll only while there is something to watch, so an idle Settings tab - // isn't waking the node every few seconds. - if (status.value.rotation.running) startPolling() - else stopPolling() + syncPolling() } catch (e) { loadError.value = e instanceof Error ? e.message : String(e) } finally { @@ -53,6 +64,15 @@ async function load() { } } +/// Poll only while there is something to watch, so an idle Settings tab isn't +/// waking the node every few seconds. +function syncPolling() { + const running = status.value?.rotation.running === true + if (running) awaitUntil = 0 + if (running || Date.now() < awaitUntil) startPolling() + else stopPolling() +} + function startPolling() { if (poll) return poll = setInterval(load, 4000) @@ -83,6 +103,7 @@ async function rotate() { try { await rpcClient.lndRotateMacaroons(password.value) closeConfirm() + awaitUntil = Date.now() + AWAIT_START_MS startPolling() await load() } catch (e) { diff --git a/neode-ui/src/views/settings/__tests__/LightningCredentialsSection.test.ts b/neode-ui/src/views/settings/__tests__/LightningCredentialsSection.test.ts new file mode 100644 index 00000000..05f4be33 --- /dev/null +++ b/neode-ui/src/views/settings/__tests__/LightningCredentialsSection.test.ts @@ -0,0 +1,292 @@ +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> = {}) { + 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 = {}) { + 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, 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) + }) +})