Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:50 +00:00
commit b67e1527a2
2068 changed files with 472303 additions and 0 deletions
@@ -0,0 +1,72 @@
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import BackupSection from '../BackupSection.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}))
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn(),
},
}))
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
function makeBackup() {
return {
id: 'backup-one',
created_at: '2026-06-10T10:00:00Z',
size_bytes: 2048,
encrypted: true,
description: 'Before upgrade',
}
}
describe('BackupSection', () => {
it('keeps backups visible while refresh is pending or fails', async () => {
vi.mocked(rpcClient.call).mockResolvedValueOnce({ backups: [makeBackup()] })
const wrapper = mount(BackupSection, {
global: {
stubs: {
Teleport: true,
},
},
})
await flushPromises()
expect(wrapper.text()).toContain('Before upgrade')
expect(wrapper.text()).toContain('2.0 KB')
const pending = deferred<{ backups: [] }>()
vi.mocked(rpcClient.call).mockReturnValueOnce(pending.promise)
const refresh = (wrapper.vm as unknown as { loadBackups: () => Promise<void> }).loadBackups()
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Before upgrade')
expect(wrapper.text()).toContain('Refreshing backups...')
expect(wrapper.text()).not.toContain('settings.loadingBackups')
expect(wrapper.text()).not.toContain('settings.noBackups')
pending.reject(new Error('offline'))
await refresh
await flushPromises()
expect(wrapper.text()).toContain('Before upgrade')
expect(wrapper.text()).toContain('offline')
expect(wrapper.text()).not.toContain('Refreshing backups...')
expect(wrapper.text()).not.toContain('settings.noBackups')
})
})
@@ -0,0 +1,58 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ChangePasswordSection from '../ChangePasswordSection.vue'
const { changePassword } = vi.hoisted(() => ({
changePassword: vi.fn(),
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}))
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
changePassword,
},
}))
vi.mock('@/composables/useModalKeyboard', () => ({
useModalKeyboard: vi.fn(),
}))
describe('ChangePasswordSection', () => {
beforeEach(() => {
changePassword.mockReset()
})
it('shows a warning when SSH update fails after web password update succeeds', async () => {
changePassword.mockResolvedValueOnce({
success: true,
ssh_updated: false,
ssh_error: 'sudo unavailable',
})
const wrapper = mount(ChangePasswordSection, {
global: {
stubs: {
Teleport: true,
},
},
})
await wrapper.find('button').trigger('click')
const inputs = wrapper.findAll('input')
await inputs[0]!.setValue('password123')
await inputs[1]!.setValue('MyP@ssw0rd!123')
await inputs[2]!.setValue('MyP@ssw0rd!123')
await wrapper.find('form').trigger('submit.prevent')
expect(changePassword).toHaveBeenCalledWith({
currentPassword: 'password123',
newPassword: 'MyP@ssw0rd!123',
alsoChangeSsh: true,
})
expect(wrapper.text()).toContain('settings.passwordUpdatedSuccess')
expect(wrapper.text()).toContain('settings.passwordUpdatedSshFailed sudo unavailable')
expect(wrapper.text()).not.toContain('settings.passwordChangeFailed')
})
})
@@ -0,0 +1,353 @@
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')
})
})
@@ -0,0 +1,65 @@
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import TransportPrefsCard from '../TransportPrefsCard.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn(),
},
}))
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
function makePrefs() {
return {
federation: 'auto',
peers: 'fips',
peer_files: 'tor',
messaging: 'auto',
mesh_file_sharing: 'fips',
}
}
describe('TransportPrefsCard', () => {
it('keeps transport preferences visible while refresh is pending or fails', async () => {
vi.mocked(rpcClient.call).mockResolvedValueOnce(makePrefs())
const wrapper = mount(TransportPrefsCard)
await flushPromises()
expect(wrapper.text()).toContain('Federation')
expect(wrapper.text()).toContain('Peer Files')
expect(wrapper.text()).toContain('Auto')
expect(wrapper.text()).toContain('FIPS')
expect(wrapper.text()).toContain('Tor')
const pending = deferred<ReturnType<typeof makePrefs>>()
vi.mocked(rpcClient.call).mockReturnValueOnce(pending.promise)
const refresh = (wrapper.vm as unknown as { load: () => Promise<void> }).load()
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Federation')
expect(wrapper.text()).toContain('Peer Files')
expect(wrapper.text()).toContain('Refreshing transport preferences...')
expect(wrapper.text()).not.toContain('Loading…')
pending.reject(new Error('offline'))
await refresh
await flushPromises()
expect(wrapper.text()).toContain('Federation')
expect(wrapper.text()).toContain('Peer Files')
expect(wrapper.text()).toContain('offline')
expect(wrapper.text()).not.toContain('Refreshing transport preferences...')
})
})
@@ -0,0 +1,66 @@
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import VpnStatusSection from '../VpnStatusSection.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn(),
},
}))
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
function makeVpnStatus() {
return {
connected: true,
provider: 'wireguard',
interface: 'wg0',
ip_address: '10.0.0.2/32',
hostname: 'node',
peers_connected: 2,
bytes_in: 1024,
bytes_out: 2048,
}
}
describe('VpnStatusSection', () => {
it('keeps VPN status visible while refresh is pending or fails', async () => {
vi.mocked(rpcClient.call).mockResolvedValueOnce(makeVpnStatus())
const wrapper = mount(VpnStatusSection)
await flushPromises()
expect(wrapper.text()).toContain('Connected')
expect(wrapper.text()).toContain('wireguard')
expect(wrapper.text()).toContain('10.0.0.2/32')
const pending = deferred<ReturnType<typeof makeVpnStatus>>()
vi.mocked(rpcClient.call).mockReturnValueOnce(pending.promise)
const refresh = (wrapper.vm as unknown as { fetchVpnStatus: () => Promise<void> }).fetchVpnStatus()
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Connected')
expect(wrapper.text()).toContain('wireguard')
expect(wrapper.text()).toContain('Refreshing VPN status...')
expect(wrapper.text()).not.toContain('Loading VPN status...')
pending.reject(new Error('offline'))
await refresh
await flushPromises()
expect(wrapper.text()).toContain('Connected')
expect(wrapper.text()).toContain('wireguard')
expect(wrapper.text()).toContain('offline')
expect(wrapper.text()).not.toContain('Refreshing VPN status...')
})
})