230 lines
8.4 KiB
Python
230 lines
8.4 KiB
Python
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_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_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', 'Timmy’s 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": "Timmy’s 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
|