New Database

This commit is contained in:
喆OvO
2026-08-11 22:07:55 +08:00
parent cbbb2e6d34
commit ceb4404c6a
28 changed files with 1825 additions and 768 deletions
+255 -107
View File
@@ -1,11 +1,18 @@
"""Multi-user system test suite (dual-token auth).
"""Multi-user system test suite (dual-token auth) — PostgreSQL edition.
Run: python3 tests/test_multi_user.py
Covers: Root bootstrap, legacy/new-DB migration + schema-version gate,
Requires a local PostgreSQL (default 127.0.0.1:5432, iot/iot@iot_dboard;
override with TEST_PG_HOST / TEST_PG_USER / TEST_PG_PASS / TEST_PG_DB).
Each test uses its own isolated PostgreSQL schema; the schema is dropped and
recreated at startup.
Covers: Root bootstrap, SQLite legacy import + 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.
SSE ticket scoping, rate limits, CSRF, per-account timezone/theme,
database administration (export / import / root-only / atomicity).
"""
import base64
import io
@@ -16,10 +23,12 @@ import sqlite3
import sys
import tempfile
import time
import urllib.parse
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
os.environ.setdefault("FLASK_SECRET_KEY", "test-secret")
import psycopg2
import pyotp
from backend.app import create_app
@@ -30,9 +39,32 @@ from backend.models import (
create_user, update_auth_config as uac,
)
from backend.events import EventBus
from backend import db_admin
PASS, FAIL = 0, 0
# ─── PostgreSQL test plumbing ─────────────────
PG_HOST = os.environ.get("TEST_PG_HOST", "127.0.0.1")
PG_USER = os.environ.get("TEST_PG_USER", "iot")
PG_PASS = os.environ.get("TEST_PG_PASS", "iot")
PG_DB = os.environ.get("TEST_PG_DB", "iot_dboard")
def _dsn(schema: str) -> str:
"""DSN for one isolated schema (search_path pinned to it)."""
opts = urllib.parse.quote(f"-csearch_path={schema}")
return f"postgresql://{PG_USER}:{PG_PASS}@{PG_HOST}/{PG_DB}?options={opts}"
def reset_schema(schema: str):
conn = psycopg2.connect(f"postgresql://{PG_USER}:{PG_PASS}@{PG_HOST}/{PG_DB}")
conn.autocommit = True
cur = conn.cursor()
cur.execute(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE')
cur.execute(f'CREATE SCHEMA "{schema}"')
cur.close()
conn.close()
class TClient:
"""Flask test client wrapper that attaches the access token to each request.
@@ -73,14 +105,14 @@ class TClient:
def _groups_for(db):
conn = sqlite3.connect(db)
conn = get_db(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):
conn = sqlite3.connect(db)
conn = get_db(db)
n = conn.execute(f"SELECT COUNT(*) FROM {table} WHERE user_id=?", (user_id,)).fetchone()[0]
conn.close()
return n
@@ -96,8 +128,9 @@ def check(name, cond, detail=""):
print(f" ❌ {name} {detail}")
def fresh_app(tmp):
db = os.path.join(tmp, "fresh.db")
def fresh_app(schema):
reset_schema(schema)
db = _dsn(schema)
app = create_app(db_path=db)
return app, db
@@ -150,8 +183,7 @@ def b64url(hexstr):
print("=" * 60)
print("TEST 1: Fresh DB bootstrap → Root user + temp password + MFA setup")
print("=" * 60)
tmp = tempfile.mkdtemp()
app, db = fresh_app(tmp)
app, db = fresh_app("t1")
_EMITTED_PASSWORDS.clear()
init_db(db)
check("Root user exists with id 0", get_user(0, db) is not None)
@@ -182,12 +214,24 @@ check("Wrong code rejected", r.status_code == 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)
# datetime('now') 翻译正确性:PG to_char 必须用 HH24/MI(HH 是 12 小时制、MM 是月份)
import re as _re
from backend.pg_compat import _translate_sql
_tr, _ = _translate_sql("UPDATE t SET ts=datetime('now') WHERE id=?")
check("datetime('now') 翻译为 HH24:MI", "HH24:MI:SS" in _tr and "HH:MM:SS" not in _tr)
conn = get_db(db)
_trow = conn.execute("SELECT to_char(now(), 'YYYY-MM-DD HH24:MI:SS') AS t").fetchone()
check("to_char HH24:MI 输出格式正确", bool(_re.fullmatch(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}", _trow["t"])))
conn.close()
print()
print("=" * 60)
print("TEST 2: Legacy single-user DB migration → data becomes Root's")
print("TEST 2: Legacy SQLite import → data becomes Root's (db_admin)")
print("=" * 60)
# Build a legacy single-user database (old schema: no users/user_id, pin-only auth)
legacy = os.path.join(tmp, "legacy.db")
legacy_fd, legacy = tempfile.mkstemp(suffix=".db")
with os.fdopen(legacy_fd, "wb"):
pass
_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')));
@@ -207,47 +251,52 @@ _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)))
conn = sqlite3.connect(legacy)
tables = [r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")]
legacy_bytes = open(legacy, "rb").read()
os.unlink(legacy)
reset_schema("t2")
db2 = _dsn("t2")
init_db(db2)
check("Legacy file detected as sqlite", db_admin.detect_format(legacy_bytes) == "sqlite")
stats = db_admin.import_sqlite(db2, legacy_bytes)
check("Import succeeds", stats is not None and stats.get("calendar_groups") == 3)
check("Root created after legacy import", get_user(0, db2) is not None)
check("Legacy calendar groups → Root (user 0)", _groups_for(db2) >= 3)
check("Legacy alarms migrated to user 0", _count(db2, "alarms", 0) >= 1)
check("Root gets a fresh temp password (legacy had only PIN)", bool(get_temp_password(db2, 0)))
conn = get_db(db2)
tables = [r["table_name"] for r in conn.execute(
"SELECT table_name FROM information_schema.tables WHERE table_schema = current_schema()").fetchall()]
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()
check("Legacy DB stamped with program version", _read_db_version(get_db(db2)) == DB_VERSION)
get_db(db2).close()
print()
print("=" * 60)
print("TEST 3: Schema-version gate (same→skip, newer→abort, migrate→stamp)")
print("TEST 3: Schema-version gate (same→skip, newer→abort, lower→migrate+stamp)")
print("=" * 60)
db3 = os.path.join(tmp, "roundtrip.db")
reset_schema("t3")
db3 = _dsn("t3")
init_db(db3)
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()
check("Same-version init idempotent", _read_db_version(get_db(db3)) == DB_VERSION)
get_db(db3).close()
# user data preserved across a version-mismatch migration (simulate future bump)
db3b = os.path.join(tmp, "migrate_up.db")
db3b = _dsn("t3b")
reset_schema("t3b")
init_db(db3b)
uid = create_user("Round Trip", db_path=db3b)
conn = sqlite3.connect(db3b)
conn = get_db(db3b)
conn.execute("INSERT INTO alarms (user_id, label, time) VALUES (?, 'test', '07:00')", (uid,))
conn.commit()
conn.close()
# simulate an old DB (version 0 → migration path) by rewriting meta
conn = sqlite3.connect(db3b)
conn = get_db(db3b)
conn.execute("UPDATE meta SET version=0 WHERE id=1")
conn.commit()
conn.close()
@@ -258,25 +307,61 @@ 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()
# missing-column migration: drop a column, downgrade version, init adds it back
reset_schema("t3c")
db3c = _dsn("t3c")
init_db(db3c)
conn = get_db(db3c)
conn.execute("ALTER TABLE users DROP COLUMN theme")
conn.execute("UPDATE meta SET version=1 WHERE id=1")
conn.commit()
conn.close()
init_db(db3c)
conn = get_db(db3c)
theme_cols = conn.execute(
"SELECT column_name FROM information_schema.columns "
"WHERE table_schema = current_schema() AND table_name='users' AND column_name='theme'").fetchall()
conn.close()
check("Missing column re-added by migration", len(theme_cols) == 1)
check("Migrated version stamped", _read_db_version(get_db(db3c)) == DB_VERSION)
get_db(db3c).close()
# a DB newer than the program → abort with SystemExit
newer = os.path.join(tmp, "newer.db")
init_db(newer)
conn = sqlite3.connect(newer)
reset_schema("t3d")
db3d = _dsn("t3d")
init_db(db3d)
conn = get_db(db3d)
conn.execute(f"UPDATE meta SET version={DB_VERSION + 99} WHERE id=1")
conn.commit()
conn.close()
try:
init_db(newer)
init_db(db3d)
check("Newer DB aborts startup", False, "no SystemExit raised")
except SystemExit:
check("Newer DB aborts startup", True)
# legacy timestamp DEFAULT repair (old build used to_char HH:MM → wrong 12h/month)
reset_schema("t3e")
db3e = _dsn("t3e")
init_db(db3e)
conn = get_db(db3e)
conn.execute("ALTER TABLE users ALTER COLUMN created_at SET DEFAULT to_char(now(), 'YYYY-MM-DD HH:MM:SS')")
conn.commit()
conn.close()
init_db(db3e) # same version → fast path must still repair the DEFAULT
conn = get_db(db3e)
dflt = conn.execute(
"SELECT column_default FROM information_schema.columns "
"WHERE table_schema=current_schema() AND table_name='users' AND column_name='created_at'"
).fetchone()["column_default"]
conn.close()
check("Legacy timestamp DEFAULT repaired (HH24:MI)", "HH24:MI:SS" in (dflt or ""))
print()
print("=" * 60)
print("TEST 4: MFA-configured DB migration → no new temp password")
print("TEST 4: MFA-configured DB → no new temp password")
print("=" * 60)
db4 = os.path.join(tmp, "mfa.db")
app4 = create_app(db_path=db4)
app4, db4 = fresh_app("t4")
c4 = root_client(app4, db4)
_EMITTED_PASSWORDS.clear()
init_db(db4) # same version → fast path, defaults intact
@@ -288,8 +373,7 @@ 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)
app5, db5 = fresh_app("t5")
root = root_client(app5, db5)
root_secret = get_auth_config(db5, 0)["totp_secret"]
# Reauth required first
@@ -357,7 +441,7 @@ 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)
conn = get_db(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)
@@ -373,27 +457,7 @@ 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("TEST 7: Data isolation between users")
print("=" * 60)
ua, _ = do_login(app5.test_client(), pyotp.TOTP(secret_a).now())
# User A creates data
@@ -423,7 +487,7 @@ check("A sees own group events", len(r) == 1)
print()
print("=" * 60)
print("TEST 9: Edit remark + delete user + session invalidation")
print("TEST 8: Edit remark + delete user + session invalidation")
print("=" * 60)
# Fresh root session → no re-auth marker → admin ops must demand reauth.
fresh_root, _ = do_login(app5.test_client(), pyotp.TOTP(root_secret).now())
@@ -450,7 +514,7 @@ check("Self-delete guard (root)", r.status_code == 400)
print()
print("=" * 60)
print("TEST 10: Kick devices (self account, all logged-in users) + Root kick-all-users")
print("TEST 9: Kick devices (self account, all logged-in users) + Root kick-all-users")
print("=" * 60)
# Two devices of the same account + one root device
ua2, _ = do_login(app5.test_client(), pyotp.TOTP(secret_a).now())
@@ -474,10 +538,9 @@ check("Re-login after kick-all", r.status_code == 200)
print()
print("=" * 60)
print("TEST 11: Access/refresh token lifecycle (rotation, replay, refresh)")
print("TEST 10: Access/refresh token lifecycle (rotation, replay, refresh)")
print("=" * 60)
db11 = os.path.join(tmp, "tok.db")
app11 = create_app(db_path=db11)
app11, db11 = fresh_app("t10")
r11 = root_client(app11, db11)
root11_secret = get_auth_config(db11, 0)["totp_secret"]
# fresh login → access + refresh cookie
@@ -503,7 +566,6 @@ 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")
@@ -516,7 +578,7 @@ check("Other device unaffected by logout", r.status_code == 200)
print()
print("=" * 60)
print("TEST 12: SSE ticket auth (one-time, scoped)")
print("TEST 11: 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)
@@ -557,16 +619,7 @@ check("Global event reaches all subscribers", q_get(q2) is not None)
print()
print("=" * 60)
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}"})
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 14: CSRF origin check on admin operations")
print("TEST 12: 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)
@@ -585,7 +638,7 @@ check("Same-origin refresh allowed", r.status_code == 200)
print()
print("=" * 60)
print("TEST 15: Passkey flows (mocked ceremonies)")
print("TEST 13: Passkey flows (mocked ceremonies)")
print("=" * 60)
from backend import webauthn_mgr
import backend.routes as routes_mod
@@ -618,8 +671,7 @@ routes_mod.complete_authentication = lambda request, credential, stored_credenti
"ok": True, "credential_id": _cred_hex(credential),
}
db15 = os.path.join(tmp, "passkey.db")
app15 = create_app(db_path=db15)
app15, db15 = fresh_app("t13")
root15 = root_client(app15, db15)
root15_secret = get_auth_config(db15, 0)["totp_secret"]
rr = reauth_totp(root15, root15_secret)
@@ -672,14 +724,13 @@ routes_mod.complete_authentication = orig_complete_auth
print()
print("=" * 60)
print("TEST 16: Scheduler thread context (per-user timezone, no request context)")
print("TEST 14: 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
db16 = os.path.join(tmp, "sched.db")
app16 = create_app(db_path=db16)
app16, db16 = fresh_app("t14")
xid = create_user("Sched User", db_path=db16)
conn = sqlite3.connect(db16)
conn = get_db(db16)
conn.execute("INSERT INTO clock_config (user_id, city, timezone, is_primary) VALUES (?, '', 'America/New_York', 1)", (xid,))
conn.commit()
conn.close()
@@ -711,7 +762,7 @@ check("Scheduler starts and stops cleanly", True)
print()
print("=" * 60)
print("TEST 17: Security hardening (SSRF, reauth gate, cascade delete, cross-user writes)")
print("TEST 15: 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)
@@ -725,7 +776,7 @@ check("Host: public host accepted", is_safe_https_host("example.com") is True)
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.
# User B was deleted in TEST 8 — 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"]
@@ -765,7 +816,7 @@ 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})
conn = sqlite3.connect(db5)
conn = get_db(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", "auth_tokens"]}
conn.close()
@@ -773,7 +824,7 @@ 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 = get_db(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))
@@ -781,10 +832,9 @@ check("Deleted user's live tokens invalidated", fc.get("/api/alarms").status_cod
print()
print("=" * 60)
print("TEST 18: Passkey stash 一次性参数暂存(小窗传参协商)")
print("TEST 16: Passkey stash 一次性参数暂存(小窗传参协商)")
print("=" * 60)
db18 = os.path.join(tmp, "stash.db")
app18 = create_app(db_path=db18)
app18, db18 = fresh_app("t16")
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})
@@ -829,10 +879,9 @@ check("读取参数后仍可 POST result(修复 410 回归)", r.status_code
print()
print("=" * 60)
print("TEST 19: 账户独立主题(dark/light/auto)")
print("TEST 17: 账户独立主题(dark/light/auto)")
print("=" * 60)
db19 = os.path.join(tmp, "theme.db")
app19 = create_app(db_path=db19)
app19, db19 = fresh_app("t17")
pw19 = get_temp_password(db19, 0)
c19, _ = do_login(app19.test_client(), pw19)
setup_totp(c19)
@@ -861,10 +910,9 @@ check("未登录 GET /api/theme → 401", anon19.get("/api/theme").status_code =
print()
print("=" * 60)
print("TEST 20: 账户独立时区(display/state 返回 timezone + 时区设置生效)")
print("TEST 18: 账户独立时区(display/state 返回 timezone + 时区设置生效)")
print("=" * 60)
db20 = os.path.join(tmp, "tz.db")
app20 = create_app(db_path=db20)
app20, db20 = fresh_app("t18")
pw20 = get_temp_password(db20, 0)
c20, _ = do_login(app20.test_client(), pw20)
setup_totp(c20)
@@ -884,11 +932,10 @@ 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 = get_db(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()
@@ -896,7 +943,7 @@ check("用户 B 时区保持默认", st.get("timezone") == "Asia/Shanghai")
print()
print("=" * 60)
print("TEST 21: 页面导航鉴权(refresh cookie,浏览器导航不带 Authorization)")
print("TEST 19: 页面导航鉴权(refresh cookie,浏览器导航不带 Authorization)")
print("=" * 60)
# 模拟真实浏览器:登录后导航页面(无 Authorization header,仅 cookie)
raw_nav = app5.test_client()
@@ -913,10 +960,14 @@ 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 仍是临时账户)导航
# temp 用户导航(用 TEST 6 的 temp 账户,仍是临时账户 → 强制 MFA 设置页)
rr = reauth_totp(root, root_secret)
r = root.post("/api/admin/users", json={"remark": "Nav Temp", "mode": "user_fills"})
nav_temp_id = r.get_json()["user"]["id"]
nav_temp_secret = get_temp_totp_secret(nav_temp_id, db5)
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)
r = bob_raw.post("/api/auth/login", json={"code": pyotp.TOTP(nav_temp_secret).now(), "view": "control"})
check("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)
@@ -925,6 +976,103 @@ 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("TEST 20: 数据库管理(仅 Root、导出/导入 roundtrip、格式识别、原子性)")
print("=" * 60)
app21, db21 = fresh_app("t20")
root21 = root_client(app21, db21)
root21_secret = get_auth_config(db21, 0)["totp_secret"]
# 造点数据
c21 = app21.test_client()
r = c21.post("/api/auth/login", json={"code": pyotp.TOTP(root21_secret).now(), "view": "control"})
root21 = TClient(c21, r.get_json()["access_token"])
r = root21.post("/api/calendar/groups", json={"name": "DB Test"})
check("Setup: group created", r.status_code == 201)
r = root21.post("/api/alarms", json={"label": "DB alarm", "time": "07:30"})
check("Setup: alarm created", r.status_code == 201)
# 非 root 用户(普通 admin 角色也拒绝——仅 root id 0)
rr = reauth_totp(root21, root21_secret)
r = root21.post("/api/admin/users", json={"remark": "DbUser", "mode": "admin_fills"})
dbu_id = r.get_json()["user"]["id"]
r = root21.post(f"/api/admin/users/{dbu_id}/totp/begin")
dbu_secret = r.get_json()["secret"]
root21.post(f"/api/admin/users/{dbu_id}/totp/verify", json={"code": pyotp.TOTP(dbu_secret).now()})
dbu, _ = do_login(app21.test_client(), pyotp.TOTP(dbu_secret).now())
r = dbu.get("/api/admin/db/status")
check("非 root 访问 db 管理 → 403", r.status_code == 403)
r = dbu.get("/api/admin/db/export")
check("非 root 导出 → 403", r.status_code == 403)
# 未二次验证 → 403(用全新的 root 会话,无 reauth 标记)
fresh_root21, _ = do_login(app21.test_client(), pyotp.TOTP(root21_secret).now())
r = fresh_root21.get("/api/admin/db/export")
check("导出未 reauth → 403", r.status_code == 403 and r.get_json().get("reauth_required"))
# 状态
r = root21.get("/api/admin/db/status")
d = r.get_json()
check("db status 返回 PostgreSQL v" + str(DB_VERSION), d.get("database") == "postgresql" and d.get("db_version") == DB_VERSION)
# 导出(reauth 后)
rr = reauth_totp(root21, root21_secret)
r = root21.get("/api/admin/db/export")
check("Root 导出成功", r.status_code == 200 and "json" in r.content_type)
backup = json.loads(r.data)
check("备份格式正确", backup.get("format") == "iot-dboard-backup")
check("备份含用户数据", len(backup["tables"]["calendar_groups"]["rows"]) >= 1
and len(backup["tables"]["alarms"]["rows"]) >= 1)
check("备份不含会话令牌", "auth_tokens" not in backup["tables"])
# 格式识别
check("SQLite magic 识别", db_admin.detect_format(b"SQLite format 3\x00" + b"x" * 8) == "sqlite")
check("JSON 备份识别", db_admin.detect_format(json.dumps({"format": "iot-dboard-backup"}).encode()) == "json")
check("未知格式识别", db_admin.detect_format(b"garbage") == "unknown")
# 导入备份 roundtrip(导入会清空会话 → root 需重登)
r = root21.post("/api/admin/db/import", data={"file": (io.BytesIO(json.dumps(backup).encode()), "b.json")},
content_type="multipart/form-data")
check("导入备份成功", r.status_code == 200 and r.get_json().get("ok"), str(r.get_json()))
check("导入后会话失效标记", r.get_json().get("session_invalidated") is True)
conn = get_db(db21)
n = conn.execute("SELECT COUNT(*) FROM calendar_groups WHERE name='DB Test'").fetchone()[0]
conn.close()
check("导入后数据恢复", n >= 1)
# 导入后旧 token 失效
check("导入后旧会话失效 → 401", root21.get("/api/admin/db/status").status_code == 401)
# 导入非法文件 → 400(不破坏数据)
root21b, _ = do_login(app21.test_client(), pyotp.TOTP(root21_secret).now())
rr = reauth_totp(root21b, root21_secret)
r = root21b.post("/api/admin/db/import", data={"file": (io.BytesIO(b"junk"), "x.bin")},
content_type="multipart/form-data")
check("导入非法文件 → 400", r.status_code == 400, str(r.get_json()))
conn = get_db(db21)
n = conn.execute("SELECT COUNT(*) FROM calendar_groups WHERE name='DB Test'").fetchone()[0]
conn.close()
check("非法导入未破坏数据", n >= 1)
# 导入失败原子性:构造一个会导致失败的备份(缺少必需列 → 行插入失败回滚)
root21c, _ = do_login(app21.test_client(), pyotp.TOTP(root21_secret).now())
rr = reauth_totp(root21c, root21_secret)
bad = {
"format": "iot-dboard-backup",
"db_version": DB_VERSION,
"tables": {"calendar_groups": {"columns": ["id", "user_id", "name", "color", "enabled", "created_at"],
"rows": [[1, 0, "OK Group", "#fff", 1, "2026-01-01 00:00:00"],
[2, 0, None, "#fff", 1, None]]}}, # name NOT NULL → None 会失败
}
# 直接把 name=None 塞进去会因参数为 None 失败(psycopg2 拒绝 None 绑定到 NOT NULL?其实 None → NULL,NOT NULL 约束报错)
r = root21c.post("/api/admin/db/import", data={"file": (io.BytesIO(json.dumps(bad).encode()), "bad.json")},
content_type="multipart/form-data")
check("含非法行的导入 → 400/500", r.status_code in (400, 500), str(r.get_json()))
conn = get_db(db21)
n = conn.execute("SELECT COUNT(*) FROM calendar_groups WHERE name='OK Group'").fetchone()[0]
conn.close()
check("失败导入整体回滚(原子性)", n == 0)
print()
print("=" * 60)
print(f"RESULT: {PASS} passed, {FAIL} failed")