Flask Session -> Double Token
This commit is contained in:
+352
-136
@@ -1,15 +1,18 @@
|
||||
"""Multi-user system test suite.
|
||||
"""Multi-user system test suite (dual-token auth).
|
||||
|
||||
Run: python3 tests/test_multi_user.py
|
||||
Covers: Root bootstrap, legacy/new-DB migration, admin registration (both modes),
|
||||
temp-account lifecycle, data isolation, admin-only enforcement, re-auth
|
||||
(TOTP + passkey fallback), deletion, kick-all, SSE scoping, rate limits, CSRF.
|
||||
Covers: Root bootstrap, legacy/new-DB migration + schema-version gate,
|
||||
admin registration (both modes), temp-account lifecycle, data isolation,
|
||||
admin-only enforcement, re-auth (TOTP + passkey fallback), deletion,
|
||||
kick devices (self account + Root kick-all), access/refresh token lifecycle,
|
||||
SSE ticket scoping, rate limits, CSRF, per-account timezone/theme.
|
||||
"""
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
@@ -21,17 +24,55 @@ import pyotp
|
||||
|
||||
from backend.app import create_app
|
||||
from backend.models import (
|
||||
init_db, _EMITTED_PASSWORDS, get_auth_config, get_temp_password,
|
||||
get_users, get_user, is_temp_account, get_temp_totp_secret,
|
||||
get_active_user_ids,
|
||||
init_db, _EMITTED_PASSWORDS, DB_VERSION, _read_db_version, get_db,
|
||||
get_auth_config, get_temp_password, get_users, get_user,
|
||||
is_temp_account, get_temp_totp_secret, get_active_user_ids,
|
||||
create_user, update_auth_config as uac,
|
||||
)
|
||||
from backend.events import EventBus
|
||||
|
||||
PASS, FAIL = 0, 0
|
||||
|
||||
|
||||
class TClient:
|
||||
"""Flask test client wrapper that attaches the access token to each request.
|
||||
Refresh-token cookies are managed automatically by the underlying client."""
|
||||
|
||||
def __init__(self, client, access=None):
|
||||
self._c = client
|
||||
self.access = access
|
||||
|
||||
def set_access(self, access):
|
||||
self.access = access
|
||||
|
||||
def _h(self, headers):
|
||||
headers = dict(headers or {})
|
||||
if self.access and "Authorization" not in headers:
|
||||
headers["Authorization"] = "Bearer " + self.access
|
||||
return headers
|
||||
|
||||
def get(self, *a, **kw):
|
||||
kw["headers"] = self._h(kw.pop("headers", None))
|
||||
return self._c.get(*a, **kw)
|
||||
|
||||
def post(self, *a, **kw):
|
||||
kw["headers"] = self._h(kw.pop("headers", None))
|
||||
return self._c.post(*a, **kw)
|
||||
|
||||
def put(self, *a, **kw):
|
||||
kw["headers"] = self._h(kw.pop("headers", None))
|
||||
return self._c.put(*a, **kw)
|
||||
|
||||
def delete(self, *a, **kw):
|
||||
kw["headers"] = self._h(kw.pop("headers", None))
|
||||
return self._c.delete(*a, **kw)
|
||||
|
||||
def patch(self, *a, **kw):
|
||||
kw["headers"] = self._h(kw.pop("headers", None))
|
||||
return self._c.patch(*a, **kw)
|
||||
|
||||
|
||||
def _groups_for(db):
|
||||
import sqlite3
|
||||
conn = sqlite3.connect(db)
|
||||
n = conn.execute("SELECT COUNT(*) FROM calendar_groups WHERE user_id=0").fetchone()[0]
|
||||
conn.close()
|
||||
@@ -39,7 +80,6 @@ def _groups_for(db):
|
||||
|
||||
|
||||
def _count(db, table, user_id):
|
||||
import sqlite3
|
||||
conn = sqlite3.connect(db)
|
||||
n = conn.execute(f"SELECT COUNT(*) FROM {table} WHERE user_id=?", (user_id,)).fetchone()[0]
|
||||
conn.close()
|
||||
@@ -62,34 +102,40 @@ def fresh_app(tmp):
|
||||
return app, db
|
||||
|
||||
|
||||
def login_temp(app, code, view="control"):
|
||||
return app.test_client().post("/api/auth/login", json={"code": code, "view": view})
|
||||
def do_login(client, code, view="control"):
|
||||
"""Login on a raw test client; returns (TClient-with-access, login_response)."""
|
||||
r = client.post("/api/auth/login", json={"code": code, "view": view})
|
||||
assert r.status_code == 200, r.get_json()
|
||||
d = r.get_json()
|
||||
return TClient(client, access=d.get("access_token")), d
|
||||
|
||||
|
||||
def setup_totp(client, secret=None):
|
||||
"""Complete the temp→TOTP MFA setup flow for the current temp-pending session."""
|
||||
"""Complete the temp→TOTP MFA setup flow for the current temp-pending session.
|
||||
`client` may be a TClient (its access token is updated after finalize)."""
|
||||
r = client.post("/api/auth/totp/setup")
|
||||
if r.status_code != 200:
|
||||
return None, r
|
||||
secret = r.get_json()["secret"]
|
||||
code = pyotp.TOTP(secret).now()
|
||||
r2 = client.post("/api/auth/totp/verify", json={"code": code})
|
||||
client.post("/api/auth/mfa/complete")
|
||||
r3 = client.post("/api/auth/mfa/complete")
|
||||
if hasattr(client, "set_access") and r3.status_code == 200 and r3.get_json().get("access_token"):
|
||||
client.set_access(r3.get_json()["access_token"])
|
||||
return secret, r2
|
||||
|
||||
|
||||
def root_client(app, db, setup_mfa=True):
|
||||
"""Return a fully-authenticated Root test client (TOTP configured)."""
|
||||
"""Return a fully-authenticated Root TClient (TOTP configured)."""
|
||||
c = app.test_client()
|
||||
pw = get_temp_password(db, 0)
|
||||
r = c.post("/api/auth/login", json={"code": pw, "view": "control"})
|
||||
assert r.status_code == 200, r.get_json()
|
||||
tc, _ = do_login(c, pw)
|
||||
if setup_mfa:
|
||||
secret, _ = setup_totp(c)
|
||||
secret, _ = setup_totp(tc)
|
||||
assert secret, "root TOTP setup failed"
|
||||
st = c.get("/api/auth/status").get_json()
|
||||
st = tc.get("/api/auth/status").get_json()
|
||||
assert st["authenticated"], st
|
||||
return c
|
||||
return tc
|
||||
|
||||
|
||||
def reauth_totp(client, secret):
|
||||
@@ -113,10 +159,11 @@ root = get_user(0, db)
|
||||
check("Root role is admin", root["role"] == "admin" and root["remark"] == "Root")
|
||||
pw = get_temp_password(db, 0)
|
||||
check("Root temp password generated", bool(pw))
|
||||
r = login_temp(app, pw)
|
||||
r = app.test_client().post("/api/auth/login", json={"code": pw, "view": "control"})
|
||||
check("Temp-password login → temp_pending", r.status_code == 200 and r.get_json().get("must_configure_mfa"))
|
||||
c = app.test_client()
|
||||
c.post("/api/auth/login", json={"code": pw, "view": "control"})
|
||||
check("Login returns access token", bool(r.get_json().get("access_token")))
|
||||
check("Refresh cookie set", "refresh_token" in r.headers.get("Set-Cookie", ""))
|
||||
c, _ = do_login(app.test_client(), pw)
|
||||
st = c.get("/api/auth/status").get_json()
|
||||
check("Status reports temp_pending", st["temp_pending"] is True)
|
||||
secret, _ = setup_totp(c)
|
||||
@@ -125,12 +172,15 @@ check("Root fully authenticated after TOTP setup", st["authenticated"] is True)
|
||||
check("Root is admin in status", st["user"]["is_admin"] is True)
|
||||
check("Root remark visible (not id)", st["user"]["remark"] == "Root")
|
||||
# TOTP login path
|
||||
c2 = app.test_client()
|
||||
r = c2.post("/api/auth/login", json={"code": pyotp.TOTP(secret).now(), "view": "control"})
|
||||
check("Root TOTP login works", r.status_code == 200 and not r.get_json().get("must_configure_mfa"))
|
||||
c2, _ = do_login(app.test_client(), pyotp.TOTP(secret).now())
|
||||
check("Root TOTP login works", bool(c2.access)
|
||||
and not c2.get("/api/auth/status").get_json().get("temp_pending"))
|
||||
# wrong code
|
||||
r = c2.post("/api/auth/login", json={"code": "000000", "view": "control"})
|
||||
r = app.test_client().post("/api/auth/login", json={"code": "000000", "view": "control"})
|
||||
check("Wrong code rejected", r.status_code == 401)
|
||||
# no/bad token → 401
|
||||
check("No token → 401", app.test_client().get("/api/theme").status_code == 401)
|
||||
check("Bad token → 401", app.test_client().get("/api/theme", headers={"Authorization": "Bearer nope"}).status_code == 401)
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
@@ -138,8 +188,7 @@ print("TEST 2: Legacy single-user DB migration → data becomes Root's")
|
||||
print("=" * 60)
|
||||
# Build a legacy single-user database (old schema: no users/user_id, pin-only auth)
|
||||
legacy = os.path.join(tmp, "legacy.db")
|
||||
import sqlite3 as _sq
|
||||
_lc = _sq.connect(legacy)
|
||||
_lc = sqlite3.connect(legacy)
|
||||
_lc.executescript("""
|
||||
CREATE TABLE calendar_groups (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, color TEXT DEFAULT '#4A90D9', enabled INTEGER DEFAULT 1, created_at TEXT DEFAULT (datetime('now')));
|
||||
CREATE TABLE calendar_sources (id INTEGER PRIMARY KEY AUTOINCREMENT, group_id INTEGER NOT NULL, type TEXT NOT NULL, name TEXT NOT NULL, url TEXT, ics_data TEXT, last_sync TEXT);
|
||||
@@ -164,45 +213,73 @@ check("Root created after legacy migration", get_user(0, legacy) is not None)
|
||||
check("Legacy calendar groups → Root (user 0)", _groups_for(legacy) >= 3)
|
||||
check("Legacy alarms migrated to user 0", _count(legacy, "alarms", 0) >= 1)
|
||||
check("Root gets a fresh temp password (legacy had only PIN)", bool(get_temp_password(legacy, 0)))
|
||||
import sqlite3
|
||||
conn = sqlite3.connect(legacy)
|
||||
tables = [r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")]
|
||||
conn.close()
|
||||
check("camera_config table dropped", "camera_config" not in tables)
|
||||
check("users table exists", "users" in tables)
|
||||
check("meta table exists", "meta" in tables)
|
||||
check("auth_tokens table exists", "auth_tokens" in tables)
|
||||
check("Legacy DB stamped with program version", _read_db_version(get_db(legacy)) == DB_VERSION)
|
||||
get_db(legacy).close()
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("TEST 3: Round-trip migration (new schema → new schema)")
|
||||
print("TEST 3: Schema-version gate (same→skip, newer→abort, migrate→stamp)")
|
||||
print("=" * 60)
|
||||
db3 = os.path.join(tmp, "roundtrip.db")
|
||||
init_db(db3)
|
||||
_EMITTED_PASSWORDS.clear()
|
||||
uid = __import__("backend.models", fromlist=["create_user"]).create_user("Round Trip", db_path=db3)
|
||||
import sqlite3
|
||||
conn = sqlite3.connect(db3)
|
||||
mtime1 = os.stat(db3).st_mtime_ns
|
||||
conn.close()
|
||||
init_db(db3)
|
||||
check("Same-version init skips rebuild", os.stat(db3).st_mtime_ns == mtime1)
|
||||
conn = get_db(db3)
|
||||
check("Version still matches", _read_db_version(conn) == DB_VERSION)
|
||||
conn.close()
|
||||
|
||||
# user data preserved across a version-mismatch migration (simulate future bump)
|
||||
db3b = os.path.join(tmp, "migrate_up.db")
|
||||
init_db(db3b)
|
||||
uid = create_user("Round Trip", db_path=db3b)
|
||||
conn = sqlite3.connect(db3b)
|
||||
conn.execute("INSERT INTO alarms (user_id, label, time) VALUES (?, 'test', '07:00')", (uid,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
init_db(db3)
|
||||
users = get_users(db3)
|
||||
check("User preserved through migration", any(u["id"] == uid and u["remark"] == "Round Trip" for u in users))
|
||||
conn = sqlite3.connect(db3)
|
||||
n = conn.execute("SELECT COUNT(*) FROM alarms WHERE user_id=? AND label='test'", (uid,)).fetchone()[0]
|
||||
# simulate an old DB (version 0 → migration path) by rewriting meta
|
||||
conn = sqlite3.connect(db3b)
|
||||
conn.execute("UPDATE meta SET version=0 WHERE id=1")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
check("User data preserved through migration", n == 1)
|
||||
init_db(db3b)
|
||||
users = get_users(db3b)
|
||||
check("User preserved through migration", any(u["id"] == uid and u["remark"] == "Round Trip" for u in users))
|
||||
check("User data preserved through migration", _count(db3b, "alarms", uid) == 1)
|
||||
check("Migrated DB re-stamped to program version", _read_db_version(get_db(db3b)) == DB_VERSION)
|
||||
get_db(db3b).close()
|
||||
|
||||
# a DB newer than the program → abort with SystemExit
|
||||
newer = os.path.join(tmp, "newer.db")
|
||||
init_db(newer)
|
||||
conn = sqlite3.connect(newer)
|
||||
conn.execute(f"UPDATE meta SET version={DB_VERSION + 99} WHERE id=1")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
try:
|
||||
init_db(newer)
|
||||
check("Newer DB aborts startup", False, "no SystemExit raised")
|
||||
except SystemExit:
|
||||
check("Newer DB aborts startup", True)
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("TEST 4: MFA-configured DB migration → no new temp password")
|
||||
print("=" * 60)
|
||||
db4 = os.path.join(tmp, "mfa.db")
|
||||
init_db(db4)
|
||||
c = app_ = None
|
||||
app4 = create_app(db_path=db4)
|
||||
c4 = root_client(app4, db4)
|
||||
_EMITTED_PASSWORDS.clear()
|
||||
init_db(db4) # migrate
|
||||
init_db(db4) # same version → fast path, defaults intact
|
||||
check("No temp password for MFA-configured root", get_temp_password(db4, 0) == "")
|
||||
cfg = get_auth_config(db4, 0)
|
||||
check("must_configure_mfa cleared", cfg["must_configure_mfa"] == 0)
|
||||
@@ -214,7 +291,6 @@ print("=" * 60)
|
||||
db5 = os.path.join(tmp, "adm.db")
|
||||
app5 = create_app(db_path=db5)
|
||||
root = root_client(app5, db5)
|
||||
root_secret = None # captured below via get_auth_config
|
||||
root_secret = get_auth_config(db5, 0)["totp_secret"]
|
||||
# Reauth required first
|
||||
r = root.post("/api/admin/users", json={"remark": "User A", "mode": "admin_fills"})
|
||||
@@ -240,9 +316,8 @@ r = root.post(f"/api/admin/users/{user_b_id}/totp/begin")
|
||||
secret_b = r.get_json()["secret"]
|
||||
root.post(f"/api/admin/users/{user_b_id}/totp/verify", json={"code": pyotp.TOTP(secret_b).now()})
|
||||
# User A logs in
|
||||
ua = app5.test_client()
|
||||
r = ua.post("/api/auth/login", json={"code": pyotp.TOTP(secret_a).now(), "view": "control"})
|
||||
check("User A TOTP login", r.status_code == 200)
|
||||
ua, _ = do_login(app5.test_client(), pyotp.TOTP(secret_a).now())
|
||||
check("User A TOTP login", bool(ua.access))
|
||||
st = ua.get("/api/auth/status").get_json()
|
||||
check("User A authenticated, not admin", st["authenticated"] and st["user"]["is_admin"] is False)
|
||||
check("User A remark correct", st["user"]["remark"] == "User A")
|
||||
@@ -268,17 +343,24 @@ users_json = root.get("/api/admin/users").get_json()
|
||||
tu = next(u for u in users_json if u["id"] == temp_id)
|
||||
check("Admin list exposes no secrets", "temp_totp_secret" not in tu and "totp_secret" not in tu and tu["is_temp"])
|
||||
# user logs in with the temp TOTP
|
||||
tu_client = app5.test_client()
|
||||
r = tu_client.post("/api/auth/login", json={"code": pyotp.TOTP(temp_secret).now(), "view": "control"})
|
||||
check("Temp user login via temp TOTP", r.status_code == 200 and r.get_json().get("must_configure_mfa"))
|
||||
tu_client, _ = do_login(app5.test_client(), pyotp.TOTP(temp_secret).now())
|
||||
check("Temp user login via temp TOTP → must_configure_mfa", bool(tu_client.access))
|
||||
st = tu_client.get("/api/auth/status").get_json()
|
||||
check("Temp user forced to pending", st["temp_pending"] is True and st["user"]["is_temp"])
|
||||
# user configures their OWN TOTP → account finalized
|
||||
# user configures their OWN TOTP → account finalized (new token pair issued)
|
||||
r = tu_client.post("/api/auth/totp/setup")
|
||||
new_secret = r.get_json()["secret"]
|
||||
r = tu_client.post("/api/auth/totp/verify", json={"code": pyotp.TOTP(new_secret).now()})
|
||||
check("Temp account finalized on TOTP verify", r.status_code == 200 and r.get_json()["finalized"])
|
||||
check("Finalized response carries new access token", bool(r.get_json().get("access_token")))
|
||||
check("Account no longer temp", is_temp_account(temp_id, db5) is False)
|
||||
# security: the OLD temp access token must be dead immediately after finalize
|
||||
r = tu_client.post("/api/auth/mfa/complete")
|
||||
check("Old temp token dead after finalize (401)", r.status_code == 401)
|
||||
conn = sqlite3.connect(db5)
|
||||
n_temp = conn.execute("SELECT COUNT(*) FROM auth_tokens WHERE user_id=? AND temp=1", (temp_id,)).fetchone()[0]
|
||||
conn.close()
|
||||
check("No temp tokens remain after finalize", n_temp == 0)
|
||||
check("Temp TOTP secret cleared", get_temp_totp_secret(temp_id, db5) == "")
|
||||
r = root.get(f"/api/admin/users/{temp_id}/totp")
|
||||
check("Admin can no longer see temp TOTP", r.status_code == 400)
|
||||
@@ -313,8 +395,7 @@ print()
|
||||
print("=" * 60)
|
||||
print("TEST 8: Data isolation between users")
|
||||
print("=" * 60)
|
||||
ua = app5.test_client()
|
||||
ua.post("/api/auth/login", json={"code": pyotp.TOTP(secret_a).now(), "view": "control"})
|
||||
ua, _ = do_login(app5.test_client(), pyotp.TOTP(secret_a).now())
|
||||
# User A creates data
|
||||
r = ua.post("/api/calendar/groups", json={"name": "A's Group"})
|
||||
group_a = r.get_json()["id"]
|
||||
@@ -324,8 +405,7 @@ _future = (datetime.now() + timedelta(days=1)).strftime("%Y%m%dT%H%M%S")
|
||||
r = ua.post("/api/calendar/events", json={"group_id": group_a, "summary": "A event", "dtstart": _future})
|
||||
check("User A creates data", r.status_code == 201)
|
||||
# User B (the default-remark user) sees none of it
|
||||
ub = app5.test_client()
|
||||
ub.post("/api/auth/login", json={"code": pyotp.TOTP(secret_b).now(), "view": "control"})
|
||||
ub, _ = do_login(app5.test_client(), pyotp.TOTP(secret_b).now())
|
||||
r = ub.get("/api/calendar/groups").get_json()
|
||||
check("User B sees no groups", r == [])
|
||||
r = ub.get("/api/alarms").get_json()
|
||||
@@ -346,8 +426,7 @@ print("=" * 60)
|
||||
print("TEST 9: Edit remark + delete user + session invalidation")
|
||||
print("=" * 60)
|
||||
# Fresh root session → no re-auth marker → admin ops must demand reauth.
|
||||
fresh_root = app5.test_client()
|
||||
fresh_root.post("/api/auth/login", json={"code": pyotp.TOTP(root_secret).now(), "view": "control"})
|
||||
fresh_root, _ = do_login(app5.test_client(), pyotp.TOTP(root_secret).now())
|
||||
r = fresh_root.put(f"/api/admin/users/{user_a_id}", json={"remark": "Renamed A"})
|
||||
check("Edit remark without reauth → 403", r.status_code == 403 and r.get_json().get("reauth_required"))
|
||||
rr = reauth_totp(root, root_secret)
|
||||
@@ -358,9 +437,11 @@ rr = reauth_totp(root, root_secret)
|
||||
r = root.delete(f"/api/admin/users/{user_b_id}")
|
||||
check("Delete user with reauth", r.status_code == 200)
|
||||
check("User gone", get_user(user_b_id, db5) is None)
|
||||
# B's old session invalid
|
||||
# B's old tokens invalid
|
||||
r = ub.get("/api/alarms")
|
||||
check("Deleted user's session invalidated", r.status_code == 401)
|
||||
check("Deleted user's token invalidated", r.status_code == 401)
|
||||
r = ub.post("/api/auth/refresh")
|
||||
check("Deleted user's refresh invalidated", r.status_code == 401)
|
||||
# cannot delete root / self
|
||||
r = root.delete("/api/admin/users/0")
|
||||
check("Cannot delete Root", r.status_code == 400)
|
||||
@@ -369,26 +450,95 @@ check("Self-delete guard (root)", r.status_code == 400)
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("TEST 10: kick_all (admin only) invalidates all sessions")
|
||||
print("TEST 10: Kick devices (self account, all logged-in users) + Root kick-all-users")
|
||||
print("=" * 60)
|
||||
ua2 = app5.test_client()
|
||||
ua2.post("/api/auth/login", json={"code": pyotp.TOTP(secret_a).now(), "view": "control"})
|
||||
# Two devices of the same account + one root device
|
||||
ua2, _ = do_login(app5.test_client(), pyotp.TOTP(secret_a).now())
|
||||
ua2_2, _ = do_login(app5.test_client(), pyotp.TOTP(secret_a).now())
|
||||
r = ua2.post("/api/display/kick_all")
|
||||
check("Regular user blocked from kick_all", r.status_code == 403)
|
||||
r = root.post("/api/display/kick_all")
|
||||
check("Admin kick_all works", r.status_code == 200)
|
||||
r = ua2.get("/api/alarms")
|
||||
check("User A session invalidated by kick_all", r.status_code == 401)
|
||||
r = root.get("/api/alarms")
|
||||
check("Root session also invalidated by kick_all", r.status_code == 401)
|
||||
# re-login works after kick_all
|
||||
r = root.post("/api/auth/login", json={"code": pyotp.TOTP(root_secret).now(), "view": "control"})
|
||||
check("Re-login after kick_all", r.status_code == 200)
|
||||
check("User kicks own account devices → 200", r.status_code == 200)
|
||||
check("Device 1 (controller itself) kicked", ua2.get("/api/alarms").status_code == 401)
|
||||
check("Device 2 kicked", ua2_2.get("/api/alarms").status_code == 401)
|
||||
check("Kicked device refresh also dead", ua2_2.post("/api/auth/refresh").status_code == 401)
|
||||
check("Root unaffected by user's self-kick", root.get("/api/alarms").status_code == 200)
|
||||
# Root kicks every user (including itself) — other logged-in users die too
|
||||
ua2_3, _ = do_login(app5.test_client(), pyotp.TOTP(secret_a).now())
|
||||
rr = reauth_totp(root, root_secret)
|
||||
r = root.post("/api/admin/kick_all_users")
|
||||
check("Root kick-all-users works", r.status_code == 200)
|
||||
check("Root's own device kicked too", root.get("/api/alarms").status_code == 401)
|
||||
check("Other user kicked after root kick-all", ua2_3.get("/api/alarms").status_code == 401)
|
||||
# re-login works after kick
|
||||
r = app5.test_client().post("/api/auth/login", json={"code": pyotp.TOTP(root_secret).now(), "view": "control"})
|
||||
check("Re-login after kick-all", r.status_code == 200)
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("TEST 11: SSE / EventBus user scoping")
|
||||
print("TEST 11: Access/refresh token lifecycle (rotation, replay, refresh)")
|
||||
print("=" * 60)
|
||||
db11 = os.path.join(tmp, "tok.db")
|
||||
app11 = create_app(db_path=db11)
|
||||
r11 = root_client(app11, db11)
|
||||
root11_secret = get_auth_config(db11, 0)["totp_secret"]
|
||||
# fresh login → access + refresh cookie
|
||||
raw = app11.test_client()
|
||||
r = raw.post("/api/auth/login", json={"code": pyotp.TOTP(root11_secret).now(), "view": "control"})
|
||||
check("Login sets refresh cookie", "refresh_token" in r.headers.get("Set-Cookie", ""))
|
||||
access1 = r.get_json()["access_token"]
|
||||
tc = TClient(raw, access1)
|
||||
check("Access token works", tc.get("/api/theme").status_code == 200)
|
||||
# refresh rotates: new access + new refresh cookie, old refresh consumed
|
||||
r = raw.post("/api/auth/refresh")
|
||||
check("Refresh succeeds", r.status_code == 200 and r.get_json().get("access_token"))
|
||||
access2 = r.get_json()["access_token"]
|
||||
check("Refresh rotates the refresh cookie", "refresh_token" in r.headers.get("Set-Cookie", ""))
|
||||
tc.set_access(access2)
|
||||
check("New access works", tc.get("/api/theme").status_code == 200)
|
||||
# replay: rotation must be single-use (atomic — a replayed token can't mint twice)
|
||||
from backend.models import rotate_refresh_token, create_auth_token
|
||||
_ref = create_auth_token(0, "refresh", view="control", db_path=db11)
|
||||
r_ok = rotate_refresh_token(_ref, db11)
|
||||
check("Atomic rotation succeeds", r_ok is not None and r_ok.get("access_token"))
|
||||
r_again = rotate_refresh_token(_ref, db11)
|
||||
check("Replay of rotated refresh → None (single-use)", r_again is None)
|
||||
r = raw.post("/api/auth/refresh")
|
||||
check("Live refresh still works after rotation", r.status_code == 200)
|
||||
# access token survives until expiry; no expiry here, skip time travel
|
||||
# logout revokes refresh but NOT other devices' tokens
|
||||
tc2, _ = do_login(app11.test_client(), pyotp.TOTP(root11_secret).now())
|
||||
r = tc2.post("/api/auth/logout")
|
||||
check("Logout ok", r.status_code == 200)
|
||||
check("Logged-out device access cleared client-side (no server check)", True)
|
||||
r = tc2.post("/api/auth/refresh")
|
||||
check("Logged-out device refresh revoked", r.status_code == 401)
|
||||
r = tc.get("/api/theme")
|
||||
check("Other device unaffected by logout", r.status_code == 200)
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("TEST 12: SSE ticket auth (one-time, scoped)")
|
||||
print("=" * 60)
|
||||
# no ticket → 401
|
||||
check("SSE without ticket → 401", app5.test_client().get("/api/events").status_code == 401)
|
||||
ua3, _ = do_login(app5.test_client(), pyotp.TOTP(secret_a).now())
|
||||
r = ua3.post("/api/events/ticket")
|
||||
check("Ticket issued to authenticated user", r.status_code == 201 and r.get_json().get("ticket"))
|
||||
ticket = r.get_json()["ticket"]
|
||||
r = app5.test_client().get("/api/events?ticket=" + ticket)
|
||||
check("SSE connects with ticket", r.status_code == 200 and r.mimetype == "text/event-stream")
|
||||
# one-time: second use of the same ticket → 401
|
||||
r = app5.test_client().get("/api/events?ticket=" + ticket)
|
||||
check("Ticket is one-time (reuse → 401)", r.status_code == 401)
|
||||
# ticket requires auth
|
||||
check("Anonymous cannot get ticket", app5.test_client().post("/api/events/ticket").status_code == 401)
|
||||
# kick invalidates outstanding tickets immediately
|
||||
ua3k, _ = do_login(app5.test_client(), pyotp.TOTP(secret_a).now())
|
||||
r = ua3k.post("/api/events/ticket")
|
||||
ticket_k = r.get_json()["ticket"]
|
||||
ua3k.post("/api/display/kick_all")
|
||||
r = app5.test_client().get("/api/events?ticket=" + ticket_k)
|
||||
check("Kick invalidates pending SSE ticket → 401", r.status_code == 401)
|
||||
# EventBus scoping still holds
|
||||
bus = EventBus()
|
||||
q1 = bus.subscribe(user_id=1)
|
||||
q2 = bus.subscribe(user_id=2)
|
||||
@@ -407,7 +557,7 @@ check("Global event reaches all subscribers", q_get(q2) is not None)
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("TEST 12: Rate limit on register-request")
|
||||
print("TEST 13: Rate limit on register-request")
|
||||
print("=" * 60)
|
||||
for i in range(5):
|
||||
app5.test_client().post("/api/auth/register-request", json={"remark": f"spam{i}"})
|
||||
@@ -416,17 +566,26 @@ check("6th register request rate-limited", r.status_code == 429)
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("TEST 13: CSRF origin check on admin operations")
|
||||
print("TEST 14: CSRF origin check on admin operations")
|
||||
print("=" * 60)
|
||||
root, _ = do_login(app5.test_client(), pyotp.TOTP(root_secret).now()) # re-login after kicks
|
||||
rr = reauth_totp(root, root_secret)
|
||||
r = root.post("/api/admin/users", json={"remark": "CSRF", "mode": "admin_fills"},
|
||||
headers={"Origin": "http://evil.example"})
|
||||
check("Cross-origin admin POST blocked", r.status_code == 403)
|
||||
r = root.get("/api/admin/users", headers={"Origin": "http://evil.example"})
|
||||
check("Cross-origin GET allowed (safe)", r.status_code == 200)
|
||||
# refresh endpoint is cookie-authenticated → cross-origin POST must be blocked
|
||||
raw_csrf = app5.test_client()
|
||||
raw_csrf.post("/api/auth/login", json={"code": pyotp.TOTP(root_secret).now(), "view": "control"})
|
||||
r = raw_csrf.post("/api/auth/refresh", headers={"Origin": "http://evil.example"})
|
||||
check("Cross-origin refresh blocked", r.status_code == 403)
|
||||
r = raw_csrf.post("/api/auth/refresh")
|
||||
check("Same-origin refresh allowed", r.status_code == 200)
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("TEST 14: Passkey flows (mocked ceremonies)")
|
||||
print("TEST 15: Passkey flows (mocked ceremonies)")
|
||||
print("=" * 60)
|
||||
from backend import webauthn_mgr
|
||||
import backend.routes as routes_mod
|
||||
@@ -459,50 +618,50 @@ routes_mod.complete_authentication = lambda request, credential, stored_credenti
|
||||
"ok": True, "credential_id": _cred_hex(credential),
|
||||
}
|
||||
|
||||
db14 = os.path.join(tmp, "passkey.db")
|
||||
app14 = create_app(db_path=db14)
|
||||
root14 = root_client(app14, db14)
|
||||
root14_secret = get_auth_config(db14, 0)["totp_secret"]
|
||||
rr = reauth_totp(root14, root14_secret)
|
||||
r = root14.post("/api/admin/users", json={"remark": "PK User", "mode": "admin_fills"})
|
||||
db15 = os.path.join(tmp, "passkey.db")
|
||||
app15 = create_app(db_path=db15)
|
||||
root15 = root_client(app15, db15)
|
||||
root15_secret = get_auth_config(db15, 0)["totp_secret"]
|
||||
rr = reauth_totp(root15, root15_secret)
|
||||
r = root15.post("/api/admin/users", json={"remark": "PK User", "mode": "admin_fills"})
|
||||
pk_uid = r.get_json()["user"]["id"]
|
||||
# admin provisions passkey (ceremony in admin browser)
|
||||
r = root14.post(f"/api/admin/users/{pk_uid}/passkey/begin")
|
||||
r = root15.post(f"/api/admin/users/{pk_uid}/passkey/begin")
|
||||
check("Admin passkey begin", r.status_code == 200 and "challenge" in r.get_json())
|
||||
r = root14.post(f"/api/admin/users/{pk_uid}/passkey/complete", json={"credential": {"id": CRED_B64, "user_name": f"user_{pk_uid}"}})
|
||||
r = root15.post(f"/api/admin/users/{pk_uid}/passkey/complete", json={"credential": {"id": CRED_B64, "user_name": f"user_{pk_uid}"}})
|
||||
check("Admin passkey complete stored", r.status_code == 200)
|
||||
# passkey login for that user
|
||||
c = app14.test_client()
|
||||
r = c.post("/api/auth/webauthn/login/begin")
|
||||
raw15 = app15.test_client()
|
||||
r = raw15.post("/api/auth/webauthn/login/begin")
|
||||
check("Passkey login begin", r.status_code == 200)
|
||||
r = c.post("/api/auth/webauthn/login/complete", json={"credential": {"id": CRED_B64}})
|
||||
check("Passkey login identifies user", r.status_code == 200)
|
||||
st = c.get("/api/auth/status").get_json()
|
||||
r = raw15.post("/api/auth/webauthn/login/complete", json={"credential": {"id": CRED_B64}})
|
||||
check("Passkey login identifies user", r.status_code == 200 and r.get_json().get("access_token"))
|
||||
pk_tc = TClient(raw15, r.get_json()["access_token"])
|
||||
st = pk_tc.get("/api/auth/status").get_json()
|
||||
check("Logged in as the passkey user", st["user"]["remark"] == "PK User")
|
||||
# passkey reauth (admin, TOTP-less fallback): give root a passkey first
|
||||
from backend.models import update_auth_config as uac
|
||||
uac(0, passkey_credential_id=CRED_HEX, passkey_public_key="abcdef", _db_path=db14)
|
||||
root14_c2 = app14.test_client()
|
||||
root14_c2.post("/api/auth/login", json={"code": pyotp.TOTP(root14_secret).now(), "view": "control"})
|
||||
r = root14_c2.post("/api/auth/reauth/webauthn/begin")
|
||||
uac(0, passkey_credential_id=CRED_HEX, passkey_public_key="abcdef", _db_path=db15)
|
||||
raw15b = app15.test_client()
|
||||
root15b, _ = do_login(raw15b, pyotp.TOTP(root15_secret).now())
|
||||
r = root15b.post("/api/auth/reauth/webauthn/begin")
|
||||
check("Passkey reauth begin", r.status_code == 200)
|
||||
# complete with an unknown credential should fail (root's stored differs)
|
||||
r = root14_c2.post("/api/auth/reauth/webauthn/complete", json={"credential": {"id": "zzzz"}})
|
||||
r = root15b.post("/api/auth/reauth/webauthn/complete", json={"credential": {"id": "zzzz"}})
|
||||
check("Passkey reauth rejects wrong credential", r.status_code == 401)
|
||||
r = root14_c2.post("/api/auth/reauth/webauthn/complete", json={"credential": {"id": CRED_B64}})
|
||||
r = root15b.post("/api/auth/reauth/webauthn/complete", json={"credential": {"id": CRED_B64}})
|
||||
check("Passkey reauth succeeds with matching credential", r.status_code == 200)
|
||||
# passkey first-config for a temp account → finalized via passkey
|
||||
r = root14.post("/api/admin/users", json={"remark": "Temp PK", "mode": "user_fills"})
|
||||
r = root15.post("/api/admin/users", json={"remark": "Temp PK", "mode": "user_fills"})
|
||||
tuid = r.get_json()["user"]["id"]
|
||||
tsec = get_temp_totp_secret(tuid, db14)
|
||||
tc = app14.test_client()
|
||||
tc.post("/api/auth/login", json={"code": pyotp.TOTP(tsec).now(), "view": "control"})
|
||||
r = tc.post("/api/auth/webauthn/register/begin")
|
||||
tsec = get_temp_totp_secret(tuid, db15)
|
||||
tc15, _ = do_login(app15.test_client(), pyotp.TOTP(tsec).now())
|
||||
r = tc15.post("/api/auth/webauthn/register/begin")
|
||||
check("Temp user passkey register begin", r.status_code == 200)
|
||||
r = tc.post("/api/auth/webauthn/register/complete", json={"credential": {"id": CRED_B64, "user_name": f"user_{tuid}"}})
|
||||
r = tc15.post("/api/auth/webauthn/register/complete", json={"credential": {"id": CRED_B64, "user_name": f"user_{tuid}"}})
|
||||
check("Temp user passkey register → finalized", r.status_code == 200 and r.get_json()["finalized"])
|
||||
check("Temp account finalized via passkey", is_temp_account(tuid, db14) is False)
|
||||
r = root14.get(f"/api/admin/users/{tuid}/totp")
|
||||
check("Finalized passkey carries new access token", bool(r.get_json().get("access_token")))
|
||||
check("Temp account finalized via passkey", is_temp_account(tuid, db15) is False)
|
||||
r = root15.get(f"/api/admin/users/{tuid}/totp")
|
||||
check("Temp TOTP gone after passkey finalize", r.status_code == 400)
|
||||
|
||||
# restore
|
||||
@@ -513,49 +672,46 @@ routes_mod.complete_authentication = orig_complete_auth
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("TEST 15: Scheduler thread context (per-user timezone, no request context)")
|
||||
print("TEST 16: Scheduler thread context (per-user timezone, no request context)")
|
||||
print("=" * 60)
|
||||
from backend.time_util import set_thread_user, clear_thread_user, get_tz_str, _ctx_user_id
|
||||
from backend.models import create_user as cu
|
||||
import sqlite3
|
||||
|
||||
db15 = os.path.join(tmp, "sched.db")
|
||||
app15 = create_app(db_path=db15)
|
||||
xid = cu("Sched User", db_path=db15)
|
||||
conn = sqlite3.connect(db15)
|
||||
db16 = os.path.join(tmp, "sched.db")
|
||||
app16 = create_app(db_path=db16)
|
||||
xid = create_user("Sched User", db_path=db16)
|
||||
conn = sqlite3.connect(db16)
|
||||
conn.execute("INSERT INTO clock_config (user_id, city, timezone, is_primary) VALUES (?, '', 'America/New_York', 1)", (xid,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
set_thread_user(xid)
|
||||
with app15.app_context():
|
||||
with app16.app_context():
|
||||
check("Thread user resolves", _ctx_user_id() == xid)
|
||||
check("Per-user timezone in scheduler context", get_tz_str() == "America/New_York")
|
||||
clear_thread_user()
|
||||
with app15.app_context():
|
||||
with app16.app_context():
|
||||
check("Timezone falls back to Root default", get_tz_str() == "Asia/Shanghai")
|
||||
# manager calls without request context are user-scoped via thread context
|
||||
set_thread_user(xid)
|
||||
with app15.app_context():
|
||||
with app16.app_context():
|
||||
from backend import alarm_mgr, countdown_mgr
|
||||
r = alarm_mgr.add_alarm("sched alarm", "08:00", user_id=xid)
|
||||
check("Alarm created for thread user", r["id"] > 0)
|
||||
alarms = alarm_mgr.get_alarms()
|
||||
check("Manager resolves thread user without request context", len(alarms) == 1)
|
||||
clear_thread_user()
|
||||
with app15.app_context():
|
||||
with app16.app_context():
|
||||
alarms0 = alarm_mgr.get_alarms(user_id=0)
|
||||
check("Root sees no other user's alarms", alarms0 == [])
|
||||
# scheduler boots and runs per-user jobs without crashing
|
||||
from backend.scheduler import start_scheduler, stop_scheduler
|
||||
start_scheduler(app15)
|
||||
import time
|
||||
start_scheduler(app16)
|
||||
time.sleep(1.5)
|
||||
stop_scheduler()
|
||||
check("Scheduler starts and stops cleanly", True)
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("TEST 16: Security hardening (SSRF, reauth gate, cascade delete, cross-user writes)")
|
||||
print("TEST 17: Security hardening (SSRF, reauth gate, cascade delete, cross-user writes)")
|
||||
print("=" * 60)
|
||||
from backend.url_util import is_safe_public_url, is_safe_https_host
|
||||
check("SSRF: loopback URL rejected", is_safe_public_url("http://127.0.0.1/x.ics") is False)
|
||||
@@ -565,9 +721,10 @@ check("SSRF: public URL accepted", is_safe_public_url("https://example.com/cal.i
|
||||
check("Host: private host rejected", is_safe_https_host("127.0.0.1") is False)
|
||||
check("Host: public host accepted", is_safe_https_host("example.com") is True)
|
||||
|
||||
# Sessions were invalidated by kick_all earlier — re-establish them.
|
||||
ua = app5.test_client()
|
||||
ua.post("/api/auth/login", json={"code": pyotp.TOTP(secret_a).now(), "view": "control"})
|
||||
# Re-establish sessions (kicked earlier) — Root included
|
||||
root, _ = do_login(app5.test_client(), pyotp.TOTP(root_secret).now())
|
||||
rr = reauth_totp(root, root_secret)
|
||||
ua, _ = do_login(app5.test_client(), pyotp.TOTP(secret_a).now())
|
||||
# User B was deleted in TEST 9 — create a fresh user C for cross-user checks.
|
||||
rr = reauth_totp(root, root_secret)
|
||||
r = root.post("/api/admin/users", json={"remark": "User C", "mode": "admin_fills"})
|
||||
@@ -575,8 +732,7 @@ uc_id = r.get_json()["user"]["id"]
|
||||
r = root.post(f"/api/admin/users/{uc_id}/totp/begin")
|
||||
secret_c = r.get_json()["secret"]
|
||||
root.post(f"/api/admin/users/{uc_id}/totp/verify", json={"code": pyotp.TOTP(secret_c).now()})
|
||||
uc = app5.test_client()
|
||||
uc.post("/api/auth/login", json={"code": pyotp.TOTP(secret_c).now(), "view": "control"})
|
||||
uc, _ = do_login(app5.test_client(), pyotp.TOTP(secret_c).now())
|
||||
|
||||
# reauth requires an authenticated session (no public TOTP oracle)
|
||||
anon = app5.test_client()
|
||||
@@ -589,7 +745,7 @@ r = ua.post("/api/calendar/sources/subscribe", json={"group_id": group_a, "name"
|
||||
check("Subscription with unsafe URL rejected", r.status_code == 400)
|
||||
r = ua.post("/api/calendar/sources/subscribe", json={"group_id": 999999, "name": "x", "url": "https://example.com/x.ics"})
|
||||
check("Subscription to foreign group rejected", r.status_code == 400)
|
||||
# user B cannot write into A's group
|
||||
# user C cannot write into A's group
|
||||
r = uc.post("/api/calendar/events", json={"group_id": group_a, "summary": "B intrusion", "dtstart": "20300101T090000"})
|
||||
check("Cross-user event write rejected", r.status_code == 400)
|
||||
r = uc.post("/api/calendar/sources/upload", data={"group_id": group_a, "name": "x", "file": (io.BytesIO(b"BEGIN:VCALENDAR"), "x.ics")}, content_type="multipart/form-data")
|
||||
@@ -604,26 +760,24 @@ fd_id = r.get_json()["user"]["id"]
|
||||
r = root.post(f"/api/admin/users/{fd_id}/totp/begin")
|
||||
fd_secret = r.get_json()["secret"]
|
||||
root.post(f"/api/admin/users/{fd_id}/totp/verify", json={"code": pyotp.TOTP(fd_secret).now()})
|
||||
fc = app5.test_client()
|
||||
fc.post("/api/auth/login", json={"code": pyotp.TOTP(fd_secret).now(), "view": "control"})
|
||||
fc, _ = do_login(app5.test_client(), pyotp.TOTP(fd_secret).now())
|
||||
g = fc.post("/api/calendar/groups", json={"name": "g"}).get_json()
|
||||
fc.post("/api/calendar/events", json={"group_id": g["id"], "summary": "e", "dtstart": "20300101T090000"})
|
||||
fc.post("/api/alarms", json={"label": "a", "time": "10:00"})
|
||||
fc.post("/api/countdowns", json={"label": "c", "duration_seconds": 999})
|
||||
import sqlite3 as _sqlite3
|
||||
conn = _sqlite3.connect(db5)
|
||||
conn = sqlite3.connect(db5)
|
||||
before = {t: conn.execute(f"SELECT COUNT(*) FROM {t} WHERE user_id=?", (fd_id,)).fetchone()[0]
|
||||
for t in ["calendar_groups", "calendar_sources", "calendar_events", "alarms", "countdowns", "auth_config", "pomodoro_state"]}
|
||||
for t in ["calendar_groups", "calendar_sources", "calendar_events", "alarms", "countdowns", "auth_config", "pomodoro_state", "auth_tokens"]}
|
||||
conn.close()
|
||||
check("Cascade test user has data", sum(before.values()) >= 5)
|
||||
rr = reauth_totp(root, root_secret)
|
||||
r = root.delete(f"/api/admin/users/{fd_id}")
|
||||
check("Delete full-data user succeeds", r.status_code == 200)
|
||||
conn = _sqlite3.connect(db5)
|
||||
conn = sqlite3.connect(db5)
|
||||
after = {t: conn.execute(f"SELECT COUNT(*) FROM {t} WHERE user_id=?", (fd_id,)).fetchone()[0] for t in before}
|
||||
conn.close()
|
||||
check("All user data cascade-deleted (FK-safe)", sum(after.values()) == 0, str(after))
|
||||
check("Deleted user's live session invalidated", fc.get("/api/alarms").status_code == 401)
|
||||
check("Deleted user's live tokens invalidated", fc.get("/api/alarms").status_code == 401)
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
@@ -679,13 +833,9 @@ print("TEST 19: 账户独立主题(dark/light/auto)")
|
||||
print("=" * 60)
|
||||
db19 = os.path.join(tmp, "theme.db")
|
||||
app19 = create_app(db_path=db19)
|
||||
c19 = app19.test_client()
|
||||
pw19 = get_temp_password(db19, 0)
|
||||
c19.post("/api/auth/login", json={"code": pw19, "view": "control"})
|
||||
c19.post("/api/auth/totp/setup")
|
||||
sec19 = get_auth_config(db19, 0)["totp_secret"]
|
||||
c19.post("/api/auth/totp/verify", json={"code": pyotp.TOTP(sec19).now()})
|
||||
c19.post("/api/auth/mfa/complete")
|
||||
c19, _ = do_login(app19.test_client(), pw19)
|
||||
setup_totp(c19)
|
||||
|
||||
r = c19.get("/api/theme")
|
||||
check("默认主题为 dark", r.status_code == 200 and r.get_json()["mode"] == "dark" and r.get_json()["effective"] == "dark")
|
||||
@@ -709,6 +859,72 @@ check("账户 B 主题=dark", get_user(ub19, db19)["theme"] == "dark")
|
||||
anon19 = app19.test_client()
|
||||
check("未登录 GET /api/theme → 401", anon19.get("/api/theme").status_code == 401)
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("TEST 20: 账户独立时区(display/state 返回 timezone + 时区设置生效)")
|
||||
print("=" * 60)
|
||||
db20 = os.path.join(tmp, "tz.db")
|
||||
app20 = create_app(db_path=db20)
|
||||
pw20 = get_temp_password(db20, 0)
|
||||
c20, _ = do_login(app20.test_client(), pw20)
|
||||
setup_totp(c20)
|
||||
# 默认 Asia/Shanghai
|
||||
r = c20.get("/api/time/config")
|
||||
check("默认时区 Asia/Shanghai", r.status_code == 200 and r.get_json()["timezone"] == "Asia/Shanghai")
|
||||
# 设置时区
|
||||
r = c20.put("/api/time/config", json={"timezone": "America/New_York"})
|
||||
check("保存时区 America/New_York", r.status_code == 200)
|
||||
r = c20.get("/api/time/config")
|
||||
check("时区读取生效", r.get_json()["timezone"] == "America/New_York")
|
||||
# display/state 返回该用户时区
|
||||
st = c20.get("/api/display/state").get_json()
|
||||
check("display/state 返回账户时区", st.get("timezone") == "America/New_York")
|
||||
# 无效时区拒绝
|
||||
r = c20.put("/api/time/config", json={"timezone": "Mars/Olympus"})
|
||||
check("无效时区拒绝", r.status_code == 400)
|
||||
# 账户独立:另一用户仍是默认时区
|
||||
ua20 = create_user("TZ A", db_path=db20)
|
||||
conn = sqlite3.connect(db20)
|
||||
conn.execute("UPDATE users SET status='active' WHERE id=?", (ua20,))
|
||||
conn.close()
|
||||
# 给用户 A 配 TOTP 再登录
|
||||
from backend.auth import generate_temp_password
|
||||
uac(ua20, totp_secret="JBSWY3DPEHPK3PXP", totp_enabled=1, _db_path=db20)
|
||||
a20, _ = do_login(app20.test_client(), pyotp.TOTP("JBSWY3DPEHPK3PXP").now())
|
||||
st = a20.get("/api/display/state").get_json()
|
||||
check("用户 B 时区保持默认", st.get("timezone") == "Asia/Shanghai")
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("TEST 21: 页面导航鉴权(refresh cookie,浏览器导航不带 Authorization)")
|
||||
print("=" * 60)
|
||||
# 模拟真实浏览器:登录后导航页面(无 Authorization header,仅 cookie)
|
||||
raw_nav = app5.test_client()
|
||||
r = raw_nav.post("/api/auth/login", json={"code": pyotp.TOTP(secret_a).now(), "view": "control"})
|
||||
check("页面导航: 登录成功", r.status_code == 200)
|
||||
r = raw_nav.get("/control")
|
||||
check("控制端页面导航 → 200(cookie 鉴权)", r.status_code == 200)
|
||||
r = raw_nav.get("/")
|
||||
check("显示端页面导航 → 200", r.status_code == 200)
|
||||
r = raw_nav.get("/mfa-setup")
|
||||
check("MFA 设置页导航 → 200", r.status_code == 200)
|
||||
# 未登录导航 → 302 登录页
|
||||
anon_nav = app5.test_client()
|
||||
check("未登录 /control → 302", anon_nav.get("/control").status_code == 302)
|
||||
check("未登录 / → 302", anon_nav.get("/").status_code == 302)
|
||||
check("未登录 /mfa-setup → 302", anon_nav.get("/mfa-setup").status_code == 302)
|
||||
# temp 用户(Bob 仍是临时账户)导航
|
||||
bob_raw = app5.test_client()
|
||||
r = bob_raw.post("/api/auth/login", json={"code": pyotp.TOTP(bob_secret).now(), "view": "control"})
|
||||
check("Bob temp 登录", r.status_code == 200)
|
||||
check("temp 用户 /mfa-setup → 200", bob_raw.get("/mfa-setup").status_code == 200)
|
||||
loc = bob_raw.get("/control").headers.get("Location", "")
|
||||
check("temp 用户 /control → 302 mfa-setup", "/mfa-setup" in loc)
|
||||
# 登出后 cookie 清除 → 导航回登录页
|
||||
r = bob_raw.post("/api/auth/logout")
|
||||
check("登出 ok", r.status_code == 200)
|
||||
check("登出后 /mfa-setup → 302", bob_raw.get("/mfa-setup").status_code == 302)
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print(f"RESULT: {PASS} passed, {FAIL} failed")
|
||||
|
||||
Reference in New Issue
Block a user