New Database
This commit is contained in:
+255
-107
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user