316 lines
15 KiB
Python
316 lines
15 KiB
Python
"""Offline regression tests. All DWS calls use isolated fake responses."""
|
|
import base64
|
|
import contextlib
|
|
import io
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
import dingtalk_notify as n
|
|
|
|
PROFILE = 'fake-corp:fake-user'
|
|
GROUP = 'fake-group'
|
|
PERSON = 'fake-person'
|
|
OK = {'ok': True, 'identity': 'user', 'tool': 'send_personal_message',
|
|
'result': {'success': True, 'errorCode': None, 'result': {'openTaskId': 'fake-task'}}}
|
|
|
|
|
|
def reply(data=None, code=0, error=''):
|
|
return subprocess.CompletedProcess([], code, json.dumps(data or {}, ensure_ascii=False), error)
|
|
|
|
|
|
class NotifyTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.temp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self.temp.cleanup)
|
|
self.root = Path(self.temp.name)
|
|
self.path = self.root / '.dingtalk' / 'config.env'
|
|
self.path.parent.mkdir()
|
|
self.config()
|
|
self.calls = []
|
|
self.sleeps = []
|
|
self.addCleanup(patch.stopall)
|
|
patch.object(n, 'dws_path', return_value='fake-dws').start()
|
|
|
|
def config(self, **changes):
|
|
config = {'ENABLED': '1', 'PROFILE': PROFILE, 'TARGET': GROUP, 'PROJECT': '测试项目'}
|
|
config.update(changes)
|
|
self.path.write_text(''.join(n.PREFIX + k + '=' + v + '\n' for k, v in config.items()), encoding='utf-8')
|
|
|
|
def fake(self, command, **kwargs):
|
|
self.calls.append((command, kwargs))
|
|
if command[1:3] == ['profile', 'list']:
|
|
return reply({'profiles': [{'profile': PROFILE, 'userName': '测试人'}]})
|
|
if command[1:3] == ['auth', 'status']:
|
|
return reply({'authenticated': True, 'token_valid': True})
|
|
if command[1:3] == ['aisearch', 'person']:
|
|
return reply({'result': [{'openDingTalkId': PERSON, 'meta': {'name': command[command.index('--query') + 1]}}]})
|
|
self.assertEqual(command[1:3], ['chat', '+messages-send'])
|
|
return reply(OK)
|
|
|
|
def execute(self, args, run=None):
|
|
out = io.StringIO()
|
|
with contextlib.redirect_stdout(out):
|
|
code = n.main(args, root=self.root, run=run or self.fake, sleep=self.sleeps.append)
|
|
return code, json.loads(out.getvalue())
|
|
|
|
def test_each_event_personal_markdown(self):
|
|
for event, label in n.EVENTS.items():
|
|
code, result = self.execute(['progress', event, '**进展**\n\n- 已归档'])
|
|
self.assertEqual(code, 0)
|
|
self.assertEqual(result['status'], 'accepted')
|
|
cmd = self.calls[-1][0]
|
|
self.assertEqual(cmd[cmd.index('--as') + 1], 'user')
|
|
self.assertEqual(cmd[cmd.index('--group') + 1], GROUP)
|
|
self.assertEqual(cmd[cmd.index('--profile') + 1], PROFILE)
|
|
body = cmd[cmd.index('--markdown') + 1]
|
|
self.assertIn(label, body)
|
|
self.assertIn('项目进度自动汇报', body)
|
|
self.assertIn('**进展**\n\n- 已归档', body)
|
|
self.assertNotIn('--ai-tag=false', cmd)
|
|
self.assertNotIn('--robot-code', cmd)
|
|
|
|
def test_message_mentions_exact_name_fixed_profile(self):
|
|
code, data = self.execute(['message', '--title', '提醒', '--text', '请补充资料', '--at', '测试人,测试人'])
|
|
self.assertEqual(code, 0)
|
|
self.assertEqual(len(self.calls), 2)
|
|
for cmd, _ in self.calls:
|
|
self.assertEqual(cmd[cmd.index('--profile') + 1], PROFILE)
|
|
cmd = self.calls[-1][0]
|
|
self.assertEqual(cmd[cmd.index('--at-open-dingtalk-ids') + 1], PERSON)
|
|
self.assertIn('<@fake-person>', cmd[cmd.index('--markdown') + 1])
|
|
self.assertEqual(data['mentions'], ['测试人'])
|
|
self.assertEqual(data['openTaskId'], 'fake-task')
|
|
|
|
def test_progress_can_include_mention_once(self):
|
|
code, _ = self.execute(['progress', 'blocked', '缺输入', '--at', '测试人'])
|
|
self.assertEqual(code, 0)
|
|
self.assertEqual(sum(c[0][1:3] == ['chat', '+messages-send'] for c in self.calls), 1)
|
|
|
|
def test_lookup_ambiguity_no_match_and_malformed_never_send(self):
|
|
for rows in [[], [{'openDingTalkId': 'a', 'meta': {'name': '甲'}},
|
|
{'openDingTalkId': 'b', 'meta': {'name': '甲'}}],
|
|
[{'userId': 'wrong-id-kind', 'meta': {'name': '甲'}}], None]:
|
|
self.calls.clear()
|
|
def lookup(command, **kwargs):
|
|
self.calls.append(command)
|
|
return reply({'result': rows})
|
|
code, data = self.execute(['message', '--title', '提醒', '--text', '正文', '--at', '甲'], lookup)
|
|
self.assertEqual(code, 1)
|
|
self.assertEqual(data['status'], 'error')
|
|
self.assertEqual(len(self.calls), 1)
|
|
|
|
def test_check_is_read_only(self):
|
|
code, data = self.execute(['check', '--at', '测试人'])
|
|
self.assertEqual(code, 0)
|
|
self.assertEqual(data['status'], 'checked')
|
|
self.assertEqual(len(self.calls), 2)
|
|
self.assertNotIn('fake-person', json.dumps(data))
|
|
|
|
def test_check_bad_login_stops(self):
|
|
code, _ = self.execute(['check'], lambda *a, **kw: reply({'authenticated': False, 'token_valid': False}))
|
|
self.assertEqual(code, 1)
|
|
|
|
def test_disabled_and_missing_config_no_tools(self):
|
|
self.config(ENABLED='0')
|
|
self.assertEqual(self.execute(['progress', 'start', '开始'])[1]['status'], 'skipped')
|
|
self.path.unlink()
|
|
self.assertEqual(self.execute(['progress', 'start', '开始'])[1]['status'], 'skipped')
|
|
self.assertFalse(self.calls)
|
|
|
|
def test_preview_without_config_or_dws(self):
|
|
self.path.unlink()
|
|
code, result = self.execute(['progress', 'blocked', '说明', '--at', '甲', '--dry-run'])
|
|
self.assertEqual(code, 0)
|
|
self.assertEqual(result['status'], 'preview')
|
|
self.assertFalse(self.calls)
|
|
|
|
def test_legacy_and_invalid_config_fail_without_echo(self):
|
|
for suffix in ['CLIENT_SECRET=fake-secret', 'MODE=app-bot', 'ENABLED=1', 'UNKNOWN=x']:
|
|
self.config()
|
|
with self.path.open('a', encoding='utf-8') as f:
|
|
f.write(n.PREFIX + suffix + '\n')
|
|
code, data = self.execute(['progress', 'start', '开始'])
|
|
self.assertEqual(code, 1)
|
|
self.assertNotIn('fake-secret', json.dumps(data))
|
|
self.assertFalse(self.calls)
|
|
|
|
def test_config_bom_crlf_and_literal_values(self):
|
|
raw = self.path.read_text(encoding='utf-8').replace('测试项目', '中文 "引号" $()')
|
|
self.path.write_bytes(b'\xef\xbb\xbf' + raw.replace('\n', '\r\n').encode())
|
|
code, data = self.execute(['progress', 'start', '开始', '--dry-run'])
|
|
self.assertEqual(code, 0)
|
|
self.assertIn('中文 "引号" $()', data['text'])
|
|
|
|
def test_files_and_invalid_bodies(self):
|
|
body = self.root / '正文.md'
|
|
body.write_text('**进展**\n\n- 中文 "引号" $()', encoding='utf-8')
|
|
for args in [['progress', 'milestone'], ['message', '--title', '说明']]:
|
|
self.assertEqual(self.execute(args + ['--file', str(body)])[0], 0)
|
|
before = len(self.calls)
|
|
for args in [['progress', 'start', ''], ['progress', 'start', 'x' * 12001],
|
|
['progress', 'start', '正文', '--file', str(body)],
|
|
['message', '--title', '坏\n标题', '--text', '内容'],
|
|
['progress', 'start', '--file', str(self.path), '--dry-run']]:
|
|
self.assertEqual(self.execute(args)[0], 1)
|
|
self.assertEqual(len(self.calls), before)
|
|
|
|
def test_dns_three_attempts_only(self):
|
|
calls = []
|
|
def failed(*a, **kw):
|
|
calls.append(a)
|
|
return reply(code=1, error='lookup api.dingtalk.com: no such host')
|
|
code, _ = self.execute(['progress', 'start', '开始'], failed)
|
|
self.assertEqual(code, 1)
|
|
self.assertEqual(len(calls), 3)
|
|
self.assertEqual(self.sleeps, [10, 10])
|
|
|
|
def test_dns_then_success(self):
|
|
responses = iter([reply(code=1, error='lookup api.dingtalk.com: no such host'), reply(OK)])
|
|
self.assertEqual(self.execute(['progress', 'start', '开始'], lambda *a, **kw: next(responses))[0], 0)
|
|
self.assertEqual(self.sleeps, [10])
|
|
|
|
def test_failure_and_unknown_never_retry(self):
|
|
cases = [reply(code=1, error='fake-secret connection reset'),
|
|
reply(code=1, error=json.dumps({'error': {'category': 'auth', 'message': 'fake-secret'}})),
|
|
reply(code=1, error=json.dumps({'error': {'retryable': False, 'message': 'lookup api.dingtalk.com: no such host'}})),
|
|
reply({'success': True}), reply({'ok': False}), reply(OK | {'partial': True})]
|
|
for result in cases:
|
|
count = []
|
|
def failed(*a, **kw):
|
|
count.append(1)
|
|
return result
|
|
code, data = self.execute(['progress', 'start', '开始'], failed)
|
|
self.assertEqual(code, 1)
|
|
self.assertEqual(len(count), 1)
|
|
self.assertNotIn('fake-secret', json.dumps(data))
|
|
self.assertFalse(self.sleeps)
|
|
|
|
def test_receipt_wins_nonzero_exit(self):
|
|
self.assertEqual(self.execute(['progress', 'start', '开始'], lambda *a, **kw: reply(OK, code=1))[0], 0)
|
|
|
|
def test_wrong_receipts_rejected(self):
|
|
for bad in [OK | {'identity': 'bot'}, OK | {'tool': 'other'}, OK | {'failures': [1]},
|
|
OK | {'result': {'success': False, 'result': {'openTaskId': 'x'}}},
|
|
OK | {'result': {'success': True, 'result': {'openTaskId': ''}}}]:
|
|
self.assertIsNone(n.accepted_task(bad))
|
|
|
|
def test_timeout_and_launch_error_no_retry(self):
|
|
for error in [subprocess.TimeoutExpired('fake', 60), OSError('fake-secret')]:
|
|
calls = []
|
|
def fail(*a, **kw):
|
|
calls.append(1)
|
|
raise error
|
|
code, _ = self.execute(['progress', 'start', '开始'], fail)
|
|
self.assertEqual(code, 1)
|
|
self.assertEqual(len(calls), 1)
|
|
|
|
def test_no_app_credentials_in_child_environment(self):
|
|
with patch.dict(os.environ, {'DWS_CLIENT_SECRET': 'fake-secret', 'DWS_CLIENT_ID': 'fake-app', 'DING_SEC': 'fake-key'}):
|
|
self.execute(['progress', 'start', '开始'])
|
|
env = self.calls[-1][1]['env']
|
|
self.assertNotIn('DWS_CLIENT_SECRET', env)
|
|
self.assertNotIn('DWS_CLIENT_ID', env)
|
|
self.assertNotIn('DING_SEC', env)
|
|
self.assertEqual(self.calls[-1][1]['timeout'], 60)
|
|
|
|
def init_args(self):
|
|
return ['init', '--profile', PROFILE, '--group', GROUP, '--project', '项目甲']
|
|
|
|
def test_init_requires_confirmation_and_explicit_replace(self):
|
|
original = self.path.read_bytes()
|
|
self.assertEqual(self.execute(self.init_args())[0], 1)
|
|
self.assertEqual(self.execute(self.init_args() + ['--yes'])[0], 1)
|
|
self.assertEqual(self.path.read_bytes(), original)
|
|
self.assertFalse(self.calls)
|
|
|
|
def test_init_creates_minimal_config_no_send(self):
|
|
self.path.unlink()
|
|
code, data = self.execute(self.init_args() + ['--yes'])
|
|
self.assertEqual(code, 0)
|
|
self.assertEqual(data['status'], 'initialized')
|
|
self.assertEqual(set(n.read_config(self.root)), n.KEYS)
|
|
self.assertNotIn(b'\r', self.path.read_bytes())
|
|
self.assertEqual(len(self.calls), 2)
|
|
if os.name != 'nt':
|
|
self.assertEqual(self.path.stat().st_mode & 0o777, 0o600)
|
|
|
|
def test_init_replaces_legacy_without_retaining_secret(self):
|
|
self.path.write_text('DINGTALK_PROGRESS_CLIENT_SECRET=fake-secret\n', encoding='utf-8')
|
|
code, data = self.execute(self.init_args() + ['--yes', '--replace'])
|
|
self.assertEqual(code, 0)
|
|
self.assertNotIn('fake-secret', self.path.read_text())
|
|
self.assertNotIn('fake-secret', json.dumps(data))
|
|
self.assertEqual(len(list(self.path.parent.iterdir())), 1)
|
|
|
|
def test_init_bad_profile_and_line_injection_leave_config(self):
|
|
before = self.path.read_bytes()
|
|
for args in [self.init_args() + ['--profile', 'other:user'],
|
|
self.init_args() + ['--project', 'x\nBAD=1']]:
|
|
self.assertEqual(self.execute(args + ['--yes', '--replace'])[0], 1)
|
|
self.assertEqual(self.path.read_bytes(), before)
|
|
|
|
def test_init_failed_auth_leaves_old_config(self):
|
|
before = self.path.read_bytes()
|
|
def fake(command, **kw):
|
|
if command[1] == 'auth':
|
|
return reply({'authenticated': False})
|
|
return self.fake(command, **kw)
|
|
self.assertEqual(self.execute(self.init_args() + ['--yes', '--replace'], fake)[0], 1)
|
|
self.assertEqual(self.path.read_bytes(), before)
|
|
|
|
def test_encoded_powershell_arguments(self):
|
|
args = ['message', '--title', '中文 "标题"', '--text', '**进展**\n\n- 引号 "$()"', '--dry-run']
|
|
with patch.dict(os.environ, {n.PREFIX + 'ARGV_B64': base64.b64encode(json.dumps(args).encode()).decode()}):
|
|
code, data = self.execute(None)
|
|
self.assertEqual(code, 0)
|
|
self.assertIn('中文 "标题"', data['text'])
|
|
self.assertIn('**进展**\n\n- 引号 "$()"', data['text'])
|
|
|
|
def test_bad_encoded_arguments(self):
|
|
for value in ['!!!', base64.b64encode(b'{}').decode()]:
|
|
with patch.dict(os.environ, {n.PREFIX + 'ARGV_B64': value}):
|
|
self.assertEqual(self.execute(None)[0], 1)
|
|
self.assertFalse(self.calls)
|
|
|
|
def test_hardlinked_config_cannot_be_message_body(self):
|
|
other = self.root / 'not-a-summary.md'
|
|
os.link(self.path, other)
|
|
self.assertEqual(self.execute(['progress', 'start', '--file', str(other), '--dry-run'])[0], 1)
|
|
|
|
def test_real_powershell_launcher_in_isolated_project(self):
|
|
pwsh = shutil.which('pwsh')
|
|
if not pwsh or 'windowsapps' in sys.executable.lower():
|
|
self.skipTest('需要 PowerShell 和独立 Python,不能用商店版作启动器验收')
|
|
folder = self.root / 'scripts'
|
|
folder.mkdir()
|
|
source = Path(n.__file__).parent
|
|
for name in ['dingtalk_notify.py', 'dingtalk-python.ps1', 'dingtalk-progress.ps1', 'dingtalk-message.ps1']:
|
|
shutil.copyfile(source / name, folder / name)
|
|
env = n.clean_env()
|
|
env['DINGTALK_PYTHON'] = sys.executable
|
|
text = '**中文**\n\n- "引号" $() `原样`'
|
|
for script, args in [('dingtalk-progress.ps1', ['milestone', text]),
|
|
('dingtalk-message.ps1', ['--title', '中文 "标题"', '--text', text])]:
|
|
result = subprocess.run([pwsh, '-NoProfile', '-File', str(folder / script), *args, '--dry-run'],
|
|
cwd=self.root, env=env, capture_output=True, encoding='utf-8', timeout=30)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
data = json.loads(result.stdout)
|
|
self.assertEqual(data['status'], 'preview')
|
|
self.assertIn(text, data['text'])
|
|
result = subprocess.run([pwsh, '-NoProfile', '-File', str(folder / 'dingtalk-python.ps1'), 'init',
|
|
'--profile', PROFILE, '--group', GROUP, '--project', '甲'],
|
|
cwd=self.root, env=env, capture_output=True, encoding='utf-8', timeout=30)
|
|
self.assertEqual(result.returncode, 1)
|
|
self.assertIn('先向负责人确认', result.stdout)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|