diff --git a/CONTEXT.md b/CONTEXT.md index 72c90cac..96d2d3dc 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -56,7 +56,7 @@ When a change alters one of these boundaries, update this document and the neare ### Deferred boundaries -- Cross-run change detection, alerts, and automatic reactions remain separate future product decisions. A scheduled occurrence only submits finite enumeration runs. +- Read-only cross-run change projections over finalized evidence are part of the release contract. Alerts and automatic reactions remain deferred, and a scheduled occurrence only submits finite enumeration runs. - Cross-run source ranking remains a reporting decision. One run's hostname-yield summary does not automatically select, disable, or rank sources. - Distributed workers, multi-host operation, PostgreSQL, and hosted multi-user authorization require measured demand and new decisions. The release remains SQLite-first and local-operator focused. - Automatic scope expansion remains deferred. Evidence can suggest a later target, while the operator controls every scope change. diff --git a/tests/e2e/test_harvestview.py b/tests/e2e/test_harvestview.py index fbf6cc14..5e3287aa 100644 --- a/tests/e2e/test_harvestview.py +++ b/tests/e2e/test_harvestview.py @@ -533,6 +533,14 @@ def test_hostname_tracking_filters_persisted_run_changes( expect(panel).to_contain_text('TimeoutError') expect(panel).not_to_contain_text('stable.example.test') + page.locator('#tracking-change-filter').select_option('persisting') + expect(page.locator('#tracking-persisting-filter')).to_be_checked() + expect(rows).to_have_count(1) + expect(panel).to_contain_text('stable.example.test') + page.locator('#tracking-persisting-filter').uncheck() + expect(page.locator('#tracking-change-filter')).to_have_value('') + expect(rows).to_have_count(3) + page.locator('#tracking-exclusive-filter').check() expect(rows).to_have_count(2) expect(panel).not_to_contain_text('missing.example.test') diff --git a/tests/test_source_yields_cli.py b/tests/test_source_yields_cli.py index fb08193a..5f57b542 100644 --- a/tests/test_source_yields_cli.py +++ b/tests/test_source_yields_cli.py @@ -16,6 +16,7 @@ from theHarvester.lib.database import ResultStore from theHarvester.lib.evidence_types import RESULT_KINDS, ExecutionStatus RUN_ONE = UUID('11111111-1111-4111-8111-111111111111') +RUN_TIE_LATER = UUID('11111111-1111-4111-8111-111111111112') RUN_TWO = UUID('22222222-2222-4222-8222-222222222222') @@ -572,6 +573,42 @@ def test_target_changes_reports_every_run_pair_and_a_clear_null_baseline( assert {row['run_id'] for row in payload['hostname_changes']} == {str(RUN_TWO)} +def test_run_id_breaks_an_equal_completion_time_tie_deterministically( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + database = tmp_path / 'runs.sqlite' + store = ResultStore(database) + asyncio.run(store.initialize()) + asyncio.run( + store.save_run( + _completed_run( + RUN_TIE_LATER, + observations=(ResultObservation('alpha', 'hostname', 'later.example.test'),), + ) + ) + ) + asyncio.run( + store.save_run( + _completed_run( + RUN_ONE, + observations=(ResultObservation('alpha', 'hostname', 'earlier.example.test'),), + ) + ) + ) + asyncio.run(store.dispose()) + + assert ( + source_yields.main( + ['--database', str(database), '--run-id', str(RUN_TIE_LATER), '--changes', '--format', 'json'] + ) + == 0 + ) + + comparison = json.loads(capsys.readouterr().out)['comparisons'][0] + assert comparison['baseline_run_id'] == str(RUN_ONE) + + def test_include_persisting_adds_unchanged_hostname_rows_without_changing_counts( tmp_path: Path, capsys: pytest.CaptureFixture[str], @@ -729,6 +766,9 @@ def test_unscoped_empty_database_reports_an_explicit_empty_scope( assert source_yields.main(['--database', str(database)]) == 0 assert capsys.readouterr().out.splitlines()[:3] == ['Targets: none', 'Kind: hostname', 'Run count: 0'] + assert source_yields.main(['--database', str(database), '--changes']) == 0 + assert capsys.readouterr().out.startswith('Targets: none\nComparison count: 0\n') + def test_missing_database_fails_without_creating_file( tmp_path: Path, diff --git a/theHarvester/lib/api/static/harvestview/app.js b/theHarvester/lib/api/static/harvestview/app.js index a204d8a5..b8987486 100644 --- a/theHarvester/lib/api/static/harvestview/app.js +++ b/theHarvester/lib/api/static/harvestview/app.js @@ -1422,10 +1422,19 @@ const query = event.target.value.trim().toLowerCase(); state.resultTable?.setFilter(row => !query || row.value.toLowerCase().includes(query)); }); - for (const filter of [ - nodes.trackingChange, nodes.trackingSource, nodes.trackingResolution, - nodes.trackingExclusive, nodes.trackingPersisting - ]) filter.addEventListener('change', () => renderHostnameTracking(state.detail)); + nodes.trackingChange.addEventListener('change', () => { + if (nodes.trackingChange.value === 'persisting') nodes.trackingPersisting.checked = true; + renderHostnameTracking(state.detail); + }); + nodes.trackingPersisting.addEventListener('change', () => { + if (!nodes.trackingPersisting.checked && nodes.trackingChange.value === 'persisting') { + nodes.trackingChange.value = ''; + } + renderHostnameTracking(state.detail); + }); + for (const filter of [nodes.trackingSource, nodes.trackingResolution, nodes.trackingExclusive]) { + filter.addEventListener('change', () => renderHostnameTracking(state.detail)); + } nodes.sourceSearch.addEventListener('input', event => renderSourceGroups(event.target.value)); nodes.selectCapability.addEventListener('click', selectCapability); nodes.selectP0.addEventListener('click', () => setP0Selection(true)); diff --git a/theHarvester/source_yields.py b/theHarvester/source_yields.py index 01840f03..ecf8b0b9 100644 --- a/theHarvester/source_yields.py +++ b/theHarvester/source_yields.py @@ -210,7 +210,8 @@ def _targets_table(rows: list[dict[str, str | int]]) -> str: def _tracking_table(payload: dict[str, object]) -> str: comparisons = cast('list[dict[str, object]]', payload['comparisons']) changes = cast('list[dict[str, object]]', payload['hostname_changes']) - lines = [f'Target: {payload["target"]}', f'Comparison count: {payload["comparison_count"]}'] + scope = f'Target: {payload["target"]}' if payload['target'] is not None else 'Targets: none' + lines = [scope, f'Comparison count: {payload["comparison_count"]}'] summary_columns = ( ('run_id', 'CURRENT RUN'), ('baseline_run_id', 'BASELINE RUN'), @@ -227,7 +228,7 @@ def _tracking_table(payload: dict[str, object]) -> str: } for comparison in comparisons ] - summary_widths = [max(len(label), *(len(str(row[key])) for row in summary_rows)) for key, label in summary_columns] + summary_widths = [max([len(label), *(len(str(row[key])) for row in summary_rows)]) for key, label in summary_columns] lines.append(' '.join(label.ljust(summary_widths[index]) for index, (_key, label) in enumerate(summary_columns))) lines.extend( ' '.join(str(row[key]).ljust(summary_widths[index]) for index, (key, _label) in enumerate(summary_columns))