feat: make Security Center Back-safe on mobile (Closes #1020)
This commit is contained in:
1 parent
34d5f86d53
commit
241f0f3a2a
6 files changed
+294
-18
No files matched your search
@@ -131,6 +131,22 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
||||
page.locator("#close-device-setup").click()
|
||||
expect(page.locator("#device-setup-sheet")).to_be_hidden()
|
||||
|
||||
page.locator("#active-devices").click()
|
||||
expect(page.locator("#active-devices-sheet")).to_be_visible()
|
||||
expect(page.locator("#security-activity-section")).to_be_visible()
|
||||
security_nav = page.locator(".security-section-nav")
|
||||
expect(security_nav).to_be_visible()
|
||||
for control in security_nav.locator("button").all():
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
page.locator("#security-section-devices").click()
|
||||
expect(page.locator("#security-section-devices")).to_have_attribute("aria-current", "page")
|
||||
page.go_back()
|
||||
expect(page.locator("#security-section-activity")).to_have_attribute("aria-current", "page")
|
||||
page.go_back()
|
||||
expect(page.locator("#active-devices-sheet")).to_be_hidden()
|
||||
expect(page.locator("#active-devices")).to_be_focused()
|
||||
|
||||
assert len(workspace_requests) == 1, workspace_requests
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
assert browser_errors == []
|
||||
|
||||
@@ -850,10 +850,22 @@ async def test_dashboard_loads_session_boundary_first_and_offers_sign_out():
|
||||
assert 'aria-labelledby="enrolled-passkeys-title"' in html
|
||||
assert 'id="enrolled-passkeys-list"' in html
|
||||
assert 'A recovery token will be required after sign-out if you remove this device’s passkey.' in html
|
||||
assert 'class="security-section-nav" aria-label="Security Center sections"' in html
|
||||
assert 'id="security-section-activity"' in html
|
||||
assert 'id="security-section-devices"' in html
|
||||
assert 'id="security-section-passkeys"' in html
|
||||
assert 'id="security-activity-section"' in html
|
||||
assert 'id="security-devices-section"' in html
|
||||
assert 'id="security-passkeys-section"' in html
|
||||
assert html.index('id="security-activity-section"') < html.index('id="security-devices-section"')
|
||||
assert 'aria-labelledby="security-activity-title"' in html
|
||||
assert 'id="security-activity-list"' in html
|
||||
assert '>Load older activity</button>' in html
|
||||
assert '#sign-out-all { min-height:44px; }' in html
|
||||
assert '.security-section-nav { position:sticky;' in html
|
||||
assert '.security-section-nav button { min-height:44px;' in html
|
||||
assert '.security-event button { min-height:44px;' in html
|
||||
assert '.security-activity, .security-devices, .enrolled-passkeys { scroll-margin-top:' in html
|
||||
|
||||
|
||||
def test_security_activity_names_confirmed_gitea_time_logging():
|
||||
|
||||
@@ -83,6 +83,181 @@ const boundary={{
|
||||
["node", "-e", harness], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
title, detail = json.loads(completed.stdout)
|
||||
title, detail, action = json.loads(completed.stdout)
|
||||
assert title == "Passkey counter anomaly"
|
||||
assert "Remove and re-enroll this passkey" in detail
|
||||
assert action == "Review passkeys"
|
||||
|
||||
|
||||
def test_activity_alert_hands_off_to_addressable_devices_section():
|
||||
harness = f"""
|
||||
const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))});
|
||||
const listeners={{}};
|
||||
const element=(id='')=>({{
|
||||
id, hidden:true, disabled:false, textContent:'', children:[], className:'', dataset:{{}}, attributes:{{}},
|
||||
addEventListener(type,listener){{(this.listeners ||= {{}})[type]=listener;}},
|
||||
click(){{this.listeners?.click?.({{preventDefault(){{}}}});}},
|
||||
focus(){{}}, replaceChildren(){{this.children=[];}}, append(...values){{this.children.push(...values);}},
|
||||
setAttribute(name,value){{this.attributes[name]=String(value);}}, removeAttribute(name){{delete this.attributes[name];}},
|
||||
scrollIntoView(){{this.scrolled=true;}},
|
||||
}});
|
||||
const names=['active-devices','active-devices-sheet','active-devices-list','active-devices-status',
|
||||
'enrolled-passkeys-list','enrolled-passkeys-status','enroll-passkey','security-activity-list',
|
||||
'security-activity-status','load-more-security-activity','close-active-devices',
|
||||
'security-section-activity','security-section-devices','security-section-passkeys',
|
||||
'security-activity-section','security-devices-section','security-passkeys-section'];
|
||||
const ids=Object.fromEntries(names.map(id=>[id,element(id)]));
|
||||
const pushed=[];
|
||||
const root={{
|
||||
document:{{getElementById:id=>ids[id]||null,createElement:()=>element()}},
|
||||
history:{{state:null,pushState(state,_title,url){{this.state=state;pushed.push({{state,url}});}}}},
|
||||
location:{{pathname:'/dashboard/',search:'',hash:''}},
|
||||
addEventListener:(type,listener)=>listeners[type]=listener,
|
||||
}};
|
||||
const boundary={{
|
||||
listActiveDevices:async()=>[], listPasskeys:async()=>[],
|
||||
listSecurityEvents:async()=>({{events:[],authentication_alerts:[{{}}],next_cursor:null}}),
|
||||
formatAuthenticationAlert:()=>({{title:'Failed sign-ins',detail:'Three attempts'}}),
|
||||
}};
|
||||
(async()=>{{
|
||||
const controller=attachSecurityCenter({{root,boundary}});
|
||||
await controller.open();
|
||||
const alert=ids['security-activity-list'].children[0];
|
||||
const action=alert.children[2];
|
||||
action.click();
|
||||
console.log(JSON.stringify({{
|
||||
initial:pushed[0], latest:pushed.at(-1), action:action.textContent,
|
||||
current:ids['security-section-devices'].attributes['aria-current'],
|
||||
scrolled:ids['security-devices-section'].scrolled||false,
|
||||
}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
completed = subprocess.run(
|
||||
["node", "-e", harness], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
output = json.loads(completed.stdout)
|
||||
assert output["initial"]["state"]["stackchainSecuritySection"] == "activity"
|
||||
assert output["initial"]["url"] == "/dashboard/#security/activity"
|
||||
assert output["latest"]["state"]["stackchainSecuritySection"] == "devices"
|
||||
assert output["action"] == "Review devices"
|
||||
assert output["current"] == "page"
|
||||
assert output["scrolled"] is True
|
||||
|
||||
|
||||
def test_device_revocation_refreshes_inventory_and_activity_for_verification():
|
||||
harness = f"""
|
||||
const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))});
|
||||
const element=()=>({{
|
||||
hidden:true, disabled:false, textContent:'', children:[], className:'', attributes:{{}},
|
||||
addEventListener(type,listener){{(this.listeners ||= {{}})[type]=listener;}},
|
||||
click(){{return this.listeners?.click?.();}}, focus(){{}}, replaceChildren(){{this.children=[];}},
|
||||
append(...values){{this.children.push(...values);}}, setAttribute(name,value){{this.attributes[name]=value;}},
|
||||
removeAttribute(name){{delete this.attributes[name];}}, scrollIntoView(){{}},
|
||||
}});
|
||||
const names=['active-devices','active-devices-sheet','active-devices-list','active-devices-status',
|
||||
'enrolled-passkeys-list','enrolled-passkeys-status','enroll-passkey','security-activity-list',
|
||||
'security-activity-status','load-more-security-activity','close-active-devices'];
|
||||
const ids=Object.fromEntries(names.map(id=>[id,element()]));
|
||||
const calls={{devices:0,activity:0,revoke:0}};
|
||||
const root={{document:{{getElementById:id=>ids[id]||null,createElement:()=>element()}}}};
|
||||
const boundary={{
|
||||
listActiveDevices:async()=>{{calls.devices++;return [{{device_label:'Lost phone',created_at:1,expires_at:2,current:false}}];}},
|
||||
listPasskeys:async()=>[],
|
||||
listSecurityEvents:async()=>{{calls.activity++;return {{events:[],authentication_alerts:[],next_cursor:null}};}},
|
||||
revokeActiveDevice:async()=>{{calls.revoke++;return true;}},
|
||||
}};
|
||||
(async()=>{{
|
||||
await attachSecurityCenter({{root,boundary}}).open();
|
||||
await ids['active-devices-list'].children[0].children[1].click();
|
||||
console.log(JSON.stringify({{calls,status:ids['security-activity-status'].textContent}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
completed = subprocess.run(
|
||||
["node", "-e", harness], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
output = json.loads(completed.stdout)
|
||||
assert output["calls"] == {"devices": 2, "activity": 2, "revoke": 1}
|
||||
assert output["status"] == "No security activity yet."
|
||||
|
||||
|
||||
def test_back_unwinds_security_sections_then_closes_and_restores_focus():
|
||||
harness = f"""
|
||||
const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))});
|
||||
const listeners={{}};
|
||||
const element=()=>({{
|
||||
hidden:true, textContent:'', children:[], attributes:{{}},
|
||||
addEventListener(type,listener){{(this.listeners ||= {{}})[type]=listener;}}, focus(){{this.focused=true;}},
|
||||
replaceChildren(){{this.children=[];}}, append(...values){{this.children.push(...values);}},
|
||||
setAttribute(name,value){{this.attributes[name]=value;}}, removeAttribute(name){{delete this.attributes[name];}}, scrollIntoView(){{}},
|
||||
}});
|
||||
const names=['active-devices','active-devices-sheet','active-devices-list','active-devices-status',
|
||||
'enrolled-passkeys-list','enrolled-passkeys-status','enroll-passkey','security-activity-list',
|
||||
'security-activity-status','load-more-security-activity','close-active-devices',
|
||||
'security-section-activity','security-section-devices','security-section-passkeys',
|
||||
'security-activity-section','security-devices-section','security-passkeys-section'];
|
||||
const ids=Object.fromEntries(names.map(id=>[id,element()]));
|
||||
const pushed=[];
|
||||
const root={{
|
||||
document:{{getElementById:id=>ids[id]||null,createElement:()=>element()}},
|
||||
history:{{state:null,pushState(state,_title,url){{this.state=state;pushed.push(url);}}}},
|
||||
location:{{pathname:'/dashboard/',search:''}},
|
||||
addEventListener:(type,listener)=>listeners[type]=listener,
|
||||
}};
|
||||
const boundary={{
|
||||
listActiveDevices:async()=>[],listPasskeys:async()=>[],
|
||||
listSecurityEvents:async()=>({{events:[],authentication_alerts:[],next_cursor:null}}),
|
||||
}};
|
||||
(async()=>{{
|
||||
const controller=attachSecurityCenter({{root,boundary}});
|
||||
await controller.open();
|
||||
controller.navigate('devices');
|
||||
listeners.popstate({{state:{{stackchainSecuritySection:'activity'}}}});
|
||||
const afterFirst={{hidden:ids['active-devices-sheet'].hidden,current:ids['security-section-activity'].attributes['aria-current']}};
|
||||
listeners.popstate({{state:null}});
|
||||
console.log(JSON.stringify({{afterFirst,hidden:ids['active-devices-sheet'].hidden,focused:ids['active-devices'].focused||false,pushed}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
completed = subprocess.run(
|
||||
["node", "-e", harness], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
output = json.loads(completed.stdout)
|
||||
assert output["afterFirst"] == {"hidden": False, "current": "page"}
|
||||
assert output["hidden"] is True
|
||||
assert output["focused"] is True
|
||||
assert output["pushed"] == ["/dashboard/#security/activity", "/dashboard/#security/devices"]
|
||||
|
||||
|
||||
def test_close_uses_history_back_to_remove_security_center_route():
|
||||
harness = f"""
|
||||
const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))});
|
||||
const element=()=>({{
|
||||
hidden:true,textContent:'',children:[],attributes:{{}},
|
||||
addEventListener(type,listener){{(this.listeners ||= {{}})[type]=listener;}},
|
||||
click(){{return this.listeners?.click?.({{preventDefault(){{}}}});}},focus(){{}},replaceChildren(){{this.children=[];}},
|
||||
append(...values){{this.children.push(...values);}},setAttribute(){{}},removeAttribute(){{}},scrollIntoView(){{}},
|
||||
}});
|
||||
const names=['active-devices','active-devices-sheet','active-devices-list','active-devices-status',
|
||||
'enrolled-passkeys-list','enrolled-passkeys-status','enroll-passkey','security-activity-list',
|
||||
'security-activity-status','load-more-security-activity','close-active-devices'];
|
||||
const ids=Object.fromEntries(names.map(id=>[id,element()]));
|
||||
let backs=0;
|
||||
const root={{
|
||||
document:{{getElementById:id=>ids[id]||null,createElement:()=>element()}},
|
||||
history:{{state:null,pushState(state){{this.state=state;}},back(){{backs++;}}}},
|
||||
location:{{pathname:'/dashboard/',search:''}},addEventListener(){{}},
|
||||
}};
|
||||
const boundary={{listActiveDevices:async()=>[],listPasskeys:async()=>[],listSecurityEvents:async()=>({{events:[],authentication_alerts:[],next_cursor:null}})}};
|
||||
(async()=>{{
|
||||
await attachSecurityCenter({{root,boundary}}).open();
|
||||
ids['close-active-devices'].click();
|
||||
console.log(JSON.stringify({{backs}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
completed = subprocess.run(
|
||||
["node", "-e", harness], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(completed.stdout) == {"backs": 1}
|
||||
Reference in new issue
Block a user