From 6b36371362cf35802ef1a7f84b897a6dda202c2e Mon Sep 17 00:00:00 2001
From: crypt0rr <57799908+crypt0rr@users.noreply.github.com>
Date: Thu, 1 Oct 2026 19:01:24 +0200
Subject: [PATCH] test: expand risk-based coverage
---
internal/app/resumable_coverage_extra_test.go | 76 ++++++++++++
.../store/latest_projection_coverage_test.go | 51 ++++++++
internal/store/sse_cursor_test.go | 27 ++++
internal/web/server_coverage_extra_test.go | 117 ++++++++++++++++++
src/pages/Dashboard.test.tsx | 65 ++++++++++
src/pages/HostDetail.test.tsx | 17 +++
src/pages/JobDetail.actions.test.tsx | 76 +++++++++++-
src/pages/JobEditor.behavior.test.tsx | 114 ++++++++++++++++-
src/pages/ScannerProfiles.test.tsx | 56 ++++++++-
9 files changed, 594 insertions(+), 5 deletions(-)
diff --git a/internal/app/resumable_coverage_extra_test.go b/internal/app/resumable_coverage_extra_test.go
index ddca9311..6bcd7398 100644
--- a/internal/app/resumable_coverage_extra_test.go
+++ b/internal/app/resumable_coverage_extra_test.go
@@ -26,6 +26,32 @@ type coverageResumableScanner struct {
clearLeases bool
}
+type lateSuccessScanner struct {
+ cancel context.CancelFunc
+}
+
+func (s lateSuccessScanner) Version(context.Context) string { return "late-success" }
+func (s lateSuccessScanner) Scan(context.Context, config.Job) (model.Snapshot, error) {
+ return model.Snapshot{}, errors.New("ordinary scan path is not expected")
+}
+func (s lateSuccessScanner) Plan(context.Context, config.Job) (scanner.WorkPlan, error) {
+ return scanner.WorkPlan{Units: []scanner.WorkUnit{
+ {Sequence: 0, Protocol: "tcp", Addresses: []string{"192.0.2.1"}, Ports: "1", PortCount: 1, Probes: 1},
+ {Sequence: 1, Protocol: "tcp", Addresses: []string{"192.0.2.1"}, Ports: "2", PortCount: 1, Probes: 1},
+ }}, nil
+}
+func (s lateSuccessScanner) ScanWorkUnit(_ context.Context, _ config.Job, unit scanner.WorkUnit, _ scanner.ProgressReporter) (model.Snapshot, error) {
+ if s.cancel != nil {
+ s.cancel()
+ return model.Snapshot{Units: []model.Unit{{Target: "192.0.2.1", Protocol: "tcp", Addresses: unit.Addresses, Ports: []model.PortState{{Port: 999, State: "open"}}}}}, nil
+ }
+ port := 443
+ if unit.Sequence == 1 {
+ port = 80
+ }
+ return model.Snapshot{Units: []model.Unit{{Target: "192.0.2.1", Protocol: "tcp", Addresses: unit.Addresses, Ports: []model.PortState{{Port: port, State: "open"}}}}}, nil
+}
+
type transientResumableScanner struct {
mu sync.Mutex
calls map[int]int
@@ -134,6 +160,56 @@ func TestResumableScanRetriesTransientUnitFailure(t *testing.T) {
}
}
+func TestResumableAttemptDiscardsLateSuccessfulResultAfterCancellation(t *testing.T) {
+ ctx := context.Background()
+ db, err := store.Open(storetest.FreshPath(t))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer db.Close()
+ cfg := &config.Config{Version: 1, Database: db.Path, Retention: config.Duration(24 * time.Hour), Scheduler: config.Scheduler{MaxConcurrent: 1}, Web: config.Web{Listen: "127.0.0.1:8080"}}
+ a, err := New(cfg, db, "missing-nmap", slog.New(slog.NewTextHandler(io.Discard, nil)))
+ if err != nil {
+ t.Fatal(err)
+ }
+ job := config.NormalizeJob(config.Job{Name: "late-success", Schedule: "0 * * * *", Timezone: "UTC", Targets: []string{"192.0.2.1"}, TCP: &config.Protocol{Ports: "1-2", Mode: "connect"}, Timeout: config.Duration(time.Minute), ResumeWindow: config.Duration(time.Hour)})
+ record, err := defaultTenant(db).CreateJob(ctx, job)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ attemptCtx, cancel := context.WithCancel(ctx)
+ var interrupted model.Scan
+ handled, lateFragment, runErr := a.runResumableAttempt(ctx, attemptCtx, defaultTenant(db), job, record.ID, &interrupted, nil, lateSuccessScanner{cancel: cancel}, false)
+ if !handled || !errors.Is(runErr, context.Canceled) || interrupted.Status != "canceled" || !interrupted.Resumable || interrupted.CycleStatus != "paused" {
+ t.Fatalf("late successful return = handled %v scan %#v err %v", handled, interrupted, runErr)
+ }
+ if len(lateFragment.Units) != 1 || lateFragment.Units[0].Ports[0].Port != 999 {
+ t.Fatalf("scanner fixture did not return its late fragment: %#v", lateFragment)
+ }
+ cycle, err := defaultTenant(db).GetActiveScanCycle(ctx, record.ID)
+ if err != nil || cycle.Status != "paused" || cycle.CompletedUnits != 0 {
+ t.Fatalf("cycle after canceled late return = %#v, %v", cycle, err)
+ }
+ summaries, err := defaultTenant(db).ListScanCycleUnitSummaries(ctx, cycle.ID)
+ if err != nil || len(summaries) != 2 || summaries[0].Status != "pending" || summaries[1].Status != "pending" {
+ t.Fatalf("late result was checkpointed: %#v, %v", summaries, err)
+ }
+ _, checkpoints, err := db.System().LoadScanCycleFragments(ctx, cycle.ID)
+ if err != nil || len(checkpoints) != 0 {
+ t.Fatalf("canceled attempt fragments = %#v, %v", checkpoints, err)
+ }
+
+ var resumed model.Scan
+ handled, snapshot, runErr := a.runResumableAttempt(ctx, ctx, defaultTenant(db), job, record.ID, &resumed, nil, lateSuccessScanner{}, false)
+ if !handled || runErr != nil || resumed.Status != "success" || resumed.CycleStatus != "completed" {
+ t.Fatalf("resumed attempt = handled %v scan %#v err %v", handled, resumed, runErr)
+ }
+ if len(snapshot.Units) != 1 || len(snapshot.Units[0].Ports) != 2 || snapshot.Units[0].Ports[0].Port != 80 || snapshot.Units[0].Ports[1].Port != 443 {
+ t.Fatalf("resumed snapshot included the discarded late result: %#v", snapshot)
+ }
+}
+
func TestResumableScanStallsAfterRetryBudget(t *testing.T) {
ctx := context.Background()
db, err := store.Open(storetest.FreshPath(t))
diff --git a/internal/store/latest_projection_coverage_test.go b/internal/store/latest_projection_coverage_test.go
index 8e29f897..c3d64c97 100644
--- a/internal/store/latest_projection_coverage_test.go
+++ b/internal/store/latest_projection_coverage_test.go
@@ -40,3 +40,54 @@ func TestRebuildLatestScanHostsWrapperRecreatesProjection(t *testing.T) {
t.Fatalf("canceled rebuild error = %v, want context.Canceled", err)
}
}
+
+func TestRepairLatestScanHostsCancellationAndRollback(t *testing.T) {
+ ctx := context.Background()
+ s := openTestStore(t)
+
+ canceled, cancel := context.WithCancel(ctx)
+ cancel()
+ if err := s.repairLatestScanHosts(canceled); !errors.Is(err, context.Canceled) {
+ t.Fatalf("canceled repair error = %v, want context.Canceled", err)
+ }
+
+ insertJobRows(t, s, "repair-rollback")
+ address := fixtureHost(0).Address
+ base := time.Date(2026, time.September, 20, 12, 0, 0, 0, time.UTC)
+ for _, scan := range []model.Scan{
+ fixtureScan("repair-old", "repair-rollback", "repair-rollback", base, []model.HostObservation{fixtureHost(0)}),
+ fixtureScan("repair-new", "repair-rollback", "repair-rollback", base.Add(time.Minute), []model.HostObservation{fixtureHost(0)}),
+ } {
+ if err := s.System().SaveScan(ctx, scan); err != nil {
+ t.Fatalf("save %s: %v", scan.ID, err)
+ }
+ }
+ if _, err := s.DB.ExecContext(ctx, `DELETE FROM scans WHERE id='repair-new'`); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.DB.ExecContext(ctx, `CREATE TRIGGER reject_latest_host_repair BEFORE INSERT ON latest_scan_hosts BEGIN SELECT RAISE(ABORT, 'temporary projection failure'); END`); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.repairLatestScanHosts(ctx); err == nil {
+ t.Fatal("repair insert failure was ignored")
+ }
+ var scanID string
+ if err := s.DB.QueryRowContext(ctx, `SELECT scan_id FROM latest_scan_hosts WHERE tenant_id=? AND address=?`, DefaultTenantID, address).Scan(&scanID); err != nil {
+ t.Fatal(err)
+ }
+ if scanID != "repair-new" {
+ t.Fatalf("failed repair partially deleted the projection row: scan_id=%q", scanID)
+ }
+ if _, err := s.DB.ExecContext(ctx, `DROP TRIGGER reject_latest_host_repair`); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.repairLatestScanHosts(ctx); err != nil {
+ t.Fatalf("repair after removing transient failure: %v", err)
+ }
+ if err := s.DB.QueryRowContext(ctx, `SELECT scan_id FROM latest_scan_hosts WHERE tenant_id=? AND address=?`, DefaultTenantID, address).Scan(&scanID); err != nil {
+ t.Fatal(err)
+ }
+ if scanID != "repair-old" {
+ t.Fatalf("repaired projection scan = %q, want retained older scan", scanID)
+ }
+}
diff --git a/internal/store/sse_cursor_test.go b/internal/store/sse_cursor_test.go
index 61056371..30fd49ed 100644
--- a/internal/store/sse_cursor_test.go
+++ b/internal/store/sse_cursor_test.go
@@ -49,3 +49,30 @@ func TestReserveSSEEventIDsSurvivesRestartAndFollowsDurableEvents(t *testing.T)
t.Fatalf("durable SSE cursor = %d, want %d", cursor, nextEnd)
}
}
+
+func TestStoreSSECursorReservesGlobalRanges(t *testing.T) {
+ ctx := context.Background()
+ db := openTestStore(t)
+ cursor := db.SSECursor()
+
+ start, end, err := cursor.Reserve(ctx, 2)
+ if err != nil || start != 1 || end != 2 {
+ t.Fatalf("first cursor range = %d-%d, %v; want 1-2", start, end, err)
+ }
+ start, end, err = cursor.ReserveAfter(ctx, 3, 10)
+ if err != nil || start != 11 || end != 13 {
+ t.Fatalf("range after future marker = %d-%d, %v; want 11-13", start, end, err)
+ }
+
+ maxEventID, err := cursor.MaxEventID(ctx)
+ if err != nil || maxEventID != 0 {
+ t.Fatalf("event high-water mark = %d, %v; want 0 because reservations are not events", maxEventID, err)
+ }
+ var nextID int64
+ if err := db.DB.QueryRowContext(ctx, `SELECT next_id FROM sse_event_cursor WHERE id=1`).Scan(&nextID); err != nil {
+ t.Fatal(err)
+ }
+ if nextID != 13 {
+ t.Fatalf("durable next ID = %d, want 13", nextID)
+ }
+}
diff --git a/internal/web/server_coverage_extra_test.go b/internal/web/server_coverage_extra_test.go
index 33100801..b580340d 100644
--- a/internal/web/server_coverage_extra_test.go
+++ b/internal/web/server_coverage_extra_test.go
@@ -3,8 +3,10 @@ package web
import (
"context"
"errors"
+ "fmt"
"io"
"log/slog"
+ "net"
"net/http"
"net/http/httptest"
"net/url"
@@ -460,3 +462,118 @@ func TestSSECursorReservationRecoversAfterStartupFailure(t *testing.T) {
t.Fatalf("durable cursor=%d is behind recovered event=%d", durableCursor, recoveredID)
}
}
+
+func TestSSEReservationFailureBackoffCapsAndRecoversMonotonically(t *testing.T) {
+ db, err := store.Open(storetest.FreshPath(t))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer db.Close()
+ if _, err := db.DB.ExecContext(context.Background(), `CREATE TRIGGER fail_sse_reservation BEFORE UPDATE ON sse_event_cursor BEGIN SELECT RAISE(ABORT, 'temporary cursor failure'); END`); err != nil {
+ t.Fatal(err)
+ }
+ server := NewServer(nil, db, slog.New(slog.NewTextHandler(io.Discard, nil)))
+ now := time.Date(2026, time.October, 1, 12, 0, 0, 0, time.UTC)
+ server.now = func() time.Time { return now }
+ server.mu.Lock()
+ server.sseDurable = false
+ server.sseRetryAt = now.Add(-time.Second)
+ server.nextEventID = 100
+ server.eventIDLimit = 99
+ server.mu.Unlock()
+
+ server.retrySSEReservationContext(context.Background())
+ server.mu.Lock()
+ if server.sseDurable || server.sseRetryDelay != 2*defaultSSEReservationRetry || !server.sseRetryAt.Equal(now.Add(2*defaultSSEReservationRetry)) || server.nextEventID <= 100 || server.eventIDLimit != ^uint64(0) {
+ got := fmt.Sprintf("durable=%t delay=%s retry=%s next=%d limit=%d", server.sseDurable, server.sseRetryDelay, server.sseRetryAt, server.nextEventID, server.eventIDLimit)
+ server.mu.Unlock()
+ t.Fatalf("first failed reservation state: %s", got)
+ }
+ fallbackID := server.nextEventID
+ server.sseRetryDelay = maxSSEReservationRetry/2 + time.Nanosecond
+ server.mu.Unlock()
+
+ now = now.Add(2 * defaultSSEReservationRetry)
+ server.retrySSEReservationContext(context.Background())
+ server.mu.Lock()
+ if server.sseDurable || server.sseRetryDelay != maxSSEReservationRetry || !server.sseRetryAt.Equal(now.Add(maxSSEReservationRetry)) {
+ got := fmt.Sprintf("durable=%t delay=%s retry=%s", server.sseDurable, server.sseRetryDelay, server.sseRetryAt)
+ server.mu.Unlock()
+ t.Fatalf("capped reservation retry state: %s", got)
+ }
+ server.mu.Unlock()
+
+ if _, err := db.DB.ExecContext(context.Background(), `DROP TRIGGER fail_sse_reservation`); err != nil {
+ t.Fatal(err)
+ }
+ now = now.Add(maxSSEReservationRetry + time.Second)
+ server.retrySSEReservationContext(context.Background())
+ server.mu.Lock()
+ recoveredID, durable, delay, retryAt := server.nextEventID, server.sseDurable, server.sseRetryDelay, server.sseRetryAt
+ server.mu.Unlock()
+ if !durable || recoveredID < fallbackID || delay != 0 || !retryAt.IsZero() {
+ t.Fatalf("recovered cursor state: durable=%t previous=%d recovered=%d delay=%s retry=%s", durable, fallbackID, recoveredID, delay, retryAt)
+ }
+ server.broadcast(map[string]any{"type": "after-reservation-recovery"})
+ server.mu.Lock()
+ monotonicID := server.nextEventID
+ server.mu.Unlock()
+ if monotonicID <= fallbackID {
+ t.Fatalf("post-recovery event ID %d did not advance beyond fallback ID %d", monotonicID, fallbackID)
+ }
+}
+
+func TestListenAndServeEnforcesLoopbackAndJoinsOnShutdown(t *testing.T) {
+ server, _, _ := newUsersTestServer(t)
+ for _, address := range []string{"0.0.0.0:8080", "example.com:8080", "127.0.0.1:0", "not-a-listener"} {
+ if err := server.ListenAndServe(context.Background(), address); err == nil {
+ t.Errorf("ListenAndServe accepted unsafe or invalid address %q", address)
+ }
+ }
+
+ reserved, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := server.ListenAndServe(context.Background(), reserved.Addr().String()); err == nil {
+ t.Fatal("ListenAndServe unexpectedly bound an address already in use")
+ }
+ if err := reserved.Close(); err != nil {
+ t.Fatal(err)
+ }
+
+ probe, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ address := probe.Addr().String()
+ if err := probe.Close(); err != nil {
+ t.Fatal(err)
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan error, 1)
+ go func() { done <- server.ListenAndServe(ctx, address) }()
+
+ deadline := time.Now().Add(2 * time.Second)
+ for {
+ connection, dialErr := net.DialTimeout("tcp", address, 25*time.Millisecond)
+ if dialErr == nil {
+ _ = connection.Close()
+ break
+ }
+ if time.Now().After(deadline) {
+ cancel()
+ t.Fatalf("loopback listener did not start: %v", dialErr)
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+ cancel()
+ select {
+ case err := <-done:
+ if err != nil {
+ t.Fatalf("graceful listener shutdown: %v", err)
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("ListenAndServe did not join after context cancellation")
+ }
+}
diff --git a/src/pages/Dashboard.test.tsx b/src/pages/Dashboard.test.tsx
index 04bfc5df..57ccbb86 100644
--- a/src/pages/Dashboard.test.tsx
+++ b/src/pages/Dashboard.test.tsx
@@ -238,6 +238,43 @@ describe('dashboard', () => {
expect(container.querySelector('button[aria-label="Run demo"]')).toBeNull()
})
+ it('allows operators to run jobs without exposing administrator notification controls', async () => {
+ vi.mocked(getSession).mockResolvedValue({ ...session('operator'), permissions: ['jobs.read', 'jobs.write', 'scans.read'] })
+ await renderDashboard()
+ expect(container.querySelector('button[aria-label="Run demo"]')).toBeTruthy()
+ expect(container.textContent).toContain('Configure job')
+ expect(container.textContent).not.toContain('Test notifications')
+ expect(notificationTest).not.toHaveBeenCalled()
+ })
+
+ it('restores run, cancel, and notification actions after API failures', async () => {
+ vi.mocked(runJob).mockRejectedValueOnce(new Error('worker unavailable'))
+ vi.mocked(cancelScan).mockRejectedValueOnce(new Error('cancel unavailable'))
+ vi.mocked(notificationTest).mockRejectedValueOnce(new Error('delivery unavailable'))
+ await renderDashboard()
+
+ const run = container.querySelector('button[aria-label="Run demo"]') as HTMLButtonElement
+ await act(async () => { run.click(); await Promise.resolve(); await Promise.resolve() })
+ expect(container.querySelector('[role="alert"]')?.textContent).toContain('worker unavailable')
+ expect(run).toBeEnabled()
+ await act(async () => { run.click(); await Promise.resolve(); await Promise.resolve() })
+ expect(runJob).toHaveBeenCalledTimes(2)
+
+ const cancel = Array.from(container.querySelectorAll('button')).find(button => button.textContent?.includes('Cancel scan')) as HTMLButtonElement
+ await act(async () => { cancel.click(); await Promise.resolve(); await Promise.resolve() })
+ expect(container.querySelector('[role="alert"]')?.textContent).toContain('cancel unavailable')
+ expect(cancel).toBeEnabled()
+ await act(async () => { cancel.click(); await Promise.resolve(); await Promise.resolve() })
+ expect(cancelScan).toHaveBeenCalledTimes(2)
+
+ const notify = Array.from(container.querySelectorAll('button')).find(button => button.textContent?.includes('Test notifications')) as HTMLButtonElement
+ await act(async () => { notify.click(); await Promise.resolve(); await Promise.resolve() })
+ expect(container.querySelector('[role="alert"]')?.textContent).toContain('delivery unavailable')
+ await act(async () => { notify.click(); await Promise.resolve(); await Promise.resolve() })
+ expect(container.querySelector('[role="status"]')?.textContent).toContain('1 destination tested')
+ expect(notificationTest).toHaveBeenCalledTimes(2)
+ })
+
it('keeps the page usable when jobs fail to load', async () => {
vi.mocked(listJobs).mockRejectedValue(new Error('database unavailable'))
await act(async () => {
@@ -287,4 +324,32 @@ describe('dashboard', () => {
})
expect(listJobs).toHaveBeenCalledTimes(2)
})
+
+ it('keeps the last live scan count visible as stale and recovers it on retry', async () => {
+ await renderDashboard()
+ vi.mocked(activeScans).mockRejectedValueOnce(new Error('temporary polling failure'))
+ const recentScansMetric = Array.from(container.querySelectorAll('.stat-card')).find(card => card.querySelector('.stat-label')?.textContent === 'Recent scans')
+
+ await act(async () => {
+ await queryClient.refetchQueries({ queryKey: ['active-scans'], exact: true })
+ })
+ await vi.waitFor(() => expect(recentScansMetric?.querySelector('.stat-detail')?.textContent).toContain('1 in progress · stale'))
+
+ const retry = recentScansMetric?.querySelector('.stat-detail .stat-retry') as HTMLButtonElement
+ expect(retry).toBeEnabled()
+ await act(async () => {
+ retry.click()
+ await Promise.resolve()
+ })
+ await vi.waitFor(() => expect(recentScansMetric?.querySelector('.stat-detail')?.textContent).not.toContain('stale'))
+ expect(recentScansMetric?.querySelector('.stat-detail')?.textContent).toContain('1 in progress')
+ })
+
+ it('explains when no notification destination is configured', async () => {
+ vi.mocked(adminStatus).mockResolvedValue({ ...status, notification_destinations: 0 })
+ await renderDashboard()
+
+ expect(container.querySelector('.page-heading')?.textContent).toContain('Notifications are configured by an administrator.')
+ expect(container.querySelector('.page-heading')?.textContent).not.toContain('notification destination configured')
+ })
})
diff --git a/src/pages/HostDetail.test.tsx b/src/pages/HostDetail.test.tsx
index 31023c57..957dee23 100644
--- a/src/pages/HostDetail.test.tsx
+++ b/src/pages/HostDetail.test.tsx
@@ -144,6 +144,23 @@ describe('host detail', () => {
expect(container.querySelector('a[href="/hosts"]')).toBeTruthy()
})
+ it('keeps job-scoped historical evidence visible when RDAP is unavailable', async () => {
+ vi.mocked(scanHostRDAP).mockRejectedValue(new Error('registry timeout'))
+ await renderRoute('/jobs/job-1/scans/scan-1/hosts/198.51.100.10', '/jobs/:id/scans/:scanId/hosts/:address')
+ expect(scanHost).toHaveBeenCalledWith('job-1', 'scan-1', '198.51.100.10')
+ expect(container.textContent).toContain('nginx · 1.25')
+ expect(container.textContent).toContain('Registry lookup is temporarily unavailable. Local host evidence is unaffected.')
+ })
+
+ it('labels legacy fallback evidence without inventing a source scan', async () => {
+ vi.mocked(baselineHost).mockResolvedValue({ ...detail, data_quality: 'legacy', source_scan: undefined })
+ await renderRoute('/jobs/job-1/baseline/hosts/198.51.100.10', '/jobs/:id/baseline/hosts/:address')
+ expect(container.textContent).toContain('Legacy detail')
+ expect(container.textContent).toContain('Legacy snapshot')
+ expect(container.textContent).not.toContain('scan-1')
+ expect(container.textContent).toContain('nginx · 1.25')
+ })
+
it('shows completed full-range coverage without an incomplete warning', async () => {
const tcpProtocol = host.protocols?.[0]
if (!tcpProtocol) throw new Error('test host is missing TCP evidence')
diff --git a/src/pages/JobDetail.actions.test.tsx b/src/pages/JobDetail.actions.test.tsx
index 2821d351..4abeb4db 100644
--- a/src/pages/JobDetail.actions.test.tsx
+++ b/src/pages/JobDetail.actions.test.tsx
@@ -42,8 +42,8 @@ describe('job detail actions', () => {
})
afterEach(() => vi.clearAllMocks())
- function renderPage() {
- return renderWithProviders(} />, { route: ['/jobs/job-1'] })
+ function renderPage(route = ['/jobs/job-1']) {
+ return renderWithProviders(} />} />, { route })
}
it('starts a scan and refreshes the relevant queries', async () => {
@@ -69,6 +69,16 @@ describe('job detail actions', () => {
await waitFor(() => expect(approveBaseline).toHaveBeenCalledWith('job-1', 'scan-1', 'scan-1', false))
})
+ it('uses the legacy baseline action when no revision guard is available', async () => {
+ vi.mocked(getJob).mockResolvedValue({ ...job, baseline: { status: 'complete', samples: 1, attempts: 1 } } as never)
+ renderPage()
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Reset baseline' })).toBeInTheDocument())
+
+ fireEvent.click(screen.getByRole('button', { name: 'Reset baseline' }))
+ fireEvent.click(screen.getByRole('dialog').querySelector('button[type="submit"]')!)
+ await waitFor(() => expect(resetBaseline).toHaveBeenCalledWith('job-1'))
+ })
+
it('reports action failures and archives through confirmation', async () => {
vi.mocked(runJob).mockRejectedValue(new Error('scanner unavailable'))
renderPage()
@@ -95,6 +105,41 @@ describe('job detail actions', () => {
await waitFor(() => expect(discardScanCycle).toHaveBeenCalledWith('job-1', 'cycle-1'))
})
+ it('hides stale cycle data after a successful poll reports no active cycle', async () => {
+ vi.mocked(getJob).mockResolvedValue({
+ ...job,
+ scan_cycle: { id: 'cycle-1', status: 'paused', completed_units: 1, total_units: 2, completed_probes: 100, total_probes: 200 },
+ } as never)
+ vi.mocked(scanCycle).mockResolvedValue({ cycle: null })
+ renderPage()
+
+ await waitFor(() => expect(screen.getByText('Expected baseline')).toBeInTheDocument())
+ await waitFor(() => {
+ expect(screen.queryByText('Broad scan paused safely.')).not.toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'Discard saved progress' })).not.toBeInTheDocument()
+ })
+ })
+
+ it('shows failed scan evidence without comparison, results, or baseline approval actions', async () => {
+ const failed = { ...scan, status: 'failed', error: 'Nmap exited unsuccessfully' }
+ vi.mocked(jobScans).mockResolvedValue({ scans: [failed], pagination: page } as never)
+ vi.mocked(scanDetail).mockResolvedValue({
+ scan: failed,
+ changes: [],
+ changes_pagination: page,
+ comparison_state: 'not_compared',
+ current_security_hash: 'scope',
+ comparison_source: 'scan_time',
+ } as never)
+ renderPage()
+
+ fireEvent.click(await screen.findByRole('button', { name: /scan-1/i }))
+ expect(await screen.findByText('No comparison was performed for this scan.')).toBeInTheDocument()
+ expect(screen.getByRole('alert')).toHaveTextContent('Nmap exited unsuccessfully')
+ expect(screen.queryByRole('button', { name: 'View results' })).not.toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'Use as baseline' })).not.toBeInTheDocument()
+ })
+
it('restores an archived job and permanently deletes it with the guarded name', async () => {
vi.mocked(getJob).mockResolvedValue({ ...job, archived: true, enabled: false } as never)
renderPage()
@@ -161,6 +206,33 @@ describe('job detail actions', () => {
expect(screen.queryByRole('button', { name: /Explore baseline/ })).not.toBeInTheDocument()
})
+ it('explains mixed Naabu and Nmap estimates and DNS expansion', async () => {
+ vi.mocked(getJob).mockResolvedValue({
+ ...job,
+ scan_estimate: { probes: 65_535, hosts: 2, naabu_invocations: 1, nmap_invocations: 2, estimated_seconds: 3_661, unknown_dns: 2 },
+ } as never)
+ renderPage()
+
+ expect(await screen.findByRole('status')).toHaveTextContent(
+ 'Estimated per run: 65,535 probes across 2 hosts (1 Naabu + 2 Nmap processes, roughly 2h). DNS expansion may increase this estimate for 2 names.',
+ )
+ })
+
+ it('keeps a directly linked older scan visible above history and closes back to the job', async () => {
+ const historical = { ...scan, id: 'historical-scan', scanner_engine: 'naabu_nmap', naabu_version: '2.6.1', scanner_profile_id: 'profile-1', scanner_profile_revision: 4, discovery_ports: 3, discovery_duration_ms: 999, confirmed_ports: 2, enrichment_duration_ms: 12_000 }
+ vi.mocked(scanDetail).mockResolvedValue({ scan: historical, changes: [], changes_pagination: page, current_security_hash: 'scope', comparison_source: 'scan_time' } as never)
+ renderPage(['/jobs/job-1/scans/historical-scan'])
+
+ expect(await screen.findByText('Selected scan historic is not on this history page.')).toBeInTheDocument()
+ expect(screen.getByRole('region', { name: 'Scan diff' })).toBeInTheDocument()
+ expect(screen.getByRole('status')).toHaveTextContent('Naabu full TCP discovery → Nmap confirmation')
+ expect(screen.getByRole('status')).toHaveTextContent('3 ports · 999 ms')
+ expect(screen.getByRole('status')).toHaveTextContent('2 ports · 12 s')
+
+ fireEvent.click(screen.getByRole('button', { name: 'Close scan detail' }))
+ await waitFor(() => expect(screen.queryByText('Selected scan historic is not on this history page.')).not.toBeInTheDocument())
+ })
+
it('presents an updating baseline as active and keeps its evidence available', async () => {
vi.mocked(getJob).mockResolvedValue({
...job,
diff --git a/src/pages/JobEditor.behavior.test.tsx b/src/pages/JobEditor.behavior.test.tsx
index 9519917e..b124101a 100644
--- a/src/pages/JobEditor.behavior.test.tsx
+++ b/src/pages/JobEditor.behavior.test.tsx
@@ -1,10 +1,10 @@
/** @vitest-environment jsdom */
-import { fireEvent, screen, waitFor } from '@testing-library/react'
+import { fireEvent, screen, waitFor, within } from '@testing-library/react'
import { act } from 'react'
import { Route, Routes } from 'react-router-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { APIError, createJob, getJob, getSession, listNotificationDestinations, listScannerProfiles, scannerCapabilities, scheduleSuggestion, updateJob } from '../api'
+import { APIError, BUILTIN_NAABU_PROFILE_ID, createJob, getJob, getSession, listNotificationDestinations, listScannerProfiles, scannerCapabilities, scheduleSuggestion, updateJob } from '../api'
import { renderWithProviders, defaultUnitScope } from '../test/test-utils'
import { setDisplayTimeZone } from '../format'
import { JobEditor } from './JobEditor'
@@ -48,6 +48,17 @@ describe('job editor workflow coverage', () => {
expect(screen.getByLabelText('Job name')).toBeInTheDocument()
})
+ it('does not replace notification routing with an empty selection when destinations fail to load', async () => {
+ vi.mocked(listNotificationDestinations).mockRejectedValue(new Error('notification store unavailable'))
+ renderWithProviders(, { route: ['/jobs/new'] })
+ await waitFor(() => expect(screen.getByRole('heading', { name: 'Create a monitoring job' })).toBeInTheDocument())
+ fireEvent.change(screen.getByLabelText('Job name'), { target: { value: 'Preserve routing' } })
+ fireEvent.change(screen.getByLabelText('Target 1'), { target: { value: '198.51.100.10' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Create job' }))
+ await waitFor(() => expect(createJob).toHaveBeenCalled())
+ expect(vi.mocked(createJob).mock.calls[0][0].notification_destinations).toBeUndefined()
+ })
+
it('creates a Naabu job with startup disabled and explicit empty notification routing', async () => {
renderWithProviders(, { route: ['/jobs/new'] })
await waitFor(() => expect(screen.getByRole('heading', { name: 'Create a monitoring job' })).toBeInTheDocument())
@@ -66,6 +77,75 @@ describe('job editor workflow coverage', () => {
expect(payload).toMatchObject({ name: 'Public edge', targets: ['198.51.100.10'], run_on_start: false, notification_destinations: [], tcp: { engine: 'naabu_nmap', ports: '1-65535' } })
})
+ it('keeps the built-in Naabu profile selectable when profile listing is unavailable', async () => {
+ vi.mocked(listScannerProfiles).mockRejectedValue(new Error('profile service unavailable'))
+ const { client } = renderWithProviders(, { route: ['/jobs/new'] })
+ await waitFor(() => expect(screen.getByRole('heading', { name: 'Create a monitoring job' })).toBeInTheDocument())
+ await waitFor(() => expect(client.getQueryState(['scanner-profiles'])?.status).toBe('error'))
+
+ fireEvent.change(screen.getByLabelText(/^TCP engine/), { target: { value: 'nmap' } })
+ fireEvent.change(screen.getByLabelText(/^TCP engine/), { target: { value: 'naabu_nmap' } })
+ fireEvent.change(screen.getByLabelText('Job name'), { target: { value: 'Fallback profile' } })
+ fireEvent.change(screen.getByLabelText('Target 1'), { target: { value: '198.51.100.10' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Create job' }))
+
+ await waitFor(() => expect(createJob).toHaveBeenCalled())
+ expect(vi.mocked(createJob).mock.calls[0][0].tcp).toMatchObject({ engine: 'naabu_nmap', profile_id: BUILTIN_NAABU_PROFILE_ID })
+ expect(vi.mocked(createJob).mock.calls[0][0].tcp?.profile_revision).toBeUndefined()
+ })
+
+ it('limits job-level scanner tuning to fields enabled by the selected profile', async () => {
+ vi.mocked(listScannerProfiles).mockResolvedValue({ profiles: [{
+ ...profile,
+ id: BUILTIN_NAABU_PROFILE_ID,
+ definition: { ...profile.definition, operator_adjustable: ['rate'], operator_bounds: { rate: { min: 100, max: 2000 } } },
+ }] } as never)
+ renderWithProviders(, { route: ['/jobs/new'] })
+ await waitFor(() => expect(screen.getByRole('heading', { name: 'Create a monitoring job' })).toBeInTheDocument())
+ await waitFor(() => expect(screen.getByLabelText('Rate')).toBeEnabled())
+
+ expect(screen.getByLabelText('Workers')).toBeDisabled()
+ expect(screen.getByRole('checkbox', { name: /Verify discoveries/ })).toBeDisabled()
+ fireEvent.change(screen.getByLabelText('Rate'), { target: { value: '1500' } })
+ fireEvent.change(screen.getByLabelText('Job name'), { target: { value: 'Bounded operator tuning' } })
+ fireEvent.change(screen.getByLabelText('Target 1'), { target: { value: '198.51.100.10' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Create job' }))
+
+ await waitFor(() => expect(createJob).toHaveBeenCalled())
+ expect(vi.mocked(createJob).mock.calls[0][0].tcp).toMatchObject({ profile_id: BUILTIN_NAABU_PROFILE_ID, naabu: { rate: 1500, workers: 25, verify: true } })
+ })
+
+ it('applies a newer scanner-profile revision only after an explicit choice', async () => {
+ const currentProfile = {
+ ...profile,
+ id: BUILTIN_NAABU_PROFILE_ID,
+ revision: 2,
+ definition: { ...profile.definition, naabu: { ...profile.definition.naabu, rate: 2500 }, operator_adjustable: ['rate'] },
+ }
+ const saved = {
+ ...approvedJob,
+ job: {
+ ...approvedJob.job,
+ tcp: {
+ ports: '1-65535', mode: 'connect', service_detection: false, engine: 'naabu_nmap',
+ profile_id: BUILTIN_NAABU_PROFILE_ID, profile_revision: 1, profile_update_available: true, profile_latest_revision: 2,
+ naabu: { scan_type: 'connect', rate: 1000, workers: 25, retries: 3, timeout_ms: 1000, warm_up_seconds: 2, verify: true, address_batch_size: 16 },
+ },
+ },
+ }
+ vi.mocked(getJob).mockResolvedValue(saved as never)
+ vi.mocked(listScannerProfiles).mockResolvedValue({ profiles: [currentProfile] } as never)
+ vi.mocked(updateJob).mockResolvedValue({ ...saved, revision: 5 } as never)
+ renderWithProviders(} />, { route: ['/jobs/job-1/edit'] })
+
+ fireEvent.click(await screen.findByRole('button', { name: 'Apply latest profile' }))
+ expect(screen.getByLabelText('Rate')).toHaveValue(2500)
+ expect(screen.queryByRole('button', { name: 'Apply latest profile' })).not.toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: 'Save changes' }))
+ await waitFor(() => expect(updateJob).toHaveBeenCalled())
+ expect(vi.mocked(updateJob).mock.calls[0][2].tcp).toMatchObject({ profile_id: BUILTIN_NAABU_PROFILE_ID, profile_revision: 2, naabu: { rate: 2500 } })
+ })
+
it('defaults a new job to the deployment timezone from the session', async () => {
setDisplayTimeZone('Asia/Kathmandu')
try {
@@ -210,6 +290,23 @@ describe('job editor workflow coverage', () => {
expect(screen.getByText('Scope change cancelled.')).toBeInTheDocument()
})
+ it('saves a scope change only after the administrator confirms the rebaseline', async () => {
+ const existing = { id: 'job-1', revision: 4, enabled: true, archived: false, security_hash: 'old', job: { name: 'Existing', schedule: '0 */6 * * *', timezone: 'UTC', targets: ['198.51.100.10'], max_expanded_hosts: 256, tcp: { ports: '22', mode: 'connect', service_detection: false, engine: 'nmap' }, timing: 'balanced', timeout: '1h', resume_window: '8d', baseline_samples: 1, change_confirmations: 1 }, baseline: { status: 'complete', samples: 1, attempts: 1 } }
+ vi.mocked(getJob).mockResolvedValue(existing as never)
+ vi.mocked(updateJob)
+ .mockRejectedValueOnce(new APIError('rebaseline confirmation required', 'rebaseline_confirmation_required', { changes: ['TCP port scope'] }))
+ .mockResolvedValueOnce({ ...existing, revision: 5 } as never)
+ renderWithProviders(} />, { route: ['/jobs/job-1/edit'] })
+ await waitFor(() => expect(screen.getByDisplayValue('Existing')).toBeInTheDocument())
+ fireEvent.change(screen.getByLabelText(/^Ports/), { target: { value: '443' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Save changes' }))
+ const dialog = await screen.findByRole('dialog')
+ fireEvent.click(within(dialog).getByRole('button', { name: 'Reset baseline and save' }))
+ await waitFor(() => expect(updateJob).toHaveBeenCalledTimes(2))
+ expect(vi.mocked(updateJob).mock.calls[1][2]).toMatchObject({ tcp: { ports: '443' } })
+ expect(vi.mocked(updateJob).mock.calls[1][3]).toBe(true)
+ })
+
it('restores server notification routing when reloading a newer revision', async () => {
const saved = {
id: 'job-1', revision: 4, enabled: true, archived: false, security_hash: 'old',
@@ -247,6 +344,19 @@ describe('job editor workflow coverage', () => {
expect(vi.mocked(updateJob).mock.calls.at(-1)?.[2]).toMatchObject({ notification_destinations: ['notify-2'] })
})
+ it('blocks saving a dirty draft after a newer job revision arrives', async () => {
+ vi.mocked(getJob).mockResolvedValue(approvedJob as never)
+ const { client } = renderWithProviders(} />, { route: ['/jobs/job-1/edit'] })
+ await waitFor(() => expect(screen.getByDisplayValue('Broad edge')).toBeInTheDocument())
+ fireEvent.change(screen.getByLabelText('Job name'), { target: { value: 'Draft name' } })
+ vi.mocked(getJob).mockResolvedValue({ ...approvedJob, revision: 5 } as never)
+ await client.invalidateQueries({ queryKey: ['job', 'job-1'] })
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Reload saved version' })).toBeInTheDocument())
+ fireEvent.click(screen.getByRole('button', { name: 'Save changes' }))
+ await waitFor(() => expect(screen.getAllByRole('alert').some(alert => alert.textContent?.includes('Reload the saved version before continuing.'))).toBe(true))
+ expect(updateJob).not.toHaveBeenCalled()
+ })
+
describe('with a saved destination that no longer exists', () => {
const rotated = {
id: 'job-1', revision: 4, enabled: true, archived: false, security_hash: 'old',
diff --git a/src/pages/ScannerProfiles.test.tsx b/src/pages/ScannerProfiles.test.tsx
index 2c0ea502..6deb98e4 100644
--- a/src/pages/ScannerProfiles.test.tsx
+++ b/src/pages/ScannerProfiles.test.tsx
@@ -48,12 +48,15 @@ describe('scanner profiles', () => {
fireEvent.change(screen.getByLabelText('Nmap argument array'), { target: { value: '-n\n{address(es)}\n-p\n{ports}\n{structured_output}' } })
fireEvent.change(screen.getByLabelText('Naabu argument array'), { target: { value: '-host\n{address(es)}\n-p\n{ports}\n{structured_output}' } })
fireEvent.change(screen.getByLabelText('Enrichment argument array'), { target: { value: '-n\n{address(es)}\n-p\n{ports}\n{structured_output}' } })
+ fireEvent.change(screen.getByLabelText('NSE profile'), { target: { value: 'banner' } })
+ fireEvent.change(screen.getByLabelText(/NSE arguments/), { target: { value: 'timeout=5s\nmode=brief' } })
+ fireEvent.click(screen.getByRole('checkbox', { name: /rate/ }))
fireEvent.change(screen.getByLabelText('Password confirmation'), { target: { value: 'administrator-password' } })
fireEvent.click(screen.getByRole('button', { name: 'Validate & preview' }))
await waitFor(() => expect(validateScannerProfile).toHaveBeenCalled())
await waitFor(() => expect(screen.getByRole('region', { name: 'Effective command preview' })).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Create profile' }))
- await waitFor(() => expect(createScannerProfile).toHaveBeenCalledWith(expect.objectContaining({ name: 'Full scan', engine: 'naabu_nmap', password: 'administrator-password' })))
+ await waitFor(() => expect(createScannerProfile).toHaveBeenCalledWith(expect.objectContaining({ name: 'Full scan', engine: 'naabu_nmap', nse_profile: 'banner', nse_args: { timeout: '5s', mode: 'brief' }, operator_adjustable: ['rate'], operator_bounds: { rate: { min: 1, max: 100000 } }, password: 'administrator-password' })))
expect(screen.getByText('Scanner profile saved. Jobs keep their pinned revision until explicitly upgraded.')).toBeInTheDocument()
})
@@ -69,6 +72,18 @@ describe('scanner profiles', () => {
expect(screen.getAllByRole('alert').some(element => element.textContent?.includes('profile changed'))).toBe(true)
})
+ it('archives a managed profile after confirmation and reports the completed action', async () => {
+ renderWithProviders()
+ await waitFor(() => expect(screen.getByText('Managed connect')).toBeInTheDocument())
+ fireEvent.click(screen.getByRole('button', { name: 'Archive Managed connect' }))
+ const dialog = screen.getByRole('dialog')
+ fireEvent.change(dialog.querySelector('input[type="password"]')!, { target: { value: 'administrator-password' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Archive profile' }))
+
+ await waitFor(() => expect(archiveScannerProfile).toHaveBeenCalledWith('profile-1', 3, 'administrator-password'))
+ expect(await screen.findByRole('status')).toHaveTextContent('Scanner profile archived.')
+ })
+
it('edits a profile, toggles operator bounds, and restores an archived profile', async () => {
const archived = { ...profile, id: 'profile-2', name: 'Archived profile', archived: true, revision: 5 }
vi.mocked(listScannerProfiles).mockResolvedValue({ profiles: [profile, archived], invalid_profiles: [{ id: 'invalid-1', name: 'Broken profile', error: 'invalid template' }] } as never)
@@ -105,4 +120,43 @@ describe('scanner profiles', () => {
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('profile rejected'))
expect(screen.getByRole('heading', { name: 'Create profile' })).toBeInTheDocument()
})
+
+ it('does not show a command preview when server validation rejects a profile', async () => {
+ vi.mocked(validateScannerProfile).mockResolvedValue({ valid: false, preview: [] })
+ renderWithProviders()
+ await waitFor(() => expect(screen.getByText('Managed connect')).toBeInTheDocument())
+ fireEvent.click(screen.getByRole('button', { name: 'New profile' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Validate & preview' }))
+ expect(await screen.findByText('Profile is invalid.')).toBeInTheDocument()
+ expect(screen.queryByRole('region', { name: 'Effective command preview' })).not.toBeInTheDocument()
+ })
+
+ it('recovers the profile list after a transient load failure', async () => {
+ vi.mocked(listScannerProfiles).mockRejectedValueOnce(new Error('profile service unavailable'))
+ const { client } = renderWithProviders()
+ await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Could not load scanner profiles.'))
+ await waitFor(() => expect(client.getQueryState(['scanner-profiles', true])?.status).toBe('error'))
+
+ fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
+ await waitFor(() => expect(screen.getByText('Managed connect')).toBeInTheDocument())
+ expect(listScannerProfiles).toHaveBeenCalledTimes(2)
+ })
+
+ it('explains an empty catalog without presenting stale profile controls', async () => {
+ vi.mocked(listScannerProfiles).mockResolvedValue({ profiles: [] })
+ renderWithProviders()
+
+ expect(await screen.findByText('No valid scanner profiles are configured.')).toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'Edit Managed connect' })).not.toBeInTheDocument()
+ })
+
+ it('quotes unsafe argument values in the documentation-only preview', async () => {
+ vi.mocked(validateScannerProfile).mockResolvedValue({ valid: true, preview: [{ executable: '/usr/bin/nmap', args: ['-p', '22, 80', '--script-args', 'mode=brief;'] }] })
+ renderWithProviders()
+ await waitFor(() => expect(screen.getByText('Managed connect')).toBeInTheDocument())
+ fireEvent.click(screen.getByRole('button', { name: 'Validate & preview' }))
+
+ const preview = await screen.findByRole('region', { name: 'Effective command preview' })
+ expect(preview.querySelector('code')).toHaveTextContent('/usr/bin/nmap -p "22, 80" --script-args "mode=brief;"')
+ })
})