diff --git a/README.md b/README.md index 46c2a7f..c46b554 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,17 @@ Route **`/crucible`** — consolidated fleet ops for machines you own. **`/agent - **Gold rain effect** — matrix overlay switches to gold flurry when a single agent is active in the Crucible - Real-time command output streamed back to the terminal +### Website Scanner (Deploy Recon) + +Route **`/deploy-recon`** — sidebar **Website Scanner** (magnifying-glass icon). Legacy alias **`/browser-spread`** redirects here. + +- **Owned-target recon from the dashboard host** — TCP port matrix, shallow same-origin web crawl for upload/SSRF/CMS hints, tech stack banner grab +- **Browser deploy one-liners** — copy-paste `curl | bash` install lines and SSRF canary probe URLs pinned to your session build +- **Fleet discoveries** — merges agent subnet recon (WS) with manual `POST /api/v1/recon/scan` results for uninfected LAN hosts +- **Crucible cross-link** — open Spread ops for a scanned host via `?reconHost=` on `/crucible` + +Run `.\scripts\test-suite.ps1 -Recon` for Go + Vitest + Playwright coverage. + ### Matrix Digital Rain - Mystic word drops — "destroy", "witchcraft", "black magic", and others appear randomly in the rain diff --git a/server/web/e2e/pages.spec.ts b/server/web/e2e/pages.spec.ts index 30f78ec..8f99713 100644 --- a/server/web/e2e/pages.spec.ts +++ b/server/web/e2e/pages.spec.ts @@ -49,14 +49,24 @@ test.describe('Page smoke', () => { await expect(page.locator('.forge-mode-toggle').getByRole('button', { name: 'Simple', exact: true }).first()).toBeVisible(); }); - test('Deploy Recon renders scan form', async ({ page }) => { - await page.goto('/deploy-recon'); + test('sidebar Website Scanner link opens Deploy Recon', async ({ page }) => { + await expect(page.getByRole('link', { name: 'Website Scanner' })).toBeVisible(); + await page.getByRole('link', { name: 'Website Scanner' }).click(); + await expect(page).toHaveURL(/\/deploy-recon/); await expect(page.getByRole('heading', { level: 1, name: /Deploy Recon/i })).toBeVisible({ timeout: 10_000, }); await expect(page.getByTestId('dr-scan-btn')).toBeVisible(); }); + test('/browser-spread redirects to Deploy Recon', async ({ page }) => { + await page.goto('/browser-spread'); + await expect(page).toHaveURL(/\/deploy-recon/); + await expect(page.getByRole('heading', { level: 1, name: /Deploy Recon/i })).toBeVisible({ + timeout: 10_000, + }); + }); + test('Emberwake Cloud Ecosystem panel expands', async ({ page }) => { await page.goto('/emberwake'); await expect(page.getByRole('heading', { level: 1, name: /Emberwake/i })).toBeVisible({ timeout: 10_000 }); @@ -85,6 +95,6 @@ test.describe('Page smoke', () => { await expect(page.getByRole('columnheader', { name: 'Time' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Action' })).toBeVisible(); await expect(page.getByRole('link', { name: /Seer reasoning/i })).toBeVisible(); - await expect(page.getByText(/No oath rows yet|Loading…/)).toBeVisible({ timeout: 15_000 }); + await expect(page.getByText(/No oath rows yet|Loading�/)).toBeVisible({ timeout: 15_000 }); }); }); \ No newline at end of file diff --git a/server/web/src/App.test.tsx b/server/web/src/App.test.tsx index 33e1ccd..ab0b6f0 100644 --- a/server/web/src/App.test.tsx +++ b/server/web/src/App.test.tsx @@ -2,8 +2,8 @@ * @vitest-environment happy-dom */ import type { ReactNode } from 'react'; -import { describe, expect, it, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { describe, expect, it, afterEach, vi } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; import { MemoryRouter, Routes, Route, Navigate } from 'react-router-dom'; import App, { PageFallback } from './App'; import { routerFuture } from './routerFuture'; @@ -35,6 +35,7 @@ vi.mock('./pages/MissionDeckPage', () => ({ default: () =>
Mission Deck Pag vi.mock('./pages/BuildManagerPage', () => ({ default: () =>
Builds Page
})); vi.mock('./pages/SettingsPage', () => ({ default: () =>
Settings Page
})); vi.mock('./pages/CruciblePage', () => ({ default: () =>
Crucible Page
})); +vi.mock('./pages/DeployReconPage', () => ({ default: () =>
Deploy Recon Page
})); describe('PageFallback', () => { it('shows loading copy', () => { @@ -44,6 +45,10 @@ describe('PageFallback', () => { }); describe('App route config', () => { + afterEach(() => { + cleanup(); + }); + it('redirects / to dashboard and /builder to forge', () => { function RedirectProbe({ path }: { path: string }) { return ( @@ -84,4 +89,23 @@ describe('App route config', () => { expect(screen.getAllByTestId('layout').length).toBeGreaterThan(0); unmount(); }); + + it('renders deploy-recon route via App shell', async () => { + render( + + + + ); + expect(await screen.findByText('Deploy Recon Page')).toBeTruthy(); + expect(screen.getByTestId('layout')).toBeTruthy(); + }); + + it('redirects /browser-spread to deploy-recon route', async () => { + render( + + + + ); + expect(await screen.findByText('Deploy Recon Page')).toBeTruthy(); + }); }); diff --git a/server/web/src/components/Layout/Layout.css b/server/web/src/components/Layout/Layout.css index 36cb1d3..28bcdc6 100644 --- a/server/web/src/components/Layout/Layout.css +++ b/server/web/src/components/Layout/Layout.css @@ -106,12 +106,16 @@ } .sidebar-nav { - /* No flex-grow: let the matrix rain claim the remaining space */ - flex: 0 0 auto; + /* Scroll when many nav items + matrix rain exceed viewport height */ + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; padding: 1rem 0.75rem; display: flex; flex-direction: column; gap: 0.35rem; + scrollbar-width: thin; + scrollbar-color: rgba(0, 232, 245, 0.35) transparent; } .nav-item { diff --git a/server/web/src/components/Layout/Layout.nav.test.ts b/server/web/src/components/Layout/Layout.nav.test.ts new file mode 100644 index 0000000..2d9c68f --- /dev/null +++ b/server/web/src/components/Layout/Layout.nav.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { operatorNavItems } from './Layout'; + +describe('operator sidebar nav', () => { + it('includes Website Scanner linking to /deploy-recon', () => { + const item = operatorNavItems.find((i) => i.to === '/deploy-recon'); + expect(item).toBeDefined(); + expect(item!.label).toBe('Website Scanner'); + expect(item!.icon).toBe('recon'); + }); + + it('places Website Scanner directly after Crucible for discoverability', () => { + const scannerIdx = operatorNavItems.findIndex((i) => i.to === '/deploy-recon'); + const crucibleIdx = operatorNavItems.findIndex((i) => i.to === '/crucible'); + expect(scannerIdx).toBe(crucibleIdx + 1); + }); +}); diff --git a/server/web/src/components/Layout/Layout.tsx b/server/web/src/components/Layout/Layout.tsx index 9bba2a8..b8a1853 100644 --- a/server/web/src/components/Layout/Layout.tsx +++ b/server/web/src/components/Layout/Layout.tsx @@ -51,6 +51,7 @@ type NavItem = { readonly to: string; readonly label: string; readonly icon: str const NAV_BASE: readonly NavItem[] = [ { to: '/dashboard', label: 'Command Deck', icon: 'deck' }, { to: '/crucible', label: 'Crucible', icon: 'crucible' }, + { to: '/deploy-recon', label: 'Website Scanner', icon: 'recon' }, { to: '/activity', label: 'Activity Feed', icon: 'activity' }, { to: '/roi', label: 'ROI Intelligence', icon: 'roi' }, { to: '/lotl-timeline', label: 'Onion', icon: 'onion' }, @@ -59,10 +60,12 @@ const NAV_BASE: readonly NavItem[] = [ { to: '/mission-deck', label: 'Mission Deck', icon: 'mission', glow: true }, { to: '/builds', label: 'Builds', icon: 'builds' }, { to: '/emberwake', label: 'Emberwake', icon: 'ember' }, - { to: '/deploy-recon', label: 'Deploy Recon', icon: 'recon' }, { to: '/settings', label: 'Calibrate', icon: 'gear' }, ]; +/** Exported for nav regression tests (route + label discoverability). */ +export const operatorNavItems = NAV_BASE; + const SEER_NAV: NavItem = { to: '/seer', label: 'Seer', icon: 'seer' }; function buildNav(aiControlEnabled: boolean): NavItem[] { @@ -340,6 +343,7 @@ export default function Layout({ children }: LayoutProps) { '/lotl-timeline': 'Onion', '/pathtracer': 'Tracer', '/forge': 'Forge', + '/deploy-recon': 'Scanner', }; return ( @@ -369,6 +373,7 @@ export default function Layout({ children }: LayoutProps) { `nav-item${'glow' in item && item.glow ? ' nav-item--mission' : ''} ${isActive ? 'active' : ''}` } diff --git a/server/web/src/help/presencePages.ts b/server/web/src/help/presencePages.ts index b641876..359de3a 100644 --- a/server/web/src/help/presencePages.ts +++ b/server/web/src/help/presencePages.ts @@ -11,8 +11,8 @@ const PAGE_LABELS: Record = { '/spread': 'Emberwake', '/settings': 'Calibrate', '/pathtracer': 'Path Tracer', - '/deploy-recon': 'Deploy Recon', - '/browser-spread': 'Deploy Recon', + '/deploy-recon': 'Website Scanner', + '/browser-spread': 'Website Scanner', }; export function presencePageLabel(path: string): string {