feat: enforce confirmed offline idle deadlines (Closes #523)
This commit is contained in:
1 parent
100d38cc5c
commit
b1890a29ff
8 files changed
+255
-34
No files matched your search
@@ -791,9 +791,39 @@ async def test_authenticated_session_status_exposes_csrf_proof_and_offline_lease
|
||||
assert payload["authenticated"] is True
|
||||
assert payload["csrf_token"] == client.cookies["stackchain_csrf"]
|
||||
assert payload["expires_at"] > int(time.time())
|
||||
assert payload["idle_expires_at"] > int(time.time())
|
||||
assert "correct horse battery staple" not in response.text
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_session_status_reports_server_idle_deadline_without_extending_it(
|
||||
access_control, monkeypatch
|
||||
):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS", "900")
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||
)
|
||||
expected_last_active = int(time.time()) - 120
|
||||
with sqlite3.connect(main.dashboard_auth._session_store().path) as connection:
|
||||
connection.execute(
|
||||
"UPDATE active_sessions SET last_active_at = ?", (expected_last_active,)
|
||||
)
|
||||
|
||||
first = await client.get("/api/v1/session")
|
||||
second = await client.get("/api/v1/session")
|
||||
with sqlite3.connect(main.dashboard_auth._session_store().path) as connection:
|
||||
stored_last_active = connection.execute(
|
||||
"SELECT last_active_at FROM active_sessions"
|
||||
).fetchone()[0]
|
||||
|
||||
assert first.status_code == 200
|
||||
assert first.json()["idle_expires_at"] == expected_last_active + 900
|
||||
assert second.json()["idle_expires_at"] == expected_last_active + 900
|
||||
assert stored_last_active == expected_last_active
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_anonymous_session_status_discloses_no_offline_lease(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
@@ -834,6 +864,7 @@ async def test_idle_session_is_rejected_with_distinct_api_and_page_recovery(acce
|
||||
@pytest.mark.anyio
|
||||
async def test_activity_heartbeat_extends_the_server_idle_deadline(access_control, monkeypatch):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS", "900")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS", "100")
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
@@ -846,10 +877,11 @@ async def test_activity_heartbeat_extends_the_server_idle_deadline(access_contro
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
},
|
||||
)
|
||||
status = await client.get("/api/v1/session")
|
||||
|
||||
assert heartbeat.status_code == 200
|
||||
assert heartbeat.json()["active"] is True
|
||||
assert heartbeat.json()["idle_expires_at"] >= int(time.time()) + 899
|
||||
assert heartbeat.json()["idle_expires_at"] == status.json()["expires_at"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -14,7 +14,7 @@ SESSION_JS = ROOT / "frontend" / "session.js"
|
||||
def run_session_scenario(scenario: str) -> dict:
|
||||
harness = f"""
|
||||
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
|
||||
const state = {{ requests: [], removed: [], deletedDatabases: [], deletionCompleted: false, deletedCaches: [], assigned: '', replaced: [], assignedAfterDeletion: false, replacedAfterDeletion: false, expiredAfterDeletion: false, workerMessages: [], confirmations: [], prompts: [], clearErrors: [], activityListeners: {{}}, now: 100000, responseStatus: 200, responsePayload: {{}}, responses: [] }};
|
||||
const state = {{ requests: [], removed: [], deletedDatabases: [], deletionCompleted: false, deletedCaches: [], assigned: '', replaced: [], assignedAfterDeletion: false, replacedAfterDeletion: false, expiredAfterDeletion: false, workerMessages: [], confirmations: [], prompts: [], clearErrors: [], activityListeners: {{}}, timers: [], now: 100000, responseStatus: 200, responsePayload: {{}}, responses: [] }};
|
||||
const storage = {{
|
||||
values: new Map([['stackchain.private', 'secret'], ['gitea.preference', 'keep']]),
|
||||
get length() {{ return this.values.size; }},
|
||||
@@ -59,7 +59,7 @@ const boundary = createSessionBoundary({{
|
||||
onExpired: () => {{ state.expiredAfterDeletion = state.deletionCompleted; }},
|
||||
addActivityListener: (type, listener) => {{ state.activityListeners[type] = listener; }},
|
||||
now: () => state.now,
|
||||
setTimer: (_callback, delay) => {{ state.leaseDelay = delay; return 1; }},
|
||||
setTimer: (callback, delay) => {{ state.leaseDelay = delay; state.timers.push({{callback, delay}}); return state.timers.length; }},
|
||||
confirmAction: message => {{ state.confirmations.push(message); return true; }},
|
||||
promptAuthorization: details => {{ state.prompts.push(details); return 'correct horse battery staple'; }},
|
||||
}});
|
||||
@@ -260,22 +260,100 @@ process.stdout.write(JSON.stringify(state));
|
||||
def test_authenticated_session_status_persists_offline_lease_for_worker_and_expiry_timer():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
state.responsePayload = {authenticated:true, csrf_token:'csrf-proof', expires_at:4102444800};
|
||||
state.responsePayload = {authenticated:true, csrf_token:'csrf-proof', expires_at:4102444800, idle_expires_at:4102441200};
|
||||
const valid = await boundary.refreshOfflineLease();
|
||||
state.valid = valid;
|
||||
state.savedExpiry = storage.getItem('stackchain.session-expires-at');
|
||||
state.savedIdleExpiry = storage.getItem('stackchain.session-idle-expires-at');
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["valid"] is True
|
||||
assert result["savedExpiry"] == "4102444800"
|
||||
assert result["savedIdleExpiry"] == "4102441200"
|
||||
assert result["workerMessages"] == [
|
||||
{"type": "stackchain-session-lease", "expiresAt": 4102444800}
|
||||
{
|
||||
"type": "stackchain-session-lease",
|
||||
"expiresAt": 4102444800,
|
||||
"idleExpiresAt": 4102441200,
|
||||
}
|
||||
]
|
||||
assert result["leaseDelay"] > 0
|
||||
|
||||
|
||||
def test_expired_idle_lease_locks_offline_without_purging_private_work():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
storage.setItem('stackchain.session-expires-at', '200');
|
||||
storage.setItem('stackchain.session-idle-expires-at', '99');
|
||||
state.failFetch = true;
|
||||
const valid = await boundary.refreshOfflineLease();
|
||||
state.valid = valid;
|
||||
state.remaining = Array.from(storage.values.keys());
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["valid"] is False
|
||||
assert result["remaining"] == [
|
||||
"stackchain.private",
|
||||
"gitea.preference",
|
||||
"stackchain.session-expires-at",
|
||||
"stackchain.session-idle-expires-at",
|
||||
]
|
||||
assert result["deletedDatabases"] == []
|
||||
assert result["deletedCaches"] == []
|
||||
assert result["replaced"] == ["/dashboard/login?reason=session-idle"]
|
||||
|
||||
|
||||
def test_successful_activity_heartbeat_republishes_only_confirmed_idle_deadline():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
storage.setItem('stackchain.session-expires-at', '4102444800');
|
||||
state.responsePayload = {active:true, idle_expires_at:4102441300};
|
||||
const recorded = await boundary.recordActivity();
|
||||
state.recorded = recorded;
|
||||
state.savedIdleExpiry = storage.getItem('stackchain.session-idle-expires-at');
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["recorded"] is True
|
||||
assert result["savedIdleExpiry"] == "4102441300"
|
||||
assert result["workerMessages"] == [
|
||||
{
|
||||
"type": "stackchain-session-lease",
|
||||
"expiresAt": 4102444800,
|
||||
"idleExpiresAt": 4102441300,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_absolute_expiry_still_purges_after_an_idle_lock():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
state.responsePayload = {authenticated:true, expires_at:200, idle_expires_at:150};
|
||||
await boundary.refreshOfflineLease();
|
||||
state.now = 150000;
|
||||
await state.timers[1].callback();
|
||||
state.deletedAtIdle = [...state.deletedDatabases];
|
||||
state.now = 200000;
|
||||
await state.timers[0].callback();
|
||||
state.remaining = Array.from(storage.values.keys());
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["deletedAtIdle"] == []
|
||||
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"]
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["replaced"] == [
|
||||
"/dashboard/login?reason=session-idle",
|
||||
"/dashboard/login?reason=session-expired",
|
||||
]
|
||||
|
||||
|
||||
def test_expired_stored_lease_purges_private_work_when_session_status_is_offline():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
|
||||
@@ -17,7 +17,10 @@ const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches
|
||||
const storedResponses = new Map();
|
||||
storedResponses.set(
|
||||
'https://forge.example/dashboard/__offline-session-lease',
|
||||
new Response(JSON.stringify({{expires_at: Math.floor(Date.now() / 1000) + 3600}})),
|
||||
new Response(JSON.stringify({{
|
||||
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
||||
idle_expires_at: Math.floor(Date.now() / 1000) + 900,
|
||||
}})),
|
||||
);
|
||||
const cache = {{
|
||||
addAll: async urls => {{ state.added = urls; }},
|
||||
@@ -503,7 +506,7 @@ def test_offline_navigation_returns_cached_shell_for_share_target_url():
|
||||
def test_expired_offline_lease_purges_private_worker_data_and_refuses_cached_shell():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
await dispatchMessage({type:'stackchain-session-lease', expiresAt:1});
|
||||
await dispatchMessage({type:'stackchain-session-lease', expiresAt:1, idleExpiresAt:1});
|
||||
state.failFetch = true;
|
||||
state.cachedBody = 'private cached dashboard';
|
||||
const response = await dispatch('fetch', {
|
||||
@@ -524,6 +527,36 @@ def test_expired_offline_lease_purges_private_worker_data_and_refuses_cached_she
|
||||
assert "private cached dashboard" not in result["body"]
|
||||
|
||||
|
||||
def test_expired_idle_lease_locks_cached_shell_without_purging_recoverable_work():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
state.clientList = [{postMessage: message => { state.clientMessage = message; }}];
|
||||
await dispatchMessage({
|
||||
type:'stackchain-session-lease',
|
||||
expiresAt:Math.floor(Date.now() / 1000) + 3600,
|
||||
idleExpiresAt:1,
|
||||
});
|
||||
state.failFetch = true;
|
||||
state.cachedBody = 'private cached dashboard';
|
||||
const response = await dispatch('fetch', {
|
||||
method:'GET', mode:'navigate', url:'https://forge.example/dashboard/',
|
||||
});
|
||||
process.stdout.write(JSON.stringify({
|
||||
state, status:response.status, body:await response.text(),
|
||||
cacheControl:response.headers.get('Cache-Control'),
|
||||
}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["status"] == 401
|
||||
assert result["body"] == "Your Stackchain session is locked. Reconnect and sign in."
|
||||
assert result["cacheControl"] == "no-store"
|
||||
assert result["state"]["outboxPurges"] == 0
|
||||
assert result["state"]["deleted"] == []
|
||||
assert result["state"]["clientMessage"] == {"type": "stackchain-session-idle"}
|
||||
assert "private cached dashboard" not in result["body"]
|
||||
|
||||
|
||||
def test_stalled_navigation_is_aborted_and_returns_cached_shell_within_deadline():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
|
||||
Reference in new issue
Block a user