Preserve Cloud and Fleet connection journeys and retry context

This commit is contained in:
archipelago
2026-10-08 06:36:13 -04:00
parent 2ca50ae36c
commit 63e21bb102
16 changed files with 243 additions and 35 deletions
+24 -11
View File
@@ -24,20 +24,33 @@ before this flow ships.
- Navigating away during initial identity loading cannot start a later orphaned
polling timer. No backend, API contract, trust elevation or payment changes.
Validation: twelve focused tests across five files pass, plus app-project
`vue-tsc -p tsconfig.app.json --noEmit`. The initial combined focused run had
11 passes and one incorrect test assertion against a shallow component stub;
correcting the assertion to inspect its Vue prop yielded 2/2 targeted navigation
passes. No production source fix was needed for that assertion. Source browser
checks at 390/1440px are prepared but resource-queued behind IndeeHub; no visual,
production-artifact, actual companion or live-node acceptance is claimed.
Additional return-path step:
- Cloud entry preserves its selected tab/category on return, including loading a
directly restored Paid Files tab. Fleet separates Connect with Nodes from its
explicit Link your own nodes entry, without automatically creating an invite.
- Peer Files returns to the correct node detail and Connected view. Closing that
detail clears only the selection query so refresh cannot reopen it.
- Manual direct entry routes known discovery identities/current requests through
the existing flow. Failed sends keep the confirmation, message and address for
retry; duplicate clicks issue one request. Unknown approval age stays unresolved
rather than enabling an accidental second request; valid expiry follows the
backend's existing 30-day limit.
Validation: 33 focused cases across seven affected files pass, plus app-project
`vue-tsc -p tsconfig.app.json --noEmit`. The initial second-slice run had 32 passes
and one stale Vue wrapper assertion after rerender; reacquiring the current dialog
and checking visible error text passed all eight targeted discovery cases. No
production source fix was needed for that test assertion. Source browser checks pass at 390/1440px with no page errors, document overflow
or unexpected RPC. The first cold navigation timed out during Vite dependency
optimization; the bounded warm retry passed. No deployed-artifact, actual
companion or live-node acceptance is claimed.
Evidence: `release-qualification/connection-journey-20261008/` under the local
Archipelago state directory. Browser fixture allows only loopback synthetic RPC.
Still open: Cloud/Fleet return paths and explicit own-node entry, authenticated
reciprocal confirmation/retry acceptance, stale-advertisement age when supplied
by the protocol, desktop/phone UAT and before/after timings. This step does not
complete task 6 or satisfy the release gates below.
Still open: authenticated reciprocal confirmation/retry acceptance,
stale-advertisement age when supplied by the protocol, desktop/phone UAT and
before/after timings. These UI steps do not complete the real-node release gates.
## Entry and return paths
@@ -15,6 +15,8 @@
at the Peer level — never trusted automatically.
</p>
<p v-if="error" role="alert" class="mb-3 text-sm text-red-400">{{ error }}</p>
<label class="block text-xs text-white/60 mb-1">Message (optional)</label>
<textarea
v-model="message"
@@ -48,6 +50,7 @@ const props = defineProps<{
show: boolean
targetLabel: string
sending?: boolean
error?: string
}>()
defineEmits<{
+21 -8
View File
@@ -197,9 +197,9 @@
</div>
<template v-else>
<div v-if="peerNodes.length === 0" class="glass-card p-8 text-center">
<p class="text-white/60 mb-3">No peers yet. Set up federation to browse files shared by other nodes.</p>
<RouterLink to="/dashboard/server/federation" class="glass-button inline-flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium">
Open Federation
<p class="text-white/60 mb-3">No peers yet. Connect with nodes to browse the files they share.</p>
<RouterLink :to="connectionEntry" class="glass-button inline-flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium">
Connect with Nodes
</RouterLink>
</div>
<div v-else-if="filteredPeerFiles.length === 0 && peerFilesPending === 0" class="glass-card p-8 text-center text-white/40 text-sm">
@@ -359,7 +359,7 @@
data-controller-container
tabindex="0"
class="glass-card p-6 cursor-pointer transition-all hover:-translate-y-1 hover:bg-white/10"
@click="router.push('/dashboard/server/federation')"
@click="router.push(connectionEntry)"
>
<div class="flex items-center gap-4 mb-4">
<div class="flex-shrink-0 w-12 h-12 rounded-xl flex items-center justify-center bg-purple-500/15">
@@ -438,7 +438,8 @@
<script setup lang="ts">
import { computed, ref, watch, onActivated, onMounted, onUnmounted } from 'vue'
import { useRouter, RouterLink } from 'vue-router'
import { useRouter, useRoute, RouterLink } from 'vue-router'
import { cloudSelection } from './federation/connectionNavigation'
import { useAppStore } from '../stores/app'
import { useCloudStore } from '../stores/cloud'
import { useResourcesStore } from '../stores/resources'
@@ -453,6 +454,7 @@ import ShareModal from '../components/cloud/ShareModal.vue'
import MediaLightbox from '../components/cloud/MediaLightbox.vue'
const router = useRouter()
const route = useRoute()
const store = useAppStore()
const cloudStore = useCloudStore()
const resources = useResourcesStore()
@@ -487,7 +489,7 @@ const CATEGORIES: Array<{ id: CategoryId; name: string }> = [
{ id: 'documents', name: 'Documents' },
]
const activeTab = ref<TabId>('folders')
const activeTab = ref<TabId>(cloudSelection(route.query).tab as TabId)
// ── Paid Files tab ──────────────────────────────────────────────────────────
interface PaidItem { onion: string; content_id: string; filename: string; mime_type: string; size_bytes: number; paid_sats: number; purchased_at: string }
@@ -515,7 +517,14 @@ async function viewPaidItem(it: PaidItem) {
}
watch(activeTab, (t) => { if (t === 'paid') void loadPaidItems() })
const selectedCategory = ref<CategoryId>('all')
const selectedCategory = ref<CategoryId>(cloudSelection(route.query).category as CategoryId)
watch(() => route.query, query => {
if (route.name !== 'cloud') return
const selection = cloudSelection(query)
activeTab.value = selection.tab as TabId
selectedCategory.value = selection.category as CategoryId
})
const connectionEntry = computed(() => ({ name: 'federation', query: { view: 'discover', from: 'cloud', tab: activeTab.value, category: selectedCategory.value } }))
const searchQuery = ref('')
const searchActive = computed(() => searchQuery.value.trim().length > 0)
// Categories narrow file LISTS; the Folders tab is already organized by kind.
@@ -1069,7 +1078,11 @@ async function syncOnEntry() {
// quick revisits don't refetch.
void loadPeerFiles()
}
onMounted(() => { void syncOnEntry() })
onMounted(() => {
if (activeTab.value === 'mine') void loadMyFiles()
if (activeTab.value === 'paid') void loadPaidItems()
void syncOnEntry()
})
onActivated(() => {
if (cloudFreshMount) { cloudFreshMount = false; return }
void syncOnEntry()
+18 -3
View File
@@ -59,6 +59,7 @@
</div>
<div v-show="activeView === 'discover'">
<p v-if="route.query.intent === 'own'" class="glass-card p-4 mb-4 text-sm text-white/70">To link nodes you own, use Link Your Nodes below. Trusted access still requires ownership confirmation; discovery requests only grant Observer access.</p>
<QuickActions
:generating-invite="generatingInvite"
:invite-type="inviteType"
@@ -221,7 +222,7 @@
:deploying="deploying"
:deploy-result="deployResult"
:action-error="nodeActionError"
@close="selectedNode = null; nodeActionError = ''"
@close="closeNode"
@change-trust="changeTrust"
@remove-node="removeNode"
@deploy-app="deployApp"
@@ -334,8 +335,22 @@ function normalizedView(value: unknown): ViewId {
if (value === 'list') return 'connected'
return viewTabs.some(tab => tab.id === value) ? value as ViewId : 'discover'
}
const activeView = ref<ViewId>(normalizedView(route.query.view))
watch(() => route.query.view, value => { activeView.value = normalizedView(value) })
const requestedView = () => route.query.view ?? (route.hash === '#connection-requests' ? 'requests' : route.query.node ? 'connected' : undefined)
const activeView = ref<ViewId>(normalizedView(requestedView()))
watch(requestedView, value => { activeView.value = normalizedView(value) })
watch([() => route.query.node, nodes], ([identity, available]) => {
if (typeof identity !== 'string') return
selectedNode.value = available.find(node => node.did === identity || node.pubkey === identity || node.onion === identity) ?? null
}, { immediate: true })
function closeNode() {
selectedNode.value = null
nodeActionError.value = ''
if (route.query.node) {
const query = { ...route.query }
delete query.node
void router.replace({ query })
}
}
function setView(id: ViewId) {
activeView.value = id
void router.replace({ query: { ...route.query, view: id } })
+7 -1
View File
@@ -58,6 +58,12 @@
</div>
</div>
<div class="glass-card p-4 mb-6 flex flex-wrap items-center gap-3">
<RouterLink :to="{ name: 'federation', query: { view: 'discover', from: 'fleet' } }" class="glass-button px-4 py-2 rounded-lg text-sm">Connect with Nodes</RouterLink>
<RouterLink :to="{ name: 'federation', query: { view: 'discover', from: 'fleet', intent: 'own' } }" class="glass-button px-4 py-2 rounded-lg text-sm">Link your own nodes</RouterLink>
<p class="w-full text-xs text-white/60">Peer connections use Observer access. Linking your own nodes requires the existing ownership confirmation for Trusted access.</p>
</div>
<!-- Loading State -->
<div v-if="fleet.loading.value" class="flex items-center justify-center py-20">
<div class="glass-card p-8 max-w-md text-center">
@@ -149,7 +155,7 @@
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { RouterLink, useRouter } from 'vue-router'
import BackButton from '@/components/BackButton.vue'
import FleetOverviewCards from './fleet/FleetOverviewCards.vue'
import FleetNodeGrid from './fleet/FleetNodeGrid.vue'
+5 -1
View File
@@ -37,6 +37,10 @@
</div>
</div>
<div class="mb-4">
<RouterLink :to="{ name: 'federation', query: { view: 'connected', from: 'peer-files', peer: peerOnion, node: currentPeer?.did } }" class="glass-button inline-flex px-4 py-2 rounded-lg text-sm">Node connections</RouterLink>
</div>
<!-- Loading -->
<div v-if="loading && catalogItems.length === 0" class="glass-card p-8 text-center">
<svg class="animate-spin h-6 w-6 text-purple-400 mx-auto mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
@@ -619,7 +623,7 @@ import { parseOnchainAttempt, type OnchainAttempt } from '@/composables/peerOnch
import { parseCashuQuote, readCashuAttempt, keepCashuAttempt, keepAuthoritativeCashuQuote, archiveMalformedCashuAttempt, clearCashuAttempt, type CashuQuote } from '@/composables/peerCashuPurchase'
import { usePeerPaymentOperations } from '@/composables/peerPaymentOperations'
import { ref, computed, reactive, watch, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { RouterLink, useRouter } from 'vue-router'
import QRCode from 'qrcode'
import { rpcClient } from '@/api/rpc-client'
import { useResourcesStore } from '@/stores/resources'
@@ -4,7 +4,10 @@ import { describe, expect, it, vi } from 'vitest'
import Cloud from '../Cloud.vue'
import { rpcClient } from '@/api/rpc-client'
const routeFixture = vi.hoisted(() => ({ name: 'cloud', query: {} as Record<string, string> }))
vi.mock('vue-router', () => ({
useRoute: () => routeFixture,
useRouter: () => ({ push: vi.fn() }),
RouterLink: { name: 'RouterLink', props: ['to'], template: '<a><slot /></a>' },
}))
@@ -16,6 +19,7 @@ vi.mock('@/stores/app', () => ({
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
federationListNodes: vi.fn(),
call: vi.fn().mockResolvedValue({ items: [] }),
},
}))
@@ -70,3 +74,16 @@ describe('Cloud peer list', () => {
expect(wrapper.text()).not.toContain('No peers yet')
})
})
it('loads paid files once when returning directly to the selected paid tab', async () => {
routeFixture.query = { tab: 'paid', category: 'music' }
vi.mocked(rpcClient.federationListNodes).mockResolvedValue({ nodes: [] })
vi.mocked(rpcClient.call).mockClear().mockResolvedValue({ items: [] })
const wrapper = mount(Cloud, { global: { plugins: [createPinia()] } })
await flushPromises()
const ownedCalls = vi.mocked(rpcClient.call).mock.calls.filter(([call]) => call.method === 'content.owned-list')
expect(ownedCalls).toHaveLength(1)
expect(wrapper.text()).toContain('Nothing purchased yet')
wrapper.unmount()
routeFixture.query = {}
})
@@ -38,6 +38,7 @@ import PeerFiles from '../PeerFiles.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('vue-router', () => ({
useRoute: () => ({ name: 'cloud', query: {} }),
useRouter: () => ({ push: vi.fn() }),
RouterLink: { name: 'RouterLink', props: ['to'], template: '<a><slot /></a>' },
}))
@@ -107,6 +107,7 @@
<PeerRequestModal
:show="visible && requestTarget !== null"
:target-label="requestTarget?.label ?? ''"
:error="error"
:sending="sendingTo !== null && sendingTo === requestTarget?.target"
@send="confirmRequest"
@cancel="requestTarget = null"
@@ -193,36 +194,49 @@ function actOn(node: DiscoverableNode) {
if (hasRequest(node.nostr_pubkey)) { emit('viewRequest'); return }
sendTo(node)
}
function isCurrentRequest(r: PendingPeerRequest): boolean {
if (r.state === 'pending' || r.state === 'sent') return true
const age = Date.now() - new Date(r.received_at).getTime()
return r.state === 'approved' && (!Number.isFinite(age) || age <= 30 * 86400000)
}
function hasRequest(key: string): boolean {
return props.outboundSent.some(r => r.from_nostr_pubkey === key && ['pending', 'sent', 'approved'].includes(r.state))
return props.outboundSent.some(r => r.from_nostr_pubkey === key && isCurrentRequest(r))
}
function sendTo(node: DiscoverableNode) {
error.value = ''
requestTarget.value = { target: node.nostr_pubkey, label: shortNpub(node.nostr_npub), clearManual: false }
}
function sendDirect() {
const v = manualNpub.value.trim()
if (!v) return
const v = manualNpub.value.trim().toLowerCase()
if (!v || sendingTo.value !== null) return
const known = nodes.value.find(node => node.nostr_npub === v || node.nostr_pubkey === v)
if (known) { actOn(known); return }
if (props.outboundSent.some(r => (r.from_nostr_npub === v || r.from_nostr_pubkey === v) && isCurrentRequest(r))) { emit('viewRequest'); return }
error.value = ''
requestTarget.value = { target: v, label: v.length > 21 ? `${v.slice(0, 12)}…${v.slice(-6)}` : v, clearManual: true }
}
async function confirmRequest(message: string | undefined) {
const req = requestTarget.value
if (!req || sendingTo.value !== null) return
await sendInternal(req.target, message)
const sent = await sendInternal(req.target, message)
if (!sent || requestTarget.value !== req) return
if (req.clearManual) manualNpub.value = ''
requestTarget.value = null
}
async function sendInternal(target: string, message?: string) {
if (sendingTo.value !== null) return
async function sendInternal(target: string, message?: string): Promise<boolean> {
if (sendingTo.value !== null) return false
sendingTo.value = target
error.value = ''
try {
await rpcClient.handshakeConnect(target, message)
emit('sent')
return true
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Send failed'
return false
} finally {
sendingTo.value = null
}
@@ -1,6 +1,6 @@
<template>
<div class="mb-6">
<BackButton label="Web5" @click="router.push('/dashboard/web5')" />
<BackButton :label="returnDestination.label" @click="router.push(returnDestination.to)" />
<div class="flex items-start justify-between gap-4">
<div>
<h1 class="text-3xl font-bold text-white mb-2">Connect with Nodes</h1>
@@ -25,7 +25,8 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useRouter, useRoute } from 'vue-router'
import { connectionReturn } from './connectionNavigation'
import BackButton from '@/components/BackButton.vue'
import { shortDid } from './utils'
import { safeClipboardWrite } from '../web5/utils'
@@ -40,6 +41,8 @@ defineEmits<{
}>()
const router = useRouter()
const route = useRoute()
const returnDestination = computed(() => connectionReturn(route.query))
const didCopied = ref(false)
const shortDidDisplay = computed(() => shortDid(props.selfDid))
@@ -119,7 +119,8 @@ function badgeLabel(r: PendingPeerRequest): string {
if (r.state === 'approved') {
if (props.peerDids?.includes(r.from_did)) return 'Peer added'
const age = Date.now() - new Date(r.received_at).getTime()
if (!Number.isFinite(age) || age > 30 * 24 * 60 * 60 * 1000) return 'Approval expired — reconnect'
if (!Number.isFinite(age)) return 'Approved — status unavailable'
if (age > 30 * 24 * 60 * 60 * 1000) return 'Approval expired — reconnect'
return r.outbound ? 'They approved — connecting' : 'Approved — connecting'
}
if (r.outbound && r.state === 'rejected') return 'They rejected'
@@ -4,6 +4,8 @@ import { createMemoryHistory, createRouter } from 'vue-router'
import Federation from '../../Federation.vue'
import { rpcClient } from '@/api/rpc-client'
const fixtures = vi.hoisted(() => ({ nodes: [] as Record<string, string>[] }))
vi.mock('@/stores/transport', () => ({ useTransportStore: () => ({ fetchPeers: vi.fn() }) }))
vi.mock('@/stores/app', () => ({ useAppStore: () => ({ serverName: 'Fixture' }) }))
vi.mock('@/stores/sync', () => ({ useSyncStore: () => ({ serverInfo: null }) }))
@@ -14,9 +16,9 @@ vi.mock('@/api/rpc-client', () => ({ rpcClient: {
} }))
vi.mock('@/composables/useCachedResource', async () => {
const { ref } = await import('vue')
return { useCachedResource: () => ({ data: ref([]), loadState: ref('ready'), error: ref(null), refresh: vi.fn() }) }
return { useCachedResource: (options: { key: string }) => ({ data: ref(options.key === 'federation.nodes' ? fixtures.nodes : []), loadState: ref('ready'), error: ref(null), refresh: vi.fn() }) }
})
afterEach(() => { vi.useRealTimers() })
afterEach(() => { fixtures.nodes = []; vi.useRealTimers() })
describe('connection journey navigation', () => {
it('renders all tabs with zero peers, accepts deep links and reacts to navigation', async () => {
@@ -55,3 +57,23 @@ it('does not start a poll timer after navigating away during initial identity lo
expect(timer).not.toHaveBeenCalled()
timer.mockRestore()
})
it('opens an existing peer deep link and clears it when the detail closes', async () => {
fixtures.nodes = [{ did: 'did:key:peer', pubkey: 'key', onion: 'fixture.onion', trust_level: 'observer', added_at: new Date().toISOString() }]
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: Federation }] })
await router.push('/?node=fixture.onion&from=cloud&tab=peers')
const wrapper = shallowMount(Federation, { global: { plugins: [router] } })
await flushPromises()
expect(wrapper.get('[role="tab"][aria-selected="true"]').text()).toBe('Connected')
const detail = wrapper.findComponent({ name: 'NodeDetailModal' })
expect(detail.props('node')?.did).toBe('did:key:peer')
detail.vm.$emit('close')
await flushPromises()
expect(router.currentRoute.value.query.node).toBeUndefined()
expect(router.currentRoute.value.query.from).toBe('cloud')
expect(detail.props('node')).toBeNull()
await router.push('/#connection-requests')
await flushPromises()
expect(wrapper.get('[role="tab"][aria-selected="true"]').text()).toBe('Requests')
wrapper.unmount()
})
@@ -111,3 +111,60 @@ describe('inline connection discovery', () => {
wrapper.unmount()
})
})
it('direct npub entry opens an existing request instead of sending again', async () => {
vi.mocked(rpcClient.handshakeDiscover).mockResolvedValueOnce({ nodes: [] })
vi.mocked(rpcClient.handshakeConnect).mockClear()
const wrapper = mount(DiscoverModal, { props: { visible: true, inline: true, outboundSent: [{ id: 'existing', from_did: 'did:key:peer', from_nostr_pubkey: 'key', from_nostr_npub: 'npub1existing', from_name: null, message: null, state: 'sent', outbound: true, received_at: new Date().toISOString() }] }, global: { stubs: { Teleport: true, PeerRequestModal: true } } })
await flushPromises()
await wrapper.get('input[placeholder="npub1…"]').setValue(' NPUB1EXISTING ')
await wrapper.findAll('button').find(b => b.text() === 'Send Request')!.trigger('click')
expect(wrapper.emitted('viewRequest')).toHaveLength(1)
expect(rpcClient.handshakeConnect).not.toHaveBeenCalled()
wrapper.unmount()
})
it('retains the request and error on send failure and prevents concurrent sends', async () => {
vi.mocked(rpcClient.handshakeDiscover).mockResolvedValueOnce({ nodes: [] })
const pending = deferred<Awaited<ReturnType<typeof rpcClient.handshakeConnect>>>()
vi.mocked(rpcClient.handshakeConnect).mockReset().mockReturnValueOnce(pending.promise)
const wrapper = mount(DiscoverModal, { props: { visible: true, inline: true, outboundSent: [] }, global: { stubs: { Teleport: true, PeerRequestModal: true } } })
await flushPromises()
await wrapper.get('input[placeholder="npub1…"]').setValue('npub1new')
await wrapper.findAll('button').find(b => b.text() === 'Send Request')!.trigger('click')
const modal = wrapper.findComponent({ name: 'PeerRequestModal' })
modal.vm.$emit('send', 'Please connect')
modal.vm.$emit('send', 'Please connect')
await flushPromises()
expect(rpcClient.handshakeConnect).toHaveBeenCalledTimes(1)
pending.reject(new Error('Relay unavailable'))
await flushPromises()
expect(modal.props('show')).toBe(true)
expect(wrapper.text()).toContain('Relay unavailable')
expect(wrapper.findComponent({ name: 'PeerRequestModal' }).props('error')).toBe('Relay unavailable')
expect(wrapper.get('input[placeholder="npub1…"]').element).toHaveProperty('value', 'npub1new')
expect(wrapper.emitted('sent')).toBeUndefined()
wrapper.unmount()
})
it('allows a new request after an approved operation has expired without claiming connection', async () => {
vi.mocked(rpcClient.handshakeDiscover).mockResolvedValueOnce({ nodes: [makeNode()] })
const wrapper = mount(DiscoverModal, { props: { visible: true, inline: true, outboundSent: [{ id: 'expired', from_did: 'did:key:node', from_nostr_pubkey: 'pubkey-one', from_nostr_npub: 'npub', from_name: null, message: null, state: 'approved', outbound: true, received_at: new Date(Date.now() - 31 * 86400000).toISOString() }] }, global: { stubs: { Teleport: true, PeerRequestModal: true } } })
await flushPromises()
expect(wrapper.text()).not.toContain('View request')
const buttons = wrapper.findAll('button').filter(b => b.text() === 'Send Request')
await buttons[buttons.length - 1]!.trigger('click')
expect(wrapper.findComponent({ name: 'PeerRequestModal' }).props('show')).toBe(true)
expect(wrapper.emitted('sent')).toBeUndefined()
wrapper.unmount()
})
it('preserves an approval with unknown age rather than allowing a duplicate request', async () => {
vi.mocked(rpcClient.handshakeDiscover).mockResolvedValueOnce({ nodes: [makeNode()] })
const wrapper = mount(DiscoverModal, { props: { visible: true, inline: true, outboundSent: [{ id: 'unknown-age', from_did: 'did:key:node', from_nostr_pubkey: 'pubkey-one', from_nostr_npub: 'npub', from_name: null, message: null, state: 'approved', outbound: true, received_at: 'unavailable' }] }, global: { stubs: { Teleport: true, PeerRequestModal: true } } })
await flushPromises()
await wrapper.findAll('button').find(b => b.text() === 'View request')!.trigger('click')
expect(wrapper.emitted('viewRequest')).toHaveLength(1)
expect(wrapper.findComponent({ name: 'PeerRequestModal' }).props('show')).toBe(false)
wrapper.unmount()
})
@@ -30,3 +30,10 @@ it('keeps the Requests tab useful when there are no requests', () => {
expect(wrapper.findAll('button').some(b => b.text() === 'Poll Now')).toBe(true)
wrapper.unmount()
})
it('does not claim an approval expired when its timestamp is unavailable', () => {
const wrapper = mount(PendingRequestsPanel, { props: { requests: [{ ...request, received_at: 'unavailable' }], polling: false, busyId: null } })
expect(wrapper.text()).toContain('Approved — status unavailable')
expect(wrapper.text()).not.toContain('expired')
wrapper.unmount()
})
@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest'
import { cloudSelection, connectionReturn } from '../connectionNavigation'
describe('connection return destinations', () => {
it('preserves Cloud tab/category and Fleet destinations without arbitrary redirects', () => {
expect(connectionReturn({ from: 'cloud', tab: 'peers', category: 'music' })).toEqual({ label: 'Cloud', to: { name: 'cloud', query: { tab: 'peers', category: 'music' } } })
expect(connectionReturn({ from: 'fleet' })).toEqual({ label: 'Fleet', to: { name: 'fleet' } })
for (const from of ['https://evil.invalid', '//evil.invalid', '/logout', ['fleet']]) expect(connectionReturn({ from }).to).toBe('/dashboard/web5')
expect(cloudSelection({ tab: ['mine'], category: 'anything' })).toEqual({ tab: 'folders', category: 'all' })
})
it('returns only to a bounded peer route parameter', () => {
expect(connectionReturn({ from: 'peer-files', peer: 'fixture.onion' }).to).toEqual({ name: 'peer-files', params: { peerId: 'fixture.onion' } })
for (const peer of ['', '../wallet', 'a?token=b', 'a/b', 'a\\b', 'a\nb', 'a'.repeat(257)]) expect(connectionReturn({ from: 'peer-files', peer }).to).toBe('/dashboard/web5')
})
})
@@ -0,0 +1,17 @@
import type { LocationQuery, RouteLocationRaw } from 'vue-router'
export function cloudSelection(query: LocationQuery) {
const tab = typeof query.tab === 'string' && ['folders', 'mine', 'peers', 'paid'].includes(query.tab) ? query.tab : 'folders'
const category = typeof query.category === 'string' && ['all', 'photos', 'music', 'documents'].includes(query.category) ? query.category : 'all'
return { tab, category }
}
// Fixed route names only: never follow an arbitrary return URL from a link.
export function connectionReturn(query: LocationQuery): { label: string; to: RouteLocationRaw } {
if (query.from === 'fleet') return { label: 'Fleet', to: { name: 'fleet' } }
if (query.from === 'cloud') return { label: 'Cloud', to: { name: 'cloud', query: cloudSelection(query) } }
if (query.from === 'peer-files' && typeof query.peer === 'string' && query.peer.length > 0 && query.peer.length <= 256 && !/[\u0000-\u0020/\\?#]/.test(query.peer)) {
return { label: 'Peer files', to: { name: 'peer-files', params: { peerId: query.peer } } }
}
return { label: 'Web5', to: '/dashboard/web5' }
}