| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436 |
- """门户跳转按需登录(mock provider)全分支测试。"""
- from __future__ import annotations
- from sqlalchemy import delete
- import pytest
- from dms.extensions import db
- from dms.models import AuditLog, IpEntry, OrgIpAssignment, Organization, User
- VERIFY_PATH = "/api/v1/auth/portal/verify"
- SETUP_PATH = "/api/v1/auth/portal/setup-password"
- @pytest.fixture()
- def portal_mock(monkeypatch):
- monkeypatch.setenv("DMS_PORTAL_PROVIDER", "mock")
- @pytest.fixture(autouse=True)
- def _clean_ip_tables(b2_app):
- """清空IP两表,保证setup-password的verify_login_ip恒走无分配放行分支。"""
- with b2_app.app_context():
- db.session.execute(delete(OrgIpAssignment))
- db.session.execute(delete(IpEntry))
- db.session.commit()
- def _verify(client, token: str):
- return client.post(VERIFY_PATH, json={"query": f"token={token}"})
- def _find_user(app, username: str) -> User:
- with app.app_context():
- user = db.session.scalar(
- db.select(User).where(User.username == username)
- )
- assert user is not None
- return user
- # ---------------------------------------------------------------- 开关与入参
- def test_verify_rejected_when_portal_disabled(b2_client, monkeypatch):
- monkeypatch.delenv("DMS_PORTAL_PROVIDER", raising=False)
- response = b2_client.post(
- VERIFY_PATH, json={"query": "token=mock:1:a:b"}
- )
- assert response.status_code == 400
- assert response.get_json()["code"] == "INVALID_ARGUMENT"
- def test_verify_rejects_payload_with_extra_fields(b2_client, portal_mock):
- response = b2_client.post(
- VERIFY_PATH, json={"query": "token=mock:1:a:b", "extra": 1}
- )
- assert response.status_code == 400
- def test_verify_rejects_missing_token(b2_client, portal_mock):
- response = b2_client.post(VERIFY_PATH, json={"query": "foo=1&bar=2"})
- assert response.status_code == 400
- assert response.get_json()["code"] == "INVALID_ARGUMENT"
- def test_verify_rejects_invalid_portal_token(b2_client, portal_mock):
- response = _verify(b2_client, "not-a-mock-token")
- assert response.status_code == 401
- assert response.get_json()["code"] == "PORTAL_TOKEN_INVALID"
- # ---------------------------------------------------------------- 新用户落库
- def test_verify_provisions_new_user_and_issues_setup_ticket(
- b2_app, b2_client, portal_mock
- ):
- response = _verify(b2_client, "mock:9001:portal_first:门户新用户:作战部")
- assert response.status_code == 200
- data = response.get_json()["data"]
- assert data["phase"] == "SET_PASSWORD"
- assert data["username"] == "portal_first"
- assert data["realName"] == "门户新用户"
- assert data["portalTicket"]
- user = _find_user(b2_app, "portal_first")
- assert user.external_id == "9001"
- assert user.organization_name == "作战部"
- assert user.last_login_at is None
- assert user.status == "ENABLED"
- assert user.role_code == "USER"
- with b2_app.app_context():
- audit = db.session.scalar(
- db.select(AuditLog).where(
- AuditLog.username == "portal_first",
- AuditLog.action_type == "CREATE_USER",
- )
- )
- assert audit is not None
- def test_provisioned_user_cannot_login_before_setup(
- b2_client, b2_password, portal_mock
- ):
- # 占位密码随机不可知:设密前任何密码都无法登录
- _verify(b2_client, "mock:9002:portal_locked:未设密用户")
- response = b2_client.post(
- "/api/v1/auth/login",
- json={
- "username": "portal_locked",
- "password": b2_password,
- "keepSignedIn": False,
- },
- )
- assert response.status_code == 401
- assert response.get_json()["code"] == "INVALID_CREDENTIALS"
- def test_organization_falls_back_to_none_when_no_match(
- b2_app, b2_client, portal_mock
- ):
- _verify(b2_client, "mock:9003:portal_noorg:无组织用户:不存在部门")
- user = _find_user(b2_app, "portal_noorg")
- assert user.organization_id is None
- assert user.organization_name is None
- def test_organization_falls_back_to_configured_default(
- b2_app, b2_client, portal_mock, monkeypatch
- ):
- with b2_app.app_context():
- root = db.session.scalar(
- db.select(Organization).where(Organization.org_code == "ORG_ROOT")
- )
- assert root is not None
- monkeypatch.setenv("DMS_PORTAL_DEFAULT_ORG_ID", str(root.id))
- _verify(b2_client, "mock:9004:portal_deforg:默认组织用户:不存在部门")
- user = _find_user(b2_app, "portal_deforg")
- assert user.organization_name == "机关"
- def test_verify_set_password_embeds_enabled_org_options(b2_client, portal_mock):
- # 匹配作战部 → 机构下拉仅含启用机构(不含停用部门),预选推断结果
- data = _verify(
- b2_client, "mock:9010:portal_orgs:机构列表用户:作战部"
- ).get_json()["data"]
- assert data["phase"] == "SET_PASSWORD"
- orgs = data["organizations"]
- assert {o["orgName"] for o in orgs} == {"机关", "作战部", "通信部"}
- assert all(
- set(o) == {"id", "orgCode", "orgName", "orgPath"} for o in orgs
- )
- assert all(isinstance(o["id"], str) for o in orgs)
- # 按 org_path 稳定排序:机关 → 作战部 → 通信部
- assert [o["orgName"] for o in orgs] == ["机关", "作战部", "通信部"]
- ops = next(o for o in orgs if o["orgName"] == "作战部")
- assert data["suggestedOrganizationId"] == ops["id"]
- def test_verify_set_password_suggested_null_without_org(b2_client, portal_mock):
- # 无匹配机构且未配置默认 → 预选为空,机构列表仍内嵌
- data = _verify(
- b2_client, "mock:9011:portal_noorg2:无预选用户:不存在部门"
- ).get_json()["data"]
- assert data["phase"] == "SET_PASSWORD"
- assert data["suggestedOrganizationId"] is None
- assert len(data["organizations"]) == 3
- # ---------------------------------------------------------------- 既有用户匹配
- def test_existing_username_matched_and_backfilled(b2_app, b2_client, portal_mock):
- # b2_app预置username="user"(作战部,从未登录)→ 按用户名匹配并同步展示信息
- response = _verify(b2_client, "mock:8001:user:门户同步姓名:通信部")
- data = response.get_json()["data"]
- assert data["phase"] == "SET_PASSWORD"
- assert data["username"] == "user"
- user = _find_user(b2_app, "user")
- assert user.external_id == "8001"
- assert user.real_name == "门户同步姓名"
- assert user.organization_name == "通信部"
- with b2_app.app_context():
- audit = db.session.scalar(
- db.select(AuditLog).where(
- AuditLog.username == "user",
- AuditLog.action_type == "EDIT_USER",
- )
- )
- assert audit is not None
- def test_disabled_user_rejected(b2_client, portal_mock):
- # b2_app预置username="disabled"(DISABLED)
- response = _verify(b2_client, "mock:8002:disabled:停用用户")
- assert response.status_code == 401
- assert response.get_json()["code"] == "USER_DISABLED"
- # ---------------------------------------------------------------- 设密与登录闭环
- def test_setup_password_completes_login_and_enables_normal_login(
- b2_client, portal_mock
- ):
- data = _verify(
- b2_client, "mock:9005:portal_setup:门户设密用户"
- ).get_json()["data"]
- response = b2_client.post(
- SETUP_PATH,
- json={
- "portalTicket": data["portalTicket"],
- "newPassword": "Portal#9005",
- "keepSignedIn": False,
- },
- )
- assert response.status_code == 200
- payload = response.get_json()["data"]
- assert payload["tokenType"] == "Bearer"
- assert payload["expiresIn"] == 7200
- # 契约1.29:设密完成响应同登录下发空闲自动登出分钟数
- assert payload["idleLogoutMinutes"] == 5
- assert payload["user"]["username"] == "portal_setup"
- # 独立密码设好后可走普通用户名密码登录
- login = b2_client.post(
- "/api/v1/auth/login",
- json={
- "username": "portal_setup",
- "password": "Portal#9005",
- "keepSignedIn": False,
- },
- )
- assert login.status_code == 200
- # 票据一次性:设密后auth_version已变更,复用被拒
- replay = b2_client.post(
- SETUP_PATH,
- json={
- "portalTicket": data["portalTicket"],
- "newPassword": "Portal#9095",
- "keepSignedIn": False,
- },
- )
- assert replay.status_code == 401
- assert replay.get_json()["code"] == "PORTAL_TICKET_INVALID"
- def test_setup_password_keep_signed_in_extends_expiry(b2_client, portal_mock):
- data = _verify(
- b2_client, "mock:9006:portal_keep:保持登录用户"
- ).get_json()["data"]
- response = b2_client.post(
- SETUP_PATH,
- json={
- "portalTicket": data["portalTicket"],
- "newPassword": "Portal#9006",
- "keepSignedIn": True,
- },
- )
- assert response.status_code == 200
- assert response.get_json()["data"]["expiresIn"] == 604800
- def test_setup_password_rejects_weak_password_without_burning_ticket(
- b2_client, portal_mock
- ):
- data = _verify(
- b2_client, "mock:9007:portal_weak:弱密码用户"
- ).get_json()["data"]
- weak = b2_client.post(
- SETUP_PATH,
- json={
- "portalTicket": data["portalTicket"],
- "newPassword": "short",
- "keepSignedIn": False,
- },
- )
- assert weak.status_code == 400
- assert weak.get_json()["code"] == "INVALID_ARGUMENT"
- # 弱密码校验先于票据消费,票据未烧毁仍可正常设密
- retry = b2_client.post(
- SETUP_PATH,
- json={
- "portalTicket": data["portalTicket"],
- "newPassword": "Portal#9007",
- "keepSignedIn": False,
- },
- )
- assert retry.status_code == 200
- def test_setup_password_rejects_garbage_ticket(b2_client, portal_mock):
- response = b2_client.post(
- SETUP_PATH,
- json={
- "portalTicket": "not-a-jwt",
- "newPassword": "Portal#9000",
- "keepSignedIn": False,
- },
- )
- assert response.status_code == 401
- assert response.get_json()["code"] == "PORTAL_TICKET_INVALID"
- def test_setup_password_updates_organization_when_provided(
- b2_app, b2_client, portal_mock
- ):
- # 用户推断挂作战部,设密时自选通信部 → 机构改为通信部并记审计
- data = _verify(
- b2_client, "mock:9012:portal_pickorg:自选机构用户:作战部"
- ).get_json()["data"]
- comms = next(
- o for o in data["organizations"] if o["orgName"] == "通信部"
- )
- response = b2_client.post(
- SETUP_PATH,
- json={
- "portalTicket": data["portalTicket"],
- "newPassword": "Portal#9012",
- "keepSignedIn": False,
- "organizationId": comms["id"],
- },
- )
- assert response.status_code == 200
- assert response.get_json()["data"]["user"]["organizationName"] == "通信部"
- user = _find_user(b2_app, "portal_pickorg")
- assert user.organization_id == int(comms["id"])
- assert user.organization_name == "通信部"
- with b2_app.app_context():
- audit = db.session.scalar(
- db.select(AuditLog).where(
- AuditLog.username == "portal_pickorg",
- AuditLog.action_type == "LOGIN",
- )
- )
- assert audit is not None
- assert audit.detail["organizationId"] == comms["id"]
- def test_setup_password_keeps_inferred_org_when_omitted(
- b2_app, b2_client, portal_mock
- ):
- # 缺省(旧客户端)不改变用户机构
- _verify(b2_client, "mock:9013:portal_keeporg:缺省机构用户:作战部")
- data = _verify(
- b2_client, "mock:9013:portal_keeporg:缺省机构用户:作战部"
- ).get_json()["data"]
- response = b2_client.post(
- SETUP_PATH,
- json={
- "portalTicket": data["portalTicket"],
- "newPassword": "Portal#9013",
- "keepSignedIn": False,
- },
- )
- assert response.status_code == 200
- user = _find_user(b2_app, "portal_keeporg")
- assert user.organization_name == "作战部"
- def test_setup_password_rejects_invalid_organization(
- b2_app, b2_client, portal_mock
- ):
- data = _verify(
- b2_client, "mock:9014:portal_badorg:非法机构用户:作战部"
- ).get_json()["data"]
- def _setup(org_id) -> object:
- return b2_client.post(
- SETUP_PATH,
- json={
- "portalTicket": data["portalTicket"],
- "newPassword": "Portal#9014",
- "keepSignedIn": False,
- "organizationId": org_id,
- },
- )
- with b2_app.app_context():
- disabled = db.session.scalar(
- db.select(Organization).where(
- Organization.org_code == "ORG_DISABLED"
- )
- )
- assert disabled is not None
- disabled_id = str(disabled.id)
- # 停用机构拒绝
- disabled_response = _setup(disabled_id)
- assert disabled_response.status_code == 400
- assert (
- disabled_response.get_json()["code"] == "INVALID_ARGUMENT"
- )
- # 不存在的机构拒绝
- missing = _setup("999999")
- assert missing.status_code == 400
- assert missing.get_json()["code"] == "INVALID_ARGUMENT"
- # 非字符串拒绝
- non_string = _setup(123)
- assert non_string.status_code == 400
- # 上述拒绝均不烧毁票据,改用合法机构仍可设密
- comms = next(
- o for o in data["organizations"] if o["orgName"] == "通信部"
- )
- ok = _setup(comms["id"])
- assert ok.status_code == 200
- def test_second_launch_returns_login_phase(b2_client, portal_mock):
- token = "mock:9008:portal_again:二次跳转用户"
- first = _verify(b2_client, token).get_json()["data"]
- assert first["phase"] == "SET_PASSWORD"
- setup = b2_client.post(
- SETUP_PATH,
- json={
- "portalTicket": first["portalTicket"],
- "newPassword": "Portal#9008",
- "keepSignedIn": False,
- },
- )
- assert setup.status_code == 200
- # 设密登录后(last_login_at非空),再次跳转直接进入独立密码登录态
- second = _verify(b2_client, token).get_json()["data"]
- assert second["phase"] == "LOGIN"
- assert second["username"] == "portal_again"
- assert "portalTicket" not in second
|