Files
rengine/tests/api/test_notes.py
T
Yogesh OjhaandClaude Opus 5 0fd89332e2 feat(notes): recon notes anchored to an asset
2.x had `recon_note.TodoNote` — a title, a body and an optional scan. 3.0
dropped it and replaced it with nothing, so the operator's own reasoning had
nowhere to live: the lead to come back to, the dead end not worth retesting,
the reason behind a conclusion.

A note anchors to a durable asset identity rather than to a scan row. A note
written on api.example.com during scan 2 is still on that host in scan 9,
because the anchor is the host name, not the `subdomains.id` scan 2 created.
Identity per dimension: host name, endpoint signature, `ip:port`, address, and
vulnerability fingerprint.

That gives the rollup for free. An asset sheet shows its own notes across every
scan; a scan shows notes for assets it observed, plus what was written on it; a
target shows everything under it; /notes holds the project. The scan reach is a
per-dimension EXISTS over the `TABLES` map `surface_scope` already holds.

Every note carries at least one tag, enforced by `min_length=1` on the create
model and a disabled Save in the composer. Tags are the project vocabulary from
`shared/models/tag.py`, so a tag means the same thing on a note as on a target.

`dimension` is a `SurfaceDimension` value, so notes inherit the labels, nouns
and ordering the rest of the product uses. No second asset-kind enum.

Surfaces: a Notes section in the web asset, endpoint, service and vulnerability
sheets; a Notes tab on a scan and on a target; and /notes with tag, dimension,
status and free-text filters. The IP sheet is left out — an address group folds
across targets, so it has no single rollup anchor.

Also gives the two ScrollAreas added with the focused-scan work a definite
height; `max-h` alone does not clip, and a short list now renders without a
scroller rather than showing an empty track.

Row markers on the result tables are not built.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01799buekQvDvH41ns8EdhdH
2026-09-11 22:21:13 +05:30

204 lines
6.3 KiB
Python

from __future__ import annotations
import uuid
from datetime import timedelta
import pytest
from fastapi import HTTPException
from pydantic import ValidationError
from sqlalchemy import select
from app.services.note import NoteService
from shared.definitions.notes import NoteStatus
from shared.definitions.surface import SurfaceDimension
from shared.models.note import Note, NoteCreate, NoteUpdate
from shared.models.tag import Tag
async def _tag(estate, name: str = "lead") -> Tag:
tag = Tag(
id=uuid.uuid4(),
name=name,
slug=name,
color="#6B7280",
project_id=estate.project_id,
created_by=estate.user_id,
)
estate.session.add(tag)
await estate.session.flush()
return tag
async def _two_runs(estate, now):
"""The same host seen by two scans of one target."""
earlier = now - timedelta(days=3)
await estate.scan("example.com", "first", at=earlier)
await estate.hosts("first", ["api.example.com", "gone.example.com"], at=earlier)
await estate.scan("example.com", "second", at=now)
await estate.hosts("second", ["api.example.com"], at=now)
return estate
async def _write(estate, scan: str, host: str, tag: Tag, body: str = "Worth a look"):
return await NoteService(estate.session).create(
NoteCreate(
target_id=estate.targets["example.com"],
scan_id=estate.scans[scan],
dimension=SurfaceDimension.WEB_ASSETS.value,
asset_key=host,
asset_label=host,
body=body,
tag_ids=[tag.id],
),
estate.project_id,
estate.user_id,
)
async def test_a_note_follows_its_asset_into_a_later_scan(estate, now):
await _two_runs(estate, now)
tag = await _tag(estate)
await _write(estate, "first", "api.example.com", tag)
service = NoteService(estate.session)
for name in ("first", "second"):
query = await service.list(estate.project_id, scan_id=estate.scans[name])
rows = (await estate.session.execute(query)).all()
assert [r[0].asset_key for r in rows] == ["api.example.com"], name
async def test_a_note_on_a_host_a_scan_never_saw_stays_out_of_it(estate, now):
await _two_runs(estate, now)
tag = await _tag(estate)
await _write(estate, "first", "gone.example.com", tag)
service = NoteService(estate.session)
first = (
await estate.session.execute(
await service.list(estate.project_id, scan_id=estate.scans["first"])
)
).all()
second = (
await estate.session.execute(
await service.list(estate.project_id, scan_id=estate.scans["second"])
)
).all()
assert len(first) == 1
assert second == []
async def test_the_target_carries_every_note_its_scans_hold(estate, now):
await _two_runs(estate, now)
tag = await _tag(estate)
await _write(estate, "first", "gone.example.com", tag, body="Retired host")
await _write(estate, "second", "api.example.com", tag, body="No auth")
service = NoteService(estate.session)
rows = (
await estate.session.execute(
await service.list(
estate.project_id, target_id=estate.targets["example.com"]
)
)
).all()
assert len(rows) == 2
async def test_the_count_equals_the_rows_it_opens(estate, now):
await _two_runs(estate, now)
tag = await _tag(estate)
await _write(estate, "second", "api.example.com", tag)
await _write(estate, "second", "api.example.com", tag, body="And another")
service = NoteService(estate.session)
counts = await service.counts(
estate.project_id, dimension=SurfaceDimension.WEB_ASSETS.value
)
assert [(c.key, c.total, c.open) for c in counts] == [("api.example.com", 2, 2)]
rows = (
await estate.session.execute(
await service.list(
estate.project_id,
dimension=SurfaceDimension.WEB_ASSETS.value,
asset_key="api.example.com",
)
)
).all()
assert len(rows) == counts[0].total
async def test_resolving_a_note_leaves_the_total_and_lowers_the_open_count(estate, now):
await _two_runs(estate, now)
tag = await _tag(estate)
note = await _write(estate, "second", "api.example.com", tag)
service = NoteService(estate.session)
await service.update(
note.id, NoteUpdate(status=NoteStatus.RESOLVED.value), estate.project_id
)
counts = await service.counts(
estate.project_id, dimension=SurfaceDimension.WEB_ASSETS.value
)
assert counts[0].total == 1
assert counts[0].open == 0
async def test_a_tag_outside_the_project_is_refused(estate, now):
await _two_runs(estate, now)
stranger = Tag(
id=uuid.uuid4(),
name="other",
slug="other",
color="#6B7280",
project_id=uuid.uuid4(),
created_by=estate.user_id,
)
with pytest.raises(HTTPException, match="not in this project"):
await _write(estate, "second", "api.example.com", stranger)
async def test_deleting_a_note_removes_its_tag_links(estate, now):
await _two_runs(estate, now)
tag = await _tag(estate)
note = await _write(estate, "second", "api.example.com", tag)
service = NoteService(estate.session)
await service.delete(note.id, estate.project_id)
left = (
(
await estate.session.execute(
select(Note).where(Note.project_id == estate.project_id)
)
)
.scalars()
.all()
)
assert left == []
def test_a_note_without_a_tag_is_refused():
with pytest.raises(ValidationError):
NoteCreate(target_id=uuid.uuid4(), body="no tags", tag_ids=[])
def test_an_asset_note_needs_both_a_dimension_and_an_asset():
with pytest.raises(ValidationError, match="both a dimension and an asset"):
NoteCreate(
target_id=uuid.uuid4(),
dimension=SurfaceDimension.WEB_ASSETS.value,
body="half an anchor",
tag_ids=[uuid.uuid4()],
)
def test_an_empty_body_is_refused():
with pytest.raises(ValidationError):
NoteCreate(target_id=uuid.uuid4(), body=" ", tag_ids=[uuid.uuid4()])
def test_an_update_cannot_strip_the_last_tag():
with pytest.raises(ValidationError, match="at least one tag"):
NoteUpdate(tag_ids=[])