stackchain-dashboard/tests/test_login_frontend.py
timmy ced644b275
All checks were successful
CI / lint (pull_request) Successful in 1m3s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
feat: add device passkeys for operator access (Closes #485)
2026-08-10 13:14:18 +00:00

382 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import json
import subprocess
from pathlib import Path
import pytest
from src.views import login
ROOT = Path(__file__).resolve().parents[1]
LOGIN_JS = ROOT / "frontend" / "login.js"
def test_expired_session_reason_explains_preserved_private_work():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
const status = {{ textContent: '' }};
const controller = createLoginController({{
form: {{ reset: () => {{}} }}, status, button: {{ disabled: false }},
fetchImpl: async () => new Response('{{}}', {{ status: 200 }}),
location: {{ replace: () => {{}} }},
}});
controller.showReason('session-expired');
const expired = status.textContent;
controller.showReason('https://evil.example/redirect');
process.stdout.write(JSON.stringify({{ expired, ignored: status.textContent }}));
"""
result = subprocess.run(
["node", "-e", harness], text=True, capture_output=True, check=True
)
state = json.loads(result.stdout)
assert state["expired"] == (
"Your session expired. Private drafts remain on this device. "
"Sign in to continue."
)
assert state["ignored"] == state["expired"]
def test_idle_session_reason_explains_that_saved_work_will_resume():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
const status = {{ textContent: '' }};
const controller = createLoginController({{
form: {{ reset: () => {{}} }}, status, button: {{ disabled: false }},
fetchImpl: async () => new Response('{{}}', {{ status: 200 }}),
location: {{ replace: () => {{}} }},
}});
controller.showReason('session-idle');
process.stdout.write(JSON.stringify({{ status: status.textContent }}));
"""
result = subprocess.run(
["node", "-e", harness], text=True, capture_output=True, check=True
)
assert json.loads(result.stdout)["status"] == (
"Stackchain locked after inactivity. Your drafts and queued work are still "
"on this device. Sign in to resume."
)
def test_revoked_session_reason_clears_private_data_before_enabling_sign_in():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
const state = {{cleared:false, snapshots:[]}};
const status = {{textContent:''}};
const button = {{disabled:false}};
const controller = createLoginController({{
form:{{reset:()=>{{}}}}, status, button,
fetchImpl:async()=>new Response('{{}}',{{status:200}}),
location:{{replace:()=>{{}}}},
clearPrivateDeviceData:async()=>{{
state.snapshots.push({{status:status.textContent,disabled:button.disabled}});
await new Promise(resolve=>setTimeout(resolve,10));
state.cleared=true;
}},
}});
(async()=>{{
const pending=controller.showReason('session-revoked');
state.snapshots.push({{status:status.textContent,disabled:button.disabled}});
await pending;
state.final={{status:status.textContent,disabled:button.disabled,cleared:state.cleared}};
process.stdout.write(JSON.stringify(state));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(
["node", "-e", harness], text=True, capture_output=True, check=True
)
state = json.loads(result.stdout)
assert all(snapshot["disabled"] for snapshot in state["snapshots"])
assert state["final"] == {
"status": "This device was remotely signed out. Stackchain private data was cleared. Sign in to use it again.",
"disabled": False,
"cleared": True,
}
@pytest.mark.anyio
async def test_login_loads_private_data_purger_before_controller():
html = await login()
assert '<script src="static/private-device-data.js"></script>' in html
assert html.index('static/private-device-data.js') < html.index('static/login.js')
def test_revoked_session_does_not_claim_success_when_purger_is_unavailable():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
const status={{textContent:''}};
const button={{disabled:false}};
const controller=createLoginController({{
form:{{reset:()=>{{}}}},status,button,
fetchImpl:async()=>new Response('{{}}',{{status:200}}),location:{{replace:()=>{{}}}},
}});
(async()=>{{
await controller.showReason('session-revoked');
process.stdout.write(JSON.stringify({{status:status.textContent,disabled:button.disabled}}));
}})();
"""
result = subprocess.run(
["node", "-e", harness], text=True, capture_output=True, check=True
)
assert json.loads(result.stdout) == {
"status": "This device was remotely signed out, but private data could not be cleared. Close other Stackchain tabs and clear this sites data before signing in.",
"disabled": True,
}
def test_rate_limited_login_disables_submit_and_counts_down():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
const state = {{ reset: 0, interval: null }};
const status = {{ textContent: '' }};
const button = {{ disabled: false }};
const form = {{ reset: () => state.reset += 1 }};
const controller = createLoginController({{
form, status, button,
fetchImpl: async () => new Response(JSON.stringify({{ detail: 'Too many sign-in attempts' }}), {{ status: 429, headers: {{ 'Retry-After': '2' }} }}),
location: {{ replace: () => {{}} }},
setIntervalImpl: callback => {{ state.interval = callback; return 1; }},
clearIntervalImpl: () => {{}},
}});
(async () => {{
await controller.submit('never-store-this-token');
state.initial = {{ disabled: button.disabled, status: status.textContent, reset: state.reset }};
state.interval();
state.afterTick = {{ disabled: button.disabled, status: status.textContent }};
state.interval();
state.finished = {{ disabled: button.disabled, status: status.textContent }};
process.stdout.write(JSON.stringify(state));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(
["node", "-e", harness], text=True, capture_output=True, check=True
)
state = json.loads(result.stdout)
assert state["initial"] == {
"disabled": True,
"status": "Too many attempts. Try again in 2 seconds.",
"reset": 1,
}
assert state["afterTick"]["disabled"] is True
assert state["finished"] == {
"disabled": False,
"status": "You can try signing in again.",
}
def test_successful_login_resumes_valid_shared_capture_continuation():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
const state = {{ replaced: null }};
const controller = createLoginController({{
form: {{ reset: () => {{}} }}, status: {{ textContent: '' }}, button: {{ disabled: false }},
fetchImpl: async () => new Response('{{}}', {{ status: 200 }}),
location: {{ replace: value => state.replaced = value }},
continuation: './?title=Production+crash&url=https%3A%2F%2Fexample.com',
}});
(async () => {{
await controller.submit('operator-token');
process.stdout.write(JSON.stringify(state));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(
["node", "-e", harness], text=True, capture_output=True, check=True
)
assert json.loads(result.stdout)["replaced"] == (
"./?title=Production+crash&url=https%3A%2F%2Fexample.com"
)
def test_passkey_login_uses_web_authentication_without_sending_the_operator_token():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
const requests = [];
const state = {{ replaced: null }};
const credential = {{
id:'credential-id', type:'public-key', rawId:Uint8Array.from([1,2,3]).buffer,
response:{{
authenticatorData:Uint8Array.from([4]).buffer,
clientDataJSON:Uint8Array.from([5]).buffer,
signature:Uint8Array.from([6]).buffer,
userHandle:null,
}},
}};
const controller = createLoginController({{
form:{{reset:()=>{{}}}}, status:{{textContent:''}}, button:{{disabled:false}},
passkeyButton:{{disabled:false}},
credentials:{{get:async options=>{{ state.publicKey=options.publicKey; return credential; }}}},
fetchImpl:async (url, options={{}})=>{{
requests.push({{url, body:options.body ? JSON.parse(options.body) : null}});
if (url.endsWith('/options')) return new Response(JSON.stringify({{
challenge:'AQID', rpId:'forge.example', userVerification:'required',
allowCredentials:[{{type:'public-key',id:'BAUG'}}],
}}),{{status:200,headers:{{'Content-Type':'application/json'}}}});
return new Response('{{}}',{{status:200}});
}},
location:{{replace:value=>state.replaced=value}},
}});
(async()=>{{
await controller.signInWithPasskey('Timmys Pixel');
process.stdout.write(JSON.stringify({{requests,state}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(
["node", "-e", harness], text=True, capture_output=True, check=True
)
output = json.loads(result.stdout)
assert output["state"]["publicKey"]["challenge"] == {"0": 1, "1": 2, "2": 3}
assert output["state"]["replaced"] == "./"
assert output["requests"][1]["url"] == "api/v1/passkeys/authentication/verify"
assert output["requests"][1]["body"] == {
"challenge": "AQID",
"credential": {
"id": "credential-id",
"type": "public-key",
"rawId": "AQID",
"response": {
"authenticatorData": "BA",
"clientDataJSON": "BQ",
"signature": "Bg",
"userHandle": None,
},
},
"device_label": "Timmys Pixel",
"action": "sign_in",
"target": "dashboard",
}
assert "access_token" not in json.dumps(output)
def test_login_sends_a_bounded_device_label_with_the_access_token():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
let request = null;
const controller = createLoginController({{
form: {{ reset: () => {{}} }}, status: {{ textContent: '' }}, button: {{ disabled: false }},
fetchImpl: async (url, options) => {{ request = {{url, body: JSON.parse(options.body)}}; return new Response('{{}}', {{ status: 200 }}); }},
location: {{ replace: () => {{}} }},
}});
(async () => {{
await controller.submit('operator-token', 'Timmys Pixel');
process.stdout.write(JSON.stringify(request));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(
["node", "-e", harness], text=True, capture_output=True, check=True
)
assert json.loads(result.stdout) == {
"url": "api/v1/session",
"body": {"access_token": "operator-token", "device_label": "Timmys Pixel"},
}
def test_shared_capture_login_explains_why_sign_in_is_required():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
const status = {{ textContent: '' }};
createLoginController({{
form: {{ reset: () => {{}} }}, status, button: {{ disabled: false }},
fetchImpl: async () => new Response('{{}}', {{ status: 200 }}),
location: {{ replace: () => {{}} }}, continuation: './?title=Shared',
}});
process.stdout.write(status.textContent);
"""
result = subprocess.run(
["node", "-e", harness], text=True, capture_output=True, check=True
)
assert result.stdout == "Sign in to continue your shared capture."
def test_login_rejects_untrusted_or_non_share_continuations():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
async function destination(continuation) {{
let replaced = null;
const controller = createLoginController({{
form: {{ reset: () => {{}} }}, status: {{ textContent: '' }}, button: {{ disabled: false }},
fetchImpl: async () => new Response('{{}}', {{ status: 200 }}),
location: {{ replace: value => replaced = value }}, continuation,
}});
await controller.submit('operator-token');
return replaced;
}}
(async () => {{
process.stdout.write(JSON.stringify({{
crossOrigin: await destination('https://evil.example/steal'),
otherRoute: await destination('./settings?title=Shared'),
extraField: await destination('./?title=Shared&next=https%3A%2F%2Fevil.example'),
prototypeField: await destination('./?toString=Shared'),
oversized: await destination('./?title=' + 'x'.repeat(201)),
}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(
["node", "-e", harness], text=True, capture_output=True, check=True
)
assert json.loads(result.stdout) == {
"crossOrigin": "./",
"otherRoute": "./",
"extraField": "./",
"prototypeField": "./",
"oversized": "./",
}
def test_login_bootstrap_continues_shared_capture_after_submit():
harness = f"""
const fs = require('fs');
const vm = require('vm');
const state = {{ replaced: null, submit: null }};
const elements = {{
'sign-in': {{ reset: () => {{}}, addEventListener: (_name, handler) => state.submit = handler }},
'status': {{ textContent: '' }},
'submit-sign-in': {{ disabled: false }},
}};
const context = {{
URLSearchParams, Response, setInterval, clearInterval,
FormData: function () {{ return {{ get: () => 'operator-token' }}; }},
fetch: async () => new Response('{{}}', {{ status: 200 }}),
document: {{ getElementById: id => elements[id] }},
window: {{ location: {{
search: '?continue=.%2F%3Ftitle%3DShared%2Blink%26url%3Dhttps%253A%252F%252Fexample.com',
replace: value => state.replaced = value,
}} }},
}};
context.fetch.bind = Function.prototype.bind;
vm.createContext(context);
vm.runInContext(fs.readFileSync({json.dumps(str(LOGIN_JS))}, 'utf8'), context);
state.submit({{ preventDefault: () => {{}} }});
setTimeout(() => process.stdout.write(JSON.stringify({{
replaced: state.replaced, status: elements.status.textContent,
}})), 0);
"""
result = subprocess.run(
["node", "-e", harness], text=True, capture_output=True, check=True
)
assert json.loads(result.stdout) == {
"replaced": "./?title=Shared+link&url=https%3A%2F%2Fexample.com",
"status": "Signing in…",
}
@pytest.mark.anyio
async def test_login_page_loads_rate_limit_controller():
html = await login()
assert '<script src="static/login.js"></script>' in html
assert "main{box-sizing:border-box" in html
assert '<p id="status" role="status" aria-live="polite"></p>' in html
assert 'name="device_label"' in html
assert 'maxlength="64"' in html