Archipelago — open-source initial import
This commit is contained in:
@@ -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...')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user