diff --git a/README.md b/README.md index 344fe3ce..b3f68da0 100644 --- a/README.md +++ b/README.md @@ -58,9 +58,13 @@ loopback-only. For a remote host, tunnel from your workstation: ssh -L 8080:127.0.0.1:8080 user@docker-host ``` -Then add notification destinations and create your first monitoring job in the -console. Runtime state and generated encryption keys live in `./data`; back -them up together. +Then open **Jobs → New job** and follow the guided monitor setup to choose authorized targets, scan +coverage, a schedule, and alerts. You can also add and test a notification +destination during setup; operators can select existing destinations, and +choosing no alerts is supported. Review the scan estimate and budget before +creating the monitor. Runtime state and generated encryption keys live in +`./data`; back them up together. See the [first-scan guide](https://edgewatch.offsec.nl/getting-started/first-scan/) +for the initial scan and baseline workflow. ## Documentation diff --git a/docs/src/content/docs/getting-started/first-scan.md b/docs/src/content/docs/getting-started/first-scan.md index 8ba8b5be..2de7b76b 100644 --- a/docs/src/content/docs/getting-started/first-scan.md +++ b/docs/src/content/docs/getting-started/first-scan.md @@ -12,16 +12,18 @@ Open **Notifications** and choose Email (SMTP), Discord webhook, ntfy, or **Advanced Shoutrrr URL**. Add a name, connection details, and confirm your account password. Credentials are write-only and encrypted; the console does not return them after you save them. Use **Test** and check that the message -arrives. When you create a job, select the destination in its notification -routing. +arrives. You can also create a destination while setting up a monitor. An +operator can select destinations but cannot create or test them. A monitor can +also be created with the explicit **Continue without alerts** choice. The [notification guide](/user-guide/notifications/) covers routing, delivery health, and destinations imported from older deployments. ## Choose a scanner profile -Open **Scanner profiles**. Keep the built-in profile to begin with, or create -an administrator-managed profile suited to your network. +Open **Scanner profiles** only if you need an administrator-managed profile +suited to your network. The guided setup uses the built-in profile by default; +profile tuning is available in the full editor. New TCP jobs default to Naabu connect discovery followed by Nmap confirmation. UDP always uses Nmap. Read [Scanning and profiles](/user-guide/scanning/) @@ -29,25 +31,61 @@ before selecting SYN discovery or expanding the probe scope. ## Create a monitoring job -Create a job with: +Open **Jobs → New job** and follow **Targets → Coverage → Schedule and +alerts → Review**. Use only systems you are authorized to scan. The guided form +starts with full-range TCP discovery: Naabu checks ports 1–65535 and Nmap +confirms discoveries. Choosing specific TCP ports switches to Nmap-only +partial coverage. UDP is optional and always uses Nmap. The full editor remains +available for advanced scanner and baseline settings. + +Review the five-field cron schedule, its timezone, selected alert destinations, +baseline sample count, confirmation threshold, and the server's probe estimate +and unit budget before creating the monitor. The preview does not resolve DNS +or send probes; actual work can differ after DNS resolution and exclusions are +applied. + +Choose **Create without starting** to save the monitor without requesting an +immediate scan, or **Create and start first scan** to request one immediately. +An enabled schedule still requests scans at its scheduled times. The full +editor's separate **Run at daemon startup** option can also request a scan when +EdgeWatch starts. A destination created during setup is saved separately and +remains in **Notifications** if you cancel the monitor. + +The full editor lets you review or change: - **Targets:** authorized IP addresses, CIDRs, or DNS names. -- **Protocols and ports:** the TCP and UDP surface you want to monitor. -- **Schedule:** five-field cron syntax with the selected IANA timezone. +- **Protocols and ports:** full-range or selected TCP coverage, plus optional + UDP ports. +- **Schedule:** five-field cron syntax with the selected IANA timezone, or a + paused schedule. - **Baseline samples:** how many successful samples establish the expected surface. - **Change confirmation:** how many matching changes confirm an incident. +- **Run at daemon startup:** an independent option to request a scan when + EdgeWatch starts. Start with a small, known scope. Deployment probe budgets and target exclusions -still apply to the job. +still apply to the monitor. If the estimate exceeds the budget, you can save +without starting; narrow the scope or ask an administrator to approve a +high-cost scan before requesting a fresh preview. ## Establish the baseline -Run the job and inspect its results. Approve a successful scan as the baseline -once you have verified that it represents the surface you expect. Until the -job has collected its baseline samples, the scan detail describes each scan as -a baseline sample instead of a comparison; see +The job page's **Next steps** card shows baseline learning progress and the +next available action. EdgeWatch establishes the baseline automatically after +the configured number of successful scans with complete, consistent coverage; +new jobs require two samples by default. The schedule supplies future samples +when it is enabled. Use **Run another sample** in Next steps when you want to +request one sooner. Until learning finishes, scans appear as baseline samples +rather than comparisons; see [Scan comparison](/user-guide/jobs-baselines-incidents/#scan-comparison). +A complete baseline with zero positive ports in the configured TCP and UDP +coverage is valid. **Use as baseline** is an optional, explicit override after +you review the scan evidence; ordinary learning does not require approval. +If learning stalls or a run is rejected or incomplete, review the scan result, +correct the target or scanner configuration, and follow the +[baseline and scan lifecycle guide](/user-guide/jobs-baselines-incidents/#first-scan-and-baseline-learning). + :::note[Incomplete observations do not change expectations] Failed, canceled, timed-out, or incomplete scans remain available for troubleshooting. They cannot establish or advance the baseline or turn a diff --git a/docs/src/content/docs/reference/api-compatibility.md b/docs/src/content/docs/reference/api-compatibility.md index ec41c9f7..f46b21f2 100644 --- a/docs/src/content/docs/reference/api-compatibility.md +++ b/docs/src/content/docs/reference/api-compatibility.md @@ -11,6 +11,106 @@ defensible duration estimate is available. Clients should treat this field as optional and must not infer that a scan will finish within any fixed time from the probe count alone. +## Job creation preview + +v0.32.0 adds `POST /api/v1/jobs/preview`. Send the same new-job JSON payload +accepted by `POST /api/v1/jobs`; the response returns the normalized public +job form, the existing `WorkEstimate`, the existing `ScanBudget` outcome, and +at most five stable warnings: + +```json +{ + "job": { + "name": "edge inventory", + "schedule": "0 * * * *", + "timezone": "UTC", + "run_on_start": false, + "assume_alive": true, + "targets": ["198.51.100.10"], + "dns_comparison_mode": "address_sensitive", + "max_expanded_hosts": 256, + "tcp": { + "ports": "22,443", + "mode": "connect", + "service_detection": false, + "engine": "nmap" + }, + "udp": null, + "timing": "balanced", + "timeout": "1m0s", + "resume_window": "192h0m0s", + "baseline_samples": 2, + "change_confirmations": 2, + "enabled": true, + "allow_high_cost": false + }, + "scan_estimate": { + "hosts": 1, + "tcp_ports": 2, + "udp_ports": 0, + "probes": 2, + "naabu_probes": 0, + "nmap_probes": 2, + "nmap_invocations": 1, + "naabu_invocations": 0, + "unknown_dns": 0 + }, + "scan_budget": {"exceeded": false}, + "warnings": [ + { + "code": "elapsed_time_unknown", + "message": "Probe and process counts are preflight estimates; elapsed scan time depends on DNS, scanner behavior, target responses, retries, and discovered ports." + }, + { + "code": "tcp_partial_coverage", + "field": "tcp.ports", + "message": "This Nmap TCP selection covers only the configured ports, not the full TCP port range." + } + ] +} +``` + +The preview applies the same new-job defaults, selected scanner-profile +resolution, destination routing, deployment target exclusions, and permission +rules as creation. It then compares the prepared estimate with the current +unit probe budget. When TCP engine/profile is omitted, the existing default +Naabu-to-Nmap profile and its full TCP discovery range are returned in `job`; +clients that deliberately select only some TCP ports must send +`engine: "nmap"`. Optional UDP work is included in the estimate. + +`scan_estimate` is a bounded preflight, not a duration promise. Each DNS name +is counted as one logical address and increments `unknown_dns`; preview does +not resolve names. Naabu's known discovery pass covers ports 1–65535, while +the subsequent Nmap confirmation work depends on discovered ports and is not +included before discovery. Warnings have stable `code` values, an optional +field path, and user-facing text. Clients should handle unknown warning codes +as generic advisories. + +A valid estimate above the unit budget still returns `200`. Its +`scan_budget.exceeded` is `true`, with `estimated_probes`, `limit`, and +`approval_would_fit`; the latter says whether the unit's high-cost ceiling +would admit the estimate if the caller is authorized to enable that approval. +A job over the absolute probe ceiling cannot fit even with that approval. +Creating a job remains compatible with existing behavior, but preview does +not reserve budget and a later run checks the current limits again. Do not +promise that an over-budget job will start. +If the unit budget cannot be read, preview returns `503 preview_unavailable` +with `details.reason: "scan_budget_unavailable"` and does not claim a fit. + +Preview is advisory and read-only: it creates no job or revision, baseline, +scan, audit entry, outbox item, or schedule change; it does not resolve DNS, +start scanner or notification processes, reserve capacity, or publish an SSE +event. Creation and run remain authoritative and revalidate current policy and +resources. Invalid input and stale profile selections keep creation's +validation/conflict semantics. A foreign unit's profile or destination ID is +indistinguishable from an unknown ID. + +The route requires `jobs.write`, so unit administrators and operators may +preview; viewers, platform administrators, and anonymous callers may not. It +uses the existing authenticated POST session and CSRF checks. It has no +additional route-specific Origin check; sending an Origin does not replace +CSRF validation. `allow_high_cost` remains administrator-only. + ## Scan history The authenticated scan-history endpoints provide metadata, full results, and diff --git a/docs/src/content/docs/user-guide/jobs-baselines-incidents.md b/docs/src/content/docs/user-guide/jobs-baselines-incidents.md index 86a88617..d0701027 100644 --- a/docs/src/content/docs/user-guide/jobs-baselines-incidents.md +++ b/docs/src/content/docs/user-guide/jobs-baselines-incidents.md @@ -66,6 +66,43 @@ editing the job, and **Resume schedule** starts them again; **Scan now** keeps working while a job is paused. Pausing and resuming are unavailable while the job's scan is running. +## First scan and baseline learning + +On the final review, choose **Create and start first scan** to save and open the +job page, then request one scan, or choose **Create without starting** to save +the job without requesting an immediate scan. Creating the job and starting its +scan are separate actions. +If the scan request fails, the job remains saved on its page; retrying starts a +scan for that job and does not create another copy. Refreshing the page or +returning to it later does not replay the one-time create-and-start action. +Existing schedule settings and the configured `run_on_start` behavior remain +authoritative, including when EdgeWatch restarts. + +The job page's **Next steps** card reads the saved job, scan, and baseline +state. It shows successful samples collected against the configured sample +count and, while the schedule is enabled, the next scheduled sample in the +job's timezone. A paused schedule has no automatic next sample. A job with no +schedule can still be sampled manually by a permitted operator or +administrator. If the unit's scan slots are full, an explicit manual request +waits in the queue and can be canceled before it starts. + +Only successful scans with complete coverage count toward learning. Failed, +canceled, timed-out, and incomplete scans stay in history but do not count as +samples or remove expected results. If learning stalls after incomplete +coverage, open the scan evidence and correct target reachability or the saved +scanner profile, then retry. The scan uses the job's current saved settings; +the next scheduled run also uses the current revision. Once the configured +number of complete successful samples has been recorded, EdgeWatch establishes +the baseline automatically. It never lowers that sample count or performs the +explicit **Use as baseline** override on your behalf. Incident acceptance +remains an explicit decision. + +An active baseline applies to the targets and TCP/UDP ports configured for that +job. It can be complete even when no positive ports were found; the job page +states this as a valid empty baseline. When the card says **Ready (updating +scope)**, the existing baseline remains active while its stored scope is +re-keyed by the next finalized scan or a saved job change. + Archiving stops a job while keeping its results and incidents available. An administrator can permanently delete an archived job by typing its exact name; this also removes that job's scan results, incidents, saved scan progress, and diff --git a/docs/src/content/docs/user-guide/notifications.md b/docs/src/content/docs/user-guide/notifications.md index 49608cf7..72022de3 100644 --- a/docs/src/content/docs/user-guide/notifications.md +++ b/docs/src/content/docs/user-guide/notifications.md @@ -29,6 +29,14 @@ provider accepted the test send; check the recipient to confirm the message arrived. Destinations added after existing job routing is frozen remain opt-in. Select a destination in each job that should use it. +You can also add and test a destination while creating a monitor. This uses the +same provider fields and account-password confirmation as this page. The +destination is saved independently from the monitor: if you cancel the monitor +or its creation fails, the destination remains here. Credentials and the +account password stay in the temporary form and are cleared after saving. +Operators can select available destinations but cannot add or test them. They +can explicitly create a monitor without alerts. + ## Routing and update alerts Each job can select its own destinations. On the **Notifications** page, diff --git a/docs/src/content/docs/user-guide/scanning.md b/docs/src/content/docs/user-guide/scanning.md index 75039788..fcc2d328 100644 --- a/docs/src/content/docs/user-guide/scanning.md +++ b/docs/src/content/docs/user-guide/scanning.md @@ -7,6 +7,14 @@ EdgeWatch observes authorized targets using fixed scanner executables and validated argument arrays. Choose the engine and profile that match your network and runtime capabilities. +The guided monitor setup starts with full-range TCP discovery: Naabu checks +ports 1–65535 and Nmap confirms discoveries. Choosing specific TCP ports +switches that job to Nmap-only partial coverage. UDP is optional and uses Nmap +for the selected ports. Switching between full-range and selected TCP coverage +preserves the prior settings for each mode; the full editor also keeps a pinned +profile revision and its tuning when you return to that mode. Review the actual +engine, port scope, profile revision, and estimate before creating the job. + ## Nmap or Naabu to Nmap Each TCP job chooses a scanner engine: diff --git a/e2e/accessibility-control-regressions.spec.ts b/e2e/accessibility-control-regressions.spec.ts index 51cf1373..13fc2eed 100644 --- a/e2e/accessibility-control-regressions.spec.ts +++ b/e2e/accessibility-control-regressions.spec.ts @@ -34,7 +34,7 @@ test('the dark scheme and form controls remain legible under either system prefe test('keyboard focus is visible on switches, host filters, unit rows and tabs (#985)', async ({ page, browser, baseURL }, testInfo) => { test.skip(testInfo.project.name !== 'desktop', 'Keyboard focus states are verified once on desktop.') await mockConsole(page) - await page.goto('/jobs/new') + await page.goto('/jobs/new/advanced') const checkbox = page.locator('.switch-row input[type="checkbox"]').first() await focusWithKeyboard(page, checkbox) await expect.poll(() => checkbox.evaluate(element => getComputedStyle(element).outlineStyle)).toBe('solid') diff --git a/e2e/admin.spec.ts b/e2e/admin.spec.ts index e931d776..980b112a 100644 --- a/e2e/admin.spec.ts +++ b/e2e/admin.spec.ts @@ -213,6 +213,7 @@ test('setup, login, and build a TCP/UDP job in the console', async ({ page }) => await expect(page.getByRole('heading', { name: '192.0.2.1' })).toBeVisible() await navigateFromShell(page, 'Jobs') await page.getByRole('button', { name: 'New job' }).click() + await page.getByRole('link', { name: 'Open full editor' }).click() await expect(page.getByRole('heading', { name: 'Create a monitoring job' })).toBeVisible() await expect(page.getByLabel('TCP engine')).toHaveValue('naabu_nmap') await expect(page.getByText('Stagger scheduled scans')).toBeVisible() diff --git a/e2e/guided-monitor-setup.spec.ts b/e2e/guided-monitor-setup.spec.ts new file mode 100644 index 00000000..1974ed67 --- /dev/null +++ b/e2e/guided-monitor-setup.spec.ts @@ -0,0 +1,290 @@ +import { expect, test } from '@playwright/test' +import { mockConsole } from './mock-console' + +const desktopOnly = (project: string) => project === 'desktop' + +async function expectNoHorizontalOverflow(page: import('@playwright/test').Page) { + await expect.poll(async () => { + const layout = await page.evaluate(() => { + const viewport = document.documentElement.clientWidth + const offenders = Array.from(document.body.querySelectorAll('*')).map(element => { + const rect = element.getBoundingClientRect() + const style = getComputedStyle(element) + return { + tag: element.tagName.toLowerCase(), + className: typeof element.className === 'string' ? element.className : '', + id: element.id, + text: (element.innerText || element.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 120), + left: Math.round(rect.left), + right: Math.round(rect.right), + width: Math.round(rect.width), + scrollWidth: element.scrollWidth, + clientWidth: element.clientWidth, + position: style.position, + overflowX: style.overflowX, + visible: style.visibility !== 'hidden' && style.display !== 'none', + } + }).filter(element => element.visible && element.right > viewport + 1 && element.left < viewport) + .sort((left, right) => right.right - left.right) + .slice(0, 12) + const chain: HTMLElement[] = [] + let ancestor = document.querySelector('.monitor-setup .panel') + while (ancestor) { + chain.push(ancestor) + ancestor = ancestor.parentElement + } + const ancestors = chain.map(element => { + const rect = element.getBoundingClientRect() + const style = getComputedStyle(element) + return { tag: element.tagName.toLowerCase(), className: typeof element.className === 'string' ? element.className : '', left: Math.round(rect.left), right: Math.round(rect.right), width: Math.round(rect.width), scrollWidth: element.scrollWidth, clientWidth: element.clientWidth, boxSizing: style.boxSizing, display: style.display, minWidth: style.minWidth, paddingLeft: style.paddingLeft, paddingRight: style.paddingRight } + }) + return { + fits: document.documentElement.scrollWidth <= viewport, + viewport, + documentScrollWidth: document.documentElement.scrollWidth, + offenders, + ancestors, + } + }) + return layout.fits ? 'fits' : JSON.stringify(layout) + }).toBe('fits') +} + +async function openReview(page: import('@playwright/test').Page, name = 'guided-edge') { + await page.goto('/jobs/new') + await page.getByLabel('Monitor name').fill(name) + await page.getByLabel('Target 1').fill('192.0.2.10') + await page.getByRole('button', { name: 'Continue to coverage' }).click() + await page.getByRole('radio', { name: /Selected TCP ports/ }).check() + await page.getByRole('textbox', { name: /TCP ports/ }).fill('22,443') + await page.getByRole('button', { name: 'Continue to schedule' }).click() + await page.getByRole('button', { name: 'Continue without alerts' }).click() + await page.getByRole('button', { name: 'Review monitor' }).click() + await expect(page.getByRole('heading', { name: 'Coverage and scan cost' })).toBeVisible() + await expect(page.locator('.estimate-grid')).toBeVisible() +} + +test('guided create failure preserves the draft; create without starting never dispatches a scan', async ({ page }, testInfo) => { + test.skip(!desktopOnly(testInfo.project.name), 'The guided mutation journey runs once on desktop.') + const controls = await mockConsole(page) + await openReview(page, 'saved-without-scan') + + controls.failNext('job-create') + await page.getByRole('button', { name: 'Create without starting' }).click() + await expect(page.getByRole('alert')).toContainText('fixture job-create failed') + await expect(page.getByText('saved-without-scan', { exact: true })).toBeVisible() + expect(controls.calls['job-create']).toBe(1) + expect(controls.calls['job-run'] ?? 0).toBe(0) + + await page.getByRole('button', { name: 'Create without starting' }).click() + await expect(page).toHaveURL(/\/jobs\/job-created$/) + await expect(page.getByRole('heading', { name: 'saved-without-scan' })).toBeVisible() + expect(controls.calls['job-create']).toBe(2) + expect(controls.calls['job-run'] ?? 0).toBe(0) + + await page.reload() + await expect(page.getByRole('heading', { name: 'saved-without-scan' })).toBeVisible() + expect(controls.calls['job-create']).toBe(2) + expect(controls.calls['job-run'] ?? 0).toBe(0) +}) + +test('create-and-start creates once, retries only the failed run, and refresh does not dispatch again', async ({ page }, testInfo) => { + test.skip(!desktopOnly(testInfo.project.name), 'The guided mutation journey runs once on desktop.') + const controls = await mockConsole(page) + await openReview(page, 'guided-first-scan') + controls.failNext('job-run') + + await page.getByRole('button', { name: 'Create and start first scan' }).click() + await expect(page).toHaveURL(/\/jobs\/job-created$/) + await expect(page.getByRole('heading', { name: 'guided-first-scan' })).toBeVisible() + await expect(page.getByRole('alert')).toContainText('fixture job-run failed') + expect(controls.calls['job-create']).toBe(1) + expect(controls.calls['job-run']).toBe(1) + + await page.getByRole('button', { name: 'Scan now' }).click() + await expect(page.getByRole('heading', { name: 'Scan queued' })).toBeVisible() + expect(controls.calls['job-create']).toBe(1) + expect(controls.calls['job-run']).toBe(2) + + await page.reload() + await expect(page.getByRole('heading', { name: 'guided-first-scan' })).toBeVisible() + await page.waitForTimeout(300) + expect(controls.calls['job-create']).toBe(1) + expect(controls.calls['job-run']).toBe(2) +}) + +test('an over-budget preview allows saving the monitor but blocks its first scan', async ({ page }, testInfo) => { + test.skip(!desktopOnly(testInfo.project.name), 'The guided budget journey runs once on desktop.') + const controls = await mockConsole(page) + controls.setPreviewResponse({ + scan_estimate: { hosts: 1, tcp_ports: 65_535, udp_ports: 0, probes: 65_535, nmap_invocations: 1, naabu_invocations: 0, unknown_dns: 0 }, + scan_budget: { exceeded: true, estimated_probes: 65_535, limit: 10_000, approval_would_fit: true }, + warnings: [], + }) + await openReview(page, 'over-budget-monitor') + + await expect(page.getByRole('button', { name: 'Create and start first scan' })).toBeDisabled() + await expect(page.getByText(/Create without starting remains available/)).toBeVisible() + await page.getByRole('button', { name: 'Create without starting' }).click() + await expect(page.getByRole('heading', { name: 'over-budget-monitor' })).toBeVisible() + expect(controls.calls['job-create']).toBe(1) + expect(controls.calls['job-run'] ?? 0).toBe(0) +}) + +test('an out-of-order preview cannot replace the current draft estimate', async ({ page }, testInfo) => { + test.skip(!desktopOnly(testInfo.project.name), 'The preview race journey runs once on desktop.') + const controls = await mockConsole(page) + const stale = controls.deferNextPreview() + + await page.goto('/jobs/new') + await page.getByLabel('Monitor name').fill('preview-race') + await page.getByLabel('Target 1').fill('192.0.2.10') + await page.getByRole('button', { name: 'Continue to coverage' }).click() + await page.getByRole('radio', { name: /Selected TCP ports/ }).check() + await page.getByRole('textbox', { name: /TCP ports/ }).fill('22') + await page.getByRole('button', { name: 'Continue to schedule' }).click() + await page.getByRole('button', { name: 'Continue without alerts' }).click() + await page.getByRole('button', { name: 'Review monitor' }).click() + await stale.started + + await page.getByRole('button', { name: 'Previous step' }).click() + await page.getByLabel('Five-field cron').fill('5 */6 * * *') + const currentPreview = page.waitForRequest(request => request.method() === 'POST' + && new URL(request.url()).pathname === '/api/v1/jobs/preview' + && JSON.parse(request.postData() ?? '{}').schedule === '5 */6 * * *') + await page.getByRole('button', { name: 'Review monitor' }).click() + await currentPreview + await expect(page.locator('.estimate-grid')).toBeVisible() + + stale.resolve({ + job: { name: 'preview-race', schedule: '0 */6 * * *', timezone: 'UTC' }, + scan_estimate: { hosts: 1, tcp_ports: 65_535, udp_ports: 0, probes: 999_999, nmap_invocations: 50, naabu_invocations: 0, unknown_dns: 0 }, + scan_budget: { exceeded: false }, + warnings: [], + }) + await page.waitForTimeout(100) + await expect(page.getByText('999,999', { exact: true })).toHaveCount(0) + await page.getByRole('button', { name: 'Create without starting' }).click() + await expect(page.getByRole('heading', { name: 'preview-race' })).toBeVisible() + expect(controls.payloads['job-create']?.[0]).toMatchObject({ schedule: '5 */6 * * *', tcp: { ports: '22' } }) +}) + +test('guided and advanced creation share the same draft and guard leaving with unsaved work', async ({ page }, testInfo) => { + test.skip(!desktopOnly(testInfo.project.name), 'The guided mode transfer runs once on desktop.') + await mockConsole(page) + await page.goto('/jobs/new') + await page.getByLabel('Monitor name').fill('transferred-monitor') + await page.getByLabel('Target 1').fill('192.0.2.10') + await page.getByRole('link', { name: 'Open full editor' }).click() + await expect(page.getByRole('heading', { name: 'Create a monitoring job' })).toBeVisible() + await expect(page.getByLabel('Job name')).toHaveValue('transferred-monitor') + await page.getByRole('link', { name: 'Back to guided setup' }).click() + await expect(page.getByRole('heading', { name: 'Choose targets' })).toBeVisible() + await expect(page.getByLabel('Monitor name')).toHaveValue('transferred-monitor') + + await page.getByRole('link', { name: 'Back to jobs' }).click() + const dialog = page.getByRole('dialog', { name: /Discard/ }) + await expect(dialog).toBeVisible() + await expect(dialog).toContainText('has not been saved') +}) + +test('keyboard users receive step focus and announced validation feedback', async ({ page }, testInfo) => { + test.skip(!desktopOnly(testInfo.project.name), 'The keyboard setup journey runs once on desktop.') + await mockConsole(page) + await page.goto('/jobs/new') + const targetsHeading = page.getByRole('heading', { name: 'Choose targets' }) + await expect(targetsHeading).toBeFocused() + + const next = page.getByRole('button', { name: 'Continue to coverage' }) + await next.focus() + await page.keyboard.press('Enter') + await expect(page.getByRole('alert')).toContainText('A monitor name is required.') + await page.getByLabel('Monitor name').fill('keyboard-monitor') + await page.getByLabel('Target 1').fill('192.0.2.10') + await next.focus() + await page.keyboard.press('Enter') + await expect(page.getByRole('heading', { name: 'Set scan coverage' })).toBeFocused() + + await page.getByRole('radio', { name: /Selected TCP ports/ }).focus() + await page.keyboard.press('Space') + await page.getByRole('textbox', { name: /TCP ports/ }).fill('') + const coverageNext = page.getByRole('button', { name: 'Continue to schedule' }) + await coverageNext.focus() + await page.keyboard.press('Enter') + await expect(page.getByRole('alert')).toContainText('Choose TCP ports for Nmap.') + await page.getByRole('textbox', { name: /TCP ports/ }).fill('22') + await coverageNext.focus() + await page.keyboard.press('Enter') + await expect(page.getByRole('heading', { name: 'Set schedule and alerts' })).toBeFocused() +}) + +test('guided setup fits narrow mobile viewports without horizontal scrolling', async ({ page }, testInfo) => { + test.skip(testInfo.project.name === 'desktop', 'The narrow viewport acceptance runs in mobile projects.') + await mockConsole(page) + await page.goto('/jobs/new') + await expect(page.getByRole('heading', { name: 'Choose targets' })).toBeVisible() + await expectNoHorizontalOverflow(page) + const continueButton = page.getByRole('button', { name: 'Continue to coverage' }) + const bounds = await continueButton.boundingBox() + const viewport = await page.evaluate(() => ({ width: document.documentElement.clientWidth, height: document.documentElement.clientHeight })) + expect(bounds).not.toBeNull() + expect(bounds!.x).toBeGreaterThanOrEqual(0) + expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(viewport.width) + expect(bounds!.height).toBeGreaterThanOrEqual(40) + + await page.getByLabel('Monitor name').fill('phone-monitor') + await page.getByLabel('Target 1').fill('192.0.2.10') + await continueButton.click() + await expect(page.getByRole('heading', { name: 'Set scan coverage' })).toBeVisible() + await expectNoHorizontalOverflow(page) + + await page.getByRole('radio', { name: /Selected TCP ports/ }).check() + await page.getByRole('textbox', { name: /TCP ports/ }).fill('22') + await page.getByRole('button', { name: 'Continue to schedule' }).click() + await expect(page.getByRole('heading', { name: 'Set schedule and alerts' })).toBeVisible() + await expectNoHorizontalOverflow(page) + + await page.getByRole('button', { name: 'Continue without alerts' }).click() + await page.getByRole('button', { name: 'Review monitor' }).click() + await expect(page.getByRole('heading', { name: 'Coverage and scan cost' })).toBeVisible() + await expectNoHorizontalOverflow(page) +}) + +test('operators can select a saved destination but cannot manage destinations or high-cost approval', async ({ page }, testInfo) => { + test.skip(!desktopOnly(testInfo.project.name), 'The operator guided access check runs once on desktop.') + await mockConsole(page, 'operator') + await page.goto('/jobs/new') + await page.getByLabel('Monitor name').fill('operator-monitor') + await page.getByLabel('Target 1').fill('192.0.2.10') + await page.getByRole('button', { name: 'Continue to coverage' }).click() + await page.getByRole('button', { name: 'Continue to schedule' }).click() + await expect(page.getByRole('checkbox', { name: /Operations/ })).toBeVisible() + await expect(page.getByRole('button', { name: 'Add destination' })).toHaveCount(0) + await expect(page.getByText(/high-cost approval/i)).toHaveCount(0) + await expect(page.getByText(/administrator can add destinations/i)).toBeVisible() +}) + +test('viewers and platform administrators are redirected away from unit monitor setup', async ({ page }, testInfo) => { + test.skip(!desktopOnly(testInfo.project.name), 'The role boundary check runs once on desktop.') + const viewer = await mockConsole(page, 'viewer') + await page.goto('/jobs/new') + await expect(page).toHaveURL(/\/jobs$/) + await expect(page.getByRole('heading', { name: 'Jobs' })).toBeVisible() + await expect(page.getByRole('heading', { name: 'Choose targets' })).toHaveCount(0) + expect(viewer.calls['job-create'] ?? 0).toBe(0) + expect(viewer.calls['job-run'] ?? 0).toBe(0) + + const platformPage = await page.context().newPage() + try { + const platform = await mockConsole(platformPage, 'platform_admin') + await platformPage.goto('/jobs/new') + await expect(platformPage).toHaveURL(/\/platform\/units$/) + await expect(platformPage.getByRole('heading', { name: 'Business units' })).toBeVisible() + await expect(platformPage.getByRole('heading', { name: 'Choose targets' })).toHaveCount(0) + expect(platform.calls['job-create'] ?? 0).toBe(0) + expect(platform.calls['job-run'] ?? 0).toBe(0) + expect(platform.calls['unit-data'] ?? 0).toBe(0) + } finally { + await platformPage.close() + } +}) diff --git a/e2e/mock-console.ts b/e2e/mock-console.ts index 33fe1967..beccc211 100644 --- a/e2e/mock-console.ts +++ b/e2e/mock-console.ts @@ -28,6 +28,8 @@ export const rolePermissions: Record = { export type ConsoleMockControls = { failNext: (operation: string) => void + setPreviewResponse: (response: unknown) => void + deferNextPreview: () => { started: Promise; resolve: (response: unknown) => void } calls: Record payloads: Record } @@ -177,12 +179,25 @@ export async function mockConsole(page: Page, role: ConsoleRole = 'administrator } const platformAdmins: any[] = [platformAccount({ id: 'user-platform_admin', username: 'platform', display_name: 'platform_admin', role: 'platform_admin', totp_enabled: true })] let createdUnitUser: Record | null = null + let createdJob: any = null + let createdDestination: any = null + let previewResponse: unknown = null + const previewGates: Array<{ started: () => void; response: Promise }> = [] let platformRouting = { configured: false, destinations: [] as string[] } const failures = new Set() const calls: Record = {} const payloads: Record = {} const controls: ConsoleMockControls = { failNext: (operation) => failures.add(operation), + setPreviewResponse: (response) => { previewResponse = response }, + deferNextPreview: () => { + let markStarted!: () => void + let release!: (response: unknown) => void + const started = new Promise(resolve => { markStarted = resolve }) + const response = new Promise(resolve => { release = resolve }) + previewGates.push({ started: markStarted, response }) + return { started, resolve: release } + }, calls, payloads, } @@ -246,7 +261,7 @@ export async function mockConsole(page: Page, role: ConsoleRole = 'administrator if (failures.delete('unit-create')) { await json(jsonError('unit-create'), 422); return } const created = platformUnit({ id: `unit-${units.length + 1}`, name: value.name, slug: value.slug || String(value.name ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '-') }) units.push(created) - unitAccounts[created.id] = [] + unitAccounts[String((created as Record).id)] = [] await json(created, 201); return } if (unit && parts.length === 2 && method === 'GET') { await json(unit); return } @@ -345,9 +360,11 @@ export async function mockConsole(page: Page, role: ConsoleRole = 'administrator const hosts = [{ ...host, job_id: 'job-1', job: 'fixture-job', scan_id: 'scan-1', scanned_at: scan.finished_at, open_ports: 1, open_filtered_ports: 0, has_open_ports: true, data_quality: 'detailed' }] await json({ hosts, pagination: pagination(hosts.length) }); return } - if (path === '/jobs' && method === 'GET') { await json({ jobs: [job] }); return } - if (path === '/jobs/schedule-suggestion' && method === 'GET') { await json({ suggested: false, gap_minutes: 45 }); return } + if (path === '/jobs' && method === 'GET') { await json({ jobs: createdJob ? [job, createdJob] : [job] }); return } + if (path === '/jobs/schedule-suggestion' && method === 'GET') { await json({ suggested: false, draft_next_run: '2026-01-01T06:00:00Z', gap_minutes: 45 }); return } if (path === '/jobs/job-1' && method === 'GET') { await json(job); return } + if (path === '/jobs/job-created' && method === 'GET') { createdJob ? await json(createdJob) : await json({ error: { code: 'not_found', message: 'job not found' } }, 404); return } + if (path === '/jobs/job-created/scans' && method === 'GET') { await json({ scans: [], pagination: pagination(0, 20) }); return } if (path === '/jobs/job-1/scans' && method === 'GET') { await json({ scans: [scan], pagination: pagination(1, 20) }); return } if (path === '/jobs/job-1/scans/scan-1' && method === 'GET') { await json({ scan, changes: [], changes_pagination: pagination(0), current_security_hash: job.security_hash }); return } if (path === '/jobs/job-1/scans/scan-1/results' && method === 'GET') { await json({ results: [], pagination: pagination(0) }); return } @@ -359,7 +376,7 @@ export async function mockConsole(page: Page, role: ConsoleRole = 'administrator if (path === '/scans/scan-1' && method === 'GET') { await json({ scan }); return } if (path.startsWith('/scans/scan-1/hosts') && method === 'GET') { await json({ job_id: 'job-1', job: job.job.name, scan, data_quality: 'detailed', hosts: [host], pagination: pagination(1) }); return } if (path === '/scans/active' && method === 'GET') { await json({ scans: [] }); return } - if (path === '/notifications/destinations' && method === 'GET') { await json({ destinations: [destination], status: { deployment: 0, managed: 1, active: 1, locked: 0, key_state: 'ready' }, update_routing: updateRouting, incident_reminders_enabled: incidentRemindersEnabled, incident_reminder_cadence: incidentReminderCadence }); return } + if (path === '/notifications/destinations' && method === 'GET') { await json({ destinations: createdDestination ? [destination, createdDestination] : [destination], status: { deployment: 0, managed: createdDestination ? 2 : 1, active: createdDestination ? 2 : 1, locked: 0, key_state: 'ready' }, update_routing: updateRouting, incident_reminders_enabled: incidentRemindersEnabled, incident_reminder_cadence: incidentReminderCadence }); return } if (path === '/users' && method === 'GET') { const users = [{ id: 'user-2', username: 'operator', display_name: 'Operator', role: 'operator', enabled: true, pending: false, totp_enabled: false, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', revision: 1 }] await json({ users: createdUnitUser ? [...users, createdUnitUser] : users }); return @@ -376,7 +393,39 @@ export async function mockConsole(page: Page, role: ConsoleRole = 'administrator if (path === '/scanner-profiles' && method === 'GET') { await json({ profiles: [profile] }); return } if (path === '/scanner-profiles/profile-1' && method === 'GET') { await json(profile); return } - if (path === '/jobs' && method === 'POST') { await mutate('job-create', { ...job, id: 'job-created', job: body() }, 201); return } + if (path === '/jobs/preview' && method === 'POST') { + const value = body() as Record + const estimate = { hosts: value.targets?.length ?? 1, tcp_ports: value.tcp?.ports === '1-65535' ? 65_535 : value.tcp ? 2 : 0, udp_ports: value.udp ? 1 : 0, probes: value.tcp?.ports === '1-65535' ? 65_535 : 2, nmap_invocations: value.tcp?.engine === 'naabu_nmap' ? 1 : Number(!!value.tcp) + Number(!!value.udp), naabu_invocations: value.tcp?.engine === 'naabu_nmap' ? 1 : 0, unknown_dns: value.targets?.some((target: string) => !/^\d/.test(target)) ? 1 : 0 } + const override = previewResponse as Record | null + let response: unknown = override + ? { ...override, job: override.job ?? value } + : { job: value, scan_estimate: estimate, scan_budget: { exceeded: false, limit: 5_000_000 }, warnings: [{ code: 'elapsed_time_unknown', message: 'Elapsed time depends on scanner behavior and target responses.' }] } + const gate = previewGates.shift() + if (gate) { + gate.started() + response = await gate.response + } + try { + await mutate('job-preview', response) + } catch { + // A guided form may abort an old preview when the draft changes. + } + return + } + if (path === '/jobs' && method === 'POST') { + const value = body() + record('job-create', value) + if (failures.delete('job-create')) { await json(jsonError('job-create'), 422); return } + createdJob = { ...job, id: 'job-created', revision: 1, job: value, baseline: { status: 'learning', samples: 0, attempts: 0 }, scan_budget: { exceeded: false }, scan_cycle: null } + await json(createdJob, 201) + return + } + if (path === '/jobs/job-created/run' && method === 'POST') { + record('job-run', body()) + if (failures.delete('job-run')) { await json(jsonError('job-run'), 422); return } + await json({ status: 'accepted', job_id: 'job-created' }, 202) + return + } if (path === '/jobs/job-1' && method === 'PUT') { await mutate('job-update', job); return } if (path === '/jobs/job-1/incidents/accept' && method === 'POST') { record('incident-accept', body()) @@ -407,10 +456,17 @@ export async function mockConsole(page: Page, role: ConsoleRole = 'administrator incidentReminderCadence = value.cadence ?? incidentReminderCadence await json({ enabled: incidentRemindersEnabled, cadence: incidentReminderCadence }); return } - if (path === '/notifications/destinations' && method === 'POST') { await mutate('notification-create', destination, 201); return } + if (path === '/notifications/destinations' && method === 'POST') { + const value = body() as { name?: string } + record('notification-create', value) + if (failures.delete('notification-create')) { await json(jsonError('notification-create'), 422); return } + createdDestination = { ...destination, id: 'dest-created', name: value.name ?? 'Test destination', provider: 'generic', source: 'web' } + await json(createdDestination, 201) + return + } if (path === '/notifications/destinations/dest-1' && method === 'PUT') { await mutate('notification-update', destination); return } if (path === '/notifications/destinations/dest-1' && method === 'DELETE') { await mutate('notification-delete', undefined, 204); return } - if (path === '/notifications/destinations/dest-1/test' && method === 'POST') { await mutate('notification-test', { sent: 1 }); return } + if (path.startsWith('/notifications/destinations/') && path.endsWith('/test') && method === 'POST') { await mutate('notification-test', { sent: 1 }); return } if (path === '/scanner-profiles/validate' && method === 'POST') { await mutate('profile-validate', { valid: true, preview: [{ executable: '/usr/bin/nmap', args: ['-n', '-Pn', '-p', '22'] }] }); return } if (path === '/scanner-profiles' && method === 'POST') { await mutate('profile-create', profile, 201); return } if (path === '/scanner-profiles/profile-1' && method === 'PUT') { await mutate('profile-update', profile); return } diff --git a/e2e/mutation-outcomes.spec.ts b/e2e/mutation-outcomes.spec.ts index f39aaedd..b2a39d4a 100644 --- a/e2e/mutation-outcomes.spec.ts +++ b/e2e/mutation-outcomes.spec.ts @@ -150,7 +150,7 @@ test('user invitation exposes failure and success outcomes', async ({ page }, te test('job creation exposes failure and success outcomes', async ({ page }, testInfo) => { test.skip(!desktopOnly(testInfo), 'Mutation journeys run once on desktop; responsive behavior is covered separately.') const controls = await mockConsole(page) - await page.goto('/jobs/new') + await page.goto('/jobs/new/advanced') await page.getByLabel('Job name').fill('created-from-browser') await page.getByLabel('Target 1').fill('192.0.2.10') // Keep this journey independent of Naabu availability: the job builder must diff --git a/e2e/real-stack-guided.spec.ts b/e2e/real-stack-guided.spec.ts new file mode 100644 index 00000000..4a8af9b4 --- /dev/null +++ b/e2e/real-stack-guided.spec.ts @@ -0,0 +1,188 @@ +import { expect, test } from '@playwright/test' +import { bootstrapAdministrator, callAPI, createHarness, navigateFromShell, password, waitForScan, waitForScanStatus, type Harness } from './real-stack-harness' + +async function openReview(page: import('@playwright/test').Page, harness: Harness, name: string, targets = ['127.0.0.1'], destinationName?: string) { + await page.goto(`${harness.url}/jobs/new`) + await page.getByLabel('Monitor name').fill(name) + await page.getByLabel('Target 1').fill(targets[0]) + for (const [index, target] of targets.slice(1).entries()) { + await page.getByRole('button', { name: 'Add target' }).click() + await page.getByRole('textbox', { name: `Target ${index + 2}` }).fill(target) + } + await page.getByRole('button', { name: 'Continue to coverage' }).click() + await page.getByRole('radio', { name: /Selected TCP ports/ }).check() + await page.getByRole('textbox', { name: /TCP ports/ }).fill('22') + await page.getByRole('button', { name: 'Continue to schedule' }).click() + if (destinationName) await page.getByRole('checkbox', { name: new RegExp(destinationName) }).check() + else await page.getByRole('button', { name: 'Continue without alerts' }).click() + await page.getByRole('button', { name: 'Review monitor' }).click() + await expect(page.getByRole('heading', { name: 'Coverage and scan cost' })).toBeVisible() + await expect(page.locator('.estimate-grid')).toBeVisible() + await expect(page.getByRole('button', { name: 'Create and start first scan' })).toBeEnabled() +} + +async function createAndStart(page: import('@playwright/test').Page, harness: Harness, name: string, targets?: string[], destinationName?: string) { + await openReview(page, harness, name, targets, destinationName) + await page.getByRole('button', { name: 'Create and start first scan' }).click() + await expect(page).toHaveURL(/\/jobs\/[0-9a-f-]+$/i) + await expect(page.getByRole('heading', { name })).toBeVisible() + return new URL(page.url()).pathname.split('/').pop()! +} + +test('fresh administrator follows guided setup through destination, first scan, restart, and active baseline', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'desktop', 'The controlled real-stack lifecycle runs once on desktop.') + test.setTimeout(180_000) + const harness = await createHarness({ scannerMode: 'stable', notificationSink: true }) + try { + const csrf = await bootstrapAdministrator(page, harness) + + // A destination is a separate saved resource. Create and test it, then + // discard this monitor draft and confirm that Notifications still owns it. + await page.goto(`${harness.url}/jobs/new`) + await page.getByLabel('Monitor name').fill('discarded-draft') + await page.getByLabel('Target 1').fill('127.0.0.1') + await page.getByRole('button', { name: 'Continue to coverage' }).click() + await page.getByRole('radio', { name: /Selected TCP ports/ }).check() + await page.getByRole('textbox', { name: /TCP ports/ }).fill('22') + await page.getByRole('button', { name: 'Continue to schedule' }).click() + await page.getByRole('button', { name: 'Add destination' }).click() + const destinationForm = page.locator('.notification-inline-create') + const destinationName = 'Loopback delivery' + if (!harness.notificationURL) throw new Error('controlled notification sink was not started') + const secretURL = harness.notificationURL.replace('/edgewatch?', '/secret-transient-marker?') + const secretPassword = password + await destinationForm.getByPlaceholder('Production alerts').fill(destinationName) + await destinationForm.getByLabel('Notification service').selectOption('url') + await destinationForm.getByLabel('Shoutrrr URL').fill(secretURL) + await destinationForm.getByLabel('Password confirmation').fill(secretPassword) + await destinationForm.getByRole('button', { name: 'Save and select destination' }).click() + await expect(page.getByText(`${destinationName} was created and selected.`)).toBeVisible() + await expect(destinationForm.getByLabel('Shoutrrr URL')).toHaveCount(0) + await expect(destinationForm.getByLabel('Password confirmation')).toHaveValue('') + await expect(destinationForm).not.toContainText(secretURL) + await expect(destinationForm).not.toContainText(secretPassword) + await expect(page.locator('body')).not.toContainText(secretURL) + await expect(page.locator('body')).not.toContainText(secretPassword) + const browserState = await page.evaluate(() => JSON.stringify({ + url: location.href, + history: history.state, + localStorage: Object.values(localStorage), + sessionStorage: Object.values(sessionStorage), + })) + expect(browserState).not.toContain(secretURL) + expect(browserState).not.toContain(secretPassword) + + await destinationForm.getByRole('button', { name: 'Test destination' }).click() + await expect(destinationForm.getByRole('status')).toContainText('Test send completed') + await expect.poll(() => harness.notificationMessages().length, { timeout: 15_000 }).toBeGreaterThan(0) + + await navigateFromShell(page, 'Jobs') + const discard = page.getByRole('dialog', { name: 'Discard monitor changes?' }) + await expect(discard).toBeVisible() + await discard.getByRole('button', { name: 'Discard changes' }).click() + await expect(page).toHaveURL(/\/jobs$/) + await navigateFromShell(page, 'Notifications') + await expect(page.getByRole('heading', { name: 'Notifications', exact: true })).toBeVisible() + await expect(page.getByText(destinationName, { exact: true })).toBeVisible() + + const jobID = await createAndStart(page, harness, 'guided-two-sample-monitor', undefined, destinationName) + const saved = await callAPI(page, `/jobs/${jobID}`, 'GET', csrf) + expect(saved.status).toBe(200) + expect(saved.body.job).toMatchObject({ + name: 'guided-two-sample-monitor', + targets: ['127.0.0.1'], + tcp: { engine: 'nmap', ports: '22' }, + baseline_samples: 2, + notification_destinations: [expect.any(String)], + }) + + await waitForScan(page, jobID, csrf, 1) + await expect(page.getByRole('heading', { name: 'Next steps' })).toBeVisible() + await expect(page.getByText('1 of 2 successful samples collected.')).toBeVisible() + await expect(page.getByRole('button', { name: 'Run another sample' })).toBeEnabled() + const afterFirst = await callAPI(page, `/jobs/${jobID}`, 'GET', csrf) + expect(afterFirst.body.baseline).toMatchObject({ status: 'collecting', samples: 1 }) + + // A consumed creation intent is gone after a normal refresh and a daemon + // restart; the saved job still offers an explicit next sample. + await page.reload() + await expect(page.getByRole('heading', { name: 'guided-two-sample-monitor' })).toBeVisible() + await expect.poll(async () => (await callAPI(page, `/jobs/${jobID}/scans?limit=10`, 'GET', csrf)).body.scans.length).toBe(1) + await harness.stop() + await harness.start() + await page.goto(`${harness.url}/jobs/${jobID}`) + await expect(page.getByRole('button', { name: 'Run another sample' })).toBeEnabled({ timeout: 15_000 }) + await expect.poll(async () => (await callAPI(page, `/jobs/${jobID}/scans?limit=10`, 'GET', csrf)).body.scans.length).toBe(1) + + await page.getByRole('button', { name: 'Run another sample' }).click() + await waitForScan(page, jobID, csrf, 2) + await expect(page.getByText('The baseline is active for the configured coverage.')).toBeVisible() + await expect(page.getByRole('link', { name: 'View baseline evidence' })).toBeVisible() + const ready = await callAPI(page, `/jobs/${jobID}`, 'GET', csrf) + expect(ready.body.baseline.status).toBe('complete') + const scans = await callAPI(page, `/jobs/${jobID}/scans?limit=10`, 'GET', csrf) + expect(scans.status).toBe(200) + expect(scans.body.scans).toHaveLength(2) + expect(scans.body.scans.map((scan: { status: string }) => scan.status)).toEqual(['success', 'success']) + const evidence = await callAPI(page, `/jobs/${jobID}/baseline?limit=10`, 'GET', csrf) + expect(evidence.status).toBe(200) + expect(evidence.body.baseline.status).toBe('complete') + await page.getByRole('link', { name: 'View baseline evidence →' }).click() + await expect(page.getByRole('heading', { name: 'Explore baseline' })).toBeVisible() + const host = page.getByRole('link', { name: /127\.0\.0\.1/ }) + await expect(host).toContainText('1 positive ports') + await host.click() + await expect(page.getByRole('heading', { name: '127.0.0.1' })).toBeVisible() + await expect(page.locator('.port-table code').filter({ hasText: '22/tcp' })).toBeVisible() + } finally { + await harness.close() + } +}) + +test('a complete baseline with zero positive ports is described as valid', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'desktop', 'The controlled real-stack lifecycle runs once on desktop.') + test.setTimeout(150_000) + const harness = await createHarness({ scannerMode: 'empty' }) + try { + const csrf = await bootstrapAdministrator(page, harness) + const jobID = await createAndStart(page, harness, 'empty-surface-monitor') + await waitForScan(page, jobID, csrf, 1) + await expect(page.getByRole('button', { name: 'Run another sample' })).toBeEnabled() + await page.getByRole('button', { name: 'Run another sample' }).click() + await waitForScan(page, jobID, csrf, 2) + await expect(page.getByText('This is a valid complete baseline with zero positive ports in the configured coverage.')).toBeVisible() + await expect(page.getByText(/setup failure/i)).toHaveCount(0) + const job = await callAPI(page, `/jobs/${jobID}`, 'GET', csrf) + expect(job.body.baseline.status).toBe('complete') + const scans = await callAPI(page, `/jobs/${jobID}/scans?limit=10`, 'GET', csrf) + expect(scans.body.scans).toHaveLength(2) + } finally { + await harness.close() + } +}) + +test('incomplete observations do not count and stalled learning links to scan evidence', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'desktop', 'The controlled real-stack lifecycle runs once on desktop.') + test.setTimeout(150_000) + const harness = await createHarness({ scannerMode: 'incomplete' }) + try { + const csrf = await bootstrapAdministrator(page, harness) + const jobID = await createAndStart(page, harness, 'incomplete-learning-monitor', ['127.0.0.1', '127.0.0.2']) + await waitForScanStatus(page, jobID, csrf, 1, 'incomplete') + await expect(page.getByText('The latest scan was incomplete.')).toBeVisible() + let job = await callAPI(page, `/jobs/${jobID}`, 'GET', csrf) + expect(job.body.baseline).toMatchObject({ status: 'collecting', samples: 0, incomplete_attempts: 1 }) + + for (const count of [2, 3]) { + await page.getByRole('button', { name: 'Run first sample' }).click() + await waitForScanStatus(page, jobID, csrf, count, 'incomplete') + } + job = await callAPI(page, `/jobs/${jobID}`, 'GET', csrf) + expect(job.body.baseline).toMatchObject({ status: 'stalled', samples: 0, incomplete_attempts: 3 }) + await expect(page.getByText('Baseline learning is stalled.')).toBeVisible() + await expect(page.getByRole('link', { name: 'Review scan evidence' })).toBeVisible() + await expect(page.getByRole('link', { name: 'Review targets and scanner profile' })).toBeVisible() + } finally { + await harness.close() + } +}) diff --git a/e2e/real-stack-harness.ts b/e2e/real-stack-harness.ts index fbee8c1a..9d684e49 100644 --- a/e2e/real-stack-harness.ts +++ b/e2e/real-stack-harness.ts @@ -4,6 +4,7 @@ import { once } from 'node:events' import { join } from 'node:path' import { tmpdir } from 'node:os' import { execFile, spawn, type ChildProcess } from 'node:child_process' +import { createServer, type Server } from 'node:http' import net from 'node:net' import { promisify } from 'node:util' @@ -26,10 +27,20 @@ export type Harness = { setupToken: () => string start: () => Promise stop: () => Promise + close: () => Promise + notificationURL?: string + notificationMessages: () => string[] /** Runs a host command of the same binary against the daemon's configuration and returns its standard output. */ cli: (args: string[]) => Promise } +export type HarnessOptions = { + /** `changing` preserves the historical port transition used for incident tests. */ + scannerMode?: 'changing' | 'stable' | 'empty' | 'incomplete' + /** Start an authorized loopback HTTP sink for Shoutrrr's generic provider. */ + notificationSink?: boolean +} + async function availablePort(): Promise { const server = net.createServer() await new Promise((resolve, reject) => { @@ -47,7 +58,7 @@ export async function delay(ms: number): Promise { await new Promise(resolve => setTimeout(resolve, ms)) } -export async function createHarness(): Promise { +export async function createHarness(options: HarnessOptions = {}): Promise { const binary = process.env.EDGEWATCH_E2E_BINARY if (!binary) throw new Error('EDGEWATCH_E2E_BINARY is unset; Playwright global setup must build the daemon first') await access(binary) @@ -55,6 +66,7 @@ export async function createHarness(): Promise { const directory = await mkdtemp(join(tmpdir(), 'edgewatch-real-stack-')) const port = await availablePort() const counter = join(directory, 'nmap-count') + const scannerMode = options.scannerMode ?? 'changing' const nmap = join(directory, 'fake-nmap.sh') await writeFile(nmap, `#!/bin/sh if [ "\${1:-}" = "--version" ]; then @@ -66,7 +78,15 @@ if [ -f '${counter}' ]; then count=$(cat '${counter}'); fi count=$((count + 1)) printf '%s' "$count" > '${counter}' port=22 -if [ "$count" -ge 2 ]; then port=23; fi +if [ '${scannerMode}' = 'changing' ] && [ "$count" -ge 2 ]; then port=23; fi +if [ '${scannerMode}' = 'incomplete' ]; then + cat < +
+ +EOF + exit 0 +fi cat < @@ -74,7 +94,7 @@ cat <
- + ${scannerMode === 'empty' ? '' : ``} @@ -100,6 +120,27 @@ notifications: let output = '' let setupToken = '' let firstStart = true + let notificationSink: Server | undefined + let notificationURL: string | undefined + const notificationMessages: string[] = [] + if (options.notificationSink) { + notificationSink = createServer((request, response) => { + const chunks: Buffer[] = [] + request.on('data', chunk => chunks.push(Buffer.from(chunk))) + request.on('end', () => { + notificationMessages.push(Buffer.concat(chunks).toString('utf8')) + response.writeHead(200, { 'Content-Type': 'text/plain' }) + response.end('accepted') + }) + }) + await new Promise((resolve, reject) => { + notificationSink!.once('error', reject) + notificationSink!.listen(0, '127.0.0.1', resolve) + }) + const address = notificationSink.address() + if (!address || typeof address === 'string') throw new Error('could not allocate the notification sink') + notificationURL = `generic://127.0.0.1:${address.port}/edgewatch?disabletls=yes&template=json` + } const url = `http://127.0.0.1:${port}` const start = async () => { @@ -171,7 +212,14 @@ notifications: return stdout } - const harness: Harness = { url, setupToken: () => setupToken, start, stop, cli } + const close = async () => { + await stop() + if (notificationSink) { + await new Promise((resolve, reject) => notificationSink!.close(error => error ? reject(error) : resolve())) + notificationSink = undefined + } + } + const harness: Harness = { url, setupToken: () => setupToken, start, stop, close, notificationURL, notificationMessages: () => [...notificationMessages], cli } // Register cleanup before waiting for readiness. If compilation, binding, // or database startup fails, the caller never receives a harness on which it // could run its normal finally block. @@ -179,13 +227,30 @@ notifications: await start() return harness } catch (error) { - await stop() + await close() throw error } } export type APIResult = { status: number; body: any } +/** Complete the first administrator setup against a fresh harness and return its CSRF token. */ +export async function bootstrapAdministrator(page: Page, harness: Harness): Promise { + await page.goto(harness.url) + await page.getByLabel('Setup token').fill(harness.setupToken()) + await page.locator('input[autocomplete="new-password"]').first().fill(password) + await page.locator('input[autocomplete="new-password"]').nth(1).fill(password) + await page.getByRole('button', { name: 'Create administrator' }).click() + await page.locator('input[autocomplete="current-password"]').fill(password) + await page.getByRole('button', { name: 'Sign in' }).click() + await page.getByRole('heading', { name: /Good day, admin/i }).waitFor({ state: 'visible' }) + const session = await callAPI(page, '/auth/session', 'GET') + if (session.status !== 200 || typeof session.body.csrf_token !== 'string') { + throw new Error(`Administrator session was not ready: ${session.status}`) + } + return session.body.csrf_token +} + export async function callAPI(page: Page, path: string, method: string, csrf = '', payload?: unknown): Promise { return page.evaluate(async ({ path, method, csrf, payload }) => { const headers: Record = {} @@ -213,6 +278,17 @@ export async function waitForScan(page: Page, jobID: string, csrf: string, count throw new Error(`scan ${count} did not complete`) } +export async function waitForScanStatus(page: Page, jobID: string, csrf: string, count: number, status: string): Promise { + for (let attempt = 0; attempt < 100; attempt++) { + const response = await callAPI(page, `/jobs/${jobID}/scans?limit=10`, 'GET', csrf) + const scans = response.body?.scans as any[] | undefined + if (response.status === 200 && Array.isArray(scans) && scans.length >= count && scans.slice(0, count).every(scan => scan.status === status)) return scans + await delay(200) + } + const latest = await callAPI(page, `/jobs/${jobID}/scans?limit=10`, 'GET', csrf) + throw new Error(`scan ${count} did not reach ${status}: ${JSON.stringify(latest.body?.scans?.slice(0, count) ?? latest.body)}`) +} + // The scan row is saved before the run releases its job lease, so a click // right after waitForScan can briefly get 409 job_active. Retry that case, as // the API message tells operators to, and fail on any other refusal. diff --git a/e2e/real-stack-units.spec.ts b/e2e/real-stack-units.spec.ts index 61def890..7b0fcd74 100644 --- a/e2e/real-stack-units.spec.ts +++ b/e2e/real-stack-units.spec.ts @@ -157,6 +157,7 @@ function storedScans(row: Locator): Locator { async function createJob(page: Page): Promise { await navigateFromShell(page, 'Jobs') await page.getByRole('button', { name: 'New job' }).click() + await page.getByRole('link', { name: 'Open full editor' }).click() await expect(page.getByRole('heading', { name: 'Create a monitoring job' })).toBeVisible() await page.getByLabel('Job name').fill(jobName) await page.getByLabel('Target 1').fill('127.0.0.1') diff --git a/e2e/real-stack.spec.ts b/e2e/real-stack.spec.ts index 8e9804d3..447f487d 100644 --- a/e2e/real-stack.spec.ts +++ b/e2e/real-stack.spec.ts @@ -24,6 +24,7 @@ test('real EdgeWatch setup, baseline, change detection, and restart persistence' await navigateFromShell(page, 'Jobs') await page.getByRole('button', { name: 'New job' }).click() + await page.getByRole('link', { name: 'Open full editor' }).click() await expect(page.getByRole('heading', { name: 'Create a monitoring job' })).toBeVisible() await page.getByLabel('Job name').fill('real-stack-fixture') await page.getByLabel('Target 1').fill('127.0.0.1') diff --git a/e2e/responsive-issue-regressions.spec.ts b/e2e/responsive-issue-regressions.spec.ts index 17b4aae4..f55f5082 100644 --- a/e2e/responsive-issue-regressions.spec.ts +++ b/e2e/responsive-issue-regressions.spec.ts @@ -416,7 +416,7 @@ test.describe('responsive issue regressions', () => { for (const width of [320, 1280]) { await page.setViewportSize({ width, height: 900 }) - await page.goto('/jobs/new') + await page.goto('/jobs/new/advanced') const suggestion = page.locator('.schedule-suggestion') await expect(suggestion).toBeVisible({ timeout: 5_000 }) const copy = suggestion.locator('.schedule-suggestion-copy') @@ -855,7 +855,7 @@ test.describe('responsive issue regressions', () => { } })) for (const width of [375, 768, 834, 1024]) { await page.setViewportSize({ width, height: 900 }) - await page.goto('/jobs/new') + await page.goto('/jobs/new/advanced') const main = (await page.locator('.editor-main').boundingBox())! const side = (await page.locator('.editor-side').boundingBox())! const actions = (await page.locator('.editor-actions').boundingBox())! diff --git a/e2e/role-matrix.spec.ts b/e2e/role-matrix.spec.ts index 371b91fb..b3d73d96 100644 --- a/e2e/role-matrix.spec.ts +++ b/e2e/role-matrix.spec.ts @@ -18,6 +18,7 @@ const routePermissions: Array<{ path: string; permission?: string; refusedToHome { path: '/', permission: 'overview.read' }, { path: '/jobs', permission: 'jobs.read' }, { path: '/jobs/new', permission: 'jobs.write' }, + { path: '/jobs/new/advanced', permission: 'jobs.write' }, { path: '/jobs/job-1', permission: 'jobs.read' }, { path: '/jobs/job-1/edit', permission: 'jobs.write' }, { path: '/jobs/job-1/baseline', permission: 'baselines.read' }, diff --git a/e2e/typography-contrast-regressions.spec.ts b/e2e/typography-contrast-regressions.spec.ts index e13331da..40b5a339 100644 --- a/e2e/typography-contrast-regressions.spec.ts +++ b/e2e/typography-contrast-regressions.spec.ts @@ -4,6 +4,7 @@ import { mockConsole } from './mock-console' const operatorPages = [ '/jobs', '/jobs/new', + '/jobs/new/advanced', '/jobs/job-1', '/incidents', '/activity', @@ -25,6 +26,12 @@ async function expectNoTinyVisibleText(page: import('@playwright/test').Page, pa expect(tiny, `visible text smaller than 11px on ${path}`).toEqual([]) } +async function expectNarrowTextAndLayout(page: import('@playwright/test').Page, path: string) { + await expectNoTinyVisibleText(page, path) + const width = await page.evaluate(() => ({ document: document.documentElement.scrollWidth, viewport: window.innerWidth })) + expect(width.document, `horizontal overflow on ${path}`).toBeLessThanOrEqual(width.viewport) +} + async function installIssue1128Fixtures(page: import('@playwright/test').Page) { await mockConsole(page) const job = { @@ -96,9 +103,26 @@ test('operational text stays at least 11px on narrow screens', async ({ page }, if (path === '/activity') await expect(page.locator('.activity-event-heading time').first()).toBeVisible() if (path === '/jobs/job-1') await expect(page.locator('.pending-detail-heading')).toBeVisible() if (path.startsWith('/scans/')) await expect(page.locator('.nse-output-panel')).toBeVisible() - await expectNoTinyVisibleText(page, path) - const width = await page.evaluate(() => ({ document: document.documentElement.scrollWidth, viewport: window.innerWidth })) - expect(width.document, `horizontal overflow on ${path}`).toBeLessThanOrEqual(width.viewport) + await expectNarrowTextAndLayout(page, path) + + if (path === '/jobs/new') { + await page.getByLabel('Monitor name').fill('typography-floor-monitor') + await page.getByLabel('Target 1').fill('192.0.2.10') + await page.getByRole('button', { name: 'Continue to coverage' }).click() + await expect(page.getByRole('heading', { name: 'Set scan coverage' })).toBeVisible() + await expectNarrowTextAndLayout(page, 'guided setup coverage') + + await page.getByRole('radio', { name: /Selected TCP ports/ }).check() + await page.getByRole('textbox', { name: /TCP ports/ }).fill('22') + await page.getByRole('button', { name: 'Continue to schedule' }).click() + await expect(page.getByRole('heading', { name: 'Set schedule and alerts' })).toBeVisible() + await expectNarrowTextAndLayout(page, 'guided setup schedule and alerts') + + await page.getByRole('button', { name: 'Continue without alerts' }).click() + await page.getByRole('button', { name: 'Review monitor' }).click() + await expect(page.getByRole('heading', { name: 'Coverage and scan cost' })).toBeVisible() + await expectNarrowTextAndLayout(page, 'guided setup review') + } } }) @@ -240,7 +264,7 @@ test('headings and primary actions meet contrast expectations and archived jobs expect(styles.footerContrast).toBeGreaterThanOrEqual(4.5) expect(styles.headingWeight).toBeGreaterThanOrEqual(600) - await page.goto('/jobs/new') + await page.goto('/jobs/new/advanced') const panelHeading = page.locator('.panel h2').first() await expect(panelHeading).toBeVisible() expect(Number.parseInt(await panelHeading.evaluate(element => getComputedStyle(element).fontWeight), 10)).toBeGreaterThanOrEqual(600) diff --git a/internal/store/audit_category_test.go b/internal/store/audit_category_test.go index 7317f4b6..9d71d5ac 100644 --- a/internal/store/audit_category_test.go +++ b/internal/store/audit_category_test.go @@ -133,9 +133,9 @@ var dottedActionPattern = regexp.MustCompile(`^[a-z][a-z0-9_]*(\.[a-z0-9_]+)+$`) var sqlAuditActionPattern = regexp.MustCompile(`'([a-z][a-z0-9_]*(?:\.[a-z0-9_]+)+)'`) // notAuditActions are the dotted string literals in the sources that are not -// audit actions: live-update message types, file and host names, and the -// paths of config.yaml settings, such as an obsolete one and the sandbox -// settings that reasons name. Permission names are declared in +// audit actions: live-update message types, file and host names, API field +// paths, and the paths of config.yaml settings, such as an obsolete one and +// the sandbox settings that reasons name. Permission names are declared in // internal/auth/permissions.go, which is not scanned. var notAuditActions = map[string]bool{ "application.update_status": true, @@ -150,6 +150,7 @@ var notAuditActions = map[string]bool{ "github.com": true, "localhost.localdomain": true, "experimental.business_units": true, + "tcp.ports": true, // Preview warning field path, not an audit action. "scanner.sandbox": true, "scanner.landlock": true, "notifications.sandbox": true, diff --git a/internal/store/jobs.go b/internal/store/jobs.go index 1ec2fb64..619508de 100644 --- a/internal/store/jobs.go +++ b/internal/store/jobs.go @@ -93,6 +93,19 @@ var ErrScannerProfileNotFound = NewValidationError(config.NewFieldValidationErro // so the store refuses one, whoever owns it, as the console does. var ErrUDPScannerProfile = NewValidationError(config.NewFieldValidationError("udp", errors.New("udp scanner profiles are not supported; UDP uses Nmap defaults"))) +// ValidateManagedJob applies the deployment's installed target-exclusion +// policy and the standard managed-job validation to a candidate job. It is +// intended for advisory preflight checks; writes validate again in their +// transaction so a preview never reserves or weakens the final decision. +// The validator preserves the distinction between an unconfigured nil policy +// and an explicitly empty policy installed by the deployment. +func (ts *TenantStore) ValidateManagedJob(job config.Job) error { + if err := ts.ready(); err != nil { + return err + } + return ts.store.validateManagedJob(config.NormalizeJob(job)) +} + // checkPinnedScannerProfileTx refuses a job whose TCP scan pins a scanner // profile that is neither built in nor the tenant's own. The TCP profile is // the one a scan applies, and the one the console checks. A UDP profile is diff --git a/internal/store/tenancy_jobs_test.go b/internal/store/tenancy_jobs_test.go index ce0bac79..037c4940 100644 --- a/internal/store/tenancy_jobs_test.go +++ b/internal/store/tenancy_jobs_test.go @@ -306,6 +306,27 @@ var jobAudit = AuditEntry{Action: "job.test", ActorUsername: "tenant-test"} // jobLeakCases hold the leak cases of the job writes and JobActive. var jobLeakCases = map[string]tenantLeakCase{ + "ValidateManagedJob": {run: func(t *testing.T, f tenantFixture) { + if err := f.store.SetTargetExclusions([]string{"127.0.0.0/8"}); err != nil { + t.Fatal(err) + } + beforeA, beforeB := tenantJobDigest(t, f.store, f.a), tenantJobDigest(t, f.store, f.b) + var want string + for _, scope := range []TenantScope{f.a, f.b} { + err := f.store.Tenant(scope).ValidateManagedJob(testJob("candidate")) + if !errors.Is(err, ErrValidation) { + t.Errorf("tenant %s validation = %v, want target policy validation error", scope.ID(), err) + } + if want == "" { + want = err.Error() + } else if err.Error() != want { + t.Errorf("tenant %s validation = %q, want same policy response %q", scope.ID(), err, want) + } + } + if tenantJobDigest(t, f.store, f.a) != beforeA || tenantJobDigest(t, f.store, f.b) != beforeB { + t.Fatal("managed-job validation changed tenant data") + } + }}, "CreateJob": {writes: true, run: func(t *testing.T, f tenantFixture) { assertTenantCreatesJobs(t, f, func(ts *TenantStore, job config.Job) (JobRecord, error) { return ts.CreateJob(context.Background(), job) diff --git a/internal/web/job_handlers.go b/internal/web/job_handlers.go index 8fc0a5d2..fe1d54c1 100644 --- a/internal/web/job_handlers.go +++ b/internal/web/job_handlers.go @@ -204,6 +204,40 @@ func (s *Server) jobScanBudget(ctx context.Context, ts *store.TenantStore, job c return budget, true } +// jobScanBudgetOutcome returns an estimate only with a complete budget +// decision. Preview uses this stricter helper so an unavailable unit capacity +// read cannot be mistaken for a fit or for a definite over-budget result. +func (s *Server) jobScanBudgetOutcome(ctx context.Context, ts *store.TenantStore, job config.Job) (config.WorkEstimate, map[string]any, error) { + if s.App == nil || s.App.Config == nil { + return config.WorkEstimate{}, nil, app.ErrProbeBudgetUnavailable + } + estimate, err := s.App.CheckScanWorkBudget(ctx, ts, job) + var budgetErr *app.ScanWorkBudgetError + switch { + case err == nil: + return estimate, map[string]any{"exceeded": false}, nil + case !errors.As(err, &budgetErr): + return estimate, nil, err + } + budget := map[string]any{"exceeded": true, "estimated_probes": budgetErr.Estimate.Probes, "limit": budgetErr.Budget, "approval_would_fit": false} + if !job.AllowHighCost { + approved := job + approved.AllowHighCost = true + _, approvedErr := s.App.CheckScanWorkBudget(ctx, ts, approved) + var approvedBudgetErr *app.ScanWorkBudgetError + switch { + case approvedErr == nil: + budget["approval_would_fit"] = true + case errors.As(approvedErr, &approvedBudgetErr): + // The elevated budget and absolute ceiling were both available and + // the estimate still did not fit. + default: + return estimate, nil, approvedErr + } + } + return estimate, budget, nil +} + type pendingChangeView struct { Key string `json:"key"` Change model.Change `json:"change"` @@ -441,9 +475,36 @@ func (s *Server) listJobs(w http.ResponseWriter, r *http.Request, ts *store.Tena } func (s *Server) createJob(w http.ResponseWriter, r *http.Request, session store.Session, ts *store.TenantStore) { + job, enabled, ok := s.prepareNewJob(w, r, session, ts) + if !ok { + return + } + record, err := ts.CreateJobWithEnabledAndAudit(r.Context(), job, enabled, actorAudit(session, "job.created", job.Name)) + if err != nil { + if s.writeAuditUnavailable(w, err, "job.created") { + return + } + if isUnique(err) { + writeError(w, 409, "conflict", "job name is already in use", nil) + } else { + s.writeStoreWriteError(w, r, err, "job not found") + } + return + } + s.App.RefreshSchedules() + state, _ := ts.RuntimeState(r.Context(), record.ID) + s.broadcastTo(context.WithoutCancel(r.Context()), audienceTenant(ts), map[string]any{"type": "job.created", "job_id": record.ID}) + writeJSON(w, http.StatusCreated, s.jobJSONWithCycle(r.Context(), ts, record, state)) +} + +// prepareNewJob centralizes the new-job defaults and policy checks shared by +// create and preview. The final create transaction repeats storage validation +// and profile ownership checks, so this advisory pass cannot reserve or pin +// resources and a create still revalidates against concurrent changes. +func (s *Server) prepareNewJob(w http.ResponseWriter, r *http.Request, session store.Session, ts *store.TenantStore) (config.Job, bool, bool) { var p jobPayload if !decodeJSON(w, r, &p) { - return + return config.Job{}, false, false } if strings.TrimSpace(p.Timezone) == "" { // New jobs without an explicit schedule timezone follow config.yaml; @@ -454,11 +515,11 @@ func (s *Server) createJob(w http.ResponseWriter, r *http.Request, session store job, err := p.config() if err != nil { writeValidationError(w, err) - return + return config.Job{}, false, false } if job.AllowHighCost && !canOverrideHighCost(session) { writeError(w, http.StatusForbidden, "high_cost_admin_required", "only administrators may enable high-cost scans", nil) - return + return config.Job{}, false, false } if err := s.applySelectedScannerProfile(r.Context(), ts, &job, false, auth.HasPermission(session, auth.PermissionScannerProfilesManage)); err != nil { if errors.Is(err, store.ErrConflict) { @@ -466,31 +527,100 @@ func (s *Server) createJob(w http.ResponseWriter, r *http.Request, session store } else { s.writeStoreWriteError(w, r, err, "scanner profile not found") } - return + return config.Job{}, false, false } if !s.validateNotificationSelection(w, r, ts, job) { - return + return config.Job{}, false, false + } + if err := ts.ValidateManagedJob(job); err != nil { + s.writeStoreWriteError(w, r, err, "job not found") + return config.Job{}, false, false } enabled := true if p.Enabled != nil { enabled = *p.Enabled } - record, err := ts.CreateJobWithEnabledAndAudit(r.Context(), job, enabled, actorAudit(session, "job.created", job.Name)) - if err != nil { - if s.writeAuditUnavailable(w, err, "job.created") { - return + return job, enabled, true +} + +type jobPreviewWarning struct { + Code string `json:"code"` + Field string `json:"field,omitempty"` + Message string `json:"message"` +} + +const maxJobPreviewWarnings = 5 + +func jobPreviewWarnings(job config.Job, estimate config.WorkEstimate) []jobPreviewWarning { + warnings := make([]jobPreviewWarning, 0, maxJobPreviewWarnings) + add := func(warning jobPreviewWarning) { + if len(warnings) < maxJobPreviewWarnings { + warnings = append(warnings, warning) } - if isUnique(err) { - writeError(w, 409, "conflict", "job name is already in use", nil) - } else { - s.writeStoreWriteError(w, r, err, "job not found") + } + add(jobPreviewWarning{ + Code: "elapsed_time_unknown", + Message: "Probe and process counts are preflight estimates; elapsed scan time depends on DNS, scanner behavior, target responses, retries, and discovered ports.", + }) + if estimate.UnknownDNS > 0 { + add(jobPreviewWarning{ + Code: "dns_expansion_unknown", + Field: "targets", + Message: "Each DNS name counts as one address here. Preview does not resolve DNS, so the address count and work may change when the scan runs.", + }) + } + if job.TCP != nil && job.TCP.Engine == config.EngineNaabuNmap { + add(jobPreviewWarning{ + Code: "naabu_enrichment_data_dependent", + Field: "tcp", + Message: "Naabu discovery uses the full TCP port range. The later Nmap confirmation work depends on discovered ports and is not included in this preflight estimate.", + }) + } + if job.TCP != nil && job.TCP.Engine == config.EngineNmap { + if ports, err := config.ParsePorts(job.TCP.Ports); err == nil && len(ports) < 65535 { + add(jobPreviewWarning{ + Code: "tcp_partial_coverage", + Field: "tcp.ports", + Message: "This Nmap TCP selection covers only the configured ports, not the full TCP port range.", + }) + } + } + if job.AllowHighCost { + add(jobPreviewWarning{ + Code: "high_cost_approved", + Field: "allow_high_cost", + Message: "High-cost approval uses the unit's elevated probe budget; the absolute probe ceiling still applies.", + }) + } + return warnings +} + +func (s *Server) previewJob(w http.ResponseWriter, r *http.Request, session store.Session, ts *store.TenantStore) { + job, enabled, ok := s.prepareNewJob(w, r, session, ts) + if !ok { + return + } + estimate, budget, err := s.jobScanBudgetOutcome(r.Context(), ts, job) + if err != nil { + if s.Log != nil { + s.Log.WarnContext(r.Context(), "job preview could not confirm scan budget", "request_id", RequestID(r.Context()), "error", err) } + writeError(w, http.StatusServiceUnavailable, "preview_unavailable", "job preview could not confirm the unit scan budget", map[string]string{"reason": "scan_budget_unavailable"}) return } - s.App.RefreshSchedules() - state, _ := ts.RuntimeState(r.Context(), record.ID) - s.broadcastTo(context.WithoutCancel(r.Context()), audienceTenant(ts), map[string]any{"type": "job.created", "job_id": record.ID}) - writeJSON(w, http.StatusCreated, s.jobJSONWithCycle(r.Context(), ts, record, state)) + + publicJob := fromConfig(job) + publicJob.Enabled = &enabled + value := s.addNotificationRouting(r.Context(), s.tenantNotifier(ts), map[string]any{"job": publicJob}) + if normalized, ok := value["job"].(jobPayload); ok { + publicJob = normalized + } + writeJSON(w, http.StatusOK, map[string]any{ + "job": publicJob, + "scan_estimate": estimate, + "scan_budget": budget, + "warnings": jobPreviewWarnings(job, estimate), + }) } // defaultNewScannerProfile keeps new web-created TCP jobs on the faster, @@ -547,7 +677,11 @@ func (s *Server) validateNotificationSelection(w http.ResponseWriter, r *http.Re } if err := s.App.Notifier.Tenant(ts).ValidateDestinationSelection(r.Context(), job.NotificationDestinations); err != nil { if errors.Is(err, notify.ErrInvalidDestinationSelection) { - writeError(w, http.StatusBadRequest, "validation_failed", err.Error(), map[string]string{"notification_destinations": err.Error()}) + // Selectors are opaque caller input. A foreign destination must be + // indistinguishable from an unknown one, and neither error should + // reflect arbitrary IDs back to the caller. + message := "notification destination selection is invalid" + writeError(w, http.StatusBadRequest, "validation_failed", message, map[string]string{"notification_destinations": message}) } else { writeError(w, http.StatusInternalServerError, "notification", "notification destinations could not be loaded", nil) } diff --git a/internal/web/job_preview_test.go b/internal/web/job_preview_test.go new file mode 100644 index 00000000..4d10793b --- /dev/null +++ b/internal/web/job_preview_test.go @@ -0,0 +1,521 @@ +package web + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "github.com/crypt0rr/edgewatch/internal/auth" + "github.com/crypt0rr/edgewatch/internal/config" + "github.com/crypt0rr/edgewatch/internal/model" + "github.com/crypt0rr/edgewatch/internal/store" +) + +type previewResponse struct { + Job jobPayload `json:"job"` + Estimate config.WorkEstimate `json:"scan_estimate"` + Budget struct { + Exceeded bool `json:"exceeded"` + EstimatedProbes int64 `json:"estimated_probes"` + Limit int64 `json:"limit"` + ApprovalWouldFit bool `json:"approval_would_fit"` + } `json:"scan_budget"` + Warnings []jobPreviewWarning `json:"warnings"` +} + +func validPreviewPayload(name string) jobPayload { + return jobPayload{ + Name: name, Schedule: "0 * * * *", Timezone: "UTC", + Targets: []string{"192.0.2.10"}, MaxExpandedHosts: 256, + TCP: &protocolPayload{Ports: "443", Mode: "connect", Engine: config.EngineNmap}, + Timing: "balanced", Timeout: "1m", BaselineSamples: 2, ChangeConfirmations: 2, + } +} + +func previewHandlerRequest(body string) *http.Request { + request := httptest.NewRequest(http.MethodPost, "/api/v1/jobs/preview", strings.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + return request +} + +type previewCountingScanner struct{ calls int } + +func (s *previewCountingScanner) Version(context.Context) string { return "preview-counting" } +func (s *previewCountingScanner) Scan(context.Context, config.Job) (model.Snapshot, error) { + s.calls++ + return model.Snapshot{}, nil +} + +func marshalPreviewPayload(t *testing.T, payload jobPayload) string { + t.Helper() + body, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + return string(body) +} + +func previewAPICall(t *testing.T, server *Server, account *routeMatrixSession, body, origin string, csrf bool) *httptest.ResponseRecorder { + t.Helper() + request := httptest.NewRequest(http.MethodPost, consoleAPIBase+"/jobs/preview", strings.NewReader(body)) + request.RemoteAddr = "127.0.0.1:9000" + request.Header.Set("Content-Type", "application/json") + if account != nil { + request.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: account.raw}) + if csrf { + request.Header.Set("X-CSRF-Token", account.session.CSRFToken) + } + } + if origin != "" { + request.Header.Set("Origin", origin) + } + recorder := httptest.NewRecorder() + server.api(recorder, request) + return recorder +} + +func decodePreviewResponse(t *testing.T, recorder *httptest.ResponseRecorder) previewResponse { + t.Helper() + var response previewResponse + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + t.Fatalf("decode preview response %s: %v", recorder.Body.String(), err) + } + return response +} + +func TestJobPreviewNormalizesSharedCreationConfigurationWithoutWrites(t *testing.T) { + t.Parallel() + server, db, admin := newUsersTestServer(t) + server.App.Config.Timezone = "Europe/Amsterdam" + scanner := &previewCountingScanner{} + server.App.Scanner = scanner + payload := validPreviewPayload("preview-equivalence") + payload.Timezone = "" + payload.Targets = []string{"edgewatch-preview.invalid"} + payload.TCP.Ports = "22" + payload.TCP.Engine = "" + payload.TCP.Mode = "" + payload.UDP = &protocolPayload{Ports: "53", ServiceDetection: false} + body := marshalPreviewPayload(t, payload) + // The direct handler test focuses on preview effects; the HTTP gate is + // exercised separately below with persisted sessions for every role. + request := httptest.NewRequest(http.MethodPost, consoleAPIBase+"/jobs/preview", strings.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + before := previewMonitoringRows(t, db) + server.mu.Lock() + historyBefore := len(server.history) + server.mu.Unlock() + recorder := httptest.NewRecorder() + server.previewJob(recorder, request, admin, defaultTenantStore(server)) + if recorder.Code != http.StatusOK { + t.Fatalf("preview = %d: %s", recorder.Code, recorder.Body.String()) + } + preview := decodePreviewResponse(t, recorder) + if preview.Job.Timezone != "Europe/Amsterdam" || preview.Job.TCP == nil || preview.Job.TCP.Engine != config.EngineNaabuNmap || preview.Job.TCP.ProfileID != store.BuiltinNaabuProfileID || preview.Job.TCP.ProfileRevision < 1 || preview.Job.TCP.Ports != config.NaabuFullPortExpression { + t.Fatalf("normalized preview job = %#v", preview.Job) + } + if preview.Job.UDP == nil || preview.Job.UDP.Ports != "53" { + t.Fatalf("optional UDP normalization = %#v", preview.Job.UDP) + } + if preview.Job.Enabled == nil || !*preview.Job.Enabled { + t.Fatalf("preview did not report the created job's enabled default: %#v", preview.Job.Enabled) + } + if preview.Estimate.UnknownDNS != 1 || preview.Estimate.TCPPorts != 65535 || preview.Estimate.UDPPorts != 1 || preview.Estimate.Probes != 65536 || preview.Budget.Exceeded { + t.Fatalf("preview estimate/budget = %+v / %+v", preview.Estimate, preview.Budget) + } + warningCodes := map[string]bool{} + for _, warning := range preview.Warnings { + warningCodes[warning.Code] = true + } + for _, code := range []string{"elapsed_time_unknown", "dns_expansion_unknown", "naabu_enrichment_data_dependent"} { + if !warningCodes[code] { + t.Errorf("preview warnings missing %q: %+v", code, preview.Warnings) + } + } + if len(preview.Warnings) > maxJobPreviewWarnings { + t.Fatalf("preview returned %d warnings; maximum is %d", len(preview.Warnings), maxJobPreviewWarnings) + } + if after := previewMonitoringRows(t, db); !reflect.DeepEqual(before, after) { + t.Fatalf("preview changed monitoring data:\nbefore %+v\nafter %+v", before, after) + } + server.mu.Lock() + historyAfter := len(server.history) + server.mu.Unlock() + if historyAfter != historyBefore { + t.Fatalf("preview published an SSE event: history %d -> %d", historyBefore, historyAfter) + } + if scanner.calls != 0 { + t.Fatalf("preview invoked scanner %d times", scanner.calls) + } + + // Create runs the same preparation and keeps the selected revision. Its + // public job projection differs only by top-level lifecycle metadata. + createPayload := payload + createPayload.Name = "created-equivalence" + createRecorder := httptest.NewRecorder() + createRequest := httptest.NewRequest(http.MethodPost, consoleAPIBase+"/jobs", strings.NewReader(marshalPreviewPayload(t, createPayload))) + createRequest.Header.Set("Content-Type", "application/json") + server.createJob(createRecorder, createRequest, admin, defaultTenantStore(server)) + if createRecorder.Code != http.StatusCreated { + t.Fatalf("create = %d: %s", createRecorder.Code, createRecorder.Body.String()) + } + var created struct { + Job jobPayload `json:"job"` + } + if err := json.Unmarshal(createRecorder.Body.Bytes(), &created); err != nil { + t.Fatal(err) + } + preview.Job.Name = createPayload.Name + preview.Job.Enabled = nil + if !reflect.DeepEqual(preview.Job, created.Job) { + t.Fatalf("preview and create public jobs differ:\npreview %#v\ncreated %#v", preview.Job, created.Job) + } +} + +func TestJobPreviewKeepsExplicitPortNmapAndCreateEquivalent(t *testing.T) { + t.Parallel() + server, _, admin := newUsersTestServer(t) + payload := validPreviewPayload("selected-port-nmap") + payload.TCP.Ports = "22,443" + payload.TCP.Engine = config.EngineNmap + previewRecorder := httptest.NewRecorder() + server.previewJob(previewRecorder, previewHandlerRequest(marshalPreviewPayload(t, payload)), admin, defaultTenantStore(server)) + if previewRecorder.Code != http.StatusOK { + t.Fatalf("preview = %d: %s", previewRecorder.Code, previewRecorder.Body.String()) + } + preview := decodePreviewResponse(t, previewRecorder) + if preview.Job.TCP == nil || preview.Job.TCP.Engine != config.EngineNmap || preview.Job.TCP.Ports != "22,443" || preview.Estimate.TCPPorts != 2 || preview.Estimate.Probes != 2 { + t.Fatalf("selected-port Nmap scope changed: job=%#v estimate=%+v", preview.Job.TCP, preview.Estimate) + } + partialCoverageWarning := false + for _, warning := range preview.Warnings { + partialCoverageWarning = partialCoverageWarning || warning.Code == "tcp_partial_coverage" + } + if !partialCoverageWarning { + t.Fatalf("selected-port Nmap preview lacks partial-coverage warning: %+v", preview.Warnings) + } + + createPayload := payload + createPayload.Name = "selected-port-nmap-created" + createRequest := httptest.NewRequest(http.MethodPost, "/api/v1/jobs", strings.NewReader(marshalPreviewPayload(t, createPayload))) + createRequest.Header.Set("Content-Type", "application/json") + createRecorder := httptest.NewRecorder() + server.createJob(createRecorder, createRequest, admin, defaultTenantStore(server)) + if createRecorder.Code != http.StatusCreated { + t.Fatalf("create = %d: %s", createRecorder.Code, createRecorder.Body.String()) + } + var created struct { + Job jobPayload `json:"job"` + } + if err := json.Unmarshal(createRecorder.Body.Bytes(), &created); err != nil { + t.Fatal(err) + } + preview.Job.Name = createPayload.Name + preview.Job.Enabled = nil + if !reflect.DeepEqual(preview.Job, created.Job) { + t.Fatalf("explicit Nmap preview/create differ:\npreview %#v\ncreated %#v", preview.Job, created.Job) + } +} + +func previewMonitoringRows(t *testing.T, db *store.Store) map[string]int { + t.Helper() + rows := map[string]int{} + for _, table := range []string{ + "jobs", "job_revisions", "job_runtime", "baseline_hosts", "scans", "scan_hosts", "latest_scan_hosts", + "scan_cycles", "scan_cycle_units", "scan_cycle_discovery_checkpoints", "events", "outbox", "security_audit", + } { + var count int + if err := db.DB.QueryRow(`SELECT COUNT(*) FROM ` + table).Scan(&count); err != nil { + t.Fatalf("count %s: %v", table, err) + } + rows[table] = count + } + return rows +} + +func TestJobPreviewUsesTargetExclusionsAndCreateValidation(t *testing.T) { + t.Parallel() + server, _, admin := newUsersTestServer(t) + payload := validPreviewPayload("blocked-by-policy") + payload.Targets = []string{"127.0.0.1"} + if err := server.Store.SetTargetExclusions(config.DefaultTargetExclusions()); err != nil { + t.Fatal(err) + } + body := marshalPreviewPayload(t, payload) + preview := httptest.NewRecorder() + server.previewJob(preview, previewHandlerRequest(body), admin, defaultTenantStore(server)) + create := httptest.NewRecorder() + createRequest := httptest.NewRequest(http.MethodPost, "/api/v1/jobs", strings.NewReader(body)) + createRequest.Header.Set("Content-Type", "application/json") + server.createJob(create, createRequest, admin, defaultTenantStore(server)) + if preview.Code != http.StatusBadRequest || preview.Body.String() != create.Body.String() || !strings.Contains(preview.Body.String(), "excluded") { + t.Fatalf("preview policy result = %d %s; create = %d %s", preview.Code, preview.Body.String(), create.Code, create.Body.String()) + } + + // A nil policy means deployment policy was not installed; an explicit + // empty policy permits every otherwise valid target, matching Store's + // existing create semantics in both cases. + for _, exclusions := range [][]string{nil, {}} { + if err := server.Store.SetTargetExclusions(exclusions); err != nil { + t.Fatal(err) + } + recorder := httptest.NewRecorder() + server.previewJob(recorder, previewHandlerRequest(body), admin, defaultTenantStore(server)) + if recorder.Code != http.StatusOK { + t.Errorf("preview under policy %#v = %d: %s", exclusions, recorder.Code, recorder.Body.String()) + } + } +} + +func TestJobPreviewValidationAndNoInputEcho(t *testing.T) { + t.Parallel() + server, _, admin := newUsersTestServer(t) + cases := []struct { + name string + mutate func(*jobPayload) + field string + }{ + {"target", func(payload *jobPayload) { payload.Targets = []string{"not a valid target"} }, "targets"}, + {"duration", func(payload *jobPayload) { payload.Timeout = "not-a-duration" }, "timeout"}, + {"ports", func(payload *jobPayload) { payload.TCP.Ports = "not-a-port" }, "tcp"}, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + payload := validPreviewPayload("invalid-" + test.name) + test.mutate(&payload) + body := marshalPreviewPayload(t, payload) + recorder := httptest.NewRecorder() + server.previewJob(recorder, previewHandlerRequest(body), admin, defaultTenantStore(server)) + if recorder.Code != http.StatusBadRequest || !strings.Contains(recorder.Body.String(), test.field) { + t.Fatalf("preview = %d %s, want validation error for %s", recorder.Code, recorder.Body.String(), test.field) + } + }) + } + + secret := "credential-that-must-not-echo" + body := marshalPreviewPayload(t, validPreviewPayload("unknown-field")) + body = strings.TrimSuffix(body, "}") + `,"password":"` + secret + `"}` + recorder := httptest.NewRecorder() + server.previewJob(recorder, previewHandlerRequest(body), admin, defaultTenantStore(server)) + if recorder.Code != http.StatusBadRequest || strings.Contains(recorder.Body.String(), secret) || strings.Contains(recorder.Body.String(), "password") { + t.Fatalf("unknown credential-shaped input response = %d %s", recorder.Code, recorder.Body.String()) + } +} + +func TestJobPreviewReportsBudgetOutcomesForBothEnginesAndHardCeiling(t *testing.T) { + t.Parallel() + server, _, admin := newUsersTestServer(t) + server.App.Config.Scheduler.MaxProbeCount = 100 + server.App.Config.Scheduler.MaxNaabuProbeCount = 100 + tests := []struct { + name string + payload jobPayload + wantProbes int64 + wantLimit int64 + wantApprovalFits bool + }{ + { + name: "Nmap budget", + payload: func() jobPayload { + payload := validPreviewPayload("nmap-budget") + payload.TCP.Ports = "1-101" + return payload + }(), + wantProbes: 101, wantLimit: 100, wantApprovalFits: true, + }, + { + name: "Naabu budget", + payload: func() jobPayload { + payload := validPreviewPayload("naabu-budget") + payload.TCP.Engine = "" + payload.TCP.Mode = "" + return payload + }(), + wantProbes: 65535, wantLimit: 100, wantApprovalFits: true, + }, + { + name: "absolute ceiling", + payload: func() jobPayload { + payload := validPreviewPayload("absolute-ceiling") + payload.Targets = []string{"198.51.0.0/15"} + payload.TCP.Ports = "1-1000" + return payload + }(), + wantProbes: 131072000, wantLimit: config.MaxProbeCountLimit, wantApprovalFits: false, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + recorder := httptest.NewRecorder() + body := marshalPreviewPayload(t, test.payload) + server.previewJob(recorder, previewHandlerRequest(body), admin, defaultTenantStore(server)) + if recorder.Code != http.StatusOK { + t.Fatalf("preview = %d: %s", recorder.Code, recorder.Body.String()) + } + response := decodePreviewResponse(t, recorder) + if !response.Budget.Exceeded || response.Budget.EstimatedProbes != test.wantProbes || response.Budget.Limit != test.wantLimit || response.Budget.ApprovalWouldFit != test.wantApprovalFits { + t.Fatalf("budget = %+v, estimate = %+v", response.Budget, response.Estimate) + } + }) + } +} + +func TestJobPreviewBudgetUnavailableIsNotReportedAsFit(t *testing.T) { + t.Parallel() + server, db, admin := newUsersTestServer(t) + if err := db.DB.Close(); err != nil { + t.Fatal(err) + } + if db.ReadDB != nil { + if err := db.ReadDB.Close(); err != nil { + t.Fatal(err) + } + } + payload := validPreviewPayload("unavailable-budget") + recorder := httptest.NewRecorder() + server.previewJob(recorder, previewHandlerRequest(marshalPreviewPayload(t, payload)), admin, defaultTenantStore(server)) + response := decodeAPIError(t, recorder) + if recorder.Code != http.StatusServiceUnavailable || response.Error.Code != "preview_unavailable" || response.Error.Details["reason"] != "scan_budget_unavailable" || strings.Contains(recorder.Body.String(), `"scan_budget"`) { + t.Fatalf("unavailable budget response = %d %s", recorder.Code, recorder.Body.String()) + } +} + +func TestJobPreviewAuthCSRFOriginAndHighCostMatrix(t *testing.T) { + t.Parallel() + server, accounts := newRouteMatrixSessions(t) + payload := validPreviewPayload("auth-matrix") + body := marshalPreviewPayload(t, payload) + for _, account := range accounts { + recorder := previewAPICall(t, server, &account, body, "", true) + want := http.StatusForbidden + if account.role == store.RoleAdministrator || account.role == store.RoleOperator { + want = http.StatusOK + } + if recorder.Code != want { + t.Errorf("%s preview = %d %s, want %d", account.role, recorder.Code, recorder.Body.String(), want) + } + } + if recorder := previewAPICall(t, server, nil, body, "", false); recorder.Code != http.StatusUnauthorized { + t.Errorf("anonymous preview = %d %s, want 401", recorder.Code, recorder.Body.String()) + } + if recorder := previewAPICall(t, server, &accounts[0], body, "http://example.com", false); recorder.Code != http.StatusForbidden || !strings.Contains(recorder.Body.String(), `"code":"csrf"`) { + t.Errorf("missing-CSRF preview = %d %s, want csrf refusal", recorder.Code, recorder.Body.String()) + } + // Authenticated mutations keep the established session+CSRF policy. A + // supplied Origin does not replace CSRF or add a route-specific origin gate. + for _, origin := range []string{"http://example.com", "https://other.example"} { + if recorder := previewAPICall(t, server, &accounts[0], body, origin, true); recorder.Code != http.StatusOK { + t.Errorf("preview with Origin %q = %d %s, want the existing CSRF-authenticated behavior", origin, recorder.Code, recorder.Body.String()) + } + } + + approvedPayload := validPreviewPayload("high-cost-operator") + approved := true + approvedPayload.AllowHighCost = &approved + approvedBody := marshalPreviewPayload(t, approvedPayload) + if recorder := previewAPICall(t, server, &accounts[1], approvedBody, "", true); recorder.Code != http.StatusForbidden || !strings.Contains(recorder.Body.String(), "high_cost_admin_required") { + t.Errorf("operator high-cost preview = %d %s, want administrator restriction", recorder.Code, recorder.Body.String()) + } + approvedPayload.Name = "high-cost-admin" + if recorder := previewAPICall(t, server, &accounts[0], marshalPreviewPayload(t, approvedPayload), "", true); recorder.Code != http.StatusOK { + t.Errorf("administrator high-cost preview = %d %s, want 200", recorder.Code, recorder.Body.String()) + } +} + +func TestJobPreviewHidesCrossUnitProfilesAndDestinations(t *testing.T) { + t.Parallel() + f := newPlatformFixture(t) + account := f.sessions[actorAdminB] + profilePayload := validPreviewPayload("cross-unit-profile") + profilePayload.TCP.ProfileID = f.profileA + profilePayload.TCP.ProfileRevision = 1 + profileForeign := callAPI(t, f.server, account, http.MethodPost, "/jobs/preview", marshalPreviewPayload(t, profilePayload)) + profilePayload.TCP.ProfileID = unknownIsolationIDs.profile + profileUnknown := callAPI(t, f.server, account, http.MethodPost, "/jobs/preview", marshalPreviewPayload(t, profilePayload)) + if profileForeign.Code != http.StatusBadRequest || profileForeign.Code != profileUnknown.Code || profileForeign.Body.String() != profileUnknown.Body.String() { + t.Fatalf("foreign profile = %d %s; unknown profile = %d %s", profileForeign.Code, profileForeign.Body.String(), profileUnknown.Code, profileUnknown.Body.String()) + } + + destinationID := f.destinationA + destinationPayload := validPreviewPayload("cross-unit-destination") + destinationPayload.NotificationDestinations = &[]string{destinationID} + destinationForeign := callAPI(t, f.server, account, http.MethodPost, "/jobs/preview", marshalPreviewPayload(t, destinationPayload)) + destinationPayload.NotificationDestinations = &[]string{unknownIsolationIDs.destination} + destinationUnknown := callAPI(t, f.server, account, http.MethodPost, "/jobs/preview", marshalPreviewPayload(t, destinationPayload)) + if destinationForeign.Code != http.StatusBadRequest || destinationForeign.Code != destinationUnknown.Code || destinationForeign.Body.String() != destinationUnknown.Body.String() { + t.Fatalf("foreign destination = %d %s; unknown destination = %d %s", destinationForeign.Code, destinationForeign.Body.String(), destinationUnknown.Code, destinationUnknown.Body.String()) + } + + ownPayload := validPreviewPayload("own-resources") + ownPayload.TCP.ProfileID = f.profileB + ownPayload.TCP.ProfileRevision = 1 + ownPayload.NotificationDestinations = &[]string{f.destinationB} + own := callAPI(t, f.server, account, http.MethodPost, "/jobs/preview", marshalPreviewPayload(t, ownPayload)) + if own.Code != http.StatusOK || !strings.Contains(own.Body.String(), f.profileB) || !strings.Contains(own.Body.String(), f.destinationB) { + t.Fatalf("tenant B preview with own resources = %d %s", own.Code, own.Body.String()) + } +} + +func TestJobPreviewDisabledUnitIsRefused(t *testing.T) { + t.Parallel() + f := newPlatformFixture(t) + if _, err := f.db.DB.Exec(`UPDATE tenants SET state=? WHERE id=?`, store.TenantStateDisabled, f.unitB); err != nil { + t.Fatal(err) + } + account := f.sessions[actorAdminB] + recorder := callAPI(t, f.server, account, http.MethodPost, "/jobs/preview", marshalPreviewPayload(t, validPreviewPayload("disabled-unit"))) + if recorder.Code != http.StatusForbidden || !strings.Contains(recorder.Body.String(), `"permission":"route"`) { + t.Fatalf("disabled-unit preview = %d %s, want unit-scoped refusal", recorder.Code, recorder.Body.String()) + } +} + +func TestJobPreviewReportsLargeCIDREstimateWithoutExpansion(t *testing.T) { + t.Parallel() + server, _, admin := newUsersTestServer(t) + payload := validPreviewPayload("large-cidr") + payload.Targets = []string{"2001:db8::/32"} + payload.TCP.Ports = "443" + recorder := httptest.NewRecorder() + server.previewJob(recorder, previewHandlerRequest(marshalPreviewPayload(t, payload)), admin, defaultTenantStore(server)) + if recorder.Code != http.StatusOK { + t.Fatalf("large CIDR preview = %d: %s", recorder.Code, recorder.Body.String()) + } + response := decodePreviewResponse(t, recorder) + if response.Estimate.Hosts != int64(^uint64(0)>>1) || response.Estimate.Probes != int64(^uint64(0)>>1) || !response.Budget.Exceeded || response.Budget.Limit != config.MaxProbeCountLimit { + t.Fatalf("large CIDR estimate was not bounded and budgeted: %+v / %+v", response.Estimate, response.Budget) + } +} + +func TestJobPreviewHistoricalAndStaleProfileRevisionRules(t *testing.T) { + t.Parallel() + f := newPlatformFixture(t) + account := f.sessions[actorAdminA] + profile, err := f.a.GetScannerProfile(context.Background(), f.profileA) + if err != nil { + t.Fatal(err) + } + profile.Definition.Description = "updated" + if _, err := f.a.UpdateScannerProfile(context.Background(), f.profileA, profile.Revision, profile.Name, profile.Description, profile.Definition, "fixture"); err != nil { + t.Fatal(err) + } + payload := validPreviewPayload("stale-profile") + payload.TCP.ProfileID = f.profileA + payload.TCP.ProfileRevision = 1 + recorder := callAPI(t, f.server, account, http.MethodPost, "/jobs/preview", marshalPreviewPayload(t, payload)) + if recorder.Code != http.StatusOK { + t.Fatalf("administrator historical profile preview = %d %s; creation permits pinned historical revisions", recorder.Code, recorder.Body.String()) + } + + operator := f.sessions[actorOperatorA] + otherProfilePayload := validPreviewPayload("stale-operator-profile") + otherProfilePayload.TCP.ProfileID = f.profileA + otherProfilePayload.TCP.ProfileRevision = 1 + stale := callAPI(t, f.server, operator, http.MethodPost, "/jobs/preview", marshalPreviewPayload(t, otherProfilePayload)) + if stale.Code != http.StatusConflict || !strings.Contains(stale.Body.String(), "profile_conflict") { + t.Fatalf("operator stale profile preview = %d %s, want profile conflict", stale.Code, stale.Body.String()) + } +} diff --git a/internal/web/permissions.go b/internal/web/permissions.go index 8bd62458..f5946b5b 100644 --- a/internal/web/permissions.go +++ b/internal/web/permissions.go @@ -112,6 +112,8 @@ func requiredPermission(path, method string) string { case http.MethodPost: return auth.PermissionJobsWrite } + case path == "/jobs/preview" && method == http.MethodPost: + return auth.PermissionJobsWrite } if strings.HasPrefix(path, "/jobs/") { return requiredJobPermission(path, method) @@ -554,6 +556,7 @@ var apiRoutes = []apiRoute{ // Jobs. {Method: http.MethodGet, Template: "/jobs", Permission: auth.PermissionJobsRead, Example: "/jobs"}, {Method: http.MethodPost, Template: "/jobs", Permission: auth.PermissionJobsWrite, Mutates: true, Example: "/jobs"}, + {Method: http.MethodPost, Template: "/jobs/preview", Permission: auth.PermissionJobsWrite, Mutates: true, Example: "/jobs/preview"}, {Method: http.MethodGet, Template: "/jobs/schedule-suggestion", Permission: auth.PermissionJobsRead, Example: "/jobs/schedule-suggestion"}, {Method: http.MethodGet, Template: "/jobs/{id}", Permission: auth.PermissionJobsRead, Example: "/jobs/job-1"}, {Method: http.MethodPut, Template: "/jobs/{id}", Permission: auth.PermissionJobsWrite, Mutates: true, Example: "/jobs/job-1"}, diff --git a/internal/web/server.go b/internal/web/server.go index ac57a4d6..016a1cc2 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -628,6 +628,8 @@ func (s *Server) api(w http.ResponseWriter, r *http.Request) { s.listJobs(w, r, ts) case path == "/jobs" && r.Method == http.MethodPost: s.createJob(w, r, session, ts) + case path == "/jobs/preview" && r.Method == http.MethodPost: + s.previewJob(w, r, session, ts) case path == "/jobs/schedule-suggestion" && r.Method == http.MethodGet: s.scheduleSuggestion(w, r, ts) case path == "/scans" && r.Method == http.MethodGet: diff --git a/scripts/coverage-gates.test.mjs b/scripts/coverage-gates.test.mjs index 4e64c832..d43d6807 100644 --- a/scripts/coverage-gates.test.mjs +++ b/scripts/coverage-gates.test.mjs @@ -28,7 +28,9 @@ const frontendReport = (overrides = {}) => { 'src/components/AuditLog.tsx', 'src/components/ErrorNotice.tsx', 'src/components/HostEmptyState.tsx', + 'src/components/MonitorNextActions.tsx', 'src/components/NotificationDestinationConfig.tsx', + 'src/components/NotificationDestinationCreateForm.tsx', 'src/components/navigation.ts', 'src/components/OneTimeLink.tsx', 'src/components/PasswordField.tsx', @@ -38,7 +40,10 @@ const frontendReport = (overrides = {}) => { 'src/components/SurfaceUnitList.tsx', 'src/components/UntrustedProxyBanner.tsx', 'src/format.ts', + 'src/firstScanIntent.ts', 'src/hostSearch.ts', + 'src/job-creation-draft.tsx', + 'src/job-form.ts', 'src/one-time-factor.ts', 'src/main.tsx', 'src/pages/Activity.tsx', @@ -50,6 +55,7 @@ const frontendReport = (overrides = {}) => { 'src/pages/Hosts.tsx', 'src/pages/JobDetail.tsx', 'src/pages/JobEditor.tsx', + 'src/pages/MonitorSetup.tsx', 'src/pages/Notifications.tsx', 'src/pages/platform/common.tsx', 'src/pages/platform/PlatformAdmins.tsx', @@ -128,6 +134,22 @@ test('frontend coverage gates reject low aggregate and missing critical entries' await writeFile(reportPath, JSON.stringify(missingSource)) assert.notEqual(runScript('check-frontend-coverage.mjs', [reportPath]).status, 0) + for (const source of [ + 'src/components/MonitorNextActions.tsx', + 'src/components/NotificationDestinationCreateForm.tsx', + 'src/firstScanIntent.ts', + 'src/job-creation-draft.tsx', + 'src/job-form.ts', + 'src/pages/MonitorSetup.tsx', + ]) { + const missingNewSource = frontendReport() + delete missingNewSource[source] + await writeFile(reportPath, JSON.stringify(missingNewSource)) + const result = runScript('check-frontend-coverage.mjs', [reportPath]) + assert.notEqual(result.status, 0, `${source} passed without a coverage entry`) + assert.match(result.stderr, new RegExp(`${source.replaceAll('.', '\\.')}\\: coverage entry is missing`)) + } + const sourceRegression = frontendReport({ 'src/api.ts': { lines: percentage(100), branches: percentage(59), functions: percentage(100) }, }) diff --git a/src/api.preview.test.ts b/src/api.preview.test.ts new file mode 100644 index 00000000..4ca20f50 --- /dev/null +++ b/src/api.preview.test.ts @@ -0,0 +1,83 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { previewJob, setCSRF } from './api' +import type { JobForm, JobPreview } from './types' + +const draft: JobForm = { + name: 'edge inventory', + schedule: '0 * * * *', + timezone: 'UTC', + targets: ['198.51.100.10'], + max_expanded_hosts: 256, + tcp: { ports: '443', mode: 'connect', service_detection: false, engine: 'nmap' }, + timing: 'balanced', + timeout: '1m', + baseline_samples: 2, + change_confirmations: 2, + notification_destinations: ['destination-1'], +} + +const response: JobPreview = { + job: draft, + scan_estimate: { hosts: 1, tcp_ports: 1, udp_ports: 0, probes: 1, nmap_invocations: 1, unknown_dns: 0 }, + scan_budget: { exceeded: false }, + warnings: [{ code: 'elapsed_time_unknown', message: 'Elapsed time depends on scanner behavior and target responses.' }], +} + +afterEach(() => { + vi.restoreAllMocks() + setCSRF('') +}) + +describe('job preview API', () => { + it('posts the draft with CSRF protection and returns the typed preview', async () => { + setCSRF('csrf-token') + const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => new Response(JSON.stringify(response), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })) + vi.stubGlobal('fetch', fetchMock) + + await expect(previewJob(draft)).resolves.toEqual(response) + + expect(String(fetchMock.mock.calls[0][0])).toBe('/api/v1/jobs/preview') + const init = fetchMock.mock.calls[0][1] as RequestInit + expect(init.method).toBe('POST') + expect(init.credentials).toBe('same-origin') + expect(new Headers(init.headers).get('Content-Type')).toBe('application/json') + expect(new Headers(init.headers).get('X-CSRF-Token')).toBe('csrf-token') + const body = JSON.parse(String(init.body)) as Record + expect(body).toEqual(draft) + expect(JSON.stringify(body)).not.toMatch(/password|notification_url|generic:\/\//i) + }) + + it('forwards an AbortSignal so stale previews can be canceled', async () => { + let observedSignal: AbortSignal | null | undefined + const fetchMock = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => new Promise((_resolve, reject) => { + observedSignal = init?.signal + init?.signal?.addEventListener('abort', () => reject(new DOMException('The operation was aborted.', 'AbortError')), { once: true }) + })) + vi.stubGlobal('fetch', fetchMock) + const controller = new AbortController() + + const pending = previewJob(draft, controller.signal) + controller.abort() + + await expect(pending).rejects.toMatchObject({ name: 'AbortError' }) + expect(observedSignal).toBe(controller.signal) + expect(controller.signal.aborted).toBe(true) + }) + + it.each([ + [400, 'validation_failed', { targets: 'target is invalid' }], + [409, 'profile_conflict', undefined], + [503, 'preview_unavailable', { reason: 'scan_budget_unavailable' }], + ] as const)('preserves structured %s %s errors', async (status, code, details) => { + const fetchMock = vi.fn(async () => new Response(JSON.stringify({ error: { code, message: 'preview failed', details } }), { + status, + headers: { 'Content-Type': 'application/json' }, + })) + vi.stubGlobal('fetch', fetchMock) + + await expect(previewJob(draft)).rejects.toMatchObject({ status, code, details }) + }) +}) diff --git a/src/api.ts b/src/api.ts index a1cac650..b144890b 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,4 +1,4 @@ -import type { ActiveScan, ActivityEvent, BaselineHostsResponse, Change, GlobalHostsResponse, HostDetailResponse, Incident, Job, JobForm, Pagination, PendingChange, QueuedRun, RdapResult, Scan, ScanComparison, ScanSummary, Unit, NaabuOptions } from './types' +import type { ActiveScan, ActivityEvent, BaselineHostsResponse, Change, GlobalHostsResponse, HostDetailResponse, Incident, Job, JobForm, JobPreview, Pagination, PendingChange, QueuedRun, RdapResult, Scan, ScanComparison, ScanSummary, Unit, NaabuOptions } from './types' import { setDisplayTimeZone } from './format' export type NotificationDestination = { @@ -209,6 +209,7 @@ export type ScheduleSuggestion = { export const scheduleSuggestion = (schedule: string, timezone: string) => api(`/jobs/schedule-suggestion?${new URLSearchParams({ schedule, timezone }).toString()}`) export const getJob = (id: string) => api(`/jobs/${id}`) export const createJob = (job: JobForm) => api('/jobs', { method: 'POST', body: JSON.stringify(job) }) +export const previewJob = (draft: JobForm, signal?: AbortSignal) => api('/jobs/preview', { method: 'POST', body: JSON.stringify(draft), signal }) export const updateJob = (id: string, revision: number, job: JobForm, confirm_rebaseline = false) => api(`/jobs/${id}`, { method: 'PUT', body: JSON.stringify({ ...job, revision, confirm_rebaseline }) }) export const archiveJob = (id: string, revision: number) => api(`/jobs/${id}/archive`, { method: 'POST', body: JSON.stringify({ revision }) }) export const restoreJob = (id: string, revision: number) => api(`/jobs/${id}/restore`, { method: 'POST', body: JSON.stringify({ revision }) }) diff --git a/src/components/MonitorNextActions.test.tsx b/src/components/MonitorNextActions.test.tsx new file mode 100644 index 00000000..4e754aef --- /dev/null +++ b/src/components/MonitorNextActions.test.tsx @@ -0,0 +1,161 @@ +/** @vitest-environment jsdom */ + +import { fireEvent, screen } from '@testing-library/react' +import type { ComponentProps } from 'react' +import { describe, expect, it, vi } from 'vitest' +import type { ScheduleSuggestion } from '../api' +import { renderWithProviders } from '../test/test-utils' +import type { Job, ScanSummary } from '../types' +import { MonitorNextActions } from './MonitorNextActions' + +const baseJob: Job = { + id: 'job-1', revision: 4, enabled: true, archived: false, security_hash: 'scope-current', + created_at: '2026-10-01T00:00:00Z', updated_at: '2026-10-01T00:00:00Z', + job: { name: 'Production', schedule: '0 * * * *', timezone: 'Europe/Amsterdam', targets: ['192.0.2.10'], max_expanded_hosts: 32, tcp: { ports: '22,443', mode: 'connect', service_detection: false }, timing: 'balanced', timeout: '1h', baseline_samples: 2, change_confirmations: 1 }, + baseline: { status: 'learning', samples: 1, attempts: 1 }, + scan_budget: { exceeded: false }, +} + +const schedule: ScheduleSuggestion = { suggested: false, draft_next_run: '2026-10-10T09:00:00+02:00', gap_minutes: 90 } +const incompleteScan: ScanSummary = { id: 'scan-incomplete', job_id: 'job-1', job: 'Production', started_at: '2026-10-09T08:00:00Z', finished_at: '2026-10-09T08:01:00Z', status: 'incomplete', error: 'target did not respond', config_hash: 'scope-current' } + +function renderActions(overrides: Partial> = {}) { + const onRun = vi.fn() + const props: ComponentProps = { + job: baseJob, + canRun: true, + canReadScans: true, + canReadBaseline: true, + canReadIncidents: true, + liveStatusRequested: true, + liveStatusReady: true, + liveStatusLoading: false, + liveStatusError: false, + pendingRun: false, + cycleKnown: true, + cycle: null, + scansLoading: false, + scansError: false, + baseline: { snapshot: { units: [] }, pagination: { limit: 10, offset: 0, total: 0, has_more: false, next_offset: null } }, + baselineLoading: false, + baselineError: false, + schedule, + scheduleLoading: false, + scheduleError: false, + runBusy: false, + onRun, + onRetryScans: vi.fn(), + onRetrySchedule: vi.fn(), + ...overrides, + } + const view = renderWithProviders(, { route: ['/jobs/job-1'] }) + return { + ...view, + onRun, + rerenderActions: (next: Partial>) => view.rerender(), + } +} + +describe('monitor next actions', () => { + it('shows persisted sample progress, the next scheduled run, and an explicit run action', () => { + const { onRun } = renderActions() + + expect(screen.getByText('1 of 2 successful samples collected.')).toBeInTheDocument() + expect(screen.getByText(/Next scheduled sample:/)).toHaveTextContent('Europe/Amsterdam') + fireEvent.click(screen.getByRole('button', { name: 'Run another sample' })) + expect(onRun).toHaveBeenCalledTimes(1) + }) + + it('explains a paused schedule and keeps manual sampling explicit', () => { + renderActions({ job: { ...baseJob, enabled: false } }) + + expect(screen.getByText(/The schedule is paused, so no automatic sample is planned/)).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Run another sample' })).toBeEnabled() + expect(screen.queryByText(/Next scheduled sample:/)).not.toBeInTheDocument() + }) + + it('does not offer another sample while a job scan is active or queued', () => { + const active = { id: 'active-1', job_id: 'job-1', job: 'Production', started_at: '2026-10-09T08:00:00Z', progress_percent: 20 } as const + const queued = { job_id: 'job-1', job: 'Production', queued_at: '2026-10-09T08:00:00Z', trigger: 'manual' } as const + + const activeView = renderActions({ activeScan: active }) + expect(screen.getByText(/A scan is running/)).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Run another sample' })).not.toBeInTheDocument() + activeView.rerenderActions({ activeScan: undefined, queuedRun: queued }) + expect(screen.getByText(/waiting for an available unit scan slot/)).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Run another sample' })).not.toBeInTheDocument() + }) + + it('keeps the run action hidden when live state or the probe budget is unknown', () => { + const statusError = renderActions({ liveStatusReady: false, liveStatusError: true }) + expect(screen.getByText(/Current scan status could not be confirmed/)).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Run another sample' })).not.toBeInTheDocument() + statusError.rerenderActions({ job: { ...baseJob, scan_budget: undefined }, liveStatusReady: true, liveStatusError: false }) + expect(screen.getByText(/probe budget could not be confirmed/)).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Run another sample' })).not.toBeInTheDocument() + }) + + it('reports the latest incomplete sample and links evidence and correction only to permitted roles', () => { + const job = { ...baseJob, baseline: { status: 'stalled', samples: 1, attempts: 3, incomplete_attempts: 2 } } + renderActions({ job, scans: [incompleteScan] }) + + expect(screen.getByText(/2 incomplete observations did not count as samples; 1 of 2 successful samples are collected/)).toBeInTheDocument() + expect(screen.getByText(/The latest scan was incomplete/)).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Review scan evidence →' })).toHaveAttribute('href', '/jobs/job-1/scans/scan-incomplete') + expect(screen.getByRole('link', { name: 'Review targets and scanner profile →' })).toHaveAttribute('href', '/jobs/job-1/edit') + expect(screen.getByRole('button', { name: 'Retry baseline scan' })).toBeInTheDocument() + }) + + it('does not call an older failure the latest attempt after a newer success', () => { + const newerSuccess: ScanSummary = { ...incompleteScan, id: 'scan-new-success', status: 'success', finished_at: '2026-10-09T09:00:00Z' } + renderActions({ scans: [newerSuccess, incompleteScan] }) + + expect(screen.queryByText(/The latest scan was incomplete/)).not.toBeInTheDocument() + expect(screen.queryByRole('link', { name: 'Review scan evidence →' })).not.toBeInTheDocument() + }) + + it('treats zero positive ports as a valid active baseline and gates evidence links by permission', () => { + const job = { ...baseJob, baseline: { status: 'complete', samples: 2, host_count: 0 } } + const baseline = { snapshot: { units: [{ target: '192.0.2.10', protocol: 'tcp', ports: [] }] }, pagination: { limit: 10, offset: 0, total: 1, has_more: false, next_offset: null } } + const ready = renderActions({ job, baseline, latestSuccessfulScan: { ...incompleteScan, status: 'success', id: 'scan-ready' } }) + + expect(screen.getByText(/valid complete baseline with zero positive ports/)).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'View baseline evidence →' })).toHaveAttribute('href', '/jobs/job-1/baseline') + expect(screen.getByRole('link', { name: 'Open latest scan →' })).toHaveAttribute('href', '/jobs/job-1/scans/scan-ready') + expect(screen.getByRole('link', { name: 'View scan activity →' })).toHaveAttribute('href', '/activity?job_id=job-1') + expect(screen.getByRole('link', { name: 'View incidents →' })).toHaveAttribute('href', '/incidents') + + ready.rerenderActions({ job, baseline, canReadBaseline: false, canReadScans: false, canReadIncidents: false }) + expect(screen.queryByRole('link', { name: 'View baseline evidence →' })).not.toBeInTheDocument() + expect(screen.queryByRole('link', { name: 'View scan activity →' })).not.toBeInTheDocument() + expect(screen.queryByRole('link', { name: 'View incidents →' })).not.toBeInTheDocument() + }) + + it('does not turn baseline evidence read errors into a zero-port result', () => { + renderActions({ + job: { ...baseJob, baseline: { status: 'complete', samples: 2 } }, + baseline: undefined, + baselineError: true, + }) + + expect(screen.queryByText(/zero positive ports/)).not.toBeInTheDocument() + }) + + it('does not call a paginated empty page a zero-port baseline', () => { + const job = { ...baseJob, baseline: { status: 'complete', samples: 2, host_count: 0 } } + renderActions({ + job, + baseline: { snapshot: { units: [] }, pagination: { limit: 10, offset: 0, total: 12, has_more: true, next_offset: 10 } }, + }) + + expect(screen.queryByText(/zero positive ports/)).not.toBeInTheDocument() + }) + + it('does not show a live-status error when the role cannot request live scan state', () => { + renderActions({ liveStatusRequested: false, liveStatusReady: false, liveStatusError: false }) + + expect(screen.queryByText(/Current scan status/)).not.toBeInTheDocument() + expect(screen.queryByText(/Checking for an active or queued scan/)).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Run another sample' })).not.toBeInTheDocument() + }) +}) diff --git a/src/components/MonitorNextActions.tsx b/src/components/MonitorNextActions.tsx new file mode 100644 index 00000000..f7ae33c2 --- /dev/null +++ b/src/components/MonitorNextActions.tsx @@ -0,0 +1,229 @@ +import { Link } from 'react-router-dom' +import type { ScheduleSuggestion } from '../api' +import { baselinePresentation } from '../baseline' +import { ErrorNotice } from './ErrorNotice' +import type { ActiveScan, Job, Pagination, QueuedRun, ScanCycle, ScanSummary, Unit } from '../types' + +type MonitorNextActionsProps = { + job: Job + canRun: boolean + canReadScans: boolean + canReadBaseline: boolean + canReadIncidents: boolean + liveStatusRequested: boolean + liveStatusReady: boolean + liveStatusLoading: boolean + liveStatusError: boolean + activeScan?: ActiveScan + queuedRun?: QueuedRun + pendingRun: boolean + cycleKnown: boolean + cycle?: ScanCycle | null + scans?: ScanSummary[] + scansLoading: boolean + scansError: boolean + baseline?: { snapshot: { units: Unit[] } | null; pagination: Pagination } + baselineLoading: boolean + baselineError: boolean + latestSuccessfulScan?: ScanSummary + schedule?: ScheduleSuggestion + scheduleLoading: boolean + scheduleError: boolean + runBusy: boolean + onRun: () => void + onRetryScans: () => void + onRetrySchedule: () => void +} + +/** A durable next step reconstructed from the saved job and scan records. */ +export function MonitorNextActions({ + job, + canRun, + canReadScans, + canReadBaseline, + canReadIncidents, + liveStatusRequested, + liveStatusReady, + liveStatusLoading, + liveStatusError, + activeScan, + queuedRun, + pendingRun, + cycleKnown, + cycle, + scans, + scansLoading, + scansError, + baseline, + baselineLoading, + baselineError, + latestSuccessfulScan, + schedule, + scheduleLoading, + scheduleError, + runBusy, + onRun, + onRetryScans, + onRetrySchedule, +}: MonitorNextActionsProps) { + const presentation = baselinePresentation(job.baseline) + const requiredSamples = Math.max(0, job.job.baseline_samples ?? 0) + const collectedSamples = Math.max(0, job.baseline.samples ?? 0) + const samplesRemaining = Math.max(0, requiredSamples - collectedSamples) + const liveWorkExists = Boolean(activeScan || queuedRun || pendingRun) + const cycleExists = cycleKnown && Boolean(cycle) + const canOfferRun = canRun + && canReadScans + && !job.archived + && job.scan_budget?.exceeded === false + && !liveWorkExists + && liveStatusReady + && cycleKnown + && (cycleExists || (presentation.status !== 'complete' && samplesRemaining > 0)) + const nextActionLabel = cycleExists + ? 'Resume saved scan' + : job.baseline.status === 'stalled' + ? 'Retry baseline scan' + : collectedSamples === 0 ? 'Run first sample' : 'Run another sample' + const latestAttempt = scans?.[0] + const latestFailedAttempt = latestAttempt && latestAttempt.status !== 'success' ? latestAttempt : undefined + const zeroPositivePorts = canReadBaseline + && !baselineLoading + && !baselineError + && baseline?.snapshot !== undefined + && baseline.snapshot !== null + && baseline.pagination.offset === 0 + && baseline.pagination.total <= baseline.snapshot.units.length + && !baseline.pagination.has_more + && !hasPositivePorts(baseline.snapshot.units) + + return ( +
+
+
+

Next steps

+

Progress is based on this job’s saved scan and baseline state.

+
+ {presentation.label} +
+ + {presentation.status === 'complete' ? ( +
+ +
+ {job.baseline.status === 'updating' ? 'The existing baseline is active while its scope updates.' : 'The baseline is active for the configured coverage.'} + EdgeWatch compares the configured targets and selected TCP/UDP ports. This status does not claim coverage outside that scope. + {job.baseline.status === 'updating' && The stored scope is re-keyed by the next finalized scan or job save.} + {zeroPositivePorts && This is a valid complete baseline with zero positive ports in the configured coverage.} +
+
+ ) : job.baseline.status === 'stalled' ? ( +
+ +
+ Baseline learning is stalled. + {job.baseline.incomplete_attempts ?? 0} incomplete observations did not count as samples; {collectedSamples} of {requiredSamples} successful samples are collected. Review the scan evidence and correct target reachability or the saved scanner profile before retrying. +
+
+ ) : ( +
+ +
+ {collectedSamples} of {requiredSamples} successful samples collected. + Only complete successful observations count toward baseline learning. Failed, canceled, timed-out, or incomplete scans do not count. +
+
+ )} + + {liveStatusRequested && liveStatusError ? ( +

Current scan status could not be confirmed. {liveWorkExists ? 'The last known scan state may be out of date.' : ''} Another sample is unavailable until status can be checked again.

+ ) : liveStatusRequested && liveWorkExists ? ( +

+ {activeScan + ? 'A scan is running. Its result will update baseline progress when it finishes.' + : queuedRun + ? 'A scan is waiting for an available unit scan slot. You can cancel it while it is queued.' + : 'The scan request was accepted. EdgeWatch is checking whether it queued, started, or completed.'} +

+ ) : liveStatusRequested && liveStatusLoading ? ( +

Checking for an active or queued scan before offering another sample…

+ ) : liveStatusRequested && !liveStatusReady ? ( +

Current scan status is not ready, so another sample is unavailable until that status loads.

+ ) : null} + + {presentation.status !== 'complete' && requiredSamples > 0 && samplesRemaining === 0 && job.baseline.status !== 'stalled' && !cycleExists && ( +

All required samples are recorded. EdgeWatch is finalizing the baseline state.

+ )} + + {presentation.status !== 'complete' && latestFailedAttempt && ( +
+ {failedAttemptMessage(latestFailedAttempt)} This observation did not add a baseline sample. + {canReadScans && Review scan evidence →} +
+ )} + {presentation.status !== 'complete' && canReadScans && scansLoading &&

Loading recent scan outcomes…

} + {presentation.status !== 'complete' && canReadScans && scansError && } + + {presentation.status !== 'complete' && ( +
+ {!job.enabled + ?

The schedule is paused, so no automatic sample is planned. Resume the schedule or start a sample explicitly.

+ : !job.job.schedule?.trim() + ?

No schedule is configured. Start each baseline sample explicitly.

+ : scheduleLoading + ?

Checking the next scheduled sample…

+ : scheduleError + ? + : schedule?.draft_next_run + ?

Next scheduled sample: {formatNextRun(schedule.draft_next_run, job.job.timezone)} ({job.job.timezone}).

+ :

The next scheduled sample time is unavailable.

} + {canRun && liveStatusReady && !liveWorkExists && cycleKnown && job.scan_budget?.exceeded === true && ( +

This job currently exceeds its unit probe budget. Reduce its scope or ask an administrator to approve a high-cost scan before starting or resuming a sample.

+ )} + {canRun && liveStatusReady && !liveWorkExists && cycleKnown && job.scan_budget === undefined && ( +

The unit probe budget could not be confirmed, so a sample cannot start or resume until the job’s budget status loads.

+ )} + {canOfferRun &&

If all unit scan slots are busy, the request waits in the queue and can be canceled before it starts.

} + {canOfferRun && } + {job.baseline.status === 'stalled' && canReadScans && !latestFailedAttempt && !scansLoading && !scansError && Review recent scan evidence →} + {job.baseline.status === 'stalled' && canRun && !job.archived && Review targets and scanner profile →} +
+ )} + + {presentation.status === 'complete' && ( +
+ {canReadBaseline && View baseline evidence →} + {canReadScans && latestSuccessfulScan && Open latest scan →} + {canReadScans && View scan activity →} + {canReadIncidents && View incidents →} +
+ )} +
+ ) +} + +function hasPositivePorts(units: Unit[]) { + return units.some((unit) => unit.ports?.some((port) => port.state === 'open' || port.state === 'open|filtered')) +} + +function failedAttemptMessage(scan: ScanSummary) { + switch (scan.status) { + case 'incomplete': return 'The latest scan was incomplete.' + case 'canceled': return 'The latest scan was canceled.' + case 'timed_out': return 'The latest scan timed out.' + default: return 'The latest scan did not complete successfully.' + } +} + +function formatNextRun(value: string, timeZone: string) { + const date = new Date(value) + if (Number.isNaN(date.getTime())) return value + const options: Intl.DateTimeFormatOptions = { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZone } + try { + return date.toLocaleString(undefined, options) + } catch { + // The server has validated the IANA zone. Keep the RFC3339 offset visible + // if this browser's Intl database does not yet recognize a newer zone. + return value + } +} diff --git a/src/components/NotificationDestinationCreateForm.test.tsx b/src/components/NotificationDestinationCreateForm.test.tsx new file mode 100644 index 00000000..fcb7aa3f --- /dev/null +++ b/src/components/NotificationDestinationCreateForm.test.tsx @@ -0,0 +1,116 @@ +/** @vitest-environment jsdom */ + +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { APIError, type NotificationDestination } from '../api' +import { renderWithProviders } from '../test/test-utils' +import { NotificationDestinationCreateForm } from './NotificationDestinationCreateForm' + +const savedDestination: NotificationDestination = { + id: 'destination-created', + name: 'Operations', + provider: 'smtp', + source: 'web', + enabled: true, + locked: false, + read_only: false, + revision: 1, +} + +function fillForm() { + fireEvent.change(screen.getByLabelText(/^Name/), { target: { value: 'Operations' } }) + fireEvent.change(screen.getByLabelText('SMTP server'), { target: { value: 'smtp.example.test' } }) + fireEvent.change(screen.getByLabelText('From address'), { target: { value: 'edge@example.test' } }) + fireEvent.change(screen.getByLabelText(/^Recipients/), { target: { value: 'ops@example.test' } }) + fireEvent.change(screen.getByLabelText('SMTP username'), { target: { value: 'smtp-user' } }) + fireEvent.change(screen.getByLabelText('SMTP password'), { target: { value: 'smtp-secret-value' } }) + fireEvent.change(screen.getByLabelText(/^Password confirmation/), { target: { value: 'account-password-value' } }) +} + +function submitForm() { + const form = document.querySelector('.notification-destination-create-form') + if (!form) throw new Error('Destination form not found') + fireEvent.submit(form) +} + +afterEach(() => vi.clearAllMocks()) + +describe('NotificationDestinationCreateForm', () => { + it('saves once, clears provider credentials, and uses explicit test delivery wording', async () => { + const create = vi.fn(async () => savedDestination) + const onCreated = vi.fn() + const onTest = vi.fn(async () => ({ sent: 1 })) + const { client } = renderWithProviders() + fillForm() + + fireEvent.click(screen.getByRole('button', { name: /Add destination/ })) + await screen.findByText(/Notification destination added/) + expect(create).toHaveBeenCalledTimes(1) + expect(onCreated).toHaveBeenCalledWith(savedDestination) + expect(screen.getByLabelText(/^Name/)).toHaveValue('') + expect(screen.getByLabelText('SMTP server')).toHaveValue('') + expect(screen.getByLabelText('SMTP password')).toHaveValue('') + expect(screen.getByLabelText(/^Password confirmation/)).toHaveValue('') + expect(JSON.stringify(client.getQueryCache().getAll())).not.toContain('smtp-secret-value') + expect(JSON.stringify(client.getQueryCache().getAll())).not.toContain('account-password-value') + + fireEvent.click(screen.getByRole('button', { name: 'Test destination' })) + await screen.findByText(/Test send completed for Operations\. Check that the message arrived\./) + expect(onTest).toHaveBeenCalledWith(savedDestination) + }) + + it('keeps edits after a failed save and redacts echoed credentials from the error', async () => { + const create = vi.fn(async () => { + throw new APIError('Save failed', 'invalid', { message: 'smtp-secret-value account-password-value' }, 400) + }) + renderWithProviders() + fillForm() + submitForm() + + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent('[redacted] [redacted]') + expect(alert).not.toHaveTextContent('smtp-secret-value') + expect(alert).not.toHaveTextContent('account-password-value') + expect(screen.getByLabelText(/^Name/)).toHaveValue('Operations') + expect(screen.getByLabelText('SMTP password')).toHaveValue('smtp-secret-value') + expect(screen.getByLabelText(/^Password confirmation/)).toHaveValue('account-password-value') + }) + + it('does not retry create when post-save list refresh fails', async () => { + const create = vi.fn(async () => savedDestination) + const onCreated = vi.fn().mockRejectedValueOnce(new Error('temporary list failure')).mockResolvedValue(undefined) + renderWithProviders() + fillForm() + submitForm() + + expect(await screen.findByRole('alert')).toHaveTextContent('The destination was created, but the destination list could not be refreshed or selected. It remains in Notifications.') + expect(screen.getByRole('button', { name: 'Retry list refresh' })).toBeInTheDocument() + expect(screen.getByText(/Notification destination added/)).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Retry list refresh' })) + await waitFor(() => expect(onCreated).toHaveBeenCalledTimes(2)) + expect(create).toHaveBeenCalledTimes(1) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('blocks duplicate submissions synchronously and keeps its secrets out of shared state', async () => { + let resolveCreate!: (value: NotificationDestination) => void + const create = vi.fn(() => new Promise(resolve => { resolveCreate = resolve })) + const { client, unmount } = renderWithProviders() + fillForm() + submitForm() + submitForm() + + expect(create).toHaveBeenCalledTimes(1) + const cacheSnapshot = JSON.stringify(client.getQueryCache().getAll()) + expect(cacheSnapshot).not.toContain('smtp-secret-value') + expect(cacheSnapshot).not.toContain('account-password-value') + unmount() + + resolveCreate(savedDestination) + renderWithProviders( savedDestination)} />) + expect(screen.getByLabelText(/^Name/)).toHaveValue('') + expect(screen.getByLabelText('SMTP password')).toHaveValue('') + expect(screen.getByLabelText(/^Password confirmation/)).toHaveValue('') + }) +}) diff --git a/src/components/NotificationDestinationCreateForm.tsx b/src/components/NotificationDestinationCreateForm.tsx new file mode 100644 index 00000000..b50ef9e9 --- /dev/null +++ b/src/components/NotificationDestinationCreateForm.tsx @@ -0,0 +1,157 @@ +import { useRef, useState, type FormEvent } from 'react' +import { AlertTriangle, Check, Plug, Send } from 'lucide-react' +import { APIError, type NotificationDestination, type NotificationProviderConfig } from '../api' +import { credentialsFromNotificationDraft, initialNotificationConfigDraft, NotificationDestinationConfig, type NotificationConfigDraft } from './NotificationDestinationConfig' + +export type CreateNotificationDestination = (name: string, credentials: string | NotificationProviderConfig, password: string, enabled: boolean) => Promise + +/** + * Small reusable create/test form. Its credential and password state lives + * only in this component and is destroyed when the form is closed. + */ +export function NotificationDestinationCreateForm({ + create, + onCreated, + onTest, + onCancel, + idPrefix = 'new-destination', + createLabel = 'Add destination', +}: { + create: CreateNotificationDestination + onCreated?: (destination: NotificationDestination) => void | Promise + onTest?: (destination: NotificationDestination) => Promise + onCancel?: () => void + idPrefix?: string + createLabel?: string +}) { + const [name, setName] = useState('') + const [configuration, setConfiguration] = useState(initialNotificationConfigDraft) + const [enabled, setEnabled] = useState(true) + const [password, setPassword] = useState('') + const [busy, setBusy] = useState<'create' | 'test' | ''>('') + const [message, setMessage] = useState('') + const [error, setError] = useState('') + const [refreshError, setRefreshError] = useState('') + const [created, setCreated] = useState(null) + const operationLock = useRef(false) + + function clearCredentials() { + setConfiguration(initialNotificationConfigDraft()) + setPassword('') + } + + function cancel() { + setName('') + clearCredentials() + setEnabled(true) + setCreated(null) + setMessage('') + setError('') + setRefreshError('') + onCancel?.() + } + + async function submit(event: FormEvent) { + event.preventDefault() + setMessage('') + setError('') + const credentials = credentialsFromNotificationDraft(configuration) + if (operationLock.current) return + if (!name.trim() || !credentials || !password) { + setError('Name, provider details, and password confirmation are required.') + return + } + operationLock.current = true + setBusy('create') + try { + const providerInput = 'url' in credentials ? credentials.url : credentials.config + const destination = await create(name.trim(), providerInput, password, enabled) + setName('') + clearCredentials() + setEnabled(true) + setCreated(destination) + setMessage('Notification destination added. Credentials are stored encrypted and cannot be read back.') + setRefreshError('') + try { + await onCreated?.(destination) + } catch { + setRefreshError('The destination was created, but the destination list could not be refreshed or selected. It remains in Notifications.') + } + } catch (err) { + setError(destinationErrorText(err, 'Could not add notification destination.', secretValues(configuration, password))) + } finally { + operationLock.current = false + setBusy('') + } + } + + async function retryRefresh() { + if (!created || operationLock.current) return + operationLock.current = true + setBusy('create') + try { + await onCreated?.(created) + setRefreshError('') + } catch { + setRefreshError('The destination was created, but the destination list could not be refreshed or selected. It remains in Notifications.') + } finally { + operationLock.current = false + setBusy('') + } + } + + async function testCreated() { + if (!created || !onTest || operationLock.current) return + operationLock.current = true + setBusy('test') + setError('') + setMessage('') + try { + await onTest(created) + setMessage(`Test send completed for ${created.name}. Check that the message arrived.`) + } catch (err) { + setError(destinationErrorText(err, 'Notification test failed.', [])) + } finally { + operationLock.current = false + setBusy('') + } + } + + return
+ + +
+ + +
+
+ + {onTest && created && } + {onCancel && } +
+ {message &&
{message}
} + {error &&
{error}
} + {refreshError &&
{refreshError}
} + +} + +function secretValues(draft: NotificationConfigDraft, password: string) { + const sensitiveField = /password|token|url|webhook|secret|key|username/i + const providerSecrets = Object.entries(draft.fields) + .filter(([field]) => sensitiveField.test(field)) + .map(([, value]) => value) + return [...providerSecrets, password].filter(value => value.length > 0).sort((left, right) => right.length - left.length) +} + +function destinationErrorText(err: unknown, fallback: string, secrets: string[]) { + let message = fallback + if (err instanceof APIError && err.details) { + const details = Object.values(err.details).filter(value => typeof value === 'string') + if (details.length > 0) message = details.join(' ') + else message = err.message + } else if (err instanceof Error) { + message = err.message + } + for (const secret of secrets) message = message.replaceAll(secret, '[redacted]') + return message +} diff --git a/src/firstScanIntent.test.ts b/src/firstScanIntent.test.ts new file mode 100644 index 00000000..137e43ad --- /dev/null +++ b/src/firstScanIntent.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { consumeFirstScanIntent, issueFirstScanIntent } from './firstScanIntent' + +describe('first scan intent', () => { + it('is job-bound and consumed exactly once', () => { + const token = issueFirstScanIntent('job-a') + + expect(consumeFirstScanIntent('job-b', token)).toBe(false) + expect(consumeFirstScanIntent('job-a', token)).toBe(true) + expect(consumeFirstScanIntent('job-a', token)).toBe(false) + }) + + it('keeps separate jobs from consuming each other’s start action', () => { + const first = issueFirstScanIntent('job-a') + const second = issueFirstScanIntent('job-b') + + expect(first).not.toBe(second) + expect(consumeFirstScanIntent('job-a', second)).toBe(false) + expect(consumeFirstScanIntent('job-b', second)).toBe(true) + expect(consumeFirstScanIntent('job-a', first)).toBe(true) + }) + + it('rejects a missing or unknown history marker', () => { + expect(consumeFirstScanIntent('job-a', undefined)).toBe(false) + expect(consumeFirstScanIntent('job-a', 'old-history-token')).toBe(false) + }) +}) diff --git a/src/firstScanIntent.ts b/src/firstScanIntent.ts new file mode 100644 index 00000000..69068641 --- /dev/null +++ b/src/firstScanIntent.ts @@ -0,0 +1,25 @@ +// Creation and the first run are separate requests. This in-memory token +// transfers the user's explicit start action to JobDetail without making it +// repeatable from a browser history entry after a reload or remount. +const pendingFirstScanIntents = new Map() +let nextIntentID = 0 + +/** Issue a one-time first-scan intent for the newly created job. */ +export function issueFirstScanIntent(jobID: string): string { + nextIntentID += 1 + const entropy = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : Math.random().toString(36).slice(2) + const token = `${nextIntentID.toString(36)}-${entropy}` + pendingFirstScanIntents.set(token, jobID) + return token +} + +/** Validate and consume the intent before any request that can start a scan. */ +export function consumeFirstScanIntent(jobID: string, token: unknown): boolean { + if (typeof token !== 'string' || token.length === 0) return false + const intendedJobID = pendingFirstScanIntents.get(token) + if (intendedJobID !== jobID) return false + pendingFirstScanIntents.delete(token) + return true +} diff --git a/src/job-creation-draft.tsx b/src/job-creation-draft.tsx new file mode 100644 index 00000000..56ecc8a7 --- /dev/null +++ b/src/job-creation-draft.tsx @@ -0,0 +1,40 @@ +import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react' +import { cloneJobCreationDraft, newJobCreationDraft, type JobCreationDraft } from './job-form' + +type CreationDraftContextValue = { + draft: JobCreationDraft + dirty: boolean + createOutcomeUnknown: boolean + updateDraft: (updater: (draft: JobCreationDraft) => JobCreationDraft, markDirty?: boolean) => void + markCreateOutcomeUnknown: () => void + clearDraft: () => void +} + +const CreationDraftContext = createContext(null) + +/** Owns only one in-memory creation journey and is mounted around its routes. */ +export function JobCreationDraftProvider({ children }: { children: ReactNode }) { + const [state, setState] = useState(() => ({ draft: newJobCreationDraft(), dirty: false, createOutcomeUnknown: false })) + const updateDraft = useCallback((updater: (draft: JobCreationDraft) => JobCreationDraft, markDirty = true) => { + setState(current => ({ + draft: cloneJobCreationDraft(updater(cloneJobCreationDraft(current.draft))), + dirty: current.dirty || markDirty, + createOutcomeUnknown: current.createOutcomeUnknown, + })) + }, []) + const markCreateOutcomeUnknown = useCallback(() => setState(current => ({ ...current, createOutcomeUnknown: true })), []) + const clearDraft = useCallback(() => setState({ draft: newJobCreationDraft(), dirty: false, createOutcomeUnknown: false }), []) + const value = useMemo(() => ({ ...state, updateDraft, markCreateOutcomeUnknown, clearDraft }), [state, updateDraft, markCreateOutcomeUnknown, clearDraft]) + return {children} +} + +/** Optional because JobEditor is also used directly by focused legacy tests. */ +export function useOptionalJobCreationDraft() { + return useContext(CreationDraftContext) +} + +export function useJobCreationDraft() { + const value = useOptionalJobCreationDraft() + if (!value) throw new Error('Job creation draft provider is missing.') + return value +} diff --git a/src/job-form.test.ts b/src/job-form.test.ts new file mode 100644 index 00000000..b8779a2a --- /dev/null +++ b/src/job-form.test.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { setDisplayTimeZone } from './format' +import { cloneJobCreationDraft, defaultTCP, jobFormSchema, newJobCreationDraft, payloadFromCreationDraft, toJobFormPayload } from './job-form' + +describe('shared job form defaults and payload conversion', () => { + afterEach(() => setDisplayTimeZone('')) + + it('creates independent default drafts with the current full-range TCP profile and two baseline samples', () => { + setDisplayTimeZone('Europe/Amsterdam') + const first = newJobCreationDraft() + const second = newJobCreationDraft() + + expect(first.fields).toMatchObject({ timezone: 'Europe/Amsterdam', baseline_samples: 2, change_confirmations: 1, allow_high_cost: false }) + expect(first.tcp).toEqual(defaultTCP()) + expect(first.tcp?.profile_revision).toBeUndefined() + expect(first.fields).not.toBe(second.fields) + expect(first.tcp).not.toBe(second.tcp) + + first.fields.name = 'Mutated first draft' + first.tcp!.naabu = { rate: 400 } + first.targets.push('198.51.100.1') + expect(second.fields.name).toBe('') + expect(second.tcp?.naabu).toBeUndefined() + expect(second.targets).toEqual(['']) + }) + + it('keeps an omitted routing selection distinct from an explicit empty selection', () => { + const draft = newJobCreationDraft() + draft.targets = [' 198.51.100.10 '] + expect(payloadFromCreationDraft(draft, false).notification_destinations).toBeUndefined() + draft.notificationSelectionTouched = true + draft.notificationIDs = [] + expect(payloadFromCreationDraft(draft, true).notification_destinations).toEqual([]) + expect(payloadFromCreationDraft(draft, true, []).notification_destinations).toEqual([]) + }) + + it('preserves selected profiles, revisions, protocols, and advanced job fields in payload conversion', () => { + const fields = jobFormSchema.parse({ ...newJobCreationDraft().fields, name: ' Edge ', max_expanded_hosts: 512, allow_high_cost: true }) + const payload = toJobFormPayload(fields, { + targets: [' 198.51.100.10 ', ''], + tcp: { ports: '22,443', mode: 'syn', service_detection: true, engine: 'nmap', profile_id: 'profile-1', profile_revision: 7, nmap_args: ['-sV'] }, + udp: { ports: '53', service_detection: true, engine: 'nmap' }, + scheduleEnabled: false, + notificationIDs: ['notify-1', 'gone'], + notificationSelectionTouched: true, + notificationsLoaded: true, + availableNotificationIDs: ['notify-1'], + }) + + expect(payload).toMatchObject({ + name: 'Edge', + targets: ['198.51.100.10'], + max_expanded_hosts: 512, + allow_high_cost: true, + enabled: false, + tcp: { ports: '22,443', profile_id: 'profile-1', profile_revision: 7, nmap_args: ['-sV'] }, + udp: { ports: '53' }, + notification_destinations: ['notify-1'], + }) + }) + + it('clones nested protocol data when a draft crosses creation modes', () => { + const draft = newJobCreationDraft() + draft.tcp!.naabu = { rate: 2000 } + draft.tcp!.nmap_args = ['-sV'] + const switched = cloneJobCreationDraft(draft) + switched.tcp!.naabu!.rate = 3000 + switched.tcp!.nmap_args!.push('-O') + + expect(draft.tcp?.naabu?.rate).toBe(2000) + expect(draft.tcp?.nmap_args).toEqual(['-sV']) + }) +}) diff --git a/src/job-form.ts b/src/job-form.ts new file mode 100644 index 00000000..e3e231cf --- /dev/null +++ b/src/job-form.ts @@ -0,0 +1,193 @@ +import { z } from 'zod' +import { BUILTIN_NAABU_PROFILE_ID } from './api' +import type { JobForm, Protocol } from './types' +import { getDisplayTimeZone } from './format' + +export const blankJobForm = (): Omit => ({ + name: '', + schedule: '0 */6 * * *', + run_on_start: false, + assume_alive: true, + dns_comparison_mode: 'address_sensitive', + targets: [''], + max_expanded_hosts: 256, + timing: 'balanced', + timeout: '1h', + resume_window: '8d', + baseline_samples: 2, + change_confirmations: 1, + allow_high_cost: false, + enabled: true, +}) + +// Resolve the configured display timezone only after the signed-in session +// has supplied it; fall back to the browser and then UTC. +export function newJobDefaults(): JobForm { + return { + ...blankJobForm(), + timezone: getDisplayTimeZone() || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC', + } +} + +export const defaultTCP = (): Protocol => ({ + ports: '1-65535', + mode: 'connect', + service_detection: false, + engine: 'naabu_nmap', + profile_id: BUILTIN_NAABU_PROFILE_ID, +}) + +export const defaultUDP = (): Protocol => ({ + ports: '53', + service_detection: true, + engine: 'nmap', +}) + +export const jobFormSchema = z.object({ + name: z.string().trim().min(1, 'A name is required.').refine((value) => Array.from(value).length <= 200, 'Use at most 200 characters.').refine((value) => !/\p{Cc}/u.test(value), 'Remove control characters such as tabs or line breaks.'), // Mirrors the server's job-name rule (config.MaxJobNameRunes). + schedule: z.string().trim().min(1, 'A cron schedule is required.'), + timezone: z.string().trim().min(1, 'A timezone is required.'), + run_on_start: z.boolean().optional(), + assume_alive: z.boolean().optional(), + dns_comparison_mode: z.enum(['address_sensitive', 'aggregate']), + max_expanded_hosts: z.number().int('Use a whole number of hosts.').min(1, 'Use at least one host.').max(1_000_000, 'The expansion limit is too high.'), + timing: z.string().refine((value) => ['conservative', 'balanced', 'fast'].includes(value), 'Choose a valid timing profile.'), + timeout: z.string().trim().min(1, 'A scan timeout is required.'), + resume_window: z.string().trim().min(1, 'A resume window is required.'), + baseline_samples: z.number().int('Use a whole number of samples.').min(1, 'Use at least one baseline sample.').max(100, 'Use no more than 100 baseline samples.'), + change_confirmations: z.number().int('Use a whole number of confirmations.').min(1, 'Use at least one confirmation.').max(100, 'Use no more than 100 confirmations.'), + allow_high_cost: z.boolean().optional(), + enabled: z.boolean().optional(), +}) + +export type JobFormFields = z.infer + +/** + * The complete non-secret creation draft shared by guided and advanced modes. + * Optional notification routing intentionally distinguishes an omitted + * selection from an explicitly empty selection. + */ +export type JobCreationDraft = { + fields: JobFormFields + targets: string[] + tcp?: Protocol + udp?: Protocol + lastTCP?: Protocol + lastUDP?: Protocol + tcpFullSnapshot?: Protocol + tcpSelectedSnapshot?: Protocol + notificationIDs: string[] + notificationSelectionTouched: boolean + scheduleEnabled: boolean +} + +export function newJobCreationDraft(): JobCreationDraft { + const defaults = newJobDefaults() + return { + fields: { + name: defaults.name, + schedule: defaults.schedule, + timezone: defaults.timezone, + run_on_start: defaults.run_on_start, + assume_alive: defaults.assume_alive, + dns_comparison_mode: defaults.dns_comparison_mode ?? 'address_sensitive', + max_expanded_hosts: defaults.max_expanded_hosts, + timing: defaults.timing, + timeout: defaults.timeout, + resume_window: defaults.resume_window ?? '8d', + baseline_samples: defaults.baseline_samples, + change_confirmations: defaults.change_confirmations, + allow_high_cost: defaults.allow_high_cost, + enabled: defaults.enabled, + }, + targets: [...defaults.targets], + tcp: defaultTCP(), + udp: undefined, + lastTCP: undefined, + lastUDP: undefined, + tcpFullSnapshot: defaultTCP(), + tcpSelectedSnapshot: undefined, + notificationIDs: [], + notificationSelectionTouched: false, + scheduleEnabled: defaults.enabled ?? true, + } +} + +export function cloneJobCreationDraft(draft: JobCreationDraft): JobCreationDraft { + return { + fields: { ...draft.fields }, + targets: [...draft.targets], + tcp: cloneProtocol(draft.tcp), + udp: cloneProtocol(draft.udp), + lastTCP: cloneProtocol(draft.lastTCP), + lastUDP: cloneProtocol(draft.lastUDP), + tcpFullSnapshot: cloneProtocol(draft.tcpFullSnapshot), + tcpSelectedSnapshot: cloneProtocol(draft.tcpSelectedSnapshot), + notificationIDs: [...draft.notificationIDs], + notificationSelectionTouched: draft.notificationSelectionTouched, + scheduleEnabled: draft.scheduleEnabled, + } +} + +export function cloneProtocol(protocol: Protocol | undefined): Protocol | undefined { + if (!protocol) return undefined + return { + ...protocol, + naabu: protocol.naabu ? { ...protocol.naabu } : undefined, + naabu_args: protocol.naabu_args ? [...protocol.naabu_args] : undefined, + nmap_args: protocol.nmap_args ? [...protocol.nmap_args] : undefined, + enrichment_args: protocol.enrichment_args ? [...protocol.enrichment_args] : undefined, + nse_args: protocol.nse_args ? { ...protocol.nse_args } : undefined, + } +} + +/** Convert either creation mode or the saved-job editor to the API shape. */ +export function toJobFormPayload( + fields: JobFormFields, + options: { + targets: string[] + tcp?: Protocol + udp?: Protocol + scheduleEnabled: boolean + notificationIDs: string[] + notificationSelectionTouched: boolean + notificationsLoaded: boolean + availableNotificationIDs?: Iterable + }, +): JobForm { + const available = options.availableNotificationIDs ? new Set(options.availableNotificationIDs) : undefined + const notificationDestinations = options.notificationSelectionTouched + ? options.notificationIDs.filter(id => !available || available.has(id)) + : options.notificationsLoaded + ? options.notificationIDs.filter(id => !available || available.has(id)) + : undefined + return { + ...fields, + enabled: options.scheduleEnabled, + targets: options.targets.map(value => value.trim()).filter(Boolean), + tcp: cloneProtocol(options.tcp), + udp: cloneProtocol(options.udp), + notification_destinations: notificationDestinations, + } +} + +export function payloadFromCreationDraft(draft: JobCreationDraft, notificationsLoaded: boolean, availableNotificationIDs?: Iterable): JobForm { + return toJobFormPayload(draft.fields, { + targets: draft.targets, + tcp: draft.tcp, + udp: draft.udp, + scheduleEnabled: draft.scheduleEnabled, + notificationIDs: draft.notificationIDs, + notificationSelectionTouched: draft.notificationSelectionTouched, + notificationsLoaded, + availableNotificationIDs, + }) +} + +export function presetFor(schedule: string) { + return ['0 */6 * * *', '0 * * * *', '0 3 * * *', '0 3 * * 0'].includes(schedule) ? schedule : 'custom' +} + +export function optionalNumber(value: string): number | undefined { + return value.trim() === '' ? undefined : Number(value) +} diff --git a/src/main.tsx b/src/main.tsx index 25fb2b11..4da5538f 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -14,6 +14,8 @@ import { TotpEnrollmentShell } from './pages/TotpEnrollment' import { Dashboard } from './pages/Dashboard' import { Activity as ActivityPage } from './pages/Activity' import { JobEditor } from './pages/JobEditor' +import { MonitorSetup } from './pages/MonitorSetup' +import { JobCreationDraftProvider } from './job-creation-draft' import { JobDetail } from './pages/JobDetail' import { Activate, activationTokenFromLocation, Login, Setup, SignedInActivation, signInReturnPath } from './pages/Auth' import { Security } from './pages/Security' @@ -55,6 +57,7 @@ const queryClient = createQueryClient() export function unitBreadcrumb(pathname: string, links: { to: string; label: string }[]) { const fixedRoutes: Array<{ path: RegExp; label: string }> = [ + { path: /^\/jobs\/new\/advanced\/?$/, label: 'Jobs / New job / Advanced' }, { path: /^\/jobs\/new\/?$/, label: 'Jobs / New job' }, { path: /^\/jobs\/[^/]+\/edit\/?$/, label: 'Jobs / Edit job' }, { path: /^\/jobs\/[^/]+\/scans\/[^/]+\/hosts\/[^/]+\/?$/, label: 'Jobs / Scan host' }, @@ -338,10 +341,20 @@ export function Shell({ displayName, role, permissions, onLogout, unit }: { disp
{displayName.trim().charAt(0).toUpperCase() || 'A'}{versionReleaseURL ? EdgeWatch {version} : <>EdgeWatch {version}}{updateAvailable && update.release_url && }{displayName}{role === 'administrator' ? 'Administrator' : role === 'operator' ? 'Operator' : 'Viewer · read only'}
{open && isMobile &&