workshop-registration.test.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. """Offline registration handler tests, including a real Python to Node pipe."""
  2. import contextvars
  3. import importlib.util
  4. import json
  5. import os
  6. from pathlib import Path
  7. import shutil
  8. import subprocess
  9. import sys
  10. import tempfile
  11. import types
  12. import unittest
  13. from unittest.mock import patch
  14. ROOT = Path(__file__).resolve().parent.parent
  15. spec = importlib.util.spec_from_file_location('workshop_registration', ROOT / 'integrations/wecom-identity/workshop_registration.py')
  16. reg = importlib.util.module_from_spec(spec); spec.loader.exec_module(reg)
  17. V3 = Path(os.environ.get('KSF_ADAPTER_REVIEW_ROOT') or ROOT / 'work/adapter-v4-review-20261102/adapter-patch-v4')
  18. sys.path.insert(0, str(V3))
  19. import aibot_identity as ai
  20. bridge_spec = importlib.util.spec_from_file_location('identity_bridge', ROOT / 'integrations/wecom-identity/identity_bridge.py')
  21. br = importlib.util.module_from_spec(bridge_spec); bridge_spec.loader.exec_module(br)
  22. # PYTHONPATH=/opt/hermes can bind the bridge to the qualified production module,
  23. # while the baseline import above creates a different, byte-identical module.
  24. # Patch the module actually referenced by read_context, not its baseline twin.
  25. bound_identity_module = sys.modules.get(br.read_context.__module__)
  26. if bound_identity_module is None or br.read_context is not getattr(bound_identity_module, 'read_context', None):
  27. raise RuntimeError('TEST_IDENTITY_MODULE_BINDING_UNAVAILABLE')
  28. ai = bound_identity_module
  29. class RegistrationTests(unittest.TestCase):
  30. def setUp(self):
  31. self.temp = tempfile.TemporaryDirectory(prefix='ksf-registration-'); self.addCleanup(self.temp.cleanup)
  32. self.base = Path(self.temp.name)
  33. self.node = shutil.which('node')
  34. self.identity = ai.build_context(bot_id='synthetic-bot', sender_id='synthetic-sender', chat_type='single', chat_id=None, message_id='msg1')
  35. module = (ROOT / 'skills/ksf-workshop-runtime/scripts/ksf-identity.mjs').as_uri()
  36. bridge = (ROOT / 'skills/ksf-workshop-runtime/scripts/ksf-identity-bridge.mjs').as_uri()
  37. opts = {'profileDir': str(self.base / 'agents'), 'participantDir': str(self.base / 'participants')}
  38. agent = {'name': '王刚001', 'role': 'workshop', 'identityBasis': 'runtime_config', 'runtimePlatform': 'hermes', 'channel': 'wecom', 'account': {'platform': 'wecom', 'accountId': self.identity['botId']}}
  39. code = f'import {{registerAgentProfile}} from {json.dumps(module)}; console.log(JSON.stringify(registerAgentProfile({json.dumps(agent, ensure_ascii=False)},{json.dumps(opts)}).profile));'
  40. p = subprocess.run([self.node, '--input-type=module', '-e', code], capture_output=True, text=True, encoding='utf-8', check=True)
  41. self.agent = json.loads(p.stdout)
  42. self.config = {'version': 'ksf.current-employee-registration.v1', 'agentProfileKey': self.agent['key'], 'botIdSha256': reg.hashlib.sha256(self.identity['botId'].encode()).hexdigest()}
  43. self.worker = self.base / 'test-worker.mjs'
  44. self.worker.write_text(f'import fs from "node:fs"; import {{registerBridgeIdentity}} from {json.dumps(bridge)}; console.log(JSON.stringify(registerBridgeIdentity(JSON.parse(fs.readFileSync(0,"utf8")),{json.dumps(opts)})));', encoding='utf-8')
  45. def decode_tool(self, value):
  46. self.assertIsInstance(value, str, 'Hermes only accepts string or multimodal tool results')
  47. if os.environ.get('KSF_REQUIRE_REAL_TOOL_REGISTRY') == '1':
  48. import tools.registry as tool_registry
  49. candidates = [getattr(cls, '_normalize_handler_result', None) or getattr(cls, 'normalize_handler_result', None)
  50. for cls in vars(tool_registry).values() if isinstance(cls, type)]
  51. candidates = list(dict.fromkeys(fn for fn in candidates if callable(fn)))
  52. self.assertEqual(len(candidates), 1, 'Find the actual Hermes result normalizer; no fixture fallback')
  53. value = candidates[0](reg.TOOL_NAME, value)
  54. self.assertIsInstance(value, str)
  55. return json.loads(value)
  56. def run_tool(self, checked=None):
  57. with patch.object(reg, '_current_identity', return_value=checked or {'ok': True, 'identity': self.identity}), patch.object(reg, '_read_maintenance_config', return_value=self.config), patch.object(reg, 'WORKER', self.worker):
  58. return self.decode_tool(reg.register_current_employee({}))
  59. def test_real_pipe_reuses_profile_and_keeps_ids_out_of_receipt(self):
  60. a = self.run_tool(); b = self.run_tool()
  61. self.assertTrue(a['ok'], a); self.assertTrue(a['changed']); self.assertFalse(b['changed'])
  62. self.assertEqual(a['profile']['key'], b['profile']['key'])
  63. self.assertNotIn('synthetic-', json.dumps(a))
  64. rows = list((self.base / 'participants').glob('*.json')); self.assertEqual(len(rows), 1)
  65. self.assertEqual(json.loads(rows[0].read_text(encoding='utf-8'))['account']['accountId'], self.identity['senderId'])
  66. def test_no_arguments_even_equal_identity(self):
  67. with patch.object(reg, '_current_identity') as read:
  68. for args in ({'messageId': 'msg1'}, [], 'text'):
  69. self.assertEqual(self.decode_tool(reg.register_current_employee(args))['error'], 'NO_ARGUMENTS_ALLOWED')
  70. read.assert_not_called()
  71. def test_failed_context_never_spawns(self):
  72. with patch.object(reg.subprocess, 'run') as spawn:
  73. self.assertFalse(self.run_tool({'ok': False, 'error': 'synthetic-private-error'})['ok']); spawn.assert_not_called()
  74. def test_bot_mismatch_never_spawns(self):
  75. self.config['botIdSha256'] = '0' * 64
  76. with patch.object(reg.subprocess, 'run') as spawn:
  77. self.assertEqual(self.run_tool()['error'], 'REGISTRATION_INSTANCE_MISMATCH'); spawn.assert_not_called()
  78. def test_worker_failure_does_not_leak_output(self):
  79. bad = types.SimpleNamespace(returncode=1, stdout='synthetic-private', stderr='synthetic-private')
  80. with patch.object(reg.subprocess, 'run', return_value=bad):
  81. r = self.run_tool(); self.assertEqual(r['error'], 'REGISTRATION_WORKER_FAILED'); self.assertNotIn('synthetic-', json.dumps(r))
  82. def test_private_maintainer_config_is_required(self):
  83. location = self.base / 'plugins/wecom-identity'; location.mkdir(parents=True)
  84. file = location / 'registration.json'; file.write_text(json.dumps(self.config), encoding='utf-8'); file.chmod(0o600)
  85. with patch.dict(os.environ, {'HERMES_HOME': str(self.base)}):
  86. self.assertEqual(reg._read_maintenance_config(), self.config)
  87. file.write_text('{"version":"unknown"}', encoding='utf-8')
  88. with self.assertRaises(reg.RegistrationError): reg._read_maintenance_config()
  89. def test_v3_context_to_handler_to_real_node_profile(self):
  90. sc = types.ModuleType('gateway.session_context'); sc._UNSET = object()
  91. values = {'PLATFORM': 'wecom', 'MESSAGE_ID': 'msg1', 'USER_ID': self.identity['senderId'], 'CHAT_TYPE': 'dm', 'CHAT_ID': self.identity['senderId']}
  92. sc._VAR_MAP = {'HERMES_SESSION_' + k: contextvars.ContextVar(k, default=sc._UNSET) for k in values}
  93. for k, v in values.items(): sc._VAR_MAP['HERMES_SESSION_' + k].set(v)
  94. packages = {}
  95. for n in ('gateway', 'plugins', 'plugins.platforms', 'plugins.platforms.wecom'):
  96. packages[n] = types.ModuleType(n); packages[n].__path__ = []
  97. packages.update({'gateway.session_context': sc, 'plugins.platforms.wecom.identity_bridge': br})
  98. with patch.dict(sys.modules, packages), patch.object(br, 'read_instance_registry', return_value={'botId': self.identity['botId']}), patch.object(ai, '_read_raw', return_value=self.identity), patch.object(reg, '_read_maintenance_config', return_value=self.config), patch.object(reg, 'WORKER', self.worker):
  99. r = self.decode_tool(reg.register_current_employee({})); self.assertTrue(r['ok'], r)
  100. sc._VAR_MAP['HERMES_SESSION_MESSAGE_ID'].set(sc._UNSET)
  101. with patch.object(reg.subprocess, 'run') as spawn:
  102. self.assertFalse(self.decode_tool(reg.register_current_employee({}))['ok']); spawn.assert_not_called()
  103. def test_registered_handlers_return_text_and_keep_internal_query_structured(self):
  104. packages = {}
  105. for name in ('plugins', 'plugins.platforms', 'plugins.platforms.wecom'):
  106. packages[name] = types.ModuleType(name); packages[name].__path__ = []
  107. packages.update({'plugins.platforms.wecom.identity_bridge': br,
  108. 'plugins.platforms.wecom.workshop_registration': reg})
  109. entry = types.ModuleType('combined_entry')
  110. with patch.dict(sys.modules, packages):
  111. exec((ROOT / 'integrations/wecom-identity/__init__.py').read_text(encoding='utf-8'), entry.__dict__)
  112. handlers = {}
  113. ctx = types.SimpleNamespace(register_tool=lambda name, _set, _schema, handler, **_kw: handlers.update({name: handler}))
  114. entry.register(ctx)
  115. self.assertEqual(set(handlers), {'wecom_identity_current', 'ksf_register_current_employee'})
  116. checked = {'ok': True, 'identity': self.identity}
  117. with patch.object(br, 'current_identity', return_value=checked):
  118. self.assertEqual(self.decode_tool(handlers[br.TOOL_NAME]({})), checked)
  119. self.assertIsInstance(br.current_identity(), dict)
  120. with patch.object(reg, '_current_identity', return_value=checked), patch.object(reg, '_read_maintenance_config', return_value=self.config), patch.object(reg, 'WORKER', self.worker):
  121. first = self.decode_tool(handlers[reg.TOOL_NAME]({}))
  122. second = self.decode_tool(handlers[reg.TOOL_NAME]({}))
  123. self.assertTrue(first['changed']); self.assertFalse(second['changed'])
  124. self.assertEqual(first['profile']['key'], second['profile']['key'])
  125. self.assertIn('员工待绑定', first['profile']['name'])
  126. with patch.object(br, 'current_identity', return_value={'ok': False, 'error': 'IDENTITY_CONTEXT_UNAVAILABLE'}):
  127. self.assertFalse(self.decode_tool(handlers[br.TOOL_NAME]({}))['ok'])
  128. with patch.object(br, 'current_identity') as read:
  129. self.assertEqual(self.decode_tool(handlers[br.TOOL_NAME]({'messageId': 'spoof'}))['error'], 'NO_ARGUMENTS_ALLOWED')
  130. read.assert_not_called()
  131. def test_every_registration_error_path_is_serialized_and_redacted(self):
  132. with patch.object(reg, '_current_identity', side_effect=RuntimeError('private-config-path-and-secret')):
  133. value = reg.register_current_employee({})
  134. self.assertEqual(self.decode_tool(value)['error'], 'IDENTITY_REGISTRATION_FAILED')
  135. self.assertNotIn('private-config', value)
  136. with patch.object(reg, '_current_identity', return_value={'ok': True, 'identity': self.identity}), patch.object(reg, '_read_maintenance_config', side_effect=reg.RegistrationError('REGISTRATION_CONFIG_UNSAFE')):
  137. self.assertEqual(self.decode_tool(reg.register_current_employee({}))['error'], 'REGISTRATION_CONFIG_UNSAFE')
  138. malformed = types.SimpleNamespace(returncode=0, stdout='not-json-private', stderr='private')
  139. with patch.object(reg.subprocess, 'run', return_value=malformed):
  140. self.assertEqual(self.run_tool()['error'], 'IDENTITY_REGISTRATION_FAILED')
  141. invalid = types.SimpleNamespace(returncode=0, stdout='{}', stderr='private')
  142. with patch.object(reg.subprocess, 'run', return_value=invalid):
  143. self.assertEqual(self.run_tool()['error'], 'REGISTRATION_RECEIPT_INVALID')
  144. if __name__ == '__main__': unittest.main()