This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from _helpers import TempDirMixin
|
||||
|
||||
from app import client_policy
|
||||
|
||||
|
||||
class ClientPolicyTests(TempDirMixin, unittest.TestCase):
|
||||
def _payload(self, *, check=True, enforce=False):
|
||||
return {
|
||||
"policy_version": 1,
|
||||
"subscription_check_enabled": check,
|
||||
"subscription_enforcement_enabled": enforce,
|
||||
"updated_at": "2026-07-28T15:19:19+08:00",
|
||||
}
|
||||
|
||||
def test_parse_supports_off_observe_and_enforce(self):
|
||||
off = client_policy.parse_client_policy(
|
||||
self._payload(check=False, enforce=False)
|
||||
)
|
||||
observe = client_policy.parse_client_policy(
|
||||
self._payload(check=True, enforce=False)
|
||||
)
|
||||
enforce = client_policy.parse_client_policy(
|
||||
self._payload(check=True, enforce=True)
|
||||
)
|
||||
|
||||
self.assertEqual("off", off.mode)
|
||||
self.assertEqual("observe", observe.mode)
|
||||
self.assertEqual("enforce", enforce.mode)
|
||||
|
||||
def test_illegal_combination_is_normalized_to_off(self):
|
||||
policy = client_policy.parse_client_policy(
|
||||
self._payload(check=False, enforce=True)
|
||||
)
|
||||
|
||||
self.assertEqual("off", policy.mode)
|
||||
self.assertFalse(policy.subscription_enforcement_enabled)
|
||||
self.assertIn("组合非法", policy.warning)
|
||||
|
||||
def test_parser_rejects_loose_types_or_timestamp_without_timezone(self):
|
||||
invalid_bool = self._payload()
|
||||
invalid_bool["subscription_check_enabled"] = 1
|
||||
invalid_time = self._payload()
|
||||
invalid_time["updated_at"] = "2026-07-28T15:19:19"
|
||||
|
||||
with self.assertRaises(client_policy.ClientPolicyError):
|
||||
client_policy.parse_client_policy(invalid_bool)
|
||||
with self.assertRaises(client_policy.ClientPolicyError):
|
||||
client_policy.parse_client_policy(invalid_time)
|
||||
|
||||
def test_valid_remote_policy_is_cached_without_sensitive_fields(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
path = os.path.join(temp_dir, "config", "client_policy.json")
|
||||
remote = client_policy.parse_client_policy(
|
||||
self._payload(check=True, enforce=True)
|
||||
)
|
||||
|
||||
resolved = client_policy.resolve_client_policy(remote, path=path)
|
||||
loaded = client_policy.load_cached_policy(path=path)
|
||||
|
||||
self.assertEqual("remote", resolved.source)
|
||||
self.assertEqual("cache", loaded.source)
|
||||
self.assertEqual("enforce", loaded.mode)
|
||||
with open(path, "r", encoding="utf-8") as handle:
|
||||
raw = json.load(handle)
|
||||
self.assertIn("cached_at", raw)
|
||||
self.assertNotIn("api_key", json.dumps(raw).lower())
|
||||
self.assertNotIn("account", json.dumps(raw).lower())
|
||||
|
||||
def test_missing_remote_uses_cache_then_observation_fallback(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
path = os.path.join(temp_dir, "client_policy.json")
|
||||
cached = client_policy.parse_client_policy(
|
||||
self._payload(check=False, enforce=False)
|
||||
)
|
||||
client_policy.save_cached_policy(cached, path=path)
|
||||
|
||||
from_cache = client_policy.resolve_client_policy(None, path=path)
|
||||
self.assertEqual("cache", from_cache.source)
|
||||
self.assertEqual("off", from_cache.mode)
|
||||
|
||||
with open(path, "w", encoding="utf-8") as handle:
|
||||
handle.write("{broken")
|
||||
fallback = client_policy.resolve_client_policy(None, path=path)
|
||||
self.assertEqual("fallback", fallback.source)
|
||||
self.assertEqual("observe", fallback.mode)
|
||||
self.assertIn("观察模式", fallback.warning)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -17,6 +17,7 @@ from app import (
|
||||
accounts,
|
||||
ai,
|
||||
appconfig,
|
||||
client_policy,
|
||||
db,
|
||||
image_paths,
|
||||
image_studio,
|
||||
@@ -2010,6 +2011,30 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
self.assertTrue(allowed)
|
||||
write_log.assert_called_once()
|
||||
|
||||
def test_startup_update_gate_returns_client_policy_to_startup(self):
|
||||
policy = client_policy.parse_client_policy(
|
||||
{
|
||||
"policy_version": 1,
|
||||
"subscription_check_enabled": False,
|
||||
"subscription_enforcement_enabled": False,
|
||||
"updated_at": "2026-07-28T15:19:19+08:00",
|
||||
}
|
||||
)
|
||||
result = update_check.UpdateCheckResult(
|
||||
current_version="1.0.0",
|
||||
checked=True,
|
||||
client_policy=policy,
|
||||
)
|
||||
captured = []
|
||||
|
||||
allowed = gui._run_startup_update_gate(
|
||||
checker=lambda: result,
|
||||
result_callback=captured.append,
|
||||
)
|
||||
|
||||
self.assertTrue(allowed)
|
||||
self.assertEqual([result], captured)
|
||||
|
||||
def test_status_callbacks_classify_success_warning_and_failure(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
statuses = []
|
||||
@@ -11364,6 +11389,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
account_name="新账号",
|
||||
plan_name="测试套餐",
|
||||
expires_at="2026-08-20T23:59:59+08:00",
|
||||
real_entitlement_allowed=True,
|
||||
)
|
||||
|
||||
with mock.patch.object(main_window.QTimer, "singleShot"):
|
||||
@@ -11405,6 +11431,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
account_name="新账号",
|
||||
plan_name="测试套餐",
|
||||
expires_at="2026-08-20T23:59:59+08:00",
|
||||
real_entitlement_allowed=True,
|
||||
)
|
||||
fake_box, boxes = self.make_fake_message_box("开始配置店铺")
|
||||
|
||||
@@ -11466,6 +11493,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
account_name="主账号",
|
||||
plan_name="专业版",
|
||||
expires_at="2026-08-20T23:59:59+08:00",
|
||||
real_entitlement_allowed=True,
|
||||
)
|
||||
|
||||
window._apply_subscription_status(status)
|
||||
@@ -11491,6 +11519,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
grace_expires_at="2026-08-23T23:59:59+08:00",
|
||||
manage_url="https://cm.example.com/subscription",
|
||||
notice_id="private-notice",
|
||||
real_entitlement_allowed=True,
|
||||
)
|
||||
|
||||
window._apply_subscription_status(grace)
|
||||
@@ -11544,6 +11573,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
account_name="主账号",
|
||||
plan_name="测试",
|
||||
expires_at="2026-08-21T23:59:59+08:00",
|
||||
real_entitlement_allowed=True,
|
||||
)
|
||||
|
||||
with mock.patch.object(
|
||||
@@ -11666,6 +11696,161 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
settings_tab.subscription_check_button.text(),
|
||||
)
|
||||
|
||||
def test_remote_policy_off_skips_check_and_keeps_workflows_available(self):
|
||||
policy = client_policy.ClientPolicy(
|
||||
policy_version=1,
|
||||
subscription_check_enabled=False,
|
||||
subscription_enforcement_enabled=False,
|
||||
updated_at="2026-07-28T15:19:19+08:00",
|
||||
)
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
window = MainWindow(
|
||||
config=self.make_config(temp_dir),
|
||||
subscription_policy=policy,
|
||||
)
|
||||
self.addCleanup(window.close)
|
||||
settings_tab = window._settings_tab()
|
||||
|
||||
with mock.patch(
|
||||
"app.gui.main_window.SubscriptionCheckWorker",
|
||||
) as worker_factory:
|
||||
window.begin_subscription_check()
|
||||
|
||||
worker_factory.assert_not_called()
|
||||
self.assertFalse(settings_tab.subscription_check_button.isHidden())
|
||||
self.assertFalse(settings_tab.subscription_check_button.isEnabled())
|
||||
self.assertEqual(
|
||||
"会员检测暂未启用",
|
||||
settings_tab.subscription_check_button.text(),
|
||||
)
|
||||
self.assertTrue(
|
||||
all(
|
||||
window.tabs.isTabEnabled(index)
|
||||
for index in range(window.tabs.count())
|
||||
)
|
||||
)
|
||||
self.assertTrue(window.ensure_subscription_for_new_submit("开始 AI 生成"))
|
||||
|
||||
def test_enforcement_uses_classified_subscription_windows(self):
|
||||
expected = [
|
||||
(subscription.STATUS_REQUIRED, "尚未开通会员套餐", "前往会员中心"),
|
||||
(subscription.STATUS_REVOKED, "会员套餐已失效", "前往会员中心"),
|
||||
(
|
||||
subscription.STATUS_ACCOUNT_DISABLED,
|
||||
"会员账号当前不可用",
|
||||
"前往会员中心",
|
||||
),
|
||||
(
|
||||
subscription.STATUS_UNAVAILABLE,
|
||||
"暂时无法验证会员状态",
|
||||
"打开设置",
|
||||
),
|
||||
]
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
window = MainWindow(config=self.make_config(temp_dir))
|
||||
self.addCleanup(window.close)
|
||||
fake_box, boxes = self.make_sequence_message_box(
|
||||
[selected for _state, _title, selected in expected]
|
||||
)
|
||||
with mock.patch.object(
|
||||
main_window,
|
||||
"QMessageBox",
|
||||
fake_box,
|
||||
), mock.patch.object(
|
||||
main_window.QDesktopServices,
|
||||
"openUrl",
|
||||
return_value=True,
|
||||
) as open_url:
|
||||
for state, _title, _selected in expected:
|
||||
window._apply_subscription_status(
|
||||
subscription.SubscriptionStatus(
|
||||
state,
|
||||
manage_url=(
|
||||
"https://cm.example.com/user/subscriptions/cmshopee"
|
||||
if state != subscription.STATUS_UNAVAILABLE
|
||||
else ""
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
[title for _state, title, _selected in expected],
|
||||
[box.title for box in boxes],
|
||||
)
|
||||
self.assertEqual(3, open_url.call_count)
|
||||
self.assertEqual(
|
||||
TAB_TITLES.index("设置"),
|
||||
window.tabs.currentIndex(),
|
||||
)
|
||||
|
||||
def test_subscription_window_state_is_deduplicated_until_entitlement_recovers(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
window = MainWindow(config=self.make_config(temp_dir))
|
||||
self.addCleanup(window.close)
|
||||
fake_box, boxes = self.make_sequence_message_box(
|
||||
["前往会员中心", "打开设置", "前往会员中心"]
|
||||
)
|
||||
required = subscription.SubscriptionStatus(
|
||||
subscription.STATUS_REQUIRED,
|
||||
manage_url="https://cm.example.com/user/subscriptions/cmshopee",
|
||||
)
|
||||
unavailable = subscription.SubscriptionStatus(
|
||||
subscription.STATUS_UNAVAILABLE
|
||||
)
|
||||
active = subscription.SubscriptionStatus(
|
||||
subscription.STATUS_ACTIVE,
|
||||
account_name="主账号",
|
||||
plan_name="测试",
|
||||
expires_at="2026-08-21T23:59:59+08:00",
|
||||
real_entitlement_allowed=True,
|
||||
)
|
||||
|
||||
with mock.patch.object(
|
||||
main_window,
|
||||
"QMessageBox",
|
||||
fake_box,
|
||||
), mock.patch.object(
|
||||
main_window.QDesktopServices,
|
||||
"openUrl",
|
||||
return_value=True,
|
||||
):
|
||||
window._apply_subscription_status(required)
|
||||
window._apply_subscription_status(unavailable)
|
||||
window._apply_subscription_status(required)
|
||||
window._apply_subscription_status(active)
|
||||
window._apply_subscription_status(required)
|
||||
|
||||
self.assertEqual(
|
||||
[
|
||||
"尚未开通会员套餐",
|
||||
"暂时无法验证会员状态",
|
||||
"尚未开通会员套餐",
|
||||
],
|
||||
[box.title for box in boxes],
|
||||
)
|
||||
|
||||
def test_invalid_key_uses_activation_dialog_in_enforcement_mode(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
window = MainWindow(config=self.make_config(temp_dir))
|
||||
self.addCleanup(window.close)
|
||||
|
||||
with mock.patch.object(
|
||||
MembershipActivationDialog,
|
||||
"focus_api_key",
|
||||
) as focus_api_key:
|
||||
window._apply_subscription_status(
|
||||
subscription.SubscriptionStatus(
|
||||
subscription.STATUS_KEY_INVALID
|
||||
)
|
||||
)
|
||||
|
||||
self.assertIsNotNone(window._activation_dialog)
|
||||
focus_api_key.assert_called_once_with()
|
||||
self.assertEqual(
|
||||
TAB_TITLES.index("设置"),
|
||||
window.tabs.currentIndex(),
|
||||
)
|
||||
|
||||
@mock.patch.object(main_window, "SUBSCRIPTION_ENFORCEMENT_ENABLED", False)
|
||||
def test_main_window_observation_mode_checks_without_restricting_workflows(self):
|
||||
self.assertTrue(main_window.SUBSCRIPTION_CHECK_ENABLED)
|
||||
@@ -11741,6 +11926,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
plan_name="测试",
|
||||
expires_at="2026-08-21T23:59:59+08:00",
|
||||
notice_id="subscription-test-plan",
|
||||
real_entitlement_allowed=True,
|
||||
)
|
||||
|
||||
with mock.patch.object(window, "_show_subscription_notice_once") as notice:
|
||||
|
||||
@@ -42,6 +42,7 @@ class SubscriptionTests(TempDirMixin, unittest.TestCase):
|
||||
"account": {"display_name": "主账号"},
|
||||
"plan": {"code": "pro", "display_name": "专业版"},
|
||||
"status": "active",
|
||||
"real_entitlement_allowed": True,
|
||||
"expires_at": "2026-08-20T23:59:59+08:00",
|
||||
"grace_expires_at": None,
|
||||
"manage_url": "https://cm.example.com/user/subscriptions/cmshopee",
|
||||
@@ -79,6 +80,7 @@ class SubscriptionTests(TempDirMixin, unittest.TestCase):
|
||||
"account": {"display_name": "新账号"},
|
||||
"plan": {"display_name": "测试套餐"},
|
||||
"status": "active",
|
||||
"real_entitlement_allowed": True,
|
||||
"expires_at": "2026-08-20T23:59:59+08:00",
|
||||
}
|
||||
|
||||
@@ -104,6 +106,7 @@ class SubscriptionTests(TempDirMixin, unittest.TestCase):
|
||||
"account": {"display_name": "主账号"},
|
||||
"plan": {"display_name": "专业版"},
|
||||
"status": "grace",
|
||||
"real_entitlement_allowed": True,
|
||||
"expires_at": "2026-08-20T23:59:59+08:00",
|
||||
"grace_expires_at": "2026-08-23T23:59:59+08:00",
|
||||
}
|
||||
@@ -118,6 +121,7 @@ class SubscriptionTests(TempDirMixin, unittest.TestCase):
|
||||
"account": {"display_name": "主账号"},
|
||||
"plan": {"display_name": "专业版"},
|
||||
"status": "expired",
|
||||
"real_entitlement_allowed": False,
|
||||
"expires_at": "2026-08-20T23:59:59+08:00",
|
||||
"manage_url": "https://cm.example.com/user/subscriptions/cmshopee",
|
||||
}
|
||||
@@ -130,7 +134,7 @@ class SubscriptionTests(TempDirMixin, unittest.TestCase):
|
||||
expired.manage_url,
|
||||
)
|
||||
|
||||
def test_legacy_404_keeps_existing_workflows_available(self):
|
||||
def test_legacy_404_does_not_claim_real_entitlement(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
def request_json(*args, **kwargs):
|
||||
raise ai.CMHubError("not_found", "接口不存在", status=404)
|
||||
@@ -141,7 +145,7 @@ class SubscriptionTests(TempDirMixin, unittest.TestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(subscription.STATUS_LEGACY, result.state)
|
||||
self.assertTrue(result.allows_product_workflows)
|
||||
self.assertFalse(result.allows_product_workflows)
|
||||
self.assertFalse(result.interface_available)
|
||||
|
||||
def test_auth_error_and_network_failure_have_different_states(self):
|
||||
@@ -180,6 +184,7 @@ class SubscriptionTests(TempDirMixin, unittest.TestCase):
|
||||
"account": {"display_name": "主账号"},
|
||||
"plan": {"display_name": "专业版"},
|
||||
"status": "required",
|
||||
"real_entitlement_allowed": False,
|
||||
"manage_url": "https://other.example.com/account",
|
||||
}
|
||||
|
||||
@@ -187,6 +192,33 @@ class SubscriptionTests(TempDirMixin, unittest.TestCase):
|
||||
self.assertEqual(subscription.STATUS_REQUIRED, result.state)
|
||||
self.assertEqual("", result.manage_url)
|
||||
|
||||
def test_real_entitlement_field_is_required_and_consistent(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
config = self._config(temp_dir)
|
||||
common = {
|
||||
"product_code": "cmshopee",
|
||||
"account": {"display_name": "主账号"},
|
||||
"plan": {"display_name": "专业版"},
|
||||
"status": "active",
|
||||
"expires_at": "2026-08-20T23:59:59+08:00",
|
||||
}
|
||||
|
||||
missing = subscription.check_status(
|
||||
config,
|
||||
request_json=lambda *args, **kwargs: dict(common),
|
||||
)
|
||||
inconsistent = subscription.check_status(
|
||||
config,
|
||||
request_json=lambda *args, **kwargs: {
|
||||
**common,
|
||||
"real_entitlement_allowed": False,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(subscription.STATUS_UNAVAILABLE, missing.state)
|
||||
self.assertEqual(subscription.STATUS_UNAVAILABLE, inconsistent.state)
|
||||
self.assertFalse(missing.allows_product_workflows)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -120,6 +120,70 @@ class UpdateCheckTests(unittest.TestCase):
|
||||
self.assertEqual("", result.package_format)
|
||||
self.assertEqual(0, result.updater_protocol)
|
||||
|
||||
def test_check_for_update_parses_client_policy_without_affecting_release(self):
|
||||
result = update_check.check_for_update(
|
||||
current_version="0.1.5",
|
||||
url="https://cm.example.test/api/v1/client/releases/latest?platform=windows",
|
||||
fetcher=lambda _url, _timeout: {
|
||||
"platform": "windows",
|
||||
"release": {
|
||||
"version": "0.1.6",
|
||||
"force_update": False,
|
||||
},
|
||||
"client_policy": {
|
||||
"policy_version": 1,
|
||||
"subscription_check_enabled": True,
|
||||
"subscription_enforcement_enabled": True,
|
||||
"updated_at": "2026-07-28T15:19:19+08:00",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual("0.1.6", result.latest_version)
|
||||
self.assertEqual("enforce", result.client_policy.mode)
|
||||
self.assertEqual("", result.client_policy_error)
|
||||
|
||||
def test_invalid_client_policy_does_not_break_update_detection(self):
|
||||
result = update_check.check_for_update(
|
||||
current_version="0.1.5",
|
||||
url="https://cm.example.test/version.json",
|
||||
fetcher=lambda _url, _timeout: {
|
||||
"release": {"version": "0.1.6"},
|
||||
"client_policy": {
|
||||
"policy_version": 1,
|
||||
"subscription_check_enabled": "false",
|
||||
"subscription_enforcement_enabled": True,
|
||||
"updated_at": "2026-07-28T15:19:19+08:00",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual("0.1.6", result.latest_version)
|
||||
self.assertIsNone(result.client_policy)
|
||||
self.assertIn("布尔值", result.client_policy_error)
|
||||
self.assertEqual("", result.error)
|
||||
|
||||
def test_release_can_be_null_when_policy_is_available(self):
|
||||
result = update_check.check_for_update(
|
||||
current_version="0.1.5",
|
||||
url="https://cm.example.test/version.json",
|
||||
fetcher=lambda _url, _timeout: {
|
||||
"platform": "windows",
|
||||
"release": None,
|
||||
"client_policy": {
|
||||
"policy_version": 1,
|
||||
"subscription_check_enabled": False,
|
||||
"subscription_enforcement_enabled": False,
|
||||
"updated_at": "2026-07-28T15:19:19+08:00",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
self.assertTrue(result.checked)
|
||||
self.assertFalse(result.forced)
|
||||
self.assertEqual("", result.error)
|
||||
self.assertEqual("off", result.client_policy.mode)
|
||||
|
||||
def test_network_failure_allows_entry(self):
|
||||
def fetcher(_url, _timeout):
|
||||
raise socket.timeout("timeout")
|
||||
|
||||
Reference in New Issue
Block a user