Files
rengine/tests/api/test_scan_compare.py
T
Yogesh OjhaandClaude Opus 5 3f6302870c feat(scans): compare two runs, and prove they are comparable first
reNgine could say what was new against a target's whole history, and 2.x could
say `[new, removed]` for subdomains. Neither answers the question an operator
actually asks after a scan finishes: what changed since that run, the named one
I am comparing against.

The hard part is not the diff. Two StandardRengine runs of vulnweb.com in this
instance, eighteen minutes apart, went from 14 findings to 0. A set diff reports
"14 findings resolved, 12 high". The truth is `vulnerability_scan.max_minutes`
went 0 to 2, and the coverage rows record 57,100 requests over 368s against four
hosts, then 27,582 over 120s against seven. The delta belongs to the run.

So comparability is stated before any delta, per dimension: like for like,
settings differ, runs differ in depth, not scanned. Stage settings and the runs'
own setup are both compared — engine, context, auth type, intensity, proxy
(as set/none, never the URL), scope exclusions, protocol, crawl. Facets that
change what a run can find raise the verdict; threads and rate limits do not.
Runs that ran between the pair are counted, a cancelled run in the pair lowers
the verdict, and when the chosen pair is not like for like the nearest run that
would be is offered.

Identity is read off the per-scan unique constraints already on each table —
`subdomains.name`, `endpoints.signature`, `ports.(ip, number, protocol)`,
`ip_addresses.ip`, `vulnerabilities.fingerprint` — so the diff key is not a new
invention. Four verbs: appeared, changed, disappeared, unchanged. Disappearance
is only asserted when the producing stage finished cleanly; dnsx drops 20-60% of
names silently, so a partial run's missing rows are unconfirmed, a bucket of
their own. A dimension one run did not produce reports not scanned, never
everything removed. EPSS and KEV movement is Class D and is counted apart.

A disappeared row does not exist in the later run, so a query scoped to it can
never return one. Rather than teach five grammars an `is:added` flag, the
service flips the scope per verb, and every column, sort key and detail sheet
keeps working. Rank is computed once as a SQL CASE and the signal name is
recovered from the rank, so the order and the label cannot disagree.

Nothing is stored: a 425k-row full outer join measures 66 ms, the whole endpoint
about 31 ms. No table, no migration.

Direction is a shape — solid, split, hollow rails — not green-and-red. New
attack surface is not good news. Diff mode keeps the git glyphs for the same
reason it colours them from the `--code-*` ramp: in a diff `+` is syntax. That
mode is generated once on the server and shown through `code-block`, so the
screen and the clipboard cannot drift.

Eligibility is explained, never hidden. `REFUSAL_REASON` is one definition
mirrored in `config/compare.ts`, so the disabled button's hint, the alert page
and the API error say the same sentence, and an impossible pair reached by URL
lands on an alert that lists the target's runs to pick from.

`recheck.py` now reads its host field list from the shared definitions, so the
product holds one answer to "did this host change".

Also fixes the scan hero, which said "No change since the previous scan" while
measuring only hosts. Beside a page reporting 21 changes that read as a
contradiction; it now says what it measured.

Vulnerability rows are one per finding rather than grouped by check, and
comparing against a run that is still going is not built.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KAVgTQe8K3EvGzPo5HVmKF
2026-09-12 06:18:23 +05:30

683 lines
23 KiB
Python

from __future__ import annotations
from datetime import timedelta
import pytest
from fastapi import HTTPException
from app.services.scan_compare import ScanCompareService
from shared.definitions.compare import (
REFUSAL_REASON,
ChangeVerb,
Comparability,
Refusal,
)
from shared.definitions.surface import SurfaceDimension
from shared.models.scan import Scan
pytestmark = pytest.mark.api
WEB = SurfaceDimension.WEB_ASSETS.value
VULNS = SurfaceDimension.VULNERABILITIES.value
SERVICES = SurfaceDimension.SERVICES.value
RAN_WEB = {"subdomain_discovery": "success", "http_probe": "success"}
RAN_VULNS = {"vulnerability_scan": "success"}
RAN_PORTS = {"port_scan": "success"}
def _dim(report, key: str):
return next(d for d in report.dimensions if d.dimension == key)
ENGINE = "Standard"
async def _pair(estate, now, *, config_a=None, config_b=None):
old = now - timedelta(hours=2)
a = await estate.scan(
"example.com", "first", at=old, config=config_a, engine=ENGINE
)
b = await estate.scan(
"example.com", "second", at=now, config=config_b, engine=ENGINE
)
return a, b, old
async def test_a_host_present_in_both_runs_is_unchanged(estate, now):
a, b, old = await _pair(estate, now)
await estate.hosts("first", ["a.example.com"], at=old, status=200)
await estate.hosts("second", ["a.example.com"], at=now, status=200)
await estate.activity("first", RAN_WEB)
await estate.activity("second", RAN_WEB)
report = await ScanCompareService(estate.session).comparison(
a, b, estate.project_id
)
web = _dim(report, WEB)
assert web.unchanged == 1
assert web.appeared == 0
assert web.changed == 0
assert web.disappeared == 0
assert report.changes_total == 0
assert report.headline == "Nothing changed"
async def test_appeared_changed_and_disappeared_are_counted_apart(estate, now):
a, b, old = await _pair(estate, now)
await estate.hosts("first", ["stays.example.com", "goes.example.com"], at=old)
await estate.hosts("first", ["moves.example.com"], at=old, status=403)
await estate.hosts("second", ["stays.example.com", "new.example.com"], at=now)
await estate.hosts("second", ["moves.example.com"], at=now, status=200)
await estate.activity("first", RAN_WEB)
await estate.activity("second", RAN_WEB)
report = await ScanCompareService(estate.session).comparison(
a, b, estate.project_id
)
web = _dim(report, WEB)
assert web.appeared == 1
assert web.disappeared == 1
assert web.changed == 1
assert web.unchanged == 1
async def test_the_sides_of_the_diff_add_up_to_each_run(estate, now):
a, b, old = await _pair(estate, now)
await estate.hosts("first", ["one.example.com", "two.example.com"], at=old)
await estate.hosts("second", ["two.example.com", "three.example.com"], at=now)
await estate.activity("first", RAN_WEB)
await estate.activity("second", RAN_WEB)
report = await ScanCompareService(estate.session).comparison(
a, b, estate.project_id
)
web = _dim(report, WEB)
assert web.disappeared + web.changed + web.unchanged == web.total_baseline
assert web.appeared + web.changed + web.unchanged == web.total_current
async def test_a_dimension_the_later_run_never_scanned_is_not_covered(estate, now):
a, b, old = await _pair(estate, now)
await estate.vulns("first", [("cve-a", "high")], at=old)
await estate.activity("first", RAN_VULNS)
await estate.activity("second", {"http_probe": "success"})
report = await ScanCompareService(estate.session).comparison(
a, b, estate.project_id
)
vulns = _dim(report, VULNS)
assert vulns.verdict.comparability == Comparability.NOT_COVERED.value
assert vulns.disappeared == 0, "not scanned is not the same as removed"
assert vulns.unconfirmed == 0
assert "Not scanned" in vulns.verdict.note
async def test_a_partial_producing_stage_leaves_missing_rows_unconfirmed(estate, now):
a, b, old = await _pair(estate, now)
await estate.hosts("first", ["gone.example.com"], at=old)
await estate.hosts("second", ["other.example.com"], at=now)
await estate.activity("first", RAN_WEB)
await estate.activity("second", {"subdomain_discovery": "partial"})
report = await ScanCompareService(estate.session).comparison(
a, b, estate.project_id
)
web = _dim(report, WEB)
assert web.disappeared == 0
assert web.unconfirmed == 1
assert web.verdict.confirmed is False
assert web.verdict.comparability == Comparability.QUALITY_DIFFERS.value
async def test_a_stage_setting_that_differs_is_named(estate, now):
a, b, _ = await _pair(
estate,
now,
config_a={"stages": {"vulnerability_scan": {"max_minutes": 0}}},
config_b={"stages": {"vulnerability_scan": {"max_minutes": 2}}},
)
await estate.activity("first", RAN_VULNS)
await estate.activity("second", RAN_VULNS)
report = await ScanCompareService(estate.session).comparison(
a, b, estate.project_id
)
vulns = _dim(report, VULNS)
assert vulns.verdict.comparability == Comparability.SETTINGS_DIFFER.value
assert [s.field for s in vulns.verdict.settings] == ["max_minutes"]
assert (vulns.verdict.settings[0].before, vulns.verdict.settings[0].after) == (
"0",
"2",
)
assert report.comparability == Comparability.SETTINGS_DIFFER.value
assert _dim(report, WEB).verdict.compared is False
async def test_a_reordered_list_setting_is_not_a_difference(estate, now):
a, b, _ = await _pair(
estate,
now,
config_a={"stages": {"subdomain_discovery": {"sources": ["a", "b"]}}},
config_b={"stages": {"subdomain_discovery": {"sources": ["b", "a"]}}},
)
await estate.activity("first", RAN_WEB)
await estate.activity("second", RAN_WEB)
report = await ScanCompareService(estate.session).comparison(
a, b, estate.project_id
)
assert report.setting_diff == []
assert _dim(report, WEB).verdict.comparability == Comparability.LIKE_FOR_LIKE.value
assert report.comparability == Comparability.LIKE_FOR_LIKE.value
async def test_a_reordered_technology_list_is_not_a_change(estate, now):
a, b, old = await _pair(estate, now)
await estate.hosts("first", ["a.example.com"], at=old, tech=["nginx", "php"])
await estate.hosts("second", ["a.example.com"], at=now, tech=["php", "nginx"])
await estate.activity("first", RAN_WEB)
await estate.activity("second", RAN_WEB)
report = await ScanCompareService(estate.session).comparison(
a, b, estate.project_id
)
assert _dim(report, WEB).changed == 0
async def test_the_count_is_a_promise(estate, now):
a, b, old = await _pair(estate, now)
await estate.hosts("first", ["gone.example.com", "stays.example.com"], at=old)
await estate.hosts("second", ["stays.example.com", "new.example.com"], at=now)
await estate.activity("first", RAN_WEB)
await estate.activity("second", RAN_WEB)
service = ScanCompareService(estate.session)
report = await service.comparison(a, b, estate.project_id)
web = _dim(report, WEB)
rows = await service.rows(
baseline_id=a,
current_id=b,
project_id=estate.project_id,
dimension=WEB,
verbs=[],
page=1,
size=50,
)
listed = web.appeared + web.changed + web.disappeared + web.unconfirmed
assert rows.total == listed
assert len(rows.items) == listed
async def test_one_verb_returns_only_its_own_rows(estate, now):
a, b, old = await _pair(estate, now)
await estate.hosts("first", ["gone.example.com"], at=old)
await estate.hosts("second", ["new.example.com"], at=now)
await estate.activity("first", RAN_WEB)
await estate.activity("second", RAN_WEB)
rows = await ScanCompareService(estate.session).rows(
baseline_id=a,
current_id=b,
project_id=estate.project_id,
dimension=WEB,
verbs=[ChangeVerb.APPEARED.value],
page=1,
size=50,
)
assert rows.total == 1
assert rows.items[0].title == "new.example.com"
assert rows.items[0].verb == ChangeVerb.APPEARED.value
async def test_a_dropped_authentication_outranks_a_new_host(estate, now):
a, b, old = await _pair(estate, now)
await estate.hosts("first", ["locked.example.com"], at=old, status=403)
await estate.hosts("second", ["locked.example.com"], at=now, status=200)
await estate.hosts("second", ["new.example.com"], at=now, status=200)
await estate.activity("first", RAN_WEB)
await estate.activity("second", RAN_WEB)
rows = await ScanCompareService(estate.session).rows(
baseline_id=a,
current_id=b,
project_id=estate.project_id,
dimension=WEB,
verbs=[],
page=1,
size=50,
)
assert [r.signal for r in rows.items] == ["auth_dropped", "host_appeared"]
async def test_a_sensitive_port_opening_is_named(estate, now):
a, b, old = await _pair(estate, now)
await estate.ports("first", [("10.0.0.1", 443, "https")], at=old)
await estate.ports(
"second", [("10.0.0.1", 443, "https"), ("10.0.0.1", 3306, "mysql")], at=now
)
await estate.activity("first", RAN_PORTS)
await estate.activity("second", RAN_PORTS)
rows = await ScanCompareService(estate.session).rows(
baseline_id=a,
current_id=b,
project_id=estate.project_id,
dimension=SERVICES,
verbs=[],
page=1,
size=50,
)
assert rows.total == 1
assert rows.items[0].signal == "sensitive_service_opened"
assert rows.items[0].sensitive is True
assert rows.items[0].title == "10.0.0.1:3306"
async def test_a_focused_run_is_refused_against_a_full_one(estate, now):
old = now - timedelta(hours=2)
a = await estate.scan("example.com", "census", at=old)
b = await estate.scan("example.com", "rescan", at=now, scope="focused")
with pytest.raises(HTTPException) as raised:
await ScanCompareService(estate.session).comparison(a, b, estate.project_id)
assert raised.value.status_code == 422
assert "focused run" in raised.value.detail
async def test_a_run_cannot_be_compared_with_itself(estate, now):
a = await estate.scan("example.com", "only", at=now)
with pytest.raises(HTTPException) as raised:
await ScanCompareService(estate.session).comparison(a, a, estate.project_id)
assert raised.value.status_code == 422
async def test_two_targets_cannot_be_compared(estate, now):
old = now - timedelta(hours=2)
a = await estate.scan("one.example.com", "first", at=old)
b = await estate.scan("two.example.com", "second", at=now)
with pytest.raises(HTTPException) as raised:
await ScanCompareService(estate.session).comparison(a, b, estate.project_id)
assert raised.value.status_code == 422
assert "same target" in raised.value.detail
async def test_the_baseline_defaults_to_the_previous_run(estate, now):
old = now - timedelta(hours=2)
a = await estate.scan("example.com", "first", at=old, engine=ENGINE)
b = await estate.scan("example.com", "second", at=now, engine=ENGINE)
await estate.activity("first", RAN_WEB)
await estate.activity("second", RAN_WEB)
report = await ScanCompareService(estate.session).comparison(
None, b, estate.project_id
)
assert report.baseline.scan_id == a
async def test_a_target_with_one_run_has_nothing_to_compare(estate, now):
b = await estate.scan("example.com", "only", at=now)
with pytest.raises(HTTPException) as raised:
await ScanCompareService(estate.session).comparison(None, b, estate.project_id)
assert raised.value.status_code == 404
async def test_comparable_runs_explain_why_a_pair_is_refused(estate, now):
old = now - timedelta(hours=2)
await estate.scan("example.com", "census", at=old)
await estate.scan(
"example.com", "focused", at=now - timedelta(hours=1), scope="focused"
)
b = await estate.scan("example.com", "latest", at=now)
runs = await ScanCompareService(estate.session).comparable(b, estate.project_id)
by_name = {r.engine_name: r for r in runs}
assert by_name["census"].comparable is True
assert by_name["focused"].comparable is False
assert "focused run" in by_name["focused"].reason
async def test_a_different_scan_context_is_a_run_difference(estate, now):
old = now - timedelta(hours=2)
a = await estate.scan("example.com", "first", at=old, engine=ENGINE)
b = await estate.scan("example.com", "second", at=now, engine=ENGINE)
first = await estate.session.get(Scan, a)
second = await estate.session.get(Scan, b)
first.context_name = "Unauthenticated"
second.context_name = "Staff session"
await estate.session.flush()
report = await ScanCompareService(estate.session).comparison(
a, b, estate.project_id
)
keys = {row.key: row for row in report.run_diff}
assert keys["context"].baseline == "Unauthenticated"
assert keys["context"].current == "Staff session"
assert keys["context"].material is True
assert report.comparability == Comparability.SETTINGS_DIFFER.value
async def test_intensity_and_scope_differences_are_named(estate, now):
a, b, _ = await _pair(
estate,
now,
config_a={"intensity": "normal", "excluded_subdomains": []},
config_b={
"intensity": "passive",
"excluded_subdomains": ["staging.example.com"],
},
)
report = await ScanCompareService(estate.session).comparison(
a, b, estate.project_id
)
keys = {row.key: row for row in report.run_diff}
assert keys["intensity"].current == "passive"
assert keys["excluded_subdomains"].current == "staging.example.com"
assert all(keys[k].material for k in ("intensity", "excluded_subdomains"))
async def test_a_proxy_is_reported_without_its_url(estate, now):
a, b, _ = await _pair(
estate,
now,
config_a={"proxy_url": None},
config_b={"proxy_url": "http://user:secret@proxy.internal:8080"},
)
report = await ScanCompareService(estate.session).comparison(
a, b, estate.project_id
)
proxy = next(row for row in report.run_diff if row.key == "proxy")
assert (proxy.baseline, proxy.current) == ("none", "set")
assert "secret" not in (proxy.current or "")
async def test_speed_settings_are_reported_but_not_material(estate, now):
a, b, _ = await _pair(
estate,
now,
config_a={"global_threads": 30},
config_b={"global_threads": 60},
)
await estate.activity("first", RAN_WEB)
await estate.activity("second", RAN_WEB)
report = await ScanCompareService(estate.session).comparison(
a, b, estate.project_id
)
threads = next(row for row in report.run_diff if row.key == "threads")
assert threads.material is False
assert report.comparability == Comparability.LIKE_FOR_LIKE.value
async def test_runs_in_between_are_counted(estate, now):
a = await estate.scan(
"example.com", "first", at=now - timedelta(hours=3), engine=ENGINE
)
await estate.scan(
"example.com", "middle", at=now - timedelta(hours=2), engine=ENGINE
)
b = await estate.scan("example.com", "last", at=now, engine=ENGINE)
report = await ScanCompareService(estate.session).comparison(
a, b, estate.project_id
)
assert report.runs_between == 1
async def test_a_like_for_like_run_is_suggested(estate, now):
twin = await estate.scan(
"example.com", "twin", at=now - timedelta(hours=3), engine=ENGINE
)
odd = await estate.scan(
"example.com",
"odd",
at=now - timedelta(hours=2),
config={"intensity": "passive"},
engine=ENGINE,
)
b = await estate.scan("example.com", "latest", at=now, engine=ENGINE)
for name in ("twin", "odd", "latest"):
await estate.activity(name, RAN_WEB)
report = await ScanCompareService(estate.session).comparison(
odd, b, estate.project_id
)
assert report.comparability == Comparability.SETTINGS_DIFFER.value
assert report.suggestion is not None
assert report.suggestion.scan_id == twin
async def test_a_cancelled_run_in_the_pair_lowers_the_verdict(estate, now):
old = now - timedelta(hours=2)
a = await estate.scan(
"example.com", "first", at=old, status="cancelled", engine=ENGINE
)
b = await estate.scan("example.com", "second", at=now, engine=ENGINE)
await estate.activity("first", RAN_WEB)
await estate.activity("second", RAN_WEB)
report = await ScanCompareService(estate.session).comparison(
a, b, estate.project_id
)
assert report.comparability == Comparability.QUALITY_DIFFERS.value
async def test_the_refusal_wording_comes_from_the_definitions(estate, now):
old = now - timedelta(hours=2)
a = await estate.scan("one.example.com", "first", at=old)
b = await estate.scan("two.example.com", "second", at=now)
with pytest.raises(HTTPException) as raised:
await ScanCompareService(estate.session).comparison(a, b, estate.project_id)
assert raised.value.detail == REFUSAL_REASON[Refusal.DIFFERENT_TARGET.value]
async def test_empty_text_and_null_are_the_same_value(estate, now):
a, b, old = await _pair(estate, now)
await estate.hosts("first", ["a.example.com"], at=old, webserver="")
await estate.hosts("second", ["a.example.com"], at=now, webserver=None)
await estate.activity("first", RAN_WEB)
await estate.activity("second", RAN_WEB)
service = ScanCompareService(estate.session)
report = await service.comparison(a, b, estate.project_id)
rows = await service.rows(
baseline_id=a,
current_id=b,
project_id=estate.project_id,
dimension=WEB,
verbs=[],
page=1,
size=50,
)
assert _dim(report, WEB).changed == 0
assert rows.total == 0
async def test_a_counted_change_always_renders_a_field(estate, now):
a, b, old = await _pair(estate, now)
await estate.hosts(
"first", ["a.example.com"], at=old, status=200, webserver="nginx"
)
await estate.hosts("second", ["a.example.com"], at=now, status=200, webserver="iis")
await estate.activity("first", RAN_WEB)
await estate.activity("second", RAN_WEB)
service = ScanCompareService(estate.session)
report = await service.comparison(a, b, estate.project_id)
rows = await service.rows(
baseline_id=a,
current_id=b,
project_id=estate.project_id,
dimension=WEB,
verbs=[ChangeVerb.CHANGED.value],
page=1,
size=50,
)
assert _dim(report, WEB).changed == rows.total == 1
assert rows.items[0].verb == ChangeVerb.CHANGED.value
assert rows.items[0].fields, "a counted change must name the field that moved"
async def test_rows_prove_coverage_when_the_rollup_columns_are_wrong(estate, now):
old = now - timedelta(hours=2)
a = await estate.scan(
"example.com", "first", at=old, status="cancelled", engine=ENGINE
)
b = await estate.scan("example.com", "second", at=now, engine=ENGINE)
await estate.hosts("first", ["gone.example.com", "kept.example.com"], at=old)
await estate.hosts("second", ["kept.example.com"], at=now)
await estate.activity("first", {"subdomain_discovery": "failed"})
await estate.activity("second", RAN_WEB)
report = await ScanCompareService(estate.session).comparison(
a, b, estate.project_id
)
web = _dim(report, WEB)
assert web.verdict.covered_baseline is True, "rows exist, so it was covered"
assert web.total_baseline == 2
assert web.disappeared == 1
async def test_service_pages_are_stable_across_a_shared_address(estate, now):
a, b, old = await _pair(estate, now)
ports = [("10.0.0.1", 8000 + n, "http") for n in range(12)]
await estate.ports("first", [], at=old)
await estate.ports("second", ports, at=now)
await estate.activity("first", RAN_PORTS)
await estate.activity("second", RAN_PORTS)
service = ScanCompareService(estate.session)
seen: list[str] = []
for page in (1, 2, 3):
result = await service.rows(
baseline_id=a,
current_id=b,
project_id=estate.project_id,
dimension=SERVICES,
verbs=[],
page=page,
size=4,
)
seen += [row.key for row in result.items]
assert len(seen) == len(set(seen)) == 12, "every port appears exactly once"
async def test_no_verbs_selected_returns_nothing(estate, now):
a, b, old = await _pair(estate, now)
await estate.hosts("first", ["gone.example.com"], at=old)
await estate.hosts("second", ["new.example.com"], at=now)
await estate.activity("first", RAN_WEB)
await estate.activity("second", RAN_WEB)
rows = await ScanCompareService(estate.session).rows(
baseline_id=a,
current_id=b,
project_id=estate.project_id,
dimension=WEB,
verbs=[ChangeVerb.UNCONFIRMED.value],
page=1,
size=50,
)
assert rows.total == 0, "the only asked-for verb cannot occur here"
assert rows.items == []
async def test_the_diff_reads_as_a_unified_diff(estate, now):
a, b, old = await _pair(estate, now)
await estate.hosts("first", ["gone.example.com", "kept.example.com"], at=old)
await estate.hosts("second", ["kept.example.com", "new.example.com"], at=now)
await estate.activity("first", RAN_WEB)
await estate.activity("second", RAN_WEB)
text = await ScanCompareService(estate.session).diff(
baseline_id=a,
current_id=b,
project_id=estate.project_id,
dimension=None,
verbs=[],
)
lines = text.splitlines()
assert lines[0].startswith("--- ")
assert lines[1].startswith("+++ ")
assert any(line.startswith("@@ web_assets") for line in lines)
assert "+ new.example.com" in lines
assert "- gone.example.com" in lines
assert not any(line.startswith(" kept.example.com") for line in lines)
async def test_the_diff_names_a_dimension_one_run_did_not_scan(estate, now):
a, b, old = await _pair(estate, now)
await estate.vulns("first", [("cve-a", "high")], at=old)
await estate.activity("first", RAN_VULNS)
await estate.activity("second", RAN_WEB)
text = await ScanCompareService(estate.session).diff(
baseline_id=a,
current_id=b,
project_id=estate.project_id,
dimension=None,
verbs=[],
)
assert "# vulnerabilities not scanned in the later run." in text
assert "@@ vulnerabilities" not in text
async def test_the_diff_honours_the_dimension_and_verb_filters(estate, now):
a, b, old = await _pair(estate, now)
await estate.hosts("first", ["gone.example.com"], at=old)
await estate.hosts("second", ["new.example.com"], at=now)
await estate.ports("second", [("10.0.0.1", 22, "ssh")], at=now)
await estate.activity("first", {**RAN_WEB, **RAN_PORTS})
await estate.activity("second", {**RAN_WEB, **RAN_PORTS})
text = await ScanCompareService(estate.session).diff(
baseline_id=a,
current_id=b,
project_id=estate.project_id,
dimension=WEB,
verbs=[ChangeVerb.APPEARED.value],
)
assert "+ new.example.com" in text
assert "gone.example.com" not in text
assert "@@ services" not in text