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;"') + }) })