"""Multi-user system test suite. 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. """ import base64 import io import json import os import shutil import sys import tempfile import time sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) os.environ.setdefault("FLASK_SECRET_KEY", "test-secret") 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, ) from backend.events import EventBus PASS, FAIL = 0, 0 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() return n 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() return n def check(name, cond, detail=""): global PASS, FAIL if cond: PASS += 1 print(f" ✅ {name}") else: FAIL += 1 print(f" ❌ {name} {detail}") def fresh_app(tmp): db = os.path.join(tmp, "fresh.db") app = create_app(db_path=db) return app, db def login_temp(app, code, view="control"): return app.test_client().post("/api/auth/login", json={"code": code, "view": view}) def setup_totp(client, secret=None): """Complete the temp→TOTP MFA setup flow for the current temp-pending session.""" 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") return secret, r2 def root_client(app, db, setup_mfa=True): """Return a fully-authenticated Root test client (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() if setup_mfa: secret, _ = setup_totp(c) assert secret, "root TOTP setup failed" st = c.get("/api/auth/status").get_json() assert st["authenticated"], st return c def reauth_totp(client, secret): code = pyotp.TOTP(secret).now() return client.post("/api/auth/reauth", json={"code": code}) def b64url(hexstr): return base64.urlsafe_b64encode(bytes.fromhex(hexstr)).rstrip(b"=").decode() print("=" * 60) print("TEST 1: Fresh DB bootstrap → Root user + temp password + MFA setup") print("=" * 60) tmp = tempfile.mkdtemp() app, db = fresh_app(tmp) _EMITTED_PASSWORDS.clear() init_db(db) check("Root user exists with id 0", get_user(0, db) is not None) 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) 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"}) st = c.get("/api/auth/status").get_json() check("Status reports temp_pending", st["temp_pending"] is True) secret, _ = setup_totp(c) st = c.get("/api/auth/status").get_json() 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")) # wrong code r = c2.post("/api/auth/login", json={"code": "000000", "view": "control"}) check("Wrong code rejected", r.status_code == 401) print() print("=" * 60) 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.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); CREATE TABLE calendar_events (id INTEGER PRIMARY KEY AUTOINCREMENT, source_id INTEGER NOT NULL, uid TEXT, summary TEXT, description TEXT, location TEXT, dtstart TEXT, dtend TEXT, is_all_day INTEGER DEFAULT 0); CREATE TABLE reminders (id INTEGER PRIMARY KEY AUTOINCREMENT, calendar_group_id INTEGER NOT NULL, minutes_before INTEGER DEFAULT 15, enabled INTEGER DEFAULT 1); CREATE TABLE alarms (id INTEGER PRIMARY KEY AUTOINCREMENT, label TEXT DEFAULT '', time TEXT NOT NULL, days TEXT DEFAULT '1,2,3,4,5,6,7', enabled INTEGER DEFAULT 1, sound TEXT DEFAULT 'default', once INTEGER DEFAULT 0); CREATE TABLE auth_config (id INTEGER PRIMARY KEY AUTOINCREMENT, pin TEXT DEFAULT ''); CREATE TABLE clock_config (id INTEGER PRIMARY KEY AUTOINCREMENT, city TEXT DEFAULT 'Beijing', timezone TEXT DEFAULT 'Asia/Shanghai', is_primary INTEGER DEFAULT 1); CREATE TABLE camera_config (id INTEGER PRIMARY KEY AUTOINCREMENT, enabled INTEGER DEFAULT 0); """) _lc.execute("INSERT INTO calendar_groups (name) VALUES ('Legacy Group 1')") _lc.execute("INSERT INTO calendar_groups (name) VALUES ('Legacy Group 2')") _lc.execute("INSERT INTO calendar_groups (name) VALUES ('Legacy Group 3')") _lc.execute("INSERT INTO alarms (label, time) VALUES ('legacy alarm', '07:30')") _lc.execute("INSERT INTO auth_config (id, pin) VALUES (1, '0411')") _lc.execute("INSERT INTO clock_config (city, timezone, is_primary) VALUES ('Beijing', 'Asia/Shanghai', 1)") _lc.commit() _lc.close() _EMITTED_PASSWORDS.clear() init_db(legacy) 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) print() print("=" * 60) print("TEST 3: Round-trip migration (new schema → new schema)") 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) 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] conn.close() check("User data preserved through migration", n == 1) 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 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) print() print("=" * 60) print("TEST 5: Admin registration (admin-fills) + TOTP provisioning + login") 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"}) check("Admin op without reauth → 403", r.status_code == 403 and r.get_json().get("reauth_required")) rr = reauth_totp(root, root_secret) check("TOTP reauth succeeds", rr.status_code == 200 and rr.get_json()["ok"]) r = root.post("/api/admin/users", json={"remark": "User A", "mode": "admin_fills"}) check("Admin creates user (admin-fills)", r.status_code == 201, str(r.get_json())) user_a_id = r.get_json()["user"]["id"] check("New user is not admin", r.get_json()["user"]["is_admin"] is False) # default remark = id when none given r2 = root.post("/api/admin/users", json={"mode": "admin_fills"}) check("Remark defaults to user id", r2.status_code == 201 and r2.get_json()["user"]["remark"] == str(r2.get_json()["user"]["id"])) user_b_id = r2.get_json()["user"]["id"] # Provision TOTP for user A r = root.post(f"/api/admin/users/{user_a_id}/totp/begin") check("Admin provisions TOTP secret", r.status_code == 200 and r.get_json().get("secret")) secret_a = r.get_json()["secret"] r = root.post(f"/api/admin/users/{user_a_id}/totp/verify", json={"code": pyotp.TOTP(secret_a).now()}) check("Admin enables TOTP after verify", r.status_code == 200) # Provision TOTP for user B too (needed for the isolation test) 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) 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") # User A cannot access admin APIs r = ua.get("/api/admin/users") check("Regular user blocked from admin list", r.status_code == 403) r = ua.post("/api/admin/users", json={"remark": "x", "mode": "admin_fills"}) check("Regular user blocked from user creation", r.status_code == 403) print() print("=" * 60) print("TEST 6: Temp account lifecycle (user-fills mode)") print("=" * 60) r = root.post("/api/admin/users", json={"remark": "Temp User", "mode": "user_fills"}) check("Admin creates temp account", r.status_code == 201 and r.get_json()["is_temp"]) temp_id = r.get_json()["user"]["id"] temp_secret = get_temp_totp_secret(temp_id, db5) check("Temp TOTP secret stored server-side", bool(temp_secret)) r = root.get(f"/api/admin/users/{temp_id}/totp") check("Admin sees real-time temp TOTP", r.status_code == 200 and r.get_json()["code"] == pyotp.TOTP(temp_secret).now()) # ensure no secret leakage in admin list 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")) 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 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("Account no longer temp", is_temp_account(temp_id, db5) is False) 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) # old temp TOTP no longer valid r = app5.test_client().post("/api/auth/login", json={"code": pyotp.TOTP(temp_secret).now(), "view": "control"}) check("Old temp TOTP invalidated", r.status_code == 401) # new TOTP works r = app5.test_client().post("/api/auth/login", json={"code": pyotp.TOTP(new_secret).now(), "view": "control"}) check("New TOTP login works", r.status_code == 200) print() print("=" * 60) print("TEST 7: Self-service request → admin confirm → temp account") print("=" * 60) req_client = app5.test_client() r = req_client.post("/api/auth/register-request", json={"remark": "Bob"}) check("Public register request accepted", r.status_code == 201) users_json = root.get("/api/admin/users").get_json() pending = [u for u in users_json if u["status"] == "pending"] check("Request appears as pending", len(pending) == 1 and pending[0]["remark"] == "Bob") req_id = pending[0]["id"] r = root.post("/api/admin/users/confirm", json={"request_id": req_id}) check("Admin confirms request → temp account", r.status_code == 200) check("Confirmed account is temp", is_temp_account(req_id, db5)) r = root.post("/api/admin/users/confirm", json={"request_id": req_id}) check("Double confirm rejected", r.status_code == 400) bob_secret = get_temp_totp_secret(req_id, db5) r = app5.test_client().post("/api/auth/login", json={"code": pyotp.TOTP(bob_secret).now(), "view": "control"}) check("Bob can log in with temp TOTP", r.status_code == 200) 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"}) # User A creates data r = ua.post("/api/calendar/groups", json={"name": "A's Group"}) group_a = r.get_json()["id"] ua.post("/api/alarms", json={"label": "A alarm", "time": "09:00"}) from datetime import datetime, timedelta _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"}) r = ub.get("/api/calendar/groups").get_json() check("User B sees no groups", r == []) r = ub.get("/api/alarms").get_json() check("User B sees no alarms", r == []) r = ub.get("/api/display/state").get_json() check("User B state has no A events", r["events"] == [] and r["alarms"] == []) # User B cannot tamper with A's group r = ub.delete(f"/api/calendar/groups/{group_a}") check("User B cannot delete A's group (404)", r.status_code == 404) r = ua.get("/api/calendar/groups").get_json() check("A's group untouched", any(g["id"] == group_a for g in r)) # User A cannot fetch B's events (B has none) — and cannot touch admin stuff r = ua.get(f"/api/calendar/groups/{group_a}/events").get_json() check("A sees own group events", len(r) == 1) print() 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"}) 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) r = root.put(f"/api/admin/users/{user_a_id}", json={"remark": "Renamed A"}) check("Edit remark with reauth", r.status_code == 200 and r.get_json()["user"]["remark"] == "Renamed A") # delete B (with reauth) 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 r = ub.get("/api/alarms") check("Deleted user's session invalidated", r.status_code == 401) # cannot delete root / self r = root.delete("/api/admin/users/0") check("Cannot delete Root", r.status_code == 400) r = root.delete(f"/api/admin/users/{0}") check("Self-delete guard (root)", r.status_code == 400) print() print("=" * 60) print("TEST 10: kick_all (admin only) invalidates all sessions") print("=" * 60) ua2 = app5.test_client() ua2.post("/api/auth/login", json={"code": pyotp.TOTP(secret_a).now(), "view": "control"}) 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) print() print("=" * 60) print("TEST 11: SSE / EventBus user scoping") print("=" * 60) bus = EventBus() q1 = bus.subscribe(user_id=1) q2 = bus.subscribe(user_id=2) qall = bus.subscribe(None) bus.publish("alarm_triggered", {"alarms": []}, user_id=1) def q_get(q, timeout=0.1): try: return q.get(timeout=timeout) except Exception: return None check("User 1 receives own event", q_get(q1) is not None) check("User 2 does NOT receive user 1 event", q_get(q2, 0.05) is None) check("Global subscriber receives all events", q_get(qall) is not None) bus.publish("global_event", {}, user_id=None) check("Global event reaches all subscribers", q_get(q2) is not None) print() print("=" * 60) print("TEST 12: 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}"}) r = app5.test_client().post("/api/auth/register-request", json={"remark": "spam5"}) check("6th register request rate-limited", r.status_code == 429) print() print("=" * 60) print("TEST 13: CSRF origin check on admin operations") print("=" * 60) 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) print() print("=" * 60) print("TEST 14: Passkey flows (mocked ceremonies)") print("=" * 60) from backend import webauthn_mgr import backend.routes as routes_mod CRED_HEX = "aabbccddeeff00112233445566778899" CRED_B64 = b64url(CRED_HEX) orig_begin_reg = routes_mod.begin_registration orig_complete_reg = routes_mod.complete_registration orig_begin_auth = routes_mod.begin_authentication orig_complete_auth = routes_mod.complete_authentication routes_mod.begin_registration = lambda request, user_id, display_name="", existing_credential_id=None: { "challenge": "AA", "user": {"id": b64url(f"user_{user_id}".encode().hex()), "name": f"user_{user_id}", "displayName": display_name}, } routes_mod.complete_registration = lambda request, credential: { "ok": True, "user_id": int(credential.get("user_name", "user_0").split("_")[-1]), "credential_id": CRED_HEX, "public_key": "abcdef", } routes_mod.begin_authentication = lambda request, allow_credential_ids=None: {"challenge": "BB"} def _cred_hex(credential): import base64 cid = credential.get("id", "") try: padded = cid + "=" * ((4 - len(cid) % 4) % 4) return base64.urlsafe_b64decode(padded.encode("ascii")).hex() except Exception: return "unknown" routes_mod.complete_authentication = lambda request, credential, stored_credential_id=None, stored_public_key="": { "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"}) 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") 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}"}}) 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") 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() 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") 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"}}) check("Passkey reauth rejects wrong credential", r.status_code == 401) r = root14_c2.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"}) 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") 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}"}}) 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("Temp TOTP gone after passkey finalize", r.status_code == 400) # restore routes_mod.begin_registration = orig_begin_reg routes_mod.complete_registration = orig_complete_reg routes_mod.begin_authentication = orig_begin_auth routes_mod.complete_authentication = orig_complete_auth print() print("=" * 60) print("TEST 15: 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) 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(): 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(): 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(): 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(): 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 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("=" * 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) check("SSRF: cloud metadata rejected", is_safe_public_url("http://169.254.169.254/latest/meta-data") is False) check("SSRF: private IP rejected", is_safe_public_url("http://10.0.0.5/cal.ics") is False) check("SSRF: public URL accepted", is_safe_public_url("https://example.com/cal.ics") is True) 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"}) # 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"}) 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"}) # reauth requires an authenticated session (no public TOTP oracle) anon = app5.test_client() r = anon.post("/api/auth/reauth", json={"code": pyotp.TOTP(secret_a).now()}) check("Reauth blocked for unauthenticated caller", r.status_code == 401) # subscription SSRF rejection + cross-user calendar write rejection rr = reauth_totp(root, root_secret) r = ua.post("/api/calendar/sources/subscribe", json={"group_id": group_a, "name": "evil", "url": "http://127.0.0.1/x.ics"}) 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 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") check("Cross-user ICS upload rejected", r.status_code == 400) # qweather host SSRF r = ua.put("/api/weather/config", json={"qweather_host": "127.0.0.1"}) check("Unsafe qweather host rejected", r.status_code == 400) # cascade delete with full data (FK-safe order) r = root.post("/api/admin/users", json={"remark": "Full Data", "mode": "admin_fills"}) 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"}) 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) 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"]} 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) 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) print() print("=" * 60) print("TEST 18: Passkey stash 一次性参数暂存(小窗传参协商)") print("=" * 60) db18 = os.path.join(tmp, "stash.db") app18 = create_app(db_path=db18) c18 = app18.test_client() opts = {"challenge": "ch123", "rp": {"id": "d.example.com"}, "user": {"id": "u1", "name": "user_1"}} r = c18.post("/api/passkey/stash", json={"op": "creation", "options": opts}) check("stash 创建返回 sid", r.status_code == 201 and r.get_json().get("sid")) sid = r.get_json()["sid"] r = c18.get(f"/api/passkey/stash/{sid}") check("stash 一次性消费返回参数", r.status_code == 200 and r.get_json()["op"] == "creation" and r.get_json()["options"]["rp"]["id"] == "d.example.com") r = c18.get(f"/api/passkey/stash/{sid}") check("stash 参数可重复读取(entry 保留至结果取走)", r.status_code == 200) r = c18.get("/api/passkey/stash/notexist") check("stash 未知 sid 返回 410", r.status_code == 410) r = c18.post("/api/passkey/stash", json={"op": "bad", "options": {}}) check("stash 非法 op 拒绝", r.status_code == 400) # 与中间件协议一致性:登录/注册/二次验证用的 options 均含 challenge + rp.id r = c18.post("/api/passkey/stash", json={"op": "assertion", "options": {"challenge": "a", "rp": {"id": "d.example.com"}}}) sid2 = r.get_json()["sid"] r = c18.get(f"/api/passkey/stash/{sid2}") check("assertion 参数一致性", r.get_json()["options"]["challenge"] == "a") # result 接口:小窗完成 → POST 结果 → App GET 取回(一次性) r = c18.post("/api/passkey/stash", json={"op": "creation", "options": {"challenge": "b", "rp": {"id": "d.example.com"}}}) sid3 = r.get_json()["sid"] cred = {"id": "cred-abc", "type": "public-key", "response": {"clientDataJSON": "x", "attestationObject": "y" * 2000, "transports": []}} r = c18.post(f"/api/passkey/stash/{sid3}/result", json={"credential": cred, "user_name": "user_1"}) check("result 保存成功", r.status_code == 200 and r.get_json()["ok"]) r = c18.get(f"/api/passkey/stash/{sid3}/result") check("App 取回完整 credential(含大数据)", r.status_code == 200 and r.get_json()["credential"]["id"] == "cred-abc" and r.get_json()["user_name"] == "user_1") r = c18.get(f"/api/passkey/stash/{sid3}/result") check("result 二次取回返回 410", r.status_code == 410) # 未 POST 结果前 GET → 410 r = c18.post("/api/passkey/stash", json={"op": "assertion", "options": {"challenge": "c", "rp": {"id": "d.example.com"}}}) sid4 = r.get_json()["sid"] r = c18.get(f"/api/passkey/stash/{sid4}/result") check("未保存结果时 GET 返回 410", r.status_code == 410) # 真实时序回归:小窗先 GET 参数(只读,不消费)→ 完成认证 → POST result 必须成功 r = c18.post("/api/passkey/stash", json={"op": "creation", "options": {"challenge": "d", "rp": {"id": "d.example.com"}}}) sid5 = r.get_json()["sid"] r = c18.get(f"/api/passkey/stash/{sid5}") check("小窗读取参数成功", r.status_code == 200) r = c18.post(f"/api/passkey/stash/{sid5}/result", json={"credential": {"id": "reg-final", "type": "public-key", "response": {}}}) check("读取参数后仍可 POST result(修复 410 回归)", r.status_code == 200) print() print("=" * 60) 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") r = c19.get("/api/theme") check("默认主题为 dark", r.status_code == 200 and r.get_json()["mode"] == "dark" and r.get_json()["effective"] == "dark") r = c19.put("/api/theme", json={"mode": "light"}) check("保存 light → effective light", r.status_code == 200 and r.get_json()["effective"] == "light") r = c19.put("/api/theme", json={"mode": "auto"}) check("auto 解析为 dark/light 之一", r.status_code == 200 and r.get_json()["effective"] in ("dark", "light")) r = c19.put("/api/theme", json={"mode": "bad"}) check("非法模式拒绝", r.status_code == 400) # 账户独立:创建用户 A/B,各自设置互不影响 from backend.models import create_user as cu19, update_user as uu19 ua19 = cu19("Theme A", db_path=db19) ub19 = cu19("Theme B", db_path=db19) uu19(ua19, theme="light", db_path=db19) uu19(ub19, theme="dark", db_path=db19) check("账户 A 主题=light", get_user(ua19, db19)["theme"] == "light") check("账户 B 主题=dark", get_user(ub19, db19)["theme"] == "dark") # 未登录访问 /api/theme → 401 anon19 = app19.test_client() check("未登录 GET /api/theme → 401", anon19.get("/api/theme").status_code == 401) print() print("=" * 60) print(f"RESULT: {PASS} passed, {FAIL} failed") print("=" * 60) sys.exit(1 if FAIL else 0)