wip: preserve two-node IndeeHub UAT fixes

This commit is contained in:
archipelago
2026-10-09 21:17:08 -04:00
parent 54f1ec8322
commit 5f01efda61
10 changed files with 276 additions and 34 deletions
@@ -443,15 +443,23 @@ private fun injectTopInset(view: WebView) {
return '';
}
function apply() {
// IndeeHub's browse hero is an image, not a body background.
// Padding the body puts a solid strip above the image; let the
// hero start at the display edge while push() keeps its fixed
// header below the status icons. Other routes keep the normal
// content inset.
var imageHero = location.port === '7778' &&
(location.pathname === '/' || location.pathname === '/browse') &&
!!document.querySelector('section img');
var bodyBg = document.body
? getComputedStyle(document.body).backgroundColor : '';
var eff = effectiveTopBg();
// Repaint body only when its declared background is not what
// the page actually renders at the top (or paints nothing).
var fix = eff && eff !== bodyBg
var fix = !imageHero && eff && eff !== bodyBg
? 'background-color:' + eff + ' !important;' : '';
styleEl().textContent =
'body{padding-top:' + SAT + 'px !important;' +
'body{padding-top:' + (imageHero ? 0 : SAT) + 'px !important;' +
'box-sizing:border-box !important;' + fix + '}';
}
function push(el) {
+64 -2
View File
@@ -338,6 +338,20 @@ impl ApiHandler {
origin == format!("http://{host}") || origin == format!("https://{host}")
}
// Browsers do not send Origin on a same-origin GET. Fetch Metadata is a
// browser-controlled header and distinguishes the dashboard from an app on
// another port, which is only same-site. Mutating requests and WebSockets
// still require the exact Origin above.
fn has_terminal_read_origin(headers: &hyper::HeaderMap) -> bool {
if headers.contains_key(hyper::header::ORIGIN) {
return Self::has_terminal_origin(headers);
}
headers
.get("sec-fetch-site")
.and_then(|value| value.to_str().ok())
== Some("same-origin")
}
/// Permissive origin check for the share-to-mesh iframe intent: any scheme
/// http(s):// followed by the configured host_ip, optionally `:port`. Apps
/// proxied under other ports (APP_PORTS) call this from within the same
@@ -533,7 +547,25 @@ impl ApiHandler {
match (method, path.as_str()) {
// RPC — auth is handled inside rpc handler per-method
(Method::POST, "/rpc/v1") => self.rpc_handler.clone().handle(req_with_bytes).await,
(Method::POST, "/rpc/v1") => {
let origin = self.app_cors_origin(&headers);
let mut response = self.rpc_handler.clone().handle(req_with_bytes).await?;
if !origin.is_empty() {
response.headers_mut().insert(
"access-control-allow-origin",
hyper::header::HeaderValue::from_str(&origin)?,
);
response.headers_mut().insert(
"access-control-allow-credentials",
hyper::header::HeaderValue::from_static("true"),
);
response.headers_mut().insert(
hyper::header::VARY,
hyper::header::HeaderValue::from_static("Origin"),
);
}
Ok(response)
}
// AIUI model proxy — session-gated forwarder to Claude/Ollama,
// replacing the unauthenticated claude-api-proxy.py sidecar and
@@ -586,7 +618,7 @@ impl ApiHandler {
if !self.is_authenticated(&headers).await {
return Ok(Self::unauthorized());
}
if !Self::has_terminal_origin(&headers) {
if !Self::has_terminal_read_origin(&headers) {
return Ok(build_response(
StatusCode::FORBIDDEN,
"application/json",
@@ -939,6 +971,18 @@ mod terminal_origin_tests {
ApiHandler::has_terminal_origin(&headers)
}
fn read_allowed(host: &str, origin: Option<&str>, fetch_site: Option<&str>) -> bool {
let mut headers = HeaderMap::new();
headers.insert(HOST, HeaderValue::from_str(host).unwrap());
if let Some(origin) = origin {
headers.insert(ORIGIN, HeaderValue::from_str(origin).unwrap());
}
if let Some(fetch_site) = fetch_site {
headers.insert("sec-fetch-site", HeaderValue::from_str(fetch_site).unwrap());
}
ApiHandler::has_terminal_read_origin(&headers)
}
#[test]
fn terminal_accepts_only_the_exact_dashboard_authority() {
assert!(allowed("node.local", Some("https://node.local")));
@@ -949,4 +993,22 @@ mod terminal_origin_tests {
assert!(!allowed("node.local", Some("null")));
assert!(!allowed("node.local", None));
}
#[test]
fn terminal_read_accepts_same_origin_fetch_but_not_same_site_apps() {
assert!(read_allowed("node.local", None, Some("same-origin")));
assert!(!read_allowed("node.local", None, Some("same-site")));
assert!(!read_allowed("node.local", None, Some("cross-site")));
assert!(!read_allowed("node.local", None, None));
assert!(!read_allowed(
"node.local",
Some("https://evil.example"),
Some("same-site")
));
assert!(!read_allowed(
"node.local",
Some("https://evil.example"),
Some("same-origin")
));
}
}
+5 -1
View File
@@ -146,7 +146,11 @@ impl RpcHandler {
.require_fips()
.single_delivery()
.timeout(std::time::Duration::from_secs(20))
.send_content_json(&self.config.data_dir, &binding.contract.seller_did, &body)
.send_rental_control_json(
&self.config.data_dir,
&binding.contract.seller_did,
&body,
)
.await?;
anyhow::ensure!(
response.status().is_success(),
+69 -1
View File
@@ -35,6 +35,23 @@ use tokio::net::UdpSocket;
/// path filter can restrict the exposed surface.
pub const PEER_PORT: u16 = 5679;
fn is_rental_control_route(path: &str) -> bool {
let parts: Vec<_> = path.split('/').collect();
let valid_id = |value: &str| {
!value.is_empty()
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
};
parts.len() == 6
&& parts[0].is_empty()
&& parts[1] == "content"
&& valid_id(parts[2])
&& parts[3] == "rental"
&& valid_id(parts[4])
&& matches!(parts[5], "prepare" | "start")
}
/// Whether a FIPS-side HTTP status should trigger a fall-back to Tor in
/// `Auto` mode. A `404` over FIPS often means the peer's mesh listener
/// doesn't expose that path (e.g. a peer on an older build with a stricter
@@ -527,8 +544,27 @@ impl<'a> PeerRequest<'a> {
request.send_encoded_json(&encoded).await
}
/// Rental preparation/start carries an already-settled capability, never a
/// payment token. Authenticate the exact control route without relaxing the
/// purchase-only guard above or replaying any purchase on a retry.
pub(crate) async fn send_rental_control_json<B: serde::Serialize>(
self,
data_dir: &std::path::Path,
expected_seller: &str,
body: &B,
) -> Result<(reqwest::Response, crate::transport::TransportKind)> {
anyhow::ensure!(
is_rental_control_route(self.path),
"Not a rental control route"
);
let (request, encoded) = self
.prepare_authenticated_content_json(data_dir, expected_seller, body)
.await?;
request.send_encoded_json(&encoded).await
}
async fn prepare_content_json<B: serde::Serialize>(
mut self,
self,
data_dir: &std::path::Path,
expected_seller: &str,
body: &B,
@@ -537,6 +573,16 @@ impl<'a> PeerRequest<'a> {
self.path.starts_with("/content/purchase/"),
"Not a purchase route"
);
self.prepare_authenticated_content_json(data_dir, expected_seller, body)
.await
}
async fn prepare_authenticated_content_json<B: serde::Serialize>(
mut self,
data_dir: &std::path::Path,
expected_seller: &str,
body: &B,
) -> Result<(Self, Vec<u8>)> {
let peer = crate::federation::load_unique_payment_peer(data_dir, self.onion_host).await?;
anyhow::ensure!(
peer.did == expected_seller,
@@ -887,6 +933,28 @@ impl<'a> PeerRequest<'a> {
mod tests {
use super::*;
#[test]
fn rental_control_routes_are_exact_and_cannot_be_purchase_routes() {
assert!(is_rental_control_route(
"/content/registered_abc/rental/1234-5678/prepare"
));
assert!(is_rental_control_route(
"/content/registered_abc/rental/1234-5678/start"
));
for path in [
"/content/purchase/abc",
"/content/registered_abc/rental/1234-5678/settle",
"/content/registered_abc/rental/1234-5678/prepare/extra",
"/content/../rental/1234-5678/prepare",
"/content/registered_abc/rental/1234-5678/prepare?token=x",
] {
assert!(
!is_rental_control_route(path),
"unexpected rental control route: {path}"
);
}
}
#[tokio::test]
async fn purchase_post_serializes_once_and_binds_the_actual_bytes_to_the_peer() {
use std::sync::atomic::{AtomicUsize, Ordering};
+25
View File
@@ -1794,3 +1794,28 @@ isolated runner. The full isolated suite, production backend build, another
explicit native update, post-update runtime/data proofs, and paired browser
acceptance remain pending. No wallet, payment, personal-media or profile changes
are part of this readiness correction.
## 2026-10-09: two-node IndeeHub UAT — open acceptance items
Yaya and Framework now have matching UAT frontend bundles, but this is **not**
feature acceptance or release publication. The backstage `indeehub.com/watch/`
slug control has been removed from the active bundle. The local My List button
now calls the library API instead of only toggling its icon; signed-in browser
acceptance is pending. Cross-node saves remain open because the library API is
local-project-only. Cross-node comments, reactions and zaps remain open: social
events use node-origin-specific content IDs and each `/relay` requires that
node's login. Do not treat cross-node public film listing as social federation.
Framework logged `content.playback-prepare: Not a purchase route` after a Cashu
rental attempt. The buyer sent a rental-control path through a purchase-only
transport guard. A narrow authenticated rental-control route is implemented;
the release binary compiled, its route unit test passed through the isolated
runner, and the identical binary is deployed on both UAT nodes with healthy
RPC and session services. An existing-purchase retry without another payment
remains pending. The earlier
Framework LND startup fix does not close this paid-file recovery incident.
Lightning purchase/zap still lacks configured BTCPay credentials on both UAT
nodes; the API now reports service unavailable instead of an opaque Invalid URL,
but payment is not accepted. Framework's listed film has no verified media
registration or signed stream offer; listing is not proof of playable media.
No OTA, ISO or public release follows from these UAT changes.
@@ -37,6 +37,17 @@ map "$archy_management_private_source:$archy_management_private_client:$archy_ma
}
# END ARCHIPELAGO MANAGEMENT SOURCE GUARD
# Only IndeeHub on this same node may read the owner's FileBrowser API for
# its in-app Cloud picker. Never reflect arbitrary cross-port origins.
map "$host|$http_origin" $archy_cloud_picker_origin {
default "";
"~^([^|]+)\|http://\1:7778$" $http_origin;
}
map "$request_method|$http_sec_fetch_site" $archy_terminal_origin {
default $http_origin;
"GET|same-origin" "$scheme://$http_host";
}
# Rate limit zones
limit_req_zone $binary_remote_addr zone=rpc:10m rate=20r/s;
limit_req_zone $binary_remote_addr zone=auth:10m rate=3r/s;
@@ -287,6 +298,7 @@ server {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Cookie $http_cookie;
proxy_set_header Origin $archy_terminal_origin;
proxy_buffering off;
}
@@ -326,6 +338,12 @@ server {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# Browser-mediated Cloud selection uses the existing node session.
# Only this node's own IndeeHub origin is admitted by the map above.
proxy_hide_header Access-Control-Allow-Origin;
proxy_hide_header Access-Control-Allow-Credentials;
add_header Access-Control-Allow-Origin $archy_cloud_picker_origin always;
add_header Access-Control-Allow-Credentials "true" always;
# Limit request body to 1MB for RPC calls
client_max_body_size 1m;
@@ -390,6 +408,24 @@ server {
error_page 504 = @backend_timeout;
}
# Public, sanitized IndeeHub listings only. The upstream is the direct
# loopback app port; video, keys and payments remain behind the app gate.
location = /api/public-catalog/indeedhub {
limit_except GET { deny all; }
proxy_pass http://127.0.0.1:7778/api/projects/public-catalog;
proxy_set_header Host $host;
proxy_set_header Cookie "";
proxy_set_header Authorization "";
proxy_hide_header Access-Control-Allow-Origin;
proxy_hide_header Access-Control-Allow-Credentials;
proxy_hide_header Cache-Control;
proxy_connect_timeout 5s;
proxy_read_timeout 10s;
add_header Access-Control-Allow-Origin "*" always;
add_header Cache-Control "public, max-age=30" always;
add_header X-Content-Type-Options "nosniff" always;
}
# Pine node status — live node facts for the Pine launcher page and the
# seeded Home Assistant sensors. Sensitive fields are token-gated at the
# backend; nginx only forwards.
@@ -614,6 +650,8 @@ server {
proxy_hide_header X-Frame-Options;
add_header X-Frame-Options "SAMEORIGIN" always;
proxy_hide_header Content-Security-Policy;
add_header Access-Control-Allow-Origin $archy_cloud_picker_origin always;
add_header Access-Control-Allow-Credentials "true" always;
add_header X-Content-Type-Options "nosniff" always;
proxy_request_buffering off;
proxy_set_header Accept-Encoding "";
@@ -1274,6 +1312,7 @@ server {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Cookie $http_cookie;
proxy_set_header Origin $archy_terminal_origin;
proxy_buffering off;
}
@@ -1326,6 +1365,24 @@ server {
error_page 504 = @backend_timeout;
}
# Public, sanitized IndeeHub listings only. The upstream is the direct
# loopback app port; video, keys and payments remain behind the app gate.
location = /api/public-catalog/indeedhub {
limit_except GET { deny all; }
proxy_pass http://127.0.0.1:7778/api/projects/public-catalog;
proxy_set_header Host $host;
proxy_set_header Cookie "";
proxy_set_header Authorization "";
proxy_hide_header Access-Control-Allow-Origin;
proxy_hide_header Access-Control-Allow-Credentials;
proxy_hide_header Cache-Control;
proxy_connect_timeout 5s;
proxy_read_timeout 10s;
add_header Access-Control-Allow-Origin "*" always;
add_header Cache-Control "public, max-age=30" always;
add_header X-Content-Type-Options "nosniff" always;
}
# Pine node status — live node facts for the Pine launcher page and the
# seeded Home Assistant sensors. Sensitive fields are token-gated at the
# backend; nginx only forwards.
@@ -1,14 +1,19 @@
<template>
<div v-if="request" class="absolute inset-0 z-[81] flex items-center justify-center bg-black/70 p-3">
<section ref="modal" role="dialog" aria-modal="true" aria-label="Confirm video rental" :aria-busy="busy" class="glass-card max-h-[90%] w-full max-w-md overflow-y-auto rounded-2xl p-5 text-white">
<p class="text-xs text-white/50">IndeeHub · Your node wallet</p>
<h2 class="mt-2 break-words text-xl font-semibold">{{ request.title }}</h2>
<dl class="my-4 space-y-2 text-sm"><div>Rental: {{ request.terms.priceSats }} sats</div><div>Viewing period: {{ Math.ceil(request.terms.viewingSeconds / 60) }} minutes after you press Start</div><template v-if="quote"><div>Payment: Cashu · {{ quote.network === 'testnet' ? 'Testnet' : 'Mainnet' }}</div><div class="break-all">Mint: {{ quote.mint_url }}</div></template><div v-if="quote" class="font-semibold">Total wallet debit: {{ quote.wallet_debit_sats }} sats</div></dl>
<p class="text-sm text-white/60">Review the exact wallet debit before paying. If a response is lost, reopen this title to recover the same purchase.</p>
<p v-if="error" role="alert" class="mt-3 break-words text-sm text-red-200">{{ error }}</p>
<p v-if="busy" role="status" class="mt-4 text-sm">{{ phase === 'preparing' ? progress : phase === 'paying' ? 'Recovering or completing your payment…' : 'Checking the original purchase and current fees…' }}</p>
<button type="button" v-if="quote" :disabled="busy" class="mt-3 min-h-11 w-full rounded-lg border border-white/20 text-sm disabled:opacity-40" @click="$emit('cancelUnpaid')">Cancel unpaid quote</button>
<footer class="mt-5 flex flex-col gap-2 sm:flex-row"><button type="button" class="glass-button min-h-11 flex-1 rounded-lg px-3" @click="$emit('cancel')">Close</button><button type="button" :disabled="busy" class="glass-button min-h-11 flex-1 rounded-lg px-3 text-orange-200 disabled:opacity-40" @click="phase === 'confirm' ? $emit('approve') : $emit('review')">{{ phase === 'confirm' ? `Pay ${quote?.wallet_debit_sats} sats` : 'Check purchase' }}</button></footer>
<div v-if="request" class="absolute inset-0 z-[81] flex items-center justify-center bg-black/80 p-3 backdrop-blur-sm">
<section ref="modal" role="dialog" aria-modal="true" aria-label="Confirm video rental" :aria-busy="busy" class="glass-card max-h-[90%] w-full max-w-lg overflow-y-auto rounded-2xl border border-white/15 p-5 text-white shadow-2xl sm:p-7">
<header class="flex items-start gap-3">
<div class="min-w-0 flex-1"><p class="text-xs uppercase tracking-widest text-white/45">IndeeHub · Node rental</p><h2 class="mt-2 break-words text-2xl font-bold">{{ request.title }}</h2></div>
<button type="button" aria-label="Close rental" class="min-h-11 min-w-11 rounded-lg text-white/60 hover:bg-white/10 hover:text-white" @click="$emit('cancel')">✕</button>
</header>
<div class="my-6 rounded-xl border border-white/10 bg-white/5 p-5">
<div class="flex items-start justify-between gap-4"><div><h3 class="text-lg font-semibold">Rental details</h3><p class="mt-1 text-sm text-white/60">{{ Math.ceil(request.terms.viewingSeconds / 60) }} minutes from when you press Start viewing</p></div><div class="shrink-0 text-right"><p class="text-2xl font-bold">{{ request.terms.priceSats.toLocaleString() }} <span class="text-sm font-normal text-white/55">sats</span></p><p class="text-xs text-white/50">Film price</p></div></div>
<div v-if="quote" class="mt-5 space-y-2 border-t border-white/10 pt-4 text-sm"><div class="flex justify-between gap-3"><span class="text-white/60">Payment method</span><span>Node wallet · Cashu</span></div><div class="flex justify-between gap-3"><span class="text-white/60">Network</span><span>{{ quote.network === 'testnet' ? 'Testnet' : 'Mainnet' }}</span></div><div class="flex justify-between gap-3 border-t border-white/10 pt-3 font-semibold"><span>Total wallet debit, including fees</span><span>{{ quote.wallet_debit_sats.toLocaleString() }} sats</span></div><details class="pt-2 text-xs text-white/45"><summary class="cursor-pointer">Mint details</summary><p class="mt-2 break-all">{{ quote.mint_url }}</p></details></div>
</div>
<p class="text-sm leading-relaxed text-white/60">No viewing time starts until you press Start viewing. Reopening this title recovers the same purchase; it must not charge you twice.</p>
<p v-if="error" role="alert" class="mt-4 rounded-lg border border-red-400/25 bg-red-500/10 p-3 break-words text-sm text-red-200">{{ error }}</p>
<p v-if="busy" role="status" class="mt-4 text-sm text-white/70">{{ phase === 'preparing' ? progress : phase === 'paying' ? 'Completing or recovering the original payment…' : 'Checking the original purchase and current fees…' }}</p>
<button type="button" v-if="quote" :disabled="busy" class="mt-4 min-h-11 w-full rounded-lg border border-white/20 text-sm text-white/70 disabled:opacity-40" @click="$emit('cancelUnpaid')">Cancel unpaid quote</button>
<footer class="mt-6 flex flex-col-reverse gap-3 sm:flex-row"><button type="button" class="glass-button min-h-11 flex-1 rounded-lg px-4" @click="$emit('cancel')">Close</button><button type="button" :disabled="busy" class="glass-button min-h-11 flex-1 rounded-lg border border-orange-400/30 px-4 font-semibold text-orange-200 disabled:opacity-40" @click="phase === 'confirm' ? $emit('approve') : $emit('review')">{{ phase === 'confirm' ? `Pay ${quote?.wallet_debit_sats} sats from node wallet` : 'Check purchase' }}</button></footer>
</section>
</div>
</template>
+15 -12
View File
@@ -166,24 +166,27 @@
</div>
</div>
<div class="mt-auto shrink-0 pt-4 space-y-3">
<!-- Always the bottom 50/50 pair, every screen size — consistent with
the other web5 cards' full-width bottom actions. -->
<div class="grid grid-cols-2 gap-3">
<button
@click="router.push('/dashboard/server/federation')"
class="mobile-card-action glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors"
>
{{ t('web5.findNodes') }}
<div class="mt-auto shrink-0 pt-4 border-t border-white/10">
<nav aria-label="Node connections" class="grid grid-cols-[repeat(2,minmax(0,1fr))_2.75rem] sm:grid-cols-[repeat(3,minmax(0,1fr))_2.75rem] gap-2">
<button @click="router.push({ name: 'federation', query: { view: 'connected' } })" class="mobile-card-action glass-button rounded-lg px-2 text-xs sm:text-sm font-medium text-white/90 hover:text-white text-center min-h-11">
My connections
</button>
<button @click="router.push({ name: 'federation', query: { view: 'discover' } })" class="mobile-card-action glass-button rounded-lg px-2 text-xs sm:text-sm font-medium text-white/90 hover:text-white text-center min-h-11">
Find Nodes
</button>
<button @click="router.push({ name: 'federation', query: { view: 'map' } })" class="mobile-card-action glass-button rounded-lg px-2 text-xs sm:text-sm font-medium text-white/90 hover:text-white text-center min-h-11 col-span-2 sm:col-span-1">
Map
</button>
<button
@click="refreshActiveTab"
:disabled="loadingPeers || loadingRequests"
class="mobile-card-action glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors"
class="mobile-card-action glass-button rounded-lg min-h-11 w-11 flex items-center justify-center text-white/80 hover:text-white disabled:opacity-50"
:aria-label="loadingPeers || loadingRequests ? 'Refreshing connected nodes' : 'Refresh connected nodes'"
title="Refresh connected nodes"
>
{{ loadingPeers || loadingRequests ? t('common.loading') : t('common.refresh') }}
<svg class="w-4 h-4" :class="{ 'animate-spin': loadingPeers || loadingRequests }" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 11a8 8 0 10-2.4 6M20 4v7h-7" /></svg>
</button>
</div>
</nav>
</div>
</div>
+1 -5
View File
@@ -1,10 +1,6 @@
<template>
<!-- Quick Actions Container -->
<div class="glass-card p-6 mb-6">
<div class="flex flex-wrap gap-3 mb-4" aria-label="Node connections">
<RouterLink :to="{ name: 'federation', query: { view: 'discover' } }" class="glass-button px-4 py-2 rounded-lg text-sm text-white">Connect with Nodes</RouterLink>
<RouterLink :to="{ name: 'federation', query: { view: 'connected' } }" class="glass-button px-4 py-2 rounded-lg text-sm text-white">Connected Nodes</RouterLink>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-5 gap-4 stagger-grid">
<!-- Networking Profits -->
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 0">
@@ -186,7 +182,7 @@
<script setup lang="ts">
import { ref } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import type { ProfitsData, NostrRelayStatsData, HwWalletDevice } from './types'
@@ -5,8 +5,9 @@ import { rpcClient } from '@/api/rpc-client'
enableAutoUnmount(afterEach)
const pushRoute = vi.hoisted(() => vi.fn())
vi.mock('vue-router', () => ({
useRouter: () => ({ push: vi.fn() }),
useRouter: () => ({ push: pushRoute }),
}))
vi.mock('vue-i18n', () => ({
@@ -47,6 +48,19 @@ describe('Web5ConnectedNodes', () => {
sessionStorage.clear()
})
it('puts the three federation destinations and icon refresh in the bottom bar', async () => {
const wrapper = mount(Web5ConnectedNodes)
const nav = wrapper.get('nav[aria-label="Node connections"]')
expect(nav.text()).toContain('My connections')
expect(nav.text()).toContain('Find Nodes')
expect(nav.text()).toContain('Map')
await nav.findAll('button')[0]!.trigger('click')
await nav.findAll('button')[1]!.trigger('click')
await nav.findAll('button')[2]!.trigger('click')
expect(pushRoute.mock.calls.map(([route]) => route.query.view)).toEqual(['connected', 'discover', 'map'])
expect(nav.findAll('button')[3]!.attributes('aria-label')).toBe('Refresh connected nodes')
})
it('shows a loading state for empty trusted nodes while peers are loading', async () => {
const wrapper = mount(Web5ConnectedNodes)