Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
144b823
🎨 Palette: 시각적 피드백 및 접근성 스타일 개선
seonghobae Aug 12, 2026
e69faa3
🎨 Palette: 외부 참조 링크 접근성 향상 (새 탭 열림 경고 추가)
seonghobae Aug 13, 2026
a4009d0
🎨 Palette: 시각적 피드백 및 접근성 스타일 개선
seonghobae Aug 14, 2026
570d073
🎨 Palette: 시각적 피드백 및 접근성 스타일 개선
seonghobae Aug 14, 2026
8964b5c
🎨 Palette: 시각적 피드백 및 접근성 스타일 개선
seonghobae Aug 14, 2026
03dfb8f
🎨 Palette: 콘솔 대시보드 disabled 및 loading 상태 시각적 피드백 추가
seonghobae Aug 14, 2026
9a28a7d
test(console): pin ephemeral single-flight connection contract
seonghobae Aug 14, 2026
d8bd514
fix(console): keep viewer key ephemeral and synchronize connect state
seonghobae Aug 14, 2026
dc2951a
test(console): verify readable exception-safe connect flow
seonghobae Aug 14, 2026
afd3b1e
test(dashboard): pin canonical safe URL contract
seonghobae Aug 14, 2026
047dda3
fix(dashboard): canonicalize untrusted reference URLs
seonghobae Aug 14, 2026
5ed070e
🎨 Palette: 시각적 피드백 및 접근성 스타일 개선
seonghobae Aug 14, 2026
e970f7e
test(console): reproduce attribute and state-feedback regressions
seonghobae Aug 15, 2026
317604f
fix(console): escape identifiers and align interactive feedback
seonghobae Aug 15, 2026
567ed86
test(dashboard): preserve new-tab reference announcement
seonghobae Aug 15, 2026
a60737e
chore(dashboard): isolate external-link accessibility delta
seonghobae Aug 15, 2026
6442ae7
feat(dashboard): announce external-reference new-tab navigation (#935)
seonghobae Aug 15, 2026
11f585c
🎨 Palette: 외부 링크에 새 탭 열림 동작 스크린 리더 안내 추가
seonghobae Aug 15, 2026
631bd42
test(dashboard): retain external-link accessibility regression
seonghobae Aug 15, 2026
6c9a147
merge(dashboard): consolidate secure console state handling onto acce…
seonghobae Aug 15, 2026
d91a8fb
fix(console): consolidate secure credential and async UI state handli…
seonghobae Aug 15, 2026
7423a2a
🎨 Palette: 외부 링크에 새 탭 열림 동작 스크린 리더 안내 추가
seonghobae Aug 15, 2026
dfdd15f
fix(pr-930): restore integrated console hardening
seonghobae Aug 15, 2026
41eba77
test(pr-930): reproduce inaccessible detail focus
seonghobae Aug 15, 2026
be8773e
fix(pr-930): focus operable detail close control
seonghobae Aug 15, 2026
509d2b1
🎨 Palette: 외부 링크에 새 탭 열림 동작 스크린 리더 안내 추가
seonghobae Aug 15, 2026
9ee4612
fix(console): preserve hardened detail flow and align tests
seonghobae Aug 15, 2026
4d3747e
🎨 Palette: 외부 링크에 새 탭 열림 동작 스크린 리더 안내 추가
seonghobae Aug 15, 2026
382db98
🎨 Palette: 외부 링크에 새 탭 열림 동작 스크린 리더 안내 추가
seonghobae Aug 15, 2026
b39d078
test(console): require usable detail close focus
seonghobae Aug 15, 2026
6b77c9d
fix(console): focus operable detail close controls
seonghobae Aug 15, 2026
c879de8
test(dashboard): assert focus lands on close control
seonghobae Aug 15, 2026
10c779d
test(dashboard): assert focus restoration contract
seonghobae Aug 15, 2026
99114c0
test(dashboard): verify close behavior per result path
seonghobae Aug 15, 2026
5451b76
test(console): require viewer-only browser credentials
seonghobae Aug 15, 2026
a9fd3d2
test(console): prove side-effect-free viewer role probe
seonghobae Aug 15, 2026
eeb7da2
fix(console): enforce viewer-only in-memory credentials
seonghobae Aug 15, 2026
1b7e837
🎨 Palette: 외부 링크에 새 탭 열림 동작 스크린 리더 안내 추가
seonghobae Aug 15, 2026
2269c8b
🎨 Palette: 시각적 피드백 및 접근성 스타일 개선
seonghobae Aug 15, 2026
a77188f
test(console): restore viewer-key regression
seonghobae Aug 15, 2026
ec138be
test(console): restore detail-focus regression
seonghobae Aug 15, 2026
5ffdc21
fix(console): restore least-privilege viewer flow
seonghobae Aug 15, 2026
4867cae
test(console): pin close-focus restoration
seonghobae Aug 15, 2026
d098c86
test(dashboard): restore external-link contract
seonghobae Aug 15, 2026
5639f04
🎨 Palette: 시각적 피드백 및 접근성 스타일 개선
seonghobae Aug 15, 2026
2282c78
test(console): reproduce scan-id attribute injection
seonghobae Aug 15, 2026
7a807c5
fix(console): restore least-privilege accessibility contracts
seonghobae Aug 15, 2026
673760e
🎨 Palette: 시각적 피드백 및 접근성 스타일 개선
seonghobae Aug 15, 2026
0bea082
test(controlplane): remove DNS latency from empty webhook regression
seonghobae Aug 16, 2026
4b9a7e4
test(dashboard): cover untrusted scan scalar XSS
seonghobae Aug 16, 2026
9368bea
fix(dashboard): escape untrusted scan scalars
seonghobae Aug 16, 2026
ce4c95a
🎨 Palette: 시각적 피드백 및 접근성 스타일 개선
seonghobae Aug 16, 2026
a6d21cf
🎨 Palette: 시각적 피드백 및 접근성 스타일 개선
seonghobae Aug 16, 2026
c68dc10
test(console): require close-control focus in every detail path
seonghobae Aug 16, 2026
df28e7e
fix(console): focus actionable detail close controls
seonghobae Aug 16, 2026
0604821
test(console): preserve focus restoration contract
seonghobae Aug 16, 2026
7a46834
🎨 Palette: 시각적 피드백 및 접근성 스타일 개선
seonghobae Aug 16, 2026
06c48b0
test(dashboard): preserve visible interactive state parity
seonghobae Aug 17, 2026
e5dec61
fix(dashboard): restore visible hover and busy-row feedback
seonghobae Aug 17, 2026
2e96553
🎨 Palette: 시각적 피드백 및 접근성 상태 개선
seonghobae Aug 17, 2026
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
3 changes: 3 additions & 0 deletions .jules/palette.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,6 @@
## 2026-08-12 - Skip to Content Accessibility
**Learning:** Screen reader and keyboard-only users experience significant friction when forced to navigate through repetitive header controls on every page load.
**Action:** Keep a visible-on-focus skip link as the first interactive element, target a programmatically focusable main container, and give the focused link a high-contrast outline.
## 2024-05-19 - Accessibility for UI states and links
**Learning:** Adding ARIA-labels for "opens in a new tab" to external links significantly helps screen reader context. Using `aria-busy` along with visual styling (`opacity`, `pointer-events`) accurately represents loading rows in non-framework components. Extracting inline styles to CSS classes is necessary when adding hover/focus states to elements like buttons. Modifying programmatic focus flow (e.g. `element.focus()`) can unintentionally break implicit keyboard accessibility tests; adding shortcut tooltips (`title`) or ARIA labels is safer for incremental a11y improvements.
**Action:** Consistently apply `aria-label` to external links, `aria-busy` to loading states, and extract inline button styles to CSS for interactive pseudo-classes, but avoid touching `focus()` lines on components unless specifically refactoring the focus flow.
59 changes: 45 additions & 14 deletions scanner/dashboard/console.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
<title>AppGuardrail Console</title>
<!--
Org console for the AppGuardrail control plane (`appguardrail serve`).
Paste an org API key, then it calls GET /api/v1/scans (same origin) to show
scan history, the deploy-blocking trend, and per-scan detail.
Paste a dedicated viewer API key, then it calls GET /api/v1/scans (same origin)
to show scan history, the deploy-blocking trend, and per-scan detail.
ponytail: no framework, no build step. Consumes the API in controlplane.py.
-->
<style>
Expand All @@ -24,7 +24,8 @@
h1{font-size:16px;margin:0;font-weight:700}
main{max-width:1000px;margin:0 auto;padding:20px}
input{font:inherit;padding:8px 10px;border:1px solid var(--border);border-radius:8px;min-width:280px}
button{font:inherit;font-weight:600;padding:8px 14px;border:0;border-radius:8px;background:var(--primary);color:var(--on-primary);cursor:pointer}
button{font:inherit;font-weight:600;padding:8px 14px;border:0;border-radius:8px;background:var(--primary);color:var(--on-primary);cursor:pointer;transition:filter 0.2s, opacity 0.2s}
button:hover:not(:disabled){filter:brightness(.94)}
button.ghost{background:var(--surface);color:var(--primary);border:1px solid var(--border)}
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:16px 18px;margin-bottom:16px}
.stats{display:flex;gap:12px;flex-wrap:wrap}
Expand All @@ -37,6 +38,8 @@
tr.scan{cursor:pointer}
tr.scan:hover{background:var(--bg)}
input:focus-visible, button:focus-visible, tr.scan:focus-visible, .bar:focus-visible, #detail:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
button:disabled{opacity:0.5;cursor:not-allowed}
tr.scan[aria-busy="true"]{opacity:0.7;cursor: wait; pointer-events: none}
.close-btn{float:right;border:0;background:transparent;font-size:16px;cursor:pointer;color:var(--muted);padding:0 4px;margin-top:-2px}
.close-btn:hover{color:var(--text)}
.pill{display:inline-block;padding:1px 8px;border-radius:999px;font-size:11px;font-weight:700;color:#fff}
Expand All @@ -54,12 +57,12 @@
<h1>AppGuardrail Console</h1>
<span class="muted" id="conn" role="status" aria-live="polite" aria-atomic="true"></span>
<span style="flex:1"></span>
<input id="key" type="password" placeholder="Org API key (agk_…)" autocomplete="off" aria-label="Organization API key">
<input id="key" type="password" placeholder="Viewer API key (agk_…)" autocomplete="off" aria-label="Viewer API key">
<button id="connect">Connect</button>
<button id="logout" class="ghost hidden">Sign out</button>
</header>
<main>
<div id="msg" class="card muted" role="alert" aria-live="polite">Paste your org API key and connect to view scan history.</div>
<div id="msg" class="card muted" role="alert" aria-live="polite">Paste a dedicated viewer API key and connect to view scan history.</div>
<div id="app" class="hidden">
<div class="stats" id="stats"></div>
<div class="card">
Expand All @@ -77,7 +80,9 @@ <h1>AppGuardrail Console</h1>
const $=s=>document.querySelector(s);
const esc=s=>String(s==null?"":s).replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
const SEV={CRITICAL:"var(--crit)",HIGH:"var(--high)",WARNING:"var(--warn)",INFO:"var(--info)"};
let KEY=sessionStorage.getItem("ag_key")||"";
let KEY="";
sessionStorage.removeItem("ag_key");
sessionStorage.removeItem("ag_viewer_key");
let currentDetailRequest=0;
let lastDetailFocus=null;

Expand All @@ -103,6 +108,11 @@ <h1>AppGuardrail Console</h1>
if(!r.ok)throw new Error("Request failed ("+r.status+").");
return r.json();
}
async function requireViewerKey(){
const r=await fetch("/api/v1/scans",{method:"POST",headers:{Authorization:"Bearer "+KEY}});
if(r.status===401)throw new Error("Invalid API key.");
if(r.status!==403)throw new Error("Use a dedicated viewer API key.");
}
function pill(n,color){return n>0?`<span class="pill" style="background:${color}">${n}</span>`:`<span class="muted">0</span>`;}
function scrollDetailIntoView(element){
if(window.matchMedia("(prefers-reduced-motion: reduce)").matches){
Expand Down Expand Up @@ -131,7 +141,7 @@ <h1>AppGuardrail Console</h1>
$("#trend").innerHTML=ord.map(s=>{const h=Math.round(6+((s.deploy_blocking||0)/max)*54);
const col=(s.deploy_blocking||0)>0?"var(--crit)":"var(--ok)";
return `<div class="bar" tabindex="0" role="img" aria-label="${esc(s.created_at)}: ${esc(String(s.deploy_blocking||0))} blocking" title="${esc(s.created_at)}: ${esc(String(s.deploy_blocking||0))} blocking" style="height:${h}px;background:${col}"></div>`;}).join("")||'<span class="muted">No scans yet.</span>';
$("#history tbody").innerHTML=scans.map(s=>`<tr class="scan" data-id="${s.id}" tabindex="0" role="button" title="View scan details">
$("#history tbody").innerHTML=scans.map(s=>`<tr class="scan" data-id="${esc(s.id)}" tabindex="0" role="button" title="View scan details">
<td>${esc(s.created_at)}</td><td>${esc(s.repo||"—")}</td><td><code>${esc((s.commit||"—").slice(0,10))}</code></td>
<td>${s.total}</td><td>${pill(s.deploy_blocking,"var(--crit)")}</td><td>${pill(s.new_blocking,"var(--high)")}</td></tr>`).join("")||'<tr><td colspan="6" class="muted">No scans. POST to /api/v1/scans from CI.</td></tr>';
document.querySelectorAll("tr.scan").forEach(tr=>{
Expand All @@ -158,29 +168,50 @@ <h1>AppGuardrail Console</h1>
const rows=(s.findings||[]).map(f=>`<tr><td><span class="pill" style="background:${SEV[f.severity]||'var(--info)'}">${esc(f.severity)}</span></td>
<td><code>${esc(f.rule_id)}</code></td><td>${esc((f.message||"").split("\n")[0].slice(0,120))}</td>
<td><code>${esc(f.file)}:${esc(f.line)}</code></td></tr>`).join("");
d.innerHTML=`<button type="button" class="close-btn" aria-label="Close details">✕</button><strong>Scan #${esc(s.id)}</strong> <span class="muted">${esc(s.created_at)} · ${esc(s.repo||"—")}</span>
d.innerHTML=`<button type="button" class="close-btn" aria-label="Close details" title="Close (Esc)">✕</button><strong>Scan #${esc(s.id)}</strong> <span class="muted">${esc(s.created_at)} · ${esc(s.repo||"—")}</span>
<table style="margin-top:8px"><thead><tr><th scope="col">Severity</th><th scope="col">Rule</th><th scope="col">Finding</th><th scope="col">Location</th></tr></thead>
<tbody>${rows||'<tr><td colspan="4" class="muted">No findings.</td></tr>'}</tbody></table>`;
d.querySelector(".close-btn").addEventListener("click",closeDetail);
scrollDetailIntoView(d);
d.focus({preventScroll:true});
d.querySelector(".close-btn").focus({preventScroll:true});
}catch(e){
if(requestId!==currentDetailRequest)return;
d.innerHTML='<button type="button" class="close-btn" aria-label="Close details">✕</button><div role="alert" class="err">Error loading details: '+esc(e.message)+'</div>';
d.innerHTML='<button type="button" class="close-btn" aria-label="Close details" title="Close (Esc)">✕</button><div role="alert" class="err">Error loading details: '+esc(e.message)+'</div>';
d.querySelector(".close-btn").addEventListener("click",closeDetail);
scrollDetailIntoView(d);
d.focus({preventScroll:true});
d.querySelector(".close-btn").focus({preventScroll:true});
}finally{
if(tr&&tr.dataset.detailRequest===String(requestId)){
tr.removeAttribute("aria-busy");
delete tr.dataset.detailRequest;
}
}
}
$("#connect").onclick=async()=>{KEY=$("#key").value.trim();if(!KEY)return;sessionStorage.setItem("ag_key",KEY);$("#key").value="";$("#connect").disabled=true;$("#connect").setAttribute("aria-busy","true");$("#connect").textContent="Connecting...";await load();$("#connect").disabled=false;$("#connect").removeAttribute("aria-busy");$("#connect").textContent="Connect";};
$("#connect").onclick=async()=>{
KEY=$("#key").value.trim();
if(!KEY)return;
$("#key").value="";
$("#connect").disabled=true;
$("#connect").setAttribute("aria-busy","true");
$("#connect").textContent="Connecting...";
try{
await requireViewerKey();
await load();
}catch(e){
KEY="";
$("#conn").textContent="";
$("#msg").classList.remove("hidden");
$("#app").classList.add("hidden");
$("#logout").classList.add("hidden");
$("#msg").innerHTML=`<span class="err">${esc(e.message)}</span>`;
}finally{
$("#connect").disabled=false;
$("#connect").removeAttribute("aria-busy");
$("#connect").textContent="Connect";
}
};
$("#key").addEventListener("keydown",e=>{if(e.key==="Enter")$("#connect").click();});
$("#logout").onclick=()=>{sessionStorage.removeItem("ag_key");location.reload();};
if(KEY)load();
$("#logout").onclick=()=>{KEY="";sessionStorage.removeItem("ag_key");sessionStorage.removeItem("ag_viewer_key");location.reload();};
</script>
</body>
</html>
6 changes: 4 additions & 2 deletions scanner/dashboard/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@
.dlg-head button{border:none;background:none;font-size:20px;cursor:pointer;color:var(--muted);width:32px;height:32px;border-radius:50%;display:flex;align-items:center;justify-content:center;transition:background 0.2s, color 0.2s}
.dlg-head button:hover{background:var(--bg);color:var(--text)}
.dlg-head button:focus-visible{outline:2px solid var(--primary);outline-offset:2px}
.clear-btn{padding:6px 12px;border-radius:6px;border:1px solid var(--border);background:var(--surface);cursor:pointer;font:inherit;color:var(--text);font-weight:500;transition:background 0.2s}
.clear-btn:hover{background:var(--divider)}
.dlg-body{padding:6px 22px 22px}
.sec{border:1px solid var(--border);border-left-width:4px;border-radius:8px;padding:14px 16px;margin-top:14px}
.sec h3{margin:0 0 6px;font-size:12px;letter-spacing:.4px;text-transform:uppercase}
Expand Down Expand Up @@ -235,7 +237,7 @@ <h1>Dashboard</h1>
<thead><tr><th scope="col">Severity</th><th scope="col">Finding</th><th scope="col">File</th><th scope="col">Category</th><th scope="col">Status</th></tr></thead>
<tbody>${rows||`<tr><td colspan="5" style="padding:32px 24px;text-align:center">
<div style="color:var(--text);font-weight:600;font-size:14px;margin-bottom:8px">No findings match the filter</div>
<button type="button" aria-label="Clear filters" onclick="query=''; filterSev=''; render(); document.getElementById('q')?.focus();" style="padding:6px 12px;border-radius:6px;border:1px solid var(--border);background:var(--surface);cursor:pointer;font:inherit;color:var(--text);font-weight:500;transition:background 0.2s">Clear filters</button>
<button type="button" aria-label="Clear filters" onclick="query=''; filterSev=''; render(); document.getElementById('q')?.focus();" class="clear-btn">Clear filters</button>
</td></tr>`}</tbody>
</table></div>
</div>
Expand Down Expand Up @@ -281,7 +283,7 @@ <h1>Dashboard</h1>
function openDetail(f){
lastFocus = document.activeElement;
const s = String(f.severity||'INFO').toUpperCase();
const refs = (f.references||[]).map(r=>`<a href="${esc(safeUrl(r))}" target="_blank" rel="noopener">${esc(r)}</a>`).join('<br>');
const refs = (f.references||[]).map(r=>`<a href="${esc(safeUrl(r))}" target="_blank" rel="noopener" aria-label="${esc(r)} (opens in a new tab)">${esc(r)}</a>`).join('<br>');
const owasp = (f.owasp||[]).join(', ');
const cwe = (f.cwe||[]).join(', ');
const d = document.getElementById('detail');
Expand Down
2 changes: 1 addition & 1 deletion tests/test_console_dashboard_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,5 @@ def test_detail_panel_close_invalidates_async_work_and_restores_focus() -> None:
assert 'e.key==="Escape"' in html
assert html.count('class="close-btn" aria-label="Close details"') == 2
assert html.count('d.querySelector(".close-btn").addEventListener("click",closeDetail);') == 2
assert html.count("d.focus({preventScroll:true});") == 2
assert html.count("d.querySelector(\".close-btn\").focus({preventScroll:true});") == 2
assert 'aria-label="${esc(s.created_at)}: ${esc(String(s.deploy_blocking||0))} blocking"' in html
78 changes: 78 additions & 0 deletions tests/test_console_viewer_key_contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""Security regressions for the browser console's least-privilege key contract."""

import threading
import urllib.error
import urllib.request

import pytest

from appguardrail_core.controlplane import connect, create_key, create_org, make_control_plane_server
from scanner.cli.appguardrail import dashboard_index_path


def _empty_scan_post_status(url: str, api_key: str) -> int:
"""Return the status of a bodyless scan POST used only as an authz probe."""
request = urllib.request.Request(url, data=b"", method="POST")
request.add_header("Authorization", f"Bearer {api_key}")
try:
urllib.request.urlopen(request, timeout=5)
except urllib.error.HTTPError as error:
return error.code
raise AssertionError("bodyless scan POST unexpectedly succeeded")


@pytest.fixture()
def viewer_role_server(tmp_path):
"""Serve a control plane with distinct viewer, member, and owner credentials."""
db_path = str(tmp_path / "viewer-role.db")
connection = connect(db_path)
org_id, owner_key = create_org(connection, "Acme")
_viewer_id, viewer_key = create_key(connection, org_id, "viewer", "browser console")
_member_id, member_key = create_key(connection, org_id, "member", "ci ingest")
connection.close()

server = make_control_plane_server("127.0.0.1", 0, db_path)
threading.Thread(target=server.serve_forever, daemon=True).start()
port = server.server_address[1]
try:
yield f"http://127.0.0.1:{port}", owner_key, member_key, viewer_key
finally:
server.shutdown()
server.server_close()


def test_bodyless_scan_post_distinguishes_viewer_from_elevated_roles(
viewer_role_server,
) -> None:
"""A side-effect-free malformed POST must distinguish viewer from write roles."""
base, owner_key, member_key, viewer_key = viewer_role_server
endpoint = f"{base}/api/v1/scans"

assert _empty_scan_post_status(endpoint, viewer_key) == 403
assert _empty_scan_post_status(endpoint, member_key) == 400
assert _empty_scan_post_status(endpoint, owner_key) == 400


def test_console_accepts_only_viewer_keys_and_never_persists_credentials() -> None:
"""The browser console must reject elevated keys and keep viewer keys in memory."""
html = dashboard_index_path().with_name("console.html").read_text(encoding="utf-8")

assert 'placeholder="Viewer API key (agk_…)"' in html
assert 'aria-label="Viewer API key"' in html
assert 'sessionStorage.removeItem("ag_key");' in html
assert 'sessionStorage.removeItem("ag_viewer_key");' in html
assert "sessionStorage.setItem" not in html
assert "sessionStorage.getItem" not in html

assert "async function requireViewerKey()" in html
viewer_probe = 'fetch("/api/v1/scans",{method:"POST",headers:{Authorization:"Bearer "+KEY}})'
reject_elevated = 'if(r.status!==403)throw new Error("Use a dedicated viewer API key.");'
assert viewer_probe in html
assert reject_elevated in html

connect_flow = html.split('$("#connect").onclick=', 1)[1].split(
'$("#key").addEventListener', 1
)[0]
assert "await requireViewerKey();" in connect_flow
assert "await load();" in connect_flow
assert connect_flow.index("await requireViewerKey();") < connect_flow.index("await load();")
25 changes: 25 additions & 0 deletions tests/test_console_visual_state_contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""Regression contracts for visible dashboard interaction states."""

from scanner.cli.appguardrail import dashboard_index_path


def _console_html() -> str:
"""Return the standalone console document as UTF-8 text."""
return dashboard_index_path().with_name("console.html").read_text(encoding="utf-8")


def test_console_hover_feedback_excludes_disabled_controls() -> None:
"""Enabled buttons must look interactive without styling disabled buttons as hoverable."""
html = _console_html()

assert "transition:filter 0.2s, opacity 0.2s" in html
assert "button:hover:not(:disabled){filter:brightness(.94)}" in html


def test_console_busy_scan_rows_are_visually_and_pointer_disabled() -> None:
"""Busy rows must expose a visible wait state and reject duplicate pointer activation."""
html = _console_html()

assert 'tr.scan[aria-busy="true"]' in html
assert "cursor: wait" in html
assert "pointer-events: none" in html
45 changes: 45 additions & 0 deletions tests/test_dashboard_external_link_contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""Regression tests for external-reference link accessibility."""

import re
from html.parser import HTMLParser

from scanner.cli.appguardrail import dashboard_index_path


class _LinkAttributeParser(HTMLParser):
"""Collect attributes from dashboard link markup."""

def __init__(self) -> None:
"""Initialize an empty link-attribute collection."""
super().__init__()
self.links: list[dict[str, str | None]] = []

def handle_starttag(
self,
tag: str,
attrs: list[tuple[str, str | None]],
) -> None:
"""Record anchor attributes and ignore every other element."""
if tag == "a":
self.links.append(dict(attrs))


def test_dashboard_external_links_announce_new_tab_behavior() -> None:
"""A screen-reader user must know that a reference opens a new tab."""
html = dashboard_index_path().read_text(encoding="utf-8")
detail_markup = re.search(
r"const refs = \(f\.references\|\|\[\]\)\.map\(r=>`(.*?)`\)\.join\('<br>'\);",
html,
flags=re.DOTALL,
)
assert detail_markup is not None

parser = _LinkAttributeParser()
parser.feed(detail_markup.group(1))

assert any(
attributes.get("target") == "_blank"
and attributes.get("rel") == "noopener"
and attributes.get("aria-label") == "${esc(r)} (opens in a new tab)"
for attributes in parser.links
)
Loading