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 - Navigating away during initial identity loading cannot start a later orphaned
polling timer. No backend, API contract, trust elevation or payment changes. polling timer. No backend, API contract, trust elevation or payment changes.
Validation: twelve focused tests across five files pass, plus app-project Additional return-path step:
`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; - Cloud entry preserves its selected tab/category on return, including loading a
correcting the assertion to inspect its Vue prop yielded 2/2 targeted navigation directly restored Paid Files tab. Fleet separates Connect with Nodes from its
passes. No production source fix was needed for that assertion. Source browser explicit Link your own nodes entry, without automatically creating an invite.
checks at 390/1440px are prepared but resource-queued behind IndeeHub; no visual, - Peer Files returns to the correct node detail and Connected view. Closing that
production-artifact, actual companion or live-node acceptance is claimed. 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 Evidence: `release-qualification/connection-journey-20261008/` under the local
Archipelago state directory. Browser fixture allows only loopback synthetic RPC. Archipelago state directory. Browser fixture allows only loopback synthetic RPC.
Still open: Cloud/Fleet return paths and explicit own-node entry, authenticated Still open: authenticated reciprocal confirmation/retry acceptance,
reciprocal confirmation/retry acceptance, stale-advertisement age when supplied stale-advertisement age when supplied by the protocol, desktop/phone UAT and
by the protocol, desktop/phone UAT and before/after timings. This step does not before/after timings. These UI steps do not complete the real-node release gates.
complete task 6 or satisfy the release gates below.
## Entry and return paths ## Entry and return paths
@@ -15,6 +15,8 @@
at the Peer level — never trusted automatically. at the Peer level — never trusted automatically.
</p> </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> <label class="block text-xs text-white/60 mb-1">Message (optional)</label>
<textarea <textarea
v-model="message" v-model="message"
@@ -48,6 +50,7 @@ const props = defineProps<{
show: boolean show: boolean
targetLabel: string targetLabel: string
sending?: boolean sending?: boolean
error?: string
}>() }>()
defineEmits<{ defineEmits<{
+21 -8
View File
@@ -197,9 +197,9 @@
</div> </div>
<template v-else> <template v-else>
<div v-if="peerNodes.length === 0" class="glass-card p-8 text-center"> <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> <p class="text-white/60 mb-3">No peers yet. Connect with nodes to browse the files they share.</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"> <RouterLink :to="connectionEntry" class="glass-button inline-flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium">
Open Federation Connect with Nodes
</RouterLink> </RouterLink>
</div> </div>
<div v-else-if="filteredPeerFiles.length === 0 && peerFilesPending === 0" class="glass-card p-8 text-center text-white/40 text-sm"> <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 data-controller-container
tabindex="0" tabindex="0"
class="glass-card p-6 cursor-pointer transition-all hover:-translate-y-1 hover:bg-white/10" 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 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"> <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"> <script setup lang="ts">
import { computed, ref, watch, onActivated, onMounted, onUnmounted } from 'vue' 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 { useAppStore } from '../stores/app'
import { useCloudStore } from '../stores/cloud' import { useCloudStore } from '../stores/cloud'
import { useResourcesStore } from '../stores/resources' import { useResourcesStore } from '../stores/resources'
@@ -453,6 +454,7 @@ import ShareModal from '../components/cloud/ShareModal.vue'
import MediaLightbox from '../components/cloud/MediaLightbox.vue' import MediaLightbox from '../components/cloud/MediaLightbox.vue'
const router = useRouter() const router = useRouter()
const route = useRoute()
const store = useAppStore() const store = useAppStore()
const cloudStore = useCloudStore() const cloudStore = useCloudStore()
const resources = useResourcesStore() const resources = useResourcesStore()
@@ -487,7 +489,7 @@ const CATEGORIES: Array<{ id: CategoryId; name: string }> = [
{ id: 'documents', name: 'Documents' }, { id: 'documents', name: 'Documents' },
] ]
const activeTab = ref<TabId>('folders') const activeTab = ref<TabId>(cloudSelection(route.query).tab as TabId)
// ── Paid Files tab ────────────────────────────────────────────────────────── // ── Paid Files tab ──────────────────────────────────────────────────────────
interface PaidItem { onion: string; content_id: string; filename: string; mime_type: string; size_bytes: number; paid_sats: number; purchased_at: string } 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() }) 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 searchQuery = ref('')
const searchActive = computed(() => searchQuery.value.trim().length > 0) const searchActive = computed(() => searchQuery.value.trim().length > 0)
// Categories narrow file LISTS; the Folders tab is already organized by kind. // Categories narrow file LISTS; the Folders tab is already organized by kind.
@@ -1069,7 +1078,11 @@ async function syncOnEntry() {
// quick revisits don't refetch. // quick revisits don't refetch.
void loadPeerFiles() void loadPeerFiles()
} }
onMounted(() => { void syncOnEntry() }) onMounted(() => {
if (activeTab.value === 'mine') void loadMyFiles()
if (activeTab.value === 'paid') void loadPaidItems()
void syncOnEntry()
})
onActivated(() => { onActivated(() => {
if (cloudFreshMount) { cloudFreshMount = false; return } if (cloudFreshMount) { cloudFreshMount = false; return }
void syncOnEntry() void syncOnEntry()
+18 -3
View File
@@ -59,6 +59,7 @@
</div> </div>
<div v-show="activeView === 'discover'"> <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 <QuickActions
:generating-invite="generatingInvite" :generating-invite="generatingInvite"
:invite-type="inviteType" :invite-type="inviteType"
@@ -221,7 +222,7 @@
:deploying="deploying" :deploying="deploying"
:deploy-result="deployResult" :deploy-result="deployResult"
:action-error="nodeActionError" :action-error="nodeActionError"
@close="selectedNode = null; nodeActionError = ''" @close="closeNode"
@change-trust="changeTrust" @change-trust="changeTrust"
@remove-node="removeNode" @remove-node="removeNode"
@deploy-app="deployApp" @deploy-app="deployApp"
@@ -334,8 +335,22 @@ function normalizedView(value: unknown): ViewId {
if (value === 'list') return 'connected' if (value === 'list') return 'connected'
return viewTabs.some(tab => tab.id === value) ? value as ViewId : 'discover' return viewTabs.some(tab => tab.id === value) ? value as ViewId : 'discover'
} }
const activeView = ref<ViewId>(normalizedView(route.query.view)) const requestedView = () => route.query.view ?? (route.hash === '#connection-requests' ? 'requests' : route.query.node ? 'connected' : undefined)
watch(() => route.query.view, value => { activeView.value = normalizedView(value) }) 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) { function setView(id: ViewId) {
activeView.value = id activeView.value = id
void router.replace({ query: { ...route.query, view: id } }) void router.replace({ query: { ...route.query, view: id } })
+7 -1
View File
@@ -58,6 +58,12 @@
</div> </div>
</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 --> <!-- Loading State -->
<div v-if="fleet.loading.value" class="flex items-center justify-center py-20"> <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"> <div class="glass-card p-8 max-w-md text-center">
@@ -149,7 +155,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useRouter } from 'vue-router' import { RouterLink, useRouter } from 'vue-router'
import BackButton from '@/components/BackButton.vue' import BackButton from '@/components/BackButton.vue'
import FleetOverviewCards from './fleet/FleetOverviewCards.vue' import FleetOverviewCards from './fleet/FleetOverviewCards.vue'
import FleetNodeGrid from './fleet/FleetNodeGrid.vue' import FleetNodeGrid from './fleet/FleetNodeGrid.vue'
+5 -1
View File
@@ -37,6 +37,10 @@
</div> </div>
</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 --> <!-- Loading -->
<div v-if="loading && catalogItems.length === 0" class="glass-card p-8 text-center"> <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"> <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 { parseCashuQuote, readCashuAttempt, keepCashuAttempt, keepAuthoritativeCashuQuote, archiveMalformedCashuAttempt, clearCashuAttempt, type CashuQuote } from '@/composables/peerCashuPurchase'
import { usePeerPaymentOperations } from '@/composables/peerPaymentOperations' import { usePeerPaymentOperations } from '@/composables/peerPaymentOperations'
import { ref, computed, reactive, watch, onMounted, onUnmounted } from 'vue' 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 QRCode from 'qrcode'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import { useResourcesStore } from '@/stores/resources' import { useResourcesStore } from '@/stores/resources'
@@ -4,7 +4,10 @@ import { describe, expect, it, vi } from 'vitest'
import Cloud from '../Cloud.vue' import Cloud from '../Cloud.vue'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
const routeFixture = vi.hoisted(() => ({ name: 'cloud', query: {} as Record<string, string> }))
vi.mock('vue-router', () => ({ vi.mock('vue-router', () => ({
useRoute: () => routeFixture,
useRouter: () => ({ push: vi.fn() }), useRouter: () => ({ push: vi.fn() }),
RouterLink: { name: 'RouterLink', props: ['to'], template: '<a><slot /></a>' }, RouterLink: { name: 'RouterLink', props: ['to'], template: '<a><slot /></a>' },
})) }))
@@ -16,6 +19,7 @@ vi.mock('@/stores/app', () => ({
vi.mock('@/api/rpc-client', () => ({ vi.mock('@/api/rpc-client', () => ({
rpcClient: { rpcClient: {
federationListNodes: vi.fn(), federationListNodes: vi.fn(),
call: vi.fn().mockResolvedValue({ items: [] }),
}, },
})) }))
@@ -70,3 +74,16 @@ describe('Cloud peer list', () => {
expect(wrapper.text()).not.toContain('No peers yet') 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' import { rpcClient } from '@/api/rpc-client'
vi.mock('vue-router', () => ({ vi.mock('vue-router', () => ({
useRoute: () => ({ name: 'cloud', query: {} }),
useRouter: () => ({ push: vi.fn() }), useRouter: () => ({ push: vi.fn() }),
RouterLink: { name: 'RouterLink', props: ['to'], template: '<a><slot /></a>' }, RouterLink: { name: 'RouterLink', props: ['to'], template: '<a><slot /></a>' },
})) }))
@@ -107,6 +107,7 @@
<PeerRequestModal <PeerRequestModal
:show="visible && requestTarget !== null" :show="visible && requestTarget !== null"
:target-label="requestTarget?.label ?? ''" :target-label="requestTarget?.label ?? ''"
:error="error"
:sending="sendingTo !== null && sendingTo === requestTarget?.target" :sending="sendingTo !== null && sendingTo === requestTarget?.target"
@send="confirmRequest" @send="confirmRequest"
@cancel="requestTarget = null" @cancel="requestTarget = null"
@@ -193,36 +194,49 @@ function actOn(node: DiscoverableNode) {
if (hasRequest(node.nostr_pubkey)) { emit('viewRequest'); return } if (hasRequest(node.nostr_pubkey)) { emit('viewRequest'); return }
sendTo(node) 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 { 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) { function sendTo(node: DiscoverableNode) {
error.value = ''
requestTarget.value = { target: node.nostr_pubkey, label: shortNpub(node.nostr_npub), clearManual: false } requestTarget.value = { target: node.nostr_pubkey, label: shortNpub(node.nostr_npub), clearManual: false }
} }
function sendDirect() { function sendDirect() {
const v = manualNpub.value.trim() const v = manualNpub.value.trim().toLowerCase()
if (!v) return 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 } requestTarget.value = { target: v, label: v.length > 21 ? `${v.slice(0, 12)}…${v.slice(-6)}` : v, clearManual: true }
} }
async function confirmRequest(message: string | undefined) { async function confirmRequest(message: string | undefined) {
const req = requestTarget.value const req = requestTarget.value
if (!req || sendingTo.value !== null) return 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 = '' if (req.clearManual) manualNpub.value = ''
requestTarget.value = null requestTarget.value = null
} }
async function sendInternal(target: string, message?: string) { async function sendInternal(target: string, message?: string): Promise<boolean> {
if (sendingTo.value !== null) return if (sendingTo.value !== null) return false
sendingTo.value = target sendingTo.value = target
error.value = '' error.value = ''
try { try {
await rpcClient.handshakeConnect(target, message) await rpcClient.handshakeConnect(target, message)
emit('sent') emit('sent')
return true
} catch (e: unknown) { } catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Send failed' error.value = e instanceof Error ? e.message : 'Send failed'
return false
} finally { } finally {
sendingTo.value = null sendingTo.value = null
} }
@@ -1,6 +1,6 @@
<template> <template>
<div class="mb-6"> <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 class="flex items-start justify-between gap-4">
<div> <div>
<h1 class="text-3xl font-bold text-white mb-2">Connect with Nodes</h1> <h1 class="text-3xl font-bold text-white mb-2">Connect with Nodes</h1>
@@ -25,7 +25,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' 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 BackButton from '@/components/BackButton.vue'
import { shortDid } from './utils' import { shortDid } from './utils'
import { safeClipboardWrite } from '../web5/utils' import { safeClipboardWrite } from '../web5/utils'
@@ -40,6 +41,8 @@ defineEmits<{
}>() }>()
const router = useRouter() const router = useRouter()
const route = useRoute()
const returnDestination = computed(() => connectionReturn(route.query))
const didCopied = ref(false) const didCopied = ref(false)
const shortDidDisplay = computed(() => shortDid(props.selfDid)) const shortDidDisplay = computed(() => shortDid(props.selfDid))
@@ -119,7 +119,8 @@ function badgeLabel(r: PendingPeerRequest): string {
if (r.state === 'approved') { if (r.state === 'approved') {
if (props.peerDids?.includes(r.from_did)) return 'Peer added' if (props.peerDids?.includes(r.from_did)) return 'Peer added'
const age = Date.now() - new Date(r.received_at).getTime() 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' return r.outbound ? 'They approved — connecting' : 'Approved — connecting'
} }
if (r.outbound && r.state === 'rejected') return 'They rejected' 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 Federation from '../../Federation.vue'
import { rpcClient } from '@/api/rpc-client' 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/transport', () => ({ useTransportStore: () => ({ fetchPeers: vi.fn() }) }))
vi.mock('@/stores/app', () => ({ useAppStore: () => ({ serverName: 'Fixture' }) })) vi.mock('@/stores/app', () => ({ useAppStore: () => ({ serverName: 'Fixture' }) }))
vi.mock('@/stores/sync', () => ({ useSyncStore: () => ({ serverInfo: null }) })) vi.mock('@/stores/sync', () => ({ useSyncStore: () => ({ serverInfo: null }) }))
@@ -14,9 +16,9 @@ vi.mock('@/api/rpc-client', () => ({ rpcClient: {
} })) } }))
vi.mock('@/composables/useCachedResource', async () => { vi.mock('@/composables/useCachedResource', async () => {
const { ref } = await import('vue') 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', () => { describe('connection journey navigation', () => {
it('renders all tabs with zero peers, accepts deep links and reacts to navigation', async () => { 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() expect(timer).not.toHaveBeenCalled()
timer.mockRestore() 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() 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) expect(wrapper.findAll('button').some(b => b.text() === 'Poll Now')).toBe(true)
wrapper.unmount() 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' }
}