chore: initialize GSREA website project
This commit is contained in:
commit
d3c5461de5
1842 files changed
+246140
No files matched your search
@@ -0,0 +1,228 @@
|
||||
import base64
|
||||
import inspect
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_captcha_missing_wrong_and_second_use():
|
||||
from app.services.captcha import CAPTCHA_ALPHABET, consume_captcha, issue_captcha
|
||||
from app.services.redis_client import get_redis
|
||||
|
||||
assert consume_captcha("", "ab12") is False
|
||||
assert consume_captcha("missing-id", "") is False
|
||||
assert consume_captcha("", "") is False
|
||||
|
||||
try:
|
||||
client = get_redis()
|
||||
client.ping()
|
||||
except Exception:
|
||||
pytest.skip("Redis 不可达")
|
||||
|
||||
wrong = issue_captcha()
|
||||
wrong_id = wrong["captchaId"]
|
||||
assert wrong["image"].startswith("data:image/png;base64,")
|
||||
raw = base64.b64decode(wrong["image"].split(",", 1)[1])
|
||||
assert raw.startswith(b"\x89PNG")
|
||||
stored = client.get(f"gsfdc:captcha:{wrong_id}")
|
||||
assert isinstance(stored, str)
|
||||
assert len(stored) == 4
|
||||
assert set(stored).issubset(set(CAPTCHA_ALPHABET.lower()))
|
||||
ttl = client.ttl(f"gsfdc:captcha:{wrong_id}")
|
||||
assert 0 < ttl <= 120
|
||||
assert consume_captcha(wrong_id, "0000") is False
|
||||
assert client.get(f"gsfdc:captcha:{wrong_id}") is None
|
||||
assert consume_captcha(wrong_id, stored) is False
|
||||
|
||||
ok = issue_captcha()
|
||||
ok_id = ok["captchaId"]
|
||||
stored_ok = client.get(f"gsfdc:captcha:{ok_id}")
|
||||
assert consume_captcha(ok_id, stored_ok.upper()) is True
|
||||
assert consume_captcha(ok_id, stored_ok) is False
|
||||
|
||||
|
||||
def test_is_currently_locked_respects_utc_and_does_not_mutate():
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.services.login_guard import is_currently_locked
|
||||
|
||||
user = SimpleNamespace(failed_login_count=2, locked_until=None)
|
||||
assert is_currently_locked(user) is False
|
||||
assert user.failed_login_count == 2
|
||||
|
||||
user.locked_until = datetime.now(timezone.utc) + timedelta(minutes=1)
|
||||
assert is_currently_locked(user) is True
|
||||
assert user.failed_login_count == 2
|
||||
|
||||
user.locked_until = datetime.now(timezone.utc) - timedelta(seconds=1)
|
||||
assert is_currently_locked(user) is False
|
||||
user.locked_until = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(seconds=1)
|
||||
assert is_currently_locked(user) is False
|
||||
assert user.failed_login_count == 2
|
||||
|
||||
|
||||
def test_lock_after_small_limit_and_past_deadline():
|
||||
pytest.importorskip("app.db")
|
||||
pytest.importorskip("app.models.content")
|
||||
pytest.importorskip("app.models.rbac")
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.models.content import SiteSetting
|
||||
from app.models.rbac import AdminUser
|
||||
from app.services.login_guard import is_currently_locked, read_login_policy, record_failure
|
||||
|
||||
engine = create_engine(
|
||||
"sqlite://",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
SiteSetting.__table__.create(engine)
|
||||
AdminUser.__table__.create(engine)
|
||||
db = sessionmaker(bind=engine, expire_on_commit=False)()
|
||||
try:
|
||||
assert read_login_policy(db) == (5, 15)
|
||||
db.add(SiteSetting(key="login_max_failures", value="2"))
|
||||
db.add(SiteSetting(key="login_lock_minutes", value="3"))
|
||||
db.commit()
|
||||
|
||||
user = _new_user(AdminUser, "sec-lock-test")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
assert is_currently_locked(user) is False
|
||||
record_failure(db, user)
|
||||
assert user.failed_login_count == 1
|
||||
assert user.locked_until is None
|
||||
assert is_currently_locked(user) is False
|
||||
assert user.failed_login_count == 1
|
||||
|
||||
record_failure(db, user)
|
||||
assert user.failed_login_count == 0
|
||||
assert user.locked_until is not None
|
||||
locked_until = user.locked_until
|
||||
if locked_until.tzinfo is not None:
|
||||
locked_until = locked_until.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
delta = locked_until - datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
assert timedelta(minutes=2) < delta < timedelta(minutes=4)
|
||||
assert is_currently_locked(user) is True
|
||||
assert user.failed_login_count == 0
|
||||
|
||||
user.locked_until = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(seconds=30)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
assert is_currently_locked(user) is False
|
||||
assert user.failed_login_count == 0
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_write_audit_does_not_accept_secret_fields():
|
||||
from app.services.audit import write_audit
|
||||
|
||||
signature = inspect.signature(write_audit)
|
||||
names = list(signature.parameters)
|
||||
assert names == ["user_id", "username", "method", "path", "status_code", "ip"]
|
||||
for parameter in signature.parameters.values():
|
||||
assert parameter.kind is inspect.Parameter.KEYWORD_ONLY
|
||||
banned = {"body", "password", "captcha", "captcha_id", "id_card", "mobile", "phone"}
|
||||
assert banned.isdisjoint(names)
|
||||
with pytest.raises(TypeError):
|
||||
write_audit(
|
||||
user_id=None,
|
||||
username=None,
|
||||
method="POST",
|
||||
path="/api/admin/auth/login",
|
||||
status_code=400,
|
||||
ip="127.0.0.1",
|
||||
body="x",
|
||||
)
|
||||
with pytest.raises(TypeError):
|
||||
write_audit(
|
||||
user_id=None,
|
||||
username=None,
|
||||
method="POST",
|
||||
path="/api/admin/auth/login",
|
||||
status_code=400,
|
||||
ip="127.0.0.1",
|
||||
password="x",
|
||||
)
|
||||
with pytest.raises(TypeError):
|
||||
write_audit(
|
||||
user_id=None,
|
||||
username=None,
|
||||
method="POST",
|
||||
path="/api/admin/auth/login",
|
||||
status_code=400,
|
||||
ip="127.0.0.1",
|
||||
captcha="x",
|
||||
)
|
||||
|
||||
|
||||
def test_ip_blacklist_blocks_then_unblocks():
|
||||
from app.core.config import get_settings
|
||||
|
||||
if not (get_settings().database_url or "").strip():
|
||||
pytest.skip("DATABASE_URL 为空")
|
||||
|
||||
from sqlalchemy import delete
|
||||
|
||||
from app.db import SessionLocal
|
||||
from app.models.security import AdminIpBlacklist
|
||||
from app.services.ip_guard import is_ip_blocked
|
||||
|
||||
test_ip = "203.0.113.214"
|
||||
db = SessionLocal()
|
||||
try:
|
||||
AdminIpBlacklist.__table__.create(db.get_bind(), checkfirst=True)
|
||||
db.execute(delete(AdminIpBlacklist).where(AdminIpBlacklist.ip == test_ip))
|
||||
db.commit()
|
||||
assert is_ip_blocked(test_ip) is False
|
||||
db.add(AdminIpBlacklist(ip=test_ip, remark="security-test"))
|
||||
db.commit()
|
||||
assert is_ip_blocked(test_ip) is True
|
||||
db.execute(delete(AdminIpBlacklist).where(AdminIpBlacklist.ip == test_ip))
|
||||
db.commit()
|
||||
assert is_ip_blocked(test_ip) is False
|
||||
finally:
|
||||
try:
|
||||
db.rollback()
|
||||
db.execute(delete(AdminIpBlacklist).where(AdminIpBlacklist.ip == test_ip))
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _new_user(model, username: str):
|
||||
user = model()
|
||||
if hasattr(user, "username"):
|
||||
user.username = username
|
||||
if hasattr(user, "status"):
|
||||
user.status = "active"
|
||||
if hasattr(user, "failed_login_count"):
|
||||
user.failed_login_count = 0
|
||||
if hasattr(user, "locked_until"):
|
||||
user.locked_until = None
|
||||
if hasattr(user, "display_name"):
|
||||
user.display_name = "lock-test"
|
||||
if hasattr(user, "password_hash"):
|
||||
user.password_hash = "test-only-hash"
|
||||
for column in user.__table__.columns:
|
||||
if column.primary_key or column.nullable:
|
||||
continue
|
||||
if column.default is not None or column.server_default is not None:
|
||||
continue
|
||||
if getattr(user, column.key) is not None:
|
||||
continue
|
||||
try:
|
||||
kind = column.type.python_type
|
||||
except Exception:
|
||||
kind = str
|
||||
setattr(user, column.key, 0 if kind is int else "test")
|
||||
return user
|
||||
Reference in new issue
Block a user