release: v1.0.3 统一个人 DWS 通知与简化初始化配置

This commit is contained in:
chenmingxuan committed 2026-09-10 17:14:23 +08:00
1 parent a08cfd6a3b
commit 20c0de9b38
17 files changed
+670 -693

No files matched your search

+277 -285
View File
@@ -1,4 +1,5 @@
"""Offline tests: fake configs, fake credentials, fake DWS. Never use live config."""
"""Offline regression tests. All DWS calls use isolated fake responses."""
import base64
import contextlib
import io
import json
@@ -9,314 +10,305 @@ import subprocess
import sys
import tempfile
import unittest
from unittest import mock
from unittest.mock import patch
import dingtalk_notify as notify
import dingtalk_notify as n
DNS = 'lookup api.dingtalk.com on 127.0.0.1:53: i/o timeout'
OK = '{"processQueryKey":"test-receipt"}'
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 response(code=0, stdout=OK, stderr=''):
return subprocess.CompletedProcess(['fake-dws'], code, stdout, stderr)
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(prefix='通知测试 with spaces ')
self.temp = tempfile.TemporaryDirectory()
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)
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 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 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 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 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 entries(self):
return [['progress', 'milestone', '**完成**\n\n- 内容'],
['message', '--title', '负责人提醒', '--text', '**请确认**\n\n- 内容']]
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_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)])
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)
text = json.loads(json.loads(run.call_args.kwargs['input'])['msgParam'])['text']
self.assertIn('**结果**\n\n- 已确认 😀', text)
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_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'])
def test_message_mentions_exact_name_fixed_profile(self):
code, data = self.execute(['message', '--title', '提醒', '--text', '请补充资料', '--at', '测试人,测试人'])
self.assertEqual(code, 0)
self.assertEqual(json.loads(run.call_args.kwargs['input'])['openConversationId'], 'fake-other')
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_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])
def test_progress_can_include_mention_once(self):
code, _ = self.execute(['progress', 'blocked', '缺输入', '--at', '测试人'])
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'])
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)
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 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 transport(command, **kwargs):
return subprocess.run([sys.executable, str(fake), *command[1:]], **kwargs)
def test_check_bad_login_stops(self):
code, _ = self.execute(['check'], lambda *a, **kw: reply({'authenticated': False, 'token_valid': False}))
self.assertEqual(code, 1)
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_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_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'])
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__':