Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2.3.0
2.3.1
23 changes: 19 additions & 4 deletions cisco_advisory_impact_agent/html_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,12 @@ def _advisories_html(advisories):
for adv in ordered:
advisory_id = esc(adv.get("advisoryId"))
url = esc(adv.get("publicationUrl") or "")
link = f'<a href="{url}" rel="noreferrer noopener">{advisory_id}</a>' if url \
else advisory_id
# Opens in a new tab so the reader keeps their place in the report — they are
# working through a list, and navigating away loses their filter and scroll
# position. `rel` is required alongside `target` to deny the opened page a
# reference back to this document.
link = (f'<a href="{url}" target="_blank" rel="noreferrer noopener">'
f'{advisory_id}</a>') if url else advisory_id
sir = esc(adv.get("sir") or "Unrated")
fix = first_fix_of(adv)
fix_html = (f'<span class="fix">fixed in {esc(fix)}</span>' if fix
Expand Down Expand Up @@ -197,7 +201,11 @@ def reason_text(reason):
# Document
# --------------------------------------------------------------------------- #
_CSS = """
:root { color-scheme: light dark; }
/* This report is light-themed throughout (see the hardcoded body colours below), so it
must not advertise dark-mode support. Declaring `light dark` made the browser style
form controls for dark mode — giving the filter buttons white text on the white
background set here, invisible until pressed. */
:root { color-scheme: light; }
* { box-sizing: border-box; }
body { margin: 0; padding: 2rem 1.5rem; font: 15px/1.5 -apple-system, BlinkMacSystemFont,
"Segoe UI", Roboto, Helvetica, Arial, sans-serif; color: #1b1b1b;
Expand Down Expand Up @@ -232,10 +240,17 @@ def reason_text(reason):
.nofix { font-style: italic; color: #8a5a00; }
.controls { margin: .75rem 0 1rem; display: flex; gap: .5rem; flex-wrap: wrap;
align-items: center; }
/* `color` is set explicitly rather than left to the user agent: `font: inherit` does not
carry colour, so an inherited default is whatever the browser decides and can end up
matching the background. */
.controls button { font: inherit; font-size: .85rem; padding: .3rem .7rem; cursor: pointer;
border: 1px solid #c3c7cc; border-radius: 4px; background: #fff; }
border: 1px solid #c3c7cc; border-radius: 4px;
background: #fff; color: #1b1b1b; }
.controls button:hover { background: #eef1f4; border-color: #9aa1a9; }
.controls button:focus-visible { outline: 2px solid #1f4e78; outline-offset: 1px; }
.controls button[aria-pressed="true"] { background: #1f4e78; color: #fff;
border-color: #1f4e78; }
.controls button[aria-pressed="true"]:hover { background: #1a4265; }
.controls .spacer { flex: 1 1 auto; }
.summary { background: #fff; border: 1px solid #e2e4e7; border-radius: 6px;
padding: 1rem 1.1rem; margin: 1rem 0; }
Expand Down
68 changes: 68 additions & 0 deletions tests/test_html_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,74 @@ def test_single_advisory_is_not_pluralised(self):
self.assertIn("1 advisory<", text)


class ControlLegibilityRegressionTests(unittest.TestCase):
"""The filter buttons were once white-on-white until pressed. Guard both causes."""

def _rule(self, selector):
"""The declaration block for an exact selector in the inlined stylesheet."""
match = re.search(re.escape(selector) + r"\s*\{([^}]*)\}", hr._CSS)
self.assertIsNotNone(match, f"no CSS rule found for {selector!r}")
return match.group(1)

def test_buttons_set_an_explicit_text_colour(self):
# `font: inherit` does not carry colour, so leaving it to the user agent is what
# allowed the text to match the background.
block = self._rule(".controls button")
self.assertRegex(block, r"(^|;|\s)color\s*:",
"'.controls button' must set an explicit color")

def test_button_colour_differs_from_its_background(self):
block = self._rule(".controls button")
colours = dict(re.findall(r"(background|color)\s*:\s*([^;]+)", block))
self.assertIn("color", colours)
self.assertIn("background", colours)
self.assertNotEqual(colours["color"].strip().lower(),
colours["background"].strip().lower())

def test_report_does_not_advertise_dark_mode_it_does_not_implement(self):
# The page hardcodes light body colours; claiming `light dark` made the browser
# style form controls for dark mode against a light background.
self.assertNotIn("color-scheme: light dark", hr._CSS)
self.assertIn("color-scheme: light", hr._CSS)

def test_pressed_and_unpressed_states_are_visually_distinct(self):
unpressed = self._rule(".controls button")
pressed = self._rule('.controls button[aria-pressed="true"]')
self.assertNotEqual(
re.search(r"background\s*:\s*([^;]+)", unpressed).group(1).strip(),
re.search(r"background\s*:\s*([^;]+)", pressed).group(1).strip())


class AdvisoryLinkTargetTests(unittest.TestCase):
"""Advisory links must open in a new tab and not leak a handle back to the report."""

def test_advisory_link_opens_in_a_new_tab(self):
text = hr.render([_device(advisories=[fx.advisory()])])
self.assertIn('target="_blank"', text)

def test_every_advisory_link_has_a_target(self):
advisories = [fx.advisory(advisory_id=f"cisco-sa-{i:02d}") for i in range(5)]
text = hr.render([_device(advisories=advisories)])
anchors = re.findall(r"<a\s[^>]*>", text)
self.assertEqual(len(anchors), 5)
for anchor in anchors:
self.assertIn('target="_blank"', anchor)

def test_new_tab_links_carry_noopener_and_noreferrer(self):
# Required alongside target="_blank": denies the opened page window.opener.
text = hr.render([_device(advisories=[fx.advisory()])])
for anchor in re.findall(r"<a\s[^>]*>", text):
self.assertIn("noopener", anchor)
self.assertIn("noreferrer", anchor)

def test_an_advisory_without_a_url_renders_no_anchor(self):
adv = dict(fx.advisory())
adv["publicationUrl"] = ""
text = hr.render([_device(advisories=[adv])])
self.assertEqual(re.findall(r"<a\s[^>]*>", text), [])
self.assertIn(adv["advisoryId"], text)


class InteractivityTests(unittest.TestCase):
def test_filter_controls_exist_for_each_verdict(self):
devices = [
Expand Down
Loading