Merge pull request 'Throttle repeated operator sign-in failures' (#271)
Closes #270
This commit is contained in:
commit
3210b8be6d
16
README.md
16
README.md
|
|
@ -63,9 +63,25 @@ export STACKCHAIN_DASHBOARD_SESSION_SECRET='<independent-cookie-signing-secret>'
|
|||
export STACKCHAIN_SESSION_DB='/var/lib/stackchain-dashboard/sessions.sqlite3'
|
||||
# Optional; defaults to eight hours.
|
||||
export STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS=28800
|
||||
# Optional sign-in throttle: five failures per five minutes, up to 10,000 sources.
|
||||
export STACKCHAIN_LOGIN_MAX_FAILURES=5
|
||||
export STACKCHAIN_LOGIN_WINDOW_SECONDS=300
|
||||
export STACKCHAIN_LOGIN_MAX_ENTRIES=10000
|
||||
# Optional; defaults to STACKCHAIN_STATE_DIR/login-attempts.sqlite3.
|
||||
export STACKCHAIN_LOGIN_ATTEMPT_DB='/var/lib/stackchain-dashboard/login-attempts.sqlite3'
|
||||
# Trust forwarding headers only from these immediate reverse-proxy networks.
|
||||
export STACKCHAIN_TRUSTED_PROXY_CIDRS='127.0.0.0/8'
|
||||
uvicorn src.main:app --host 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
Sign-in failures are scoped to a hashed canonical client address and persisted across
|
||||
workers and restarts. Once the budget is exhausted, the server returns `429` with
|
||||
`Retry-After`; the mobile login form disables retries for that interval. Expired
|
||||
source records are pruned and the ledger is size-bounded. Keep its SQLite file on
|
||||
shared writable storage. `X-Forwarded-For` is ignored unless the immediate peer is
|
||||
inside `STACKCHAIN_TRUSTED_PROXY_CIDRS`; list only networks you operate. Without
|
||||
that setting, a reverse proxy is safely treated as one shared source.
|
||||
|
||||
Each signed cookie includes an opaque session identifier whose hash and expiry are
|
||||
kept in the SQLite session registry. Keep that registry on persistent, writable
|
||||
storage shared by all dashboard workers. Sign-out revokes only the current session
|
||||
|
|
|
|||
76
frontend/login.js
Normal file
76
frontend/login.js
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createLoginController = factory;
|
||||
}(typeof self !== 'undefined' ? self : this, function createLoginController(options) {
|
||||
const form = options.form;
|
||||
const status = options.status;
|
||||
const button = options.button;
|
||||
const fetchImpl = options.fetchImpl;
|
||||
const location = options.location;
|
||||
const setIntervalImpl = options.setIntervalImpl || setInterval;
|
||||
const clearIntervalImpl = options.clearIntervalImpl || clearInterval;
|
||||
let timer = null;
|
||||
|
||||
function showRetryCountdown(seconds) {
|
||||
let remaining = Math.max(1, Number.parseInt(seconds, 10) || 1);
|
||||
button.disabled = true;
|
||||
status.textContent = `Too many attempts. Try again in ${remaining} seconds.`;
|
||||
timer = setIntervalImpl(() => {
|
||||
remaining -= 1;
|
||||
if (remaining <= 0) {
|
||||
clearIntervalImpl(timer);
|
||||
timer = null;
|
||||
button.disabled = false;
|
||||
status.textContent = 'You can try signing in again.';
|
||||
return;
|
||||
}
|
||||
status.textContent = `Too many attempts. Try again in ${remaining} seconds.`;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
async function submit(accessToken) {
|
||||
status.textContent = 'Signing in…';
|
||||
let response;
|
||||
try {
|
||||
response = await fetchImpl('api/v1/session', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ access_token: accessToken }),
|
||||
});
|
||||
} catch (_error) {
|
||||
form.reset();
|
||||
status.textContent = 'Sign-in failed. Check your connection and try again.';
|
||||
return;
|
||||
}
|
||||
form.reset();
|
||||
if (response.ok) {
|
||||
location.replace('./');
|
||||
return;
|
||||
}
|
||||
if (response.status === 429) {
|
||||
showRetryCountdown(response.headers.get('Retry-After'));
|
||||
return;
|
||||
}
|
||||
status.textContent = 'Sign-in failed. Check the token and try again.';
|
||||
}
|
||||
|
||||
return { submit };
|
||||
}));
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
const form = document.getElementById('sign-in');
|
||||
const status = document.getElementById('status');
|
||||
const button = document.getElementById('submit-sign-in');
|
||||
const controller = createLoginController({
|
||||
form,
|
||||
status,
|
||||
button,
|
||||
fetchImpl: fetch.bind(window),
|
||||
location: window.location,
|
||||
});
|
||||
form.addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
const accessToken = new FormData(form).get('access_token');
|
||||
controller.submit(accessToken);
|
||||
});
|
||||
}
|
||||
149
src/login_attempt_store.py
Normal file
149
src/login_attempt_store.py
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
"""Durable throttling state for operator sign-in attempts."""
|
||||
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import math
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
|
||||
class LoginAttemptStoreError(RuntimeError):
|
||||
"""Raised when sign-in throttling state cannot be accessed safely."""
|
||||
|
||||
|
||||
def client_source(peer_host: str, forwarded_for: str, trusted_proxy_cidrs: str) -> str:
|
||||
"""Resolve a canonical client IP without trusting arbitrary forwarding headers."""
|
||||
try:
|
||||
peer = ipaddress.ip_address(peer_host)
|
||||
trusted = [
|
||||
ipaddress.ip_network(value.strip())
|
||||
for value in trusted_proxy_cidrs.split(",")
|
||||
if value.strip()
|
||||
]
|
||||
except ValueError:
|
||||
return peer_host
|
||||
if not any(peer in network for network in trusted):
|
||||
return str(peer)
|
||||
try:
|
||||
forwarded = [
|
||||
ipaddress.ip_address(value.strip())
|
||||
for value in forwarded_for.split(",")
|
||||
if value.strip()
|
||||
]
|
||||
except ValueError:
|
||||
return str(peer)
|
||||
for address in reversed(forwarded):
|
||||
if not any(address in network for network in trusted):
|
||||
return str(address)
|
||||
return str(forwarded[0]) if forwarded else str(peer)
|
||||
|
||||
|
||||
class LoginAttemptStore:
|
||||
def __init__(
|
||||
self,
|
||||
path: str | Path,
|
||||
*,
|
||||
clock: Callable[[], float],
|
||||
max_failures: int,
|
||||
window_seconds: int,
|
||||
max_entries: int = 10_000,
|
||||
lock_timeout_seconds: float = 0.1,
|
||||
) -> None:
|
||||
self.path = Path(path)
|
||||
self.clock = clock
|
||||
self.max_failures = max(1, max_failures)
|
||||
self.window_seconds = max(1, window_seconds)
|
||||
self.max_entries = max(1, max_entries)
|
||||
self.lock_timeout_seconds = lock_timeout_seconds
|
||||
|
||||
@staticmethod
|
||||
def _digest(source: str) -> str:
|
||||
return hashlib.sha256(source.encode()).hexdigest()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
try:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
connection = sqlite3.connect(self.path, timeout=self.lock_timeout_seconds)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS login_attempts (
|
||||
source_hash TEXT PRIMARY KEY,
|
||||
failures INTEGER NOT NULL,
|
||||
window_started_at REAL NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
return connection
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise LoginAttemptStoreError(
|
||||
"Sign-in throttling is temporarily unavailable"
|
||||
) from exc
|
||||
|
||||
def retry_after(self, source: str) -> int:
|
||||
now = self.clock()
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT failures, window_started_at FROM login_attempts WHERE source_hash = ?",
|
||||
(self._digest(source),),
|
||||
).fetchone()
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise LoginAttemptStoreError(
|
||||
"Sign-in throttling is temporarily unavailable"
|
||||
) from exc
|
||||
if row is None or row[0] < self.max_failures:
|
||||
return 0
|
||||
remaining = row[1] + self.window_seconds - now
|
||||
return max(0, math.ceil(remaining))
|
||||
|
||||
def record_failure(self, source: str) -> None:
|
||||
now = self.clock()
|
||||
source_hash = self._digest(source)
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
connection.execute(
|
||||
"DELETE FROM login_attempts WHERE window_started_at + ? <= ?",
|
||||
(self.window_seconds, now),
|
||||
)
|
||||
row = connection.execute(
|
||||
"SELECT failures, window_started_at FROM login_attempts WHERE source_hash = ?",
|
||||
(source_hash,),
|
||||
).fetchone()
|
||||
if row is None or row[1] + self.window_seconds <= now:
|
||||
connection.execute(
|
||||
"INSERT OR REPLACE INTO login_attempts VALUES (?, 1, ?)",
|
||||
(source_hash, now),
|
||||
)
|
||||
else:
|
||||
connection.execute(
|
||||
"UPDATE login_attempts SET failures = failures + 1 WHERE source_hash = ?",
|
||||
(source_hash,),
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
DELETE FROM login_attempts
|
||||
WHERE source_hash NOT IN (
|
||||
SELECT source_hash FROM login_attempts
|
||||
ORDER BY window_started_at DESC, rowid DESC LIMIT ?
|
||||
)
|
||||
""",
|
||||
(self.max_entries,),
|
||||
)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise LoginAttemptStoreError(
|
||||
"Sign-in throttling is temporarily unavailable"
|
||||
) from exc
|
||||
|
||||
def clear(self, source: str) -> None:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"DELETE FROM login_attempts WHERE source_hash = ?",
|
||||
(self._digest(source),),
|
||||
)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise LoginAttemptStoreError(
|
||||
"Sign-in throttling is temporarily unavailable"
|
||||
) from exc
|
||||
55
src/main.py
55
src/main.py
|
|
@ -28,6 +28,7 @@ from src.gitea_proxy import (
|
|||
repos,
|
||||
)
|
||||
from src.idempotency import IdempotencyLedger, IdempotencyLedgerBusy
|
||||
from src.login_attempt_store import LoginAttemptStore, LoginAttemptStoreError, client_source
|
||||
from src.models import Issue, Milestone, PullRequest, Repo, User
|
||||
from src.suggestion_engine import compute
|
||||
from src.views import router as frontend_router
|
||||
|
|
@ -129,6 +130,20 @@ class DashboardSignIn(BaseModel):
|
|||
access_token: str = Field(min_length=1, max_length=1_024)
|
||||
|
||||
|
||||
def _login_attempt_store() -> LoginAttemptStore:
|
||||
state_dir = os.getenv("STACKCHAIN_STATE_DIR", ".stackchain-state")
|
||||
return LoginAttemptStore(
|
||||
os.getenv(
|
||||
"STACKCHAIN_LOGIN_ATTEMPT_DB",
|
||||
os.path.join(state_dir, "login-attempts.sqlite3"),
|
||||
),
|
||||
clock=time.time,
|
||||
max_failures=int(os.getenv("STACKCHAIN_LOGIN_MAX_FAILURES", "5")),
|
||||
window_seconds=int(os.getenv("STACKCHAIN_LOGIN_WINDOW_SECONDS", "300")),
|
||||
max_entries=int(os.getenv("STACKCHAIN_LOGIN_MAX_ENTRIES", "10000")),
|
||||
)
|
||||
|
||||
|
||||
class NotificationReadBatch(BaseModel):
|
||||
ids: list[PositiveInt] = Field(min_length=1, max_length=50)
|
||||
|
||||
|
|
@ -521,11 +536,51 @@ def health() -> dict[str, str]:
|
|||
|
||||
@app.post("/api/v1/session")
|
||||
async def sign_in(payload: DashboardSignIn, request: Request, response: Response):
|
||||
peer_host = request.client.host if request.client is not None else "unknown"
|
||||
source = client_source(
|
||||
peer_host,
|
||||
request.headers.get("x-forwarded-for", ""),
|
||||
os.getenv("STACKCHAIN_TRUSTED_PROXY_CIDRS", ""),
|
||||
)
|
||||
attempts = _login_attempt_store()
|
||||
try:
|
||||
retry_after = attempts.retry_after(source)
|
||||
except LoginAttemptStoreError:
|
||||
return JSONResponse(
|
||||
{"detail": "Sign-in throttling is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
if retry_after:
|
||||
return JSONResponse(
|
||||
{"detail": "Too many sign-in attempts"},
|
||||
status_code=429,
|
||||
headers={
|
||||
"Cache-Control": "no-store",
|
||||
"Retry-After": str(retry_after),
|
||||
},
|
||||
)
|
||||
configured_token = dashboard_auth.access_token()
|
||||
if not configured_token or not hmac.compare_digest(
|
||||
payload.access_token, configured_token
|
||||
):
|
||||
try:
|
||||
attempts.record_failure(source)
|
||||
except LoginAttemptStoreError:
|
||||
return JSONResponse(
|
||||
{"detail": "Sign-in throttling is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
raise HTTPException(status_code=401, detail="Invalid access token")
|
||||
try:
|
||||
attempts.clear(source)
|
||||
except LoginAttemptStoreError:
|
||||
return JSONResponse(
|
||||
{"detail": "Sign-in throttling is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
try:
|
||||
signed, session = dashboard_auth.issue_session()
|
||||
except dashboard_auth.SessionStoreError:
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ LOGIN_HTML = """<!doctype html>
|
|||
<title>Sign in · Stackchain Dashboard</title>
|
||||
<style>body{margin:0;background:#07111f;color:#eef6ff;font:16px system-ui;display:grid;min-height:100vh;place-items:center}main{width:min(90vw,24rem);padding:2rem;border:1px solid #29415d;border-radius:1rem;background:#0d1b2b}label,input,button{display:block;width:100%;box-sizing:border-box}input,button{min-height:48px;margin-top:.6rem;border-radius:.6rem;border:1px solid #49647f;padding:.75rem}button{margin-top:1rem;background:#55d6be;color:#06121b;font-weight:700}p{color:#a9bed3}</style></head>
|
||||
<body><main><h1>Operator sign in</h1><p>Enter the dashboard access token. It is exchanged for a private, short-lived session and is never stored on this device.</p>
|
||||
<form id="sign-in"><label>Access token<input name="access_token" type="password" autocomplete="current-password" required></label><button>Sign in</button><p id="status" role="status"></p></form></main>
|
||||
<script>document.getElementById('sign-in').addEventListener('submit',async(event)=>{event.preventDefault();const status=document.getElementById('status');status.textContent='Signing in…';const access_token=new FormData(event.currentTarget).get('access_token');const response=await fetch('api/v1/session',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({access_token})});if(response.ok){location.replace('./');return}status.textContent='Sign-in failed. Check the token and try again.';event.currentTarget.reset()});</script></body></html>"""
|
||||
<form id="sign-in"><label>Access token<input name="access_token" type="password" autocomplete="current-password" required></label><button id="submit-sign-in">Sign in</button><p id="status" role="status"></p></form></main>
|
||||
<script src="static/login.js"></script></body></html>"""
|
||||
|
||||
|
||||
class RevalidatingHTMLResponse(HTMLResponse):
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ def access_control(monkeypatch, tmp_path):
|
|||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "a-separate-session-signing-secret-with-enough-entropy")
|
||||
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login-attempts.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_LOGIN_MAX_FAILURES", "3")
|
||||
monkeypatch.setenv("STACKCHAIN_LOGIN_WINDOW_SECONDS", "60")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
|
@ -111,6 +114,43 @@ async def test_sign_in_creates_secure_session_without_echoing_access_token(acces
|
|||
assert response.headers["cache-control"] == "no-store"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_sign_in_throttles_repeated_failures_with_retry_guidance(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app, client=("203.0.113.7", 1234))
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
failures = [
|
||||
await client.post("/api/v1/session", json={"access_token": "wrong"})
|
||||
for _ in range(3)
|
||||
]
|
||||
blocked = await client.post(
|
||||
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||
)
|
||||
|
||||
assert [response.status_code for response in failures] == [401, 401, 401]
|
||||
assert blocked.status_code == 429
|
||||
assert blocked.json() == {"detail": "Too many sign-in attempts"}
|
||||
assert blocked.headers["retry-after"].isdigit()
|
||||
assert blocked.headers["cache-control"] == "no-store"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_successful_sign_in_clears_prior_failures(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app, client=("203.0.113.8", 1234))
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
for _ in range(2):
|
||||
await client.post("/api/v1/session", json={"access_token": "wrong"})
|
||||
success = await client.post(
|
||||
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||
)
|
||||
after_success = [
|
||||
await client.post("/api/v1/session", json={"access_token": "wrong"})
|
||||
for _ in range(3)
|
||||
]
|
||||
|
||||
assert success.status_code == 200
|
||||
assert [response.status_code for response in after_success] == [401, 401, 401]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authenticated_get_reaches_private_api(access_control, monkeypatch):
|
||||
async def user():
|
||||
|
|
|
|||
74
tests/test_login_attempt_store.py
Normal file
74
tests/test_login_attempt_store.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import sqlite3
|
||||
|
||||
from src.login_attempt_store import LoginAttemptStore, client_source
|
||||
|
||||
|
||||
def test_forwarded_client_is_used_only_for_explicitly_trusted_proxies():
|
||||
trusted = "127.0.0.0/8, 10.0.0.0/8"
|
||||
|
||||
assert client_source("198.51.100.4", "203.0.113.7", trusted) == "198.51.100.4"
|
||||
assert (
|
||||
client_source("127.0.0.1", "203.0.113.7, 10.1.2.3", trusted)
|
||||
== "203.0.113.7"
|
||||
)
|
||||
assert client_source("127.0.0.1", "not-an-ip", trusted) == "127.0.0.1"
|
||||
|
||||
|
||||
def test_failure_budget_is_shared_across_store_instances_and_expires(tmp_path):
|
||||
now = [1_000.0]
|
||||
database = tmp_path / "login-attempts.sqlite3"
|
||||
first = LoginAttemptStore(
|
||||
database,
|
||||
clock=lambda: now[0],
|
||||
max_failures=3,
|
||||
window_seconds=60,
|
||||
)
|
||||
second = LoginAttemptStore(
|
||||
database,
|
||||
clock=lambda: now[0],
|
||||
max_failures=3,
|
||||
window_seconds=60,
|
||||
)
|
||||
|
||||
assert first.retry_after("203.0.113.7") == 0
|
||||
first.record_failure("203.0.113.7")
|
||||
second.record_failure("203.0.113.7")
|
||||
first.record_failure("203.0.113.7")
|
||||
|
||||
assert second.retry_after("203.0.113.7") == 60
|
||||
now[0] = 1_060.0
|
||||
assert first.retry_after("203.0.113.7") == 0
|
||||
|
||||
|
||||
def test_recording_a_failure_prunes_expired_source_records(tmp_path):
|
||||
now = [1_000.0]
|
||||
store = LoginAttemptStore(
|
||||
tmp_path / "login-attempts.sqlite3",
|
||||
clock=lambda: now[0],
|
||||
max_failures=3,
|
||||
window_seconds=60,
|
||||
)
|
||||
store.record_failure("203.0.113.1")
|
||||
store.record_failure("203.0.113.2")
|
||||
|
||||
now[0] = 1_060.0
|
||||
store.record_failure("203.0.113.3")
|
||||
|
||||
with sqlite3.connect(store.path) as connection:
|
||||
assert connection.execute("SELECT COUNT(*) FROM login_attempts").fetchone() == (1,)
|
||||
|
||||
|
||||
def test_failure_ledger_evicts_oldest_sources_at_its_size_limit(tmp_path):
|
||||
store = LoginAttemptStore(
|
||||
tmp_path / "login-attempts.sqlite3",
|
||||
clock=lambda: 1_000.0,
|
||||
max_failures=3,
|
||||
window_seconds=60,
|
||||
max_entries=2,
|
||||
)
|
||||
|
||||
for address in ("203.0.113.1", "203.0.113.2", "203.0.113.3"):
|
||||
store.record_failure(address)
|
||||
|
||||
with sqlite3.connect(store.path) as connection:
|
||||
assert connection.execute("SELECT COUNT(*) FROM login_attempts").fetchone() == (2,)
|
||||
59
tests/test_login_frontend.py
Normal file
59
tests/test_login_frontend.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
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_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.",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_login_page_loads_rate_limit_controller():
|
||||
html = await login()
|
||||
|
||||
assert '<script src="static/login.js"></script>' in html
|
||||
Loading…
Reference in New Issue
Block a user