Preserve Cloud and Fleet connection journeys and retry context
This commit is contained in:
@@ -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<{
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 } })
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
Reference in New Issue
Block a user