From 8255b69af2df9285fabb5b30b5ba05291c8bfa92 Mon Sep 17 00:00:00 2001 From: archipelago Date: Sun, 2 Aug 2026 10:03:26 -0400 Subject: [PATCH] fix(01-17): pin the FIPS/Tor pills and let the peer-card badge row wrap (UIFIX-01) Audited every transport-pill render site in the cloud surfaces at 390x740 and 320x640 in a real browser. Two sites render a pill (Cloud.vue peer cards, PeerFiles.vue header) and both already appear on a phone; three file-level sites carry none, by decision recorded in the SUMMARY. - Cloud.vue peer-card badge row: add flex-wrap + shrink-0 on the transport badge. Measured at 320px, a longer trust label squeezed the badge until its own text broke mid-label ("TOR ." / "120.0s"). It now drops to a second line intact. Inert whenever the row fits, so desktop is unchanged. - New TransportPills.test.ts: one site-specific assertion per render site, so removing a pill fails the build. Dorian asked that these never be removed in a future cleanup; nothing in the repo pinned them before. - Unknown-transport cases assert no pill is fabricated (T-01-78), and the labels/colours are asserted against PeerFiles.vue's canonical mapping rather than a duplicated table. Co-Authored-By: Claude Opus 5 (1M context) --- neode-ui/src/views/Cloud.vue | 17 +- .../views/__tests__/TransportPills.test.ts | 315 ++++++++++++++++++ 2 files changed, 329 insertions(+), 3 deletions(-) create mode 100644 neode-ui/src/views/__tests__/TransportPills.test.ts diff --git a/neode-ui/src/views/Cloud.vue b/neode-ui/src/views/Cloud.vue index d83a397d..27270cde 100644 --- a/neode-ui/src/views/Cloud.vue +++ b/neode-ui/src/views/Cloud.vue @@ -308,7 +308,14 @@ -
+ +
+ browse (FIPS = direct mesh, fast; Tor = fallback, slow). + shrink-0 keeps the transport WORD whole: this badge is the + security signal a phone user reads, so it wraps to the next + line at full size rather than being compressed into a + two-line stub (UIFIX-01). --> diff --git a/neode-ui/src/views/__tests__/TransportPills.test.ts b/neode-ui/src/views/__tests__/TransportPills.test.ts new file mode 100644 index 00000000..755e8cde --- /dev/null +++ b/neode-ui/src/views/__tests__/TransportPills.test.ts @@ -0,0 +1,315 @@ +/** + * ───────────────────────────────────────────────────────────────────────────── + * THE FIPS / TOR TRANSPORT PILLS ARE A PERMANENT, USER-REQUESTED FEATURE. + * ───────────────────────────────────────────────────────────────────────────── + * + * Dorian asked for these pills explicitly ("really helpful") and asked that no + * future cleanup or refactor ever remove them. They are the one place the app + * tells a user whether their file arrived over the fast encrypted mesh (FIPS) + * or over Tor — a security signal, not decoration. Requirement UIFIX-01. + * + * IF A TEST IN THIS FILE FAILS, THE MOST LIKELY CAUSE IS THAT SOMEONE REMOVED + * OR RENAMED A TRANSPORT PILL — not that the test went stale. Put the pill + * back. If a pill genuinely has to move, move the assertion with it; do not + * delete the assertion. + * + * Each `it()` below is keyed to ONE specific render site so that deleting the + * pill from that site, and only that site, fails. A pill somewhere else in the + * app does not satisfy these assertions. + * + * Render sites pinned here (audited 2026-08-02 at 390×740 and 320×640): + * S1 Cloud.vue — peer card badge row, Folders tab (renders a pill) + * S2 Cloud.vue — Peer Files aggregated rows (no pill, by decision) + * S3 Cloud.vue — Paid Files rows (no pill, by decision) + * S4 PeerFiles.vue — header, desktop copy + mobile copy (renders a pill) + * S5 PeerFiles.vue — per-file card body (no pill, by decision) + * + * The S2/S3/S5 "no pill" assertions pin a recorded product decision, not a + * bug: transport is measured PER PEER PER BROWSE, never per file, so a + * per-file pill would claim a reading the app never took. The reasoning is + * written up in .planning/phases/01-federation-mesh-hardening/01-17-SUMMARY.md. + * If you deliberately add a per-file pill, update that decision record and + * this test together. + */ +import { flushPromises, mount } from '@vue/test-utils' +import { createPinia } from 'pinia' +import { describe, expect, it, vi, beforeEach } from 'vitest' +import Cloud from '../Cloud.vue' +import PeerFiles from '../PeerFiles.vue' +import { rpcClient } from '@/api/rpc-client' + +vi.mock('vue-router', () => ({ + useRouter: () => ({ push: vi.fn() }), + RouterLink: { name: 'RouterLink', props: ['to'], template: '' }, +})) + +vi.mock('@/stores/app', () => ({ + useAppStore: () => ({ packages: {} }), +})) + +vi.mock('@/composables/useAudioPlayer', () => ({ + useAudioPlayer: () => ({ play: vi.fn() }), +})) + +vi.mock('@/api/rpc-client', () => ({ + rpcClient: { + call: vi.fn(), + federationListNodes: vi.fn(), + }, +})) + +const PEER_ONION = 'peeraaaa1111bbbb2222cccc3333dddd4444eeee.onion' + +function makePeer(overrides: Record = {}) { + return { + did: 'did:key:peerAlpha', + pubkey: 'peer', + onion: PEER_ONION, + name: 'Peer Alpha', + trust_level: 'trusted', + added_at: '2026-06-10T10:00:00Z', + ...overrides, + } +} + +function makeItem(overrides: Record = {}) { + return { + id: 'file-1', + filename: 'notes.txt', + mime_type: 'text/plain', + size_bytes: 128, + description: '', + access: 'free', + ...overrides, + } +} + +/** Mount Cloud.vue with one peer whose last browse resolved over `transport`. + * Pass `transport: null` for the "we have not observed a transport" edge. */ +async function mountCloud(transport: string | null, peerOverrides: Record = {}) { + vi.mocked(rpcClient.federationListNodes).mockResolvedValue({ nodes: [makePeer(peerOverrides)] } as never) + vi.mocked(rpcClient.call).mockImplementation((async (req: { method: string }) => { + if (req.method === 'content.browse-peer') { + return transport === null ? { items: [makeItem()] } : { items: [makeItem()], transport } + } + if (req.method === 'content.owned-list') { + return { + items: [{ + onion: PEER_ONION, + content_id: 'file-1', + filename: 'paid-track.mp3', + mime_type: 'audio/mpeg', + size_bytes: 4096, + paid_sats: 2500, + purchased_at: '2026-07-30T10:00:00Z', + }], + } + } + return {} + }) as never) + + const wrapper = mount(Cloud, { + global: { plugins: [createPinia()], stubs: { Teleport: true } }, + }) + await flushPromises() + await flushPromises() + return wrapper +} + +async function mountPeerFiles(transport: string | null) { + vi.mocked(rpcClient.federationListNodes).mockResolvedValue({ nodes: [makePeer()] } as never) + vi.mocked(rpcClient.call).mockImplementation((async (req: { method: string }) => { + if (req.method === 'content.browse-peer') { + return transport === null ? { items: [makeItem()] } : { items: [makeItem()], transport } + } + if (req.method === 'content.owned-list') return { items: [] } + return {} + }) as never) + + const wrapper = mount(PeerFiles, { + props: { peerId: PEER_ONION }, + global: { plugins: [createPinia()], stubs: { Teleport: true } }, + }) + await flushPromises() + await flushPromises() + return wrapper +} + +/** The Cloud.vue peer-card badge pill renders " · s" — a shape + * no other pill in either view produces, which is what keys these assertions + * to site S1 specifically. */ +function cloudPeerCardPill(wrapper: ReturnType) { + return wrapper.findAll('span').find(s => /^(FIPS|TOR|MESH|LAN)\s·\s[\d.]+s$/.test(s.text().trim())) +} + +beforeEach(() => { + vi.clearAllMocks() + // The peer-browse cache is a persist:true key — it snapshots into + // sessionStorage, which outlives a per-test `createPinia()`. Without this, + // the transport from an earlier test leaks into the next one and the + // unknown-transport case would "pass" against a stale FIPS reading. + sessionStorage.clear() +}) + +// ── S1: Cloud.vue peer card badge row ─────────────────────────────────────── +describe('S1 — Cloud.vue peer card transport pill (Folders tab)', () => { + it('renders the FIPS pill on the peer card when the last browse used FIPS', async () => { + const wrapper = await mountCloud('fips') + const pill = cloudPeerCardPill(wrapper) + + expect(pill, 'peer card transport pill is missing — see the header of this file').toBeTruthy() + expect(pill!.text()).toMatch(/^FIPS · [\d.]+s$/) + // FIPS reads as the good/fast path — emerald, matching the canonical palette. + expect(pill!.classes().join(' ')).toContain('bg-emerald-500/15') + expect(pill!.attributes('title')).toContain('FIPS') + }) + + it('renders the TOR pill, in the slow-path colour, when the last browse used Tor', async () => { + const wrapper = await mountCloud('tor') + const pill = cloudPeerCardPill(wrapper) + + expect(pill, 'peer card transport pill is missing — see the header of this file').toBeTruthy() + expect(pill!.text()).toMatch(/^TOR · [\d.]+s$/) + // Tor is the slow fallback — amber, visibly different from FIPS. + expect(pill!.classes().join(' ')).toContain('bg-amber-500/15') + expect(pill!.attributes('title')).toContain('TOR') + }) + + it('fabricates no pill when no transport has been observed, and keeps the not-known treatment', async () => { + const wrapper = await mountCloud(null) + + // T-01-78: never claim a transport the app has not actually measured. + expect(cloudPeerCardPill(wrapper)).toBeUndefined() + expect(wrapper.text()).not.toMatch(/\bFIPS\b/) + // The existing "we don't know yet" treatment stays. + expect(wrapper.text()).toContain('Peer Node') + }) + + // UIFIX-01 mobile half. The badge row has no horizontal give at 320px: with a + // longer trust label and no wrapping, flexbox compresses the transport badge + // until its OWN text breaks mid-label ("TOR ·" / "120.0s") — measured in a + // real browser at 320×640. flex-wrap makes the badge drop to a second line + // intact instead, and shrink-0 stops it being squeezed on the way there. + it('lets the badge row wrap and keeps the transport pill unsqueezed (mobile legibility)', async () => { + const wrapper = await mountCloud('tor') + const pill = cloudPeerCardPill(wrapper) + expect(pill).toBeTruthy() + + expect(pill!.classes(), 'transport pill must not be compressible').toContain('shrink-0') + + const row = pill!.element.parentElement as HTMLElement + expect(row, 'transport pill has no parent row').toBeTruthy() + expect( + Array.from(row.classList), + 'the peer-card badge row must wrap, or the pill text breaks mid-label at 320px', + ).toContain('flex-wrap') + }) +}) + +// ── S2/S3: Cloud.vue file lists carry no per-file transport pill (decision) ── +describe('S2/S3 — Cloud.vue file rows carry no per-file transport pill (recorded decision)', () => { + it('shows no transport pill on the aggregated Peer Files rows or the Paid Files rows', async () => { + const wrapper = await mountCloud('fips') + const vm = wrapper.vm as unknown as { activeTab: string } + + for (const tab of ['peers', 'paid']) { + vm.activeTab = tab + await flushPromises() + + // Transport is a per-peer, per-browse reading. These rows list files — + // from many peers at once in the Peer Files case, and from the local + // purchase cache (no live transport at all) in the Paid Files case. + // A pill here would assert a per-file measurement that was never taken. + expect( + cloudPeerCardPill(wrapper), + `a transport pill appeared on the "${tab}" rows — see the header of this file`, + ).toBeUndefined() + expect(wrapper.text(), `"${tab}" rows must not label files with a transport`).not.toMatch(/\bFIPS\b/) + } + }) +}) + +// ── S4: PeerFiles.vue header, desktop copy AND mobile copy ────────────────── +describe('S4 — PeerFiles.vue header transport pill', () => { + it('renders the pill in the desktop title block', async () => { + const wrapper = await mountPeerFiles('fips') + + const desktopBlock = wrapper.findAll('div').find(d => { + const c = d.classes() + return c.includes('hidden') && c.includes('md:block') + }) + expect(desktopBlock, 'PeerFiles desktop title block is missing').toBeTruthy() + + const pill = desktopBlock!.findAll('span').find(s => s.text().trim() === 'FIPS') + expect(pill, 'desktop header transport pill is missing — see the header of this file').toBeTruthy() + // Canonical mapping (PeerFiles.vue transportPill), not a duplicated table. + expect(pill!.classes().join(' ')).toContain('bg-green-500/20') + expect(pill!.attributes('title')).toContain('FIPS') + }) + + it('renders a separate mobile copy of the pill, because the desktop title block is hidden on a phone', async () => { + const wrapper = await mountPeerFiles('fips') + + // This is the UIFIX-01 mobile half for this site: the title block that + // carries the desktop pill is `hidden md:block`, so without this copy a + // phone user would see no transport at all on the peer's file page. + const mobilePill = wrapper.findAll('span').find(s => + s.classes().includes('md:hidden') && s.text().trim() === 'FIPS', + ) + expect( + mobilePill, + 'the md:hidden mobile transport pill is missing — a phone would show no transport here', + ).toBeTruthy() + expect(mobilePill!.classes().join(' ')).toContain('bg-green-500/20') + }) + + it.each([ + ['fips', 'FIPS', 'bg-green-500/20'], + ['mesh', 'Mesh', 'bg-green-500/20'], + ['lan', 'LAN', 'bg-blue-500/20'], + ['tor', 'Tor', 'bg-amber-500/20'], + ])('maps transport %s to the canonical label %s and its canonical colour', async (transport, label, colour) => { + const wrapper = await mountPeerFiles(transport) + + const pills = wrapper.findAll('span').filter(s => s.text().trim() === label) + // One desktop copy + one mobile copy, both from the same canonical mapping. + expect(pills.length, `expected the ${label} pill in both the desktop and mobile header copies`).toBe(2) + for (const p of pills) expect(p.classes().join(' ')).toContain(colour) + }) + + it('fabricates no pill for an unobserved transport, in either the desktop or the mobile copy', async () => { + const wrapper = await mountPeerFiles(null) + + for (const label of ['FIPS', 'Mesh', 'LAN', 'Tor']) { + expect( + wrapper.findAll('span').some(s => s.text().trim() === label), + `PeerFiles fabricated a "${label}" pill with no observed transport`, + ).toBe(false) + } + // The file list itself is unaffected — only the transport claim is absent. + expect(wrapper.text()).toContain('notes.txt') + }) +}) + +// ── S5: PeerFiles.vue per-file cards carry no transport pill (decision) ───── +describe('S5 — PeerFiles.vue per-file cards carry no transport pill (recorded decision)', () => { + it('labels file cards with access only, leaving transport to the single peer-level pill', async () => { + const wrapper = await mountPeerFiles('tor') + + // Every file on this page came from the same peer over the same transport, + // so the header pill already states it once. Repeating it per card would + // add no information and would crowd the row at 320px. + const cardBody = wrapper.findAll('div').find(d => { + const c = d.classes() + return c.includes('p-4') && c.includes('flex') && c.includes('mt-auto') + }) + expect(cardBody, 'PeerFiles per-file card body is missing').toBeTruthy() + + for (const label of ['FIPS', 'Mesh', 'LAN', 'Tor']) { + expect( + cardBody!.findAll('span').some(s => s.text().trim() === label), + `a transport pill appeared on a per-file card — see the header of this file`, + ).toBe(false) + } + }) +})