release: v1.0.2 跨平台钉钉通知与流程版本编号约定

This commit is contained in:
chenmingxuan committed 2026-09-09 11:52:17 +08:00
1 parent 4f9efd175d
commit a08cfd6a3b
17 files changed
+858 -290

No files matched your search

+323
View File
@@ -0,0 +1,323 @@
"""Offline tests: fake configs, fake credentials, fake DWS. Never use live config."""
import contextlib
import io
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
from unittest import mock
import dingtalk_notify as notify
DNS = 'lookup api.dingtalk.com on 127.0.0.1:53: i/o timeout'
OK = '{"processQueryKey":"test-receipt"}'
def response(code=0, stdout=OK, stderr=''):
return subprocess.CompletedProcess(['fake-dws'], code, stdout, stderr)
class NotifyTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(prefix='通知测试 with spaces ')
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
(self.root / '.dingtalk').mkdir()
self.config = self.root / '.dingtalk/config.env'
self.configure()
self.environment = mock.patch.dict(os.environ, {'DING_SEC': 'ignored-env-secret',
'DWS_CLIENT_SECRET': 'ignored-dws-secret'}, clear=True)
self.environment.start()
self.addCleanup(self.environment.stop)
self.locator = mock.patch.object(notify, 'dws_path', return_value='fake-dws')
self.locator.start()
self.addCleanup(self.locator.stop)
def configure(self, **updates):
values = dict(ENABLED='1', DRY_RUN='0', MODE='app-bot', CLIENT_ID='fake-client',
TARGET='fake-group', ROBOT_CODE='fake-robot', PROJECT='离线测试',
PROFILE='fake-corp:fake-user', CLIENT_SECRET='fake-secret')
values.update(updates)
self.config.write_text('\n'.join(notify.PREFIX + k + '=' + v for k, v in values.items()),
encoding='utf-8')
self.config.chmod(0o600)
def call(self, args, responses=None):
run = mock.Mock(side_effect=responses or [response()])
sleep = mock.Mock()
output, errors = io.StringIO(), io.StringIO()
with contextlib.redirect_stdout(output), contextlib.redirect_stderr(errors):
code = notify.main(args, root=self.root, run=run, sleep=sleep)
return code, json.loads(output.getvalue()), run, sleep, errors.getvalue()
def entries(self):
return [['progress', 'milestone', '**完成**\n\n- 内容'],
['message', '--title', '负责人提醒', '--text', '**请确认**\n\n- 内容']]
def test_events(self):
for event, label in notify.EVENTS.items():
with self.subTest(event=event):
code, data, run, _, _ = self.call(['progress', event, '中文摘要'])
self.assertEqual((code, data['status']), (0, 'accepted'))
payload = json.loads(run.call_args.kwargs['input'])
self.assertIn(label, json.loads(payload['msgParam'])['title'])
def test_payload_and_credentials_not_on_argv(self):
text = '**结果**\n\n- "中文" 😀 `代码` $变量 & <内容> \\路径'
code, data, run, _, _ = self.call(['message', '--title', '摘要', '--text', text])
self.assertEqual(code, 0)
command = run.call_args.args[0]
kwargs = run.call_args.kwargs
payload = json.loads(kwargs['input'])
self.assertEqual(json.loads(payload['msgParam'])['text'], text)
self.assertEqual(payload['openConversationId'], 'fake-group')
self.assertEqual(payload['msgKey'], 'sampleMarkdown')
self.assertEqual(command[command.index('--data') + 1], '-')
self.assertNotIn('fake-secret', str(command))
self.assertNotIn('fake-group', str(command))
self.assertEqual(kwargs['env']['DWS_CLIENT_SECRET'], 'fake-secret')
self.assertNotIn('DING_SEC', kwargs['env'])
self.assertNotIn('fake-secret', json.dumps(data))
def test_dns_retries_both_entries(self):
for args in self.entries():
with self.subTest(args=args):
code, data, run, sleep, _ = self.call(args, [response(1, '', DNS)] * 3)
self.assertEqual((code, data['status'], run.call_count), (1, 'error', 3))
self.assertEqual(sleep.call_args_list, [mock.call(10), mock.call(10)])
def test_transient_dns_success_stops(self):
for args in self.entries():
code, data, run, sleep, _ = self.call(args, [response(1, '', DNS), response()])
self.assertEqual((code, data['status'], run.call_count, sleep.call_count), (0, 'accepted', 2, 1))
def test_ambiguous_and_auth_errors_never_replayed_or_echoed(self):
for args in self.entries():
for error in ['i/o timeout', 'EOF', 'HTTP 503', 'permission denied', 'fake-secret App Token']:
with self.subTest(error=error):
code, data, run, sleep, stderr = self.call(args, [response(1, '', error)])
self.assertEqual((code, run.call_count, sleep.call_count), (1, 1, 0))
self.assertNotIn(error, json.dumps(data, ensure_ascii=False) + stderr)
def test_receipt_wins_even_nonzero(self):
for args in self.entries():
code, data, run, sleep, _ = self.call(args, [response(1, OK, DNS)])
self.assertEqual((code, data['status'], run.call_count, sleep.call_count), (0, 'accepted', 1, 0))
def test_bad_receipts_do_not_replay(self):
for raw in ['{}', '{"processQueryKey":" "}', '{"processQueryKey":123}', 'not json', '[]']:
code, _, run, sleep, _ = self.call(self.entries()[0], [response(0, raw)])
self.assertEqual((code, run.call_count, sleep.call_count), (1, 1, 0))
def test_timeout_no_replay(self):
code, data, run, sleep, _ = self.call(self.entries()[0], [subprocess.TimeoutExpired('fake', 60)])
self.assertEqual((code, run.call_count, sleep.call_count), (1, 1, 0))
self.assertIn('结果未知', data['message'])
self.assertEqual(run.call_args.kwargs['timeout'], 60)
def test_startup_failure_no_replay(self):
code, _, run, sleep, _ = self.call(self.entries()[0], [OSError('private machine detail')])
self.assertEqual((code, run.call_count, sleep.call_count), (1, 1, 0))
def test_missing_credentials_no_commands(self):
self.configure(CLIENT_SECRET='')
code, data, run, _, _ = self.call(self.entries()[0])
self.assertEqual((code, run.call_count), (1, 0))
self.assertIn('凭据', data['message'])
def test_skipped_no_tools(self):
self.configure(ENABLED='0')
for args in self.entries():
code, data, run, _, _ = self.call(args)
self.assertEqual((code, data['status'], run.call_count), (0, 'skipped', 0))
self.config.unlink()
self.assertEqual(self.call(self.entries()[0])[1]['status'], 'skipped')
def test_preview_never_uses_or_prints_secret_or_resolves_people(self):
for configured in [False, True]:
self.configure(DRY_RUN='1' if configured else '0')
for args in self.entries():
if args[0] == 'message':
args += ['--at', '完整姓名']
if not configured:
args += ['--dry-run']
with mock.patch.object(notify, 'secret_for', side_effect=AssertionError('secret accessed')):
code, data, run, _, _ = self.call(args)
self.assertEqual((code, data['status'], run.call_count), (0, 'preview', 0))
self.assertNotIn('fake-group', str(data))
self.assertNotIn('fake-secret', str(data))
def test_preview_without_secret(self):
self.configure(CLIENT_SECRET='')
code, data, run, _, _ = self.call(self.entries()[0] + ['--dry-run'])
self.assertEqual((code, data['status'], run.call_count), (0, 'preview', 0))
def test_config_cannot_be_used_as_body_even_in_preview(self):
for args in [['progress', 'milestone'], ['message', '--title', '摘要']]:
for flags in [[], ['--dry-run']]:
code, data, run, _, _ = self.call(args + ['--file', str(self.config)] + flags)
self.assertEqual((code, run.call_count), (1, 0))
self.assertNotIn('fake-secret', str(data))
def test_preview_without_config(self):
self.config.unlink()
self.assertEqual(self.call(self.entries()[0] + ['--dry-run'])[1]['status'], 'preview')
def test_utf8_bom_crlf_literal_config(self):
raw = self.config.read_text(encoding='utf-8') + '\nDINGTALK_PROGRESS_TITLE_PREFIX=$(not-executed)'
self.config.write_bytes(('\ufeff' + raw.replace('\n', '\r\n')).encode('utf-8'))
self.assertEqual(notify.read_config(self.root)['TITLE_PREFIX'], '$(not-executed)')
def test_invalid_config_safe_error(self):
for raw in ['DING_SEC=fake-secret', 'invalid fake-secret',
'DINGTALK_PROGRESS_ENABLED=1\nDINGTALK_PROGRESS_ENABLED=0',
'DINGTALK_PROGRESS_ENABLED=yes']:
self.config.write_text(raw, encoding='utf-8')
code, data, run, _, _ = self.call(self.entries()[0])
self.assertEqual((code, run.call_count), (1, 0))
self.assertNotIn('fake-secret', str(data))
def test_body_files_both_entries(self):
body = self.root / '中文 summary.md'
body.write_bytes('\ufeff**结果**\r\n\r\n- 已确认 😀'.encode('utf-8'))
for args in [['progress', 'milestone'], ['message', '--title', '摘要']]:
code, _, run, _, _ = self.call(args + ['--file', str(body)])
self.assertEqual(code, 0)
text = json.loads(json.loads(run.call_args.kwargs['input'])['msgParam'])['text']
self.assertIn('**结果**\n\n- 已确认 😀', text)
def test_empty_or_large_body_fails_before_tools(self):
for text in ['', ' ', '字' * 5000]:
code, _, run, _, _ = self.call(['message', '--title', '摘要', '--text', text])
self.assertEqual((code, run.call_count), (1, 0))
def test_exact_name_and_dedup_same_profile(self):
found = json.dumps({'result': [{'meta': {'name': '完整姓名'}, 'userId': 'fake-person'}]})
args = self.entries()[1] + ['--at', '完整姓名,完整姓名', '--at-id', '完整姓名=fake-person']
code, _, run, _, _ = self.call(args, [response(0, found), response()])
self.assertEqual((code, run.call_count), (0, 2))
for call in run.call_args_list:
command = call.args[0]
self.assertEqual(command[command.index('--profile') + 1], 'fake-corp:fake-user')
self.assertNotIn('DWS_CLIENT_SECRET', run.call_args_list[0].kwargs['env'])
message = json.loads(json.loads(run.call_args.kwargs['input'])['msgParam'])
self.assertEqual(message['atUserIds'], ['fake-person'])
self.assertTrue(message['text'].endswith('@fake-person'))
def test_names_zero_multiple_malformed_stop_before_send(self):
for rows in [[], [{'title': '完整姓名', 'userId': 'a'}, {'title': '完整姓名', 'userId': 'b'}],
[{'title': '别的姓名', 'userId': 'a'}], {}, [None]]:
code, _, run, sleep, _ = self.call(self.entries()[1] + ['--at', '完整姓名'],
[response(0, json.dumps({'result': rows}))])
self.assertEqual((code, run.call_count, sleep.call_count), (1, 1, 0))
def test_name_search_requires_explicit_profile(self):
self.configure(PROFILE='')
code, _, run, _, _ = self.call(self.entries()[1] + ['--at', '完整姓名'])
self.assertEqual((code, run.call_count), (1, 0))
def test_known_mapping_and_group_compatibility(self):
code, _, run, _, _ = self.call(self.entries()[1] + ['--at-id', '姓名=fake-id', '--group', 'fake-other'])
self.assertEqual(code, 0)
self.assertEqual(json.loads(run.call_args.kwargs['input'])['openConversationId'], 'fake-other')
def test_config_secret_is_only_source_on_all_platforms(self):
for platform in ['darwin', 'win32', 'linux']:
with self.subTest(platform=platform), mock.patch.object(sys, 'platform', platform):
self.assertEqual(notify.secret_for({'CLIENT_SECRET': 'fake-secret'}), 'fake-secret')
with self.assertRaises(notify.NoticeError):
notify.secret_for({})
def test_secret_special_characters_are_literal(self):
secret = 'fake=secret#literal$not-expanded'
self.configure(CLIENT_SECRET=secret)
code, data, run, _, _ = self.call(self.entries()[0])
self.assertEqual(code, 0)
self.assertEqual(run.call_args.kwargs['env']['DWS_CLIENT_SECRET'], secret)
self.assertNotIn(secret, str(data))
self.assertNotIn(secret, str(run.call_args.args))
self.assertNotIn(secret, run.call_args.kwargs['input'])
class LauncherTests(unittest.TestCase):
def test_real_subprocess_stdin_without_live_dws(self):
with tempfile.TemporaryDirectory(prefix='通知 fake transport ') as folder:
root = Path(folder)
(root / '.dingtalk').mkdir()
config = root / '.dingtalk/config.env'
config.write_text('DINGTALK_PROGRESS_ENABLED=1\nDINGTALK_PROGRESS_DRY_RUN=0\n'
'DINGTALK_PROGRESS_CLIENT_ID=fake-client\n'
'DINGTALK_PROGRESS_CLIENT_SECRET=fake-secret\n'
'DINGTALK_PROGRESS_TARGET=fake-group\n'
'DINGTALK_PROGRESS_ROBOT_CODE=fake-robot\n', encoding='utf-8')
config.chmod(0o600)
fake = root / 'fake dws.py'
fake.write_text(
'import json, os, sys\n'
'assert sys.argv[1:] == ["api", "POST", "/v1.0/robot/groupMessages/send", '
'"--data", "-", "--yes", "--format", "json"]\n'
'payload = json.loads(sys.stdin.buffer.read().decode("utf-8"))\n'
'assert payload["openConversationId"] == "fake-group"\n'
'assert json.loads(payload["msgParam"])["text"] == "中文\\n\\n- \\\"引号\\\" 😀"\n'
'assert os.environ["DWS_CLIENT_SECRET"] == "fake-secret"\n'
'assert "DING_SEC" not in os.environ\n'
'print(json.dumps({"processQueryKey": "fake-transport-receipt"}))\n', encoding='utf-8')
def transport(command, **kwargs):
return subprocess.run([sys.executable, str(fake), *command[1:]], **kwargs)
out = io.StringIO()
with mock.patch.object(notify, 'dws_path', return_value='fake-dws'), \
mock.patch.dict(os.environ, {'DING_SEC': 'ignored-env-secret'}), \
contextlib.redirect_stdout(out):
code = notify.main(['message', '--title', '摘要', '--text', '中文\n\n- "引号" 😀'],
root=root, run=transport)
self.assertEqual(code, 0, out.getvalue())
self.assertEqual(json.loads(out.getvalue())['status'], 'accepted')
def test_real_launchers_preview_in_isolated_copy(self):
"""Exercise OS processes/quoting, but copy no real config and never run dws."""
with tempfile.TemporaryDirectory(prefix='通知 launch with spaces ') as folder:
scripts = Path(folder) / 'scripts'
scripts.mkdir()
for name in ['dingtalk_notify.py', 'dingtalk-progress', 'dingtalk-bot-message',
'dingtalk-progress.ps1', 'dingtalk-bot-message.ps1']:
shutil.copy2(Path(__file__).parent / name, scripts / name)
text = '**结果**\n\n- "中文" 😀 `代码` $变量 & 内容'
body = Path(folder) / '中文正文.md'
body.write_text(text, encoding='utf-8')
env = {k: v for k, v in os.environ.items()
if not k.startswith(('DING', 'DWS_'))}
# Run from outside the script project: --file is cwd-relative.
commands = [[sys.executable, str(scripts / 'dingtalk_notify.py'), 'progress', 'milestone',
'--file', body.name, '--dry-run']]
pwsh = shutil.which('pwsh')
if os.name == 'nt':
self.assertIsNotNone(pwsh, 'Windows launcher test needs PowerShell 7.3+')
if pwsh:
commands += [[pwsh, '-NoProfile', '-File', str(scripts / 'dingtalk-progress.ps1'),
'milestone', text, '--dry-run'],
[pwsh, '-NoProfile', '-File', str(scripts / 'dingtalk-bot-message.ps1'),
'--title', '中文摘要', '--text', text, '--dry-run']]
bash = Path('C:/Program Files/Git/bin/bash.exe') if os.name == 'nt' else shutil.which('bash')
if bash and Path(bash).exists():
for name, args in [('dingtalk-progress', ['milestone']),
('dingtalk-bot-message', ['--title', '中文摘要'])]:
commands.append([str(bash), str(scripts / name), *args,
'--file', body.name, '--dry-run'])
for command in commands:
with self.subTest(entry=command[:3]):
result = subprocess.run(command, cwd=folder, env=env, capture_output=True,
text=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'])
if __name__ == '__main__':
unittest.main()