aps-agent/tests/golden/test_web_license_codes_auth.py

433 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""正式授权码(codes)模式:Web 端授权码即身份的端到端用例。"""
from __future__ import annotations
import json
import time
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from tests.auth_provider import install_test_auth
TENANT = "tenant-codes-000000000000000001"
CODES = {
"USER-CODE-ALPHA": "year",
"USER-CODE-BETA": {"type": "month", "name": "beta-user"},
"USER-CODE-HOUR": "hour",
}
@pytest.fixture()
def codes_app(tmp_path, monkeypatch):
monkeypatch.setenv("APS_LICENSE_PROVIDER", "codes")
monkeypatch.setenv("APS_LICENSE_SECRET", "codes-test-secret-0123456789abcdef")
monkeypatch.setenv("APS_LICENSE_TENANT_UUID", TENANT)
monkeypatch.setenv("APS_LICENSE_CLIENTS", "web,desktop")
monkeypatch.setenv("APS_LICENSE_CODES", json.dumps(CODES))
# 钉死认证提供方:套件中途 server.main 的 load_dotenv 可能把开发机 .env 的 jms 重新注入
monkeypatch.setenv("APS_AUTH_PROVIDER", "unconfigured")
monkeypatch.setenv("APS_DB_PATH", str(tmp_path / "codes.db"))
monkeypatch.setenv("APS_WORLD_PATH", str(tmp_path / "world.json"))
from server.auth import licenses
from server.auth import providers as auth_providers
from server.db.database import reset_engine
from server.state import store as world_store
install_test_auth(monkeypatch, TENANT)
licenses._provider = None
licenses._provider_mode = None
auth_providers._provider = None
auth_providers._provider_mode = None
world_store._stores.clear()
reset_engine()
from server.gateway.app import create_app
app = create_app()
yield app
reset_engine()
world_store._stores.clear()
licenses._provider = None
licenses._provider_mode = None
auth_providers._provider = None
auth_providers._provider_mode = None
def test_health_reports_license_auth(codes_app):
client = TestClient(codes_app)
body = client.get("/api/health").json()
assert body["ok"] is True
assert body["licenseProvider"] == "codes"
assert body["licenseAuth"] is True
def test_health_survives_missing_secret(tmp_path, monkeypatch):
monkeypatch.setenv("APS_LICENSE_PROVIDER", "codes")
monkeypatch.delenv("APS_LICENSE_SECRET", raising=False)
monkeypatch.setenv("APS_LICENSE_CLIENTS", "web")
monkeypatch.setenv("APS_AUTH_PROVIDER", "unconfigured")
monkeypatch.setenv("APS_DB_PATH", str(tmp_path / "bad.db"))
monkeypatch.setenv("APS_WORLD_PATH", str(tmp_path / "world.json"))
from server.auth import licenses
from server.auth import providers as auth_providers
from server.db.database import reset_engine
from server.state import store as world_store
install_test_auth(monkeypatch, TENANT)
licenses._provider = None
licenses._provider_mode = None
auth_providers._provider = None
auth_providers._provider_mode = None
world_store._stores.clear()
reset_engine()
from server.gateway.app import create_app
client = TestClient(create_app())
response = client.get("/api/health")
assert response.status_code == 200
assert response.json()["licenseProvider"] == "invalid:LICENSE_CONFIG_INVALID"
reset_engine()
world_store._stores.clear()
licenses._provider = None
licenses._provider_mode = None
auth_providers._provider = None
auth_providers._provider_mode = None
def test_provider_requires_secret(monkeypatch):
from server.auth.licenses import CodeLicenseProvider
from server.auth.providers import AuthError
monkeypatch.delenv("APS_LICENSE_SECRET", raising=False)
with pytest.raises(AuthError, match="授权配置缺失") as exc:
CodeLicenseProvider()
assert exc.value.code == "LICENSE_CONFIG_INVALID"
def test_web_activation_binds_identity_and_isolates_data(codes_app):
alpha = TestClient(codes_app)
beta = TestClient(codes_app)
response = alpha.post("/api/auth/license/activate", json={"code": "USER-CODE-ALPHA"})
assert response.status_code == 200
body = response.json()
assert body["user"]["auth_kind"] == "license"
assert body["user"]["tenant_uuid"] == TENANT
assert body["license"]["type"] == "year"
alpha_id = body["user"]["user_id"]
assert beta.post("/api/auth/license/activate", json={"code": "USER-CODE-BETA"}).status_code == 200
beta_me = beta.get("/api/auth/me").json()
assert beta_me["user"]["username"] == "beta-user"
assert beta_me["user"]["user_id"] != alpha_id
assert alpha.post(
"/api/projects", json={"id": "alpha_private", "name": "授权A的私有项目"},
).status_code == 200
workspace = beta.get("/api/workspace")
assert workspace.status_code == 200
assert workspace.json()["projects"] == []
assert beta.get("/api/projects/alpha_private/members").status_code == 404
# 同一授权码换浏览器激活:找回同一身份与同一份数据
alpha_second_browser = TestClient(codes_app)
assert alpha_second_browser.post(
"/api/auth/license/activate", json={"code": "USER-CODE-ALPHA"},
).status_code == 200
me_again = alpha_second_browser.get("/api/auth/me").json()
assert me_again["user"]["user_id"] == alpha_id
projects = alpha_second_browser.get("/api/workspace").json()["projects"]
assert any(project["id"] == "alpha_private" for project in projects)
def test_admin_license_code_is_permanent_with_admin_roles(codes_app):
client = TestClient(codes_app)
response = client.post("/api/auth/license/activate", json={"code": "aps@Z@Agent@Admin"})
assert response.status_code == 200
body = response.json()
assert body["user"]["fullname"] == "超级管理员"
assert "admin" in body["user"]["roles"]
assert body["license"]["type"] == "permanent"
assert body["license"]["expiresAt"] is None
assert body["license"]["permanent"] is True
def test_codes_are_case_sensitive(codes_app):
client = TestClient(codes_app)
response = client.post("/api/auth/license/activate", json={"code": "user-code-alpha"})
assert response.status_code == 401
assert response.json()["error"]["code"] == "LICENSE_CODE_INVALID"
def test_invalid_and_blank_code_rejected(codes_app):
client = TestClient(codes_app)
response = client.post("/api/auth/license/activate", json={"code": "NO-SUCH-CODE"})
assert response.status_code == 401
assert response.json()["error"]["code"] == "LICENSE_CODE_INVALID"
blank = client.post("/api/auth/license/activate", json={"code": " "})
assert blank.status_code == 400
assert blank.json()["error"]["code"] == "LICENSE_CODE_REQUIRED"
def test_unactivated_web_request_requires_license(codes_app):
client = TestClient(codes_app)
response = client.get("/api/auth/me")
assert response.status_code == 401
assert response.json()["error"]["code"] == "LICENSE_REQUIRED"
assert "授权码" in response.json()["error"]["message"]
def test_login_endpoint_redirects_to_license_under_codes(codes_app):
client = TestClient(codes_app)
response = client.post("/api/auth/login", json={
"method": "password", "username": "planner", "password": "test",
})
assert response.status_code == 400
assert response.json()["error"]["code"] == "LICENSE_ACTIVATION_REQUIRED"
def test_expired_code_is_rejected_on_next_request(codes_app):
client = TestClient(codes_app)
assert client.post(
"/api/auth/license/activate", json={"code": "USER-CODE-HOUR"},
).status_code == 200
from server.db.database import get_session
from server.db.models import LicenseActivation
with get_session() as session:
row = session.scalar(select(LicenseActivation))
assert row is not None
row.expires_at = int(time.time()) - 1
session.commit()
response = client.get("/api/auth/me")
assert response.status_code == 401
assert response.json()["error"]["code"] == "LICENSE_EXPIRED"
again = client.post("/api/auth/license/activate", json={"code": "USER-CODE-HOUR"})
assert again.status_code == 401
assert again.json()["error"]["code"] == "LICENSE_EXPIRED"
def test_desktop_client_also_activates_with_codes(codes_app):
client = TestClient(codes_app)
headers = {"X-APS-Client": "desktop", "X-APS-Device-ID": "device-codes-0001"}
response = client.post("/api/auth/license/activate", headers=headers, json={"code": "USER-CODE-ALPHA"})
assert response.status_code == 200
me = client.get("/api/auth/me", headers=headers)
assert me.status_code == 200
assert me.json()["user"]["auth_kind"] == "license"
def test_logout_clears_web_license_session(codes_app):
client = TestClient(codes_app)
assert client.post("/api/auth/license/activate", json={"code": "USER-CODE-ALPHA"}).status_code == 200
assert client.get("/api/auth/me").status_code == 200
assert client.post("/api/auth/logout").status_code == 200
assert client.get("/api/auth/me").status_code == 401
# ---------------- 超管授权码管理 API ----------------
def _admin_client(app) -> TestClient:
client = TestClient(app)
assert client.post("/api/auth/license/activate", json={"code": "aps@Z@Agent@Admin"}).status_code == 200
return client
def test_admin_issues_code_and_user_activates_with_name(codes_app):
admin = _admin_client(codes_app)
created = admin.post("/api/admin/license/codes", json={
"name": "张三", "mobile": "13800001111", "company": "锐扬", "licenseType": "month",
})
assert created.status_code == 200
issued = created.json()["code"]
assert issued["name"] == "张三"
assert len(issued["code"]) == 19 # XXXX-XXXX-XXXX-XXXX
listed = admin.get("/api/admin/license/codes").json()["codes"]
assert any(item["id"] == issued["id"] for item in listed)
user = TestClient(codes_app)
response = user.post("/api/auth/license/activate", json={"code": issued["code"]})
assert response.status_code == 200
body = response.json()
assert body["user"]["username"] == "张三"
assert body["user"]["fullname"] == "张三"
assert body["license"]["type"] == "month"
listed_after = admin.get("/api/admin/license/codes").json()["codes"]
entry = next(item for item in listed_after if item["id"] == issued["id"])
assert entry["activation"] is not None
assert entry["activation"]["status"] == "active"
def test_non_admin_cannot_manage_codes(codes_app):
user = TestClient(codes_app)
assert user.post("/api/auth/license/activate", json={"code": "USER-CODE-ALPHA"}).status_code == 200
response = user.get("/api/admin/license/codes")
assert response.status_code == 403
assert response.json()["detail"]["code"] == "LICENSE_ADMIN_REQUIRED"
denied = user.post("/api/admin/license/codes", json={"name": "越权", "licenseType": "year"})
assert denied.status_code == 403
def test_disable_code_blocks_existing_session_and_new_activation(codes_app):
admin = _admin_client(codes_app)
issued = admin.post("/api/admin/license/codes", json={
"name": "李四", "licenseType": "year",
}).json()["code"]
user = TestClient(codes_app)
assert user.post("/api/auth/license/activate", json={"code": issued["code"]}).status_code == 200
assert user.get("/api/auth/me").status_code == 200
assert admin.post(
f"/api/admin/license/codes/{issued['id']}/status", json={"status": "disabled"},
).status_code == 200
denied = user.get("/api/auth/me")
assert denied.status_code == 401
assert denied.json()["error"]["code"] == "LICENSE_REVOKED"
reactivate = user.post("/api/auth/license/activate", json={"code": issued["code"]})
assert reactivate.status_code == 401
def test_delete_code_removes_it_from_catalog(codes_app):
admin = _admin_client(codes_app)
issued = admin.post("/api/admin/license/codes", json={
"name": "王五", "licenseType": "day",
}).json()["code"]
assert admin.delete(f"/api/admin/license/codes/{issued['id']}").status_code == 200
user = TestClient(codes_app)
response = user.post("/api/auth/license/activate", json={"code": issued["code"]})
assert response.status_code == 401
assert response.json()["error"]["code"] == "LICENSE_CODE_INVALID"
def test_duplicate_and_admin_code_rejected(codes_app):
admin = _admin_client(codes_app)
first = admin.post("/api/admin/license/codes", json={
"name": "甲", "licenseType": "year", "code": "CUSTOM-CODE-1",
})
assert first.status_code == 200
dup = admin.post("/api/admin/license/codes", json={
"name": "乙", "licenseType": "year", "code": "CUSTOM-CODE-1",
})
assert dup.status_code == 409
admin_code = admin.post("/api/admin/license/codes", json={
"name": "丙", "licenseType": "year", "code": "aps@Z@Agent@Admin",
})
assert admin_code.status_code == 409
@pytest.fixture()
def mock_license_app(tmp_path, monkeypatch):
monkeypatch.setenv("APS_LICENSE_PROVIDER", "mock")
monkeypatch.setenv("APS_MOCK_LICENSE_SECRET", "license-test-secret-0123456789abcdef")
monkeypatch.setenv("APS_MOCK_TENANT_UUID", TENANT)
monkeypatch.setenv("APS_AUTH_PROVIDER", "unconfigured")
monkeypatch.setenv("APS_DB_PATH", str(tmp_path / "mock.db"))
monkeypatch.setenv("APS_WORLD_PATH", str(tmp_path / "world.json"))
from server.auth import licenses
from server.auth import providers as auth_providers
from server.db.database import reset_engine
from server.state import store as world_store
install_test_auth(monkeypatch, TENANT)
licenses._provider = None
licenses._provider_mode = None
auth_providers._provider = None
auth_providers._provider_mode = None
world_store._stores.clear()
reset_engine()
from server.gateway.app import create_app
app = create_app()
yield app
reset_engine()
world_store._stores.clear()
licenses._provider = None
licenses._provider_mode = None
auth_providers._provider = None
auth_providers._provider_mode = None
def test_admin_api_requires_codes_mode(mock_license_app):
# mock 模式下管理接口明确拒绝,避免误以为配置生效
client = TestClient(mock_license_app)
headers = {"X-APS-Client": "desktop", "X-APS-Device-ID": "device-admin-0001"}
assert client.post(
"/api/auth/license/activate", headers=headers, json={"code": "APS-PERP-DEMO"},
).status_code == 200
response = client.get("/api/admin/license/codes", headers=headers)
assert response.status_code == 409
assert response.json()["detail"]["code"] == "LICENSE_MODE_DISABLED"
def test_custom_expiry_code_uses_absolute_deadline(codes_app):
admin = _admin_client(codes_app)
deadline = int(time.time()) + 3 * 24 * 3600
created = admin.post("/api/admin/license/codes", json={
"name": "自定义用户", "licenseType": "custom", "expiresAt": deadline,
})
assert created.status_code == 200
issued = created.json()["code"]
assert issued["licenseType"] == "custom"
assert issued["expiresAt"] == deadline
user = TestClient(codes_app)
body = user.post("/api/auth/license/activate", json={"code": issued["code"]}).json()
assert body["license"]["type"] == "custom"
assert body["license"]["label"] == "自定义授权"
assert body["license"]["expiresAt"] == deadline
def test_custom_expiry_validation(codes_app):
admin = _admin_client(codes_app)
missing = admin.post("/api/admin/license/codes", json={
"name": "缺时间", "licenseType": "custom",
})
assert missing.status_code == 400
assert missing.json()["detail"]["code"] == "LICENSE_EXPIRY_REQUIRED"
past = admin.post("/api/admin/license/codes", json={
"name": "过去时间", "licenseType": "custom", "expiresAt": int(time.time()) - 60,
})
assert past.status_code == 400
assert past.json()["detail"]["code"] == "LICENSE_EXPIRY_PAST"
def test_expired_custom_code_cannot_activate(codes_app):
admin = _admin_client(codes_app)
deadline = int(time.time()) + 3600
issued = admin.post("/api/admin/license/codes", json={
"name": "将到期", "licenseType": "custom", "expiresAt": deadline,
}).json()["code"]
# 直接把码的截止时间改到过去,模拟到期后激活
from server.db.database import get_session
from server.db.models import LicenseCode
with get_session() as session:
row = session.get(LicenseCode, issued["id"])
row.expires_at = int(time.time()) - 1
session.commit()
user = TestClient(codes_app)
response = user.post("/api/auth/license/activate", json={"code": issued["code"]})
assert response.status_code == 401
assert response.json()["error"]["code"] == "LICENSE_EXPIRED"