286 lines
17 KiB
Python
286 lines
17 KiB
Python
"""My Customers recipient tests: synthetic databases, no native calls or sends."""
|
|
import contextlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import sqlite3
|
|
import tempfile
|
|
import unittest
|
|
from unittest import mock
|
|
|
|
import review_assistant_contacts as contacts
|
|
|
|
ACCOUNT = '168800000000001'
|
|
ACTIVE = {'ready': True, 'account_id': ACCOUNT, 'account_name': 'Test', 'reason': ''}
|
|
S_TARGET = f'S:{ACCOUNT}_101'
|
|
|
|
|
|
class ContactDirectoryTest(unittest.TestCase):
|
|
def setUp(self):
|
|
self.temp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self.temp.cleanup)
|
|
self.root = Path(self.temp.name)
|
|
self.folder = self.root / ACCOUNT
|
|
self.folder.mkdir()
|
|
self.sql('message.db', '''CREATE TABLE message_table(conversation_id TEXT,send_time REAL,content TEXT);
|
|
INSERT INTO message_table VALUES('S:168800000000001_101',1700000000,'MESSAGE_BODY_NOT_READ');
|
|
INSERT INTO message_table VALUES('M:102',1700000020,'SECOND_BODY_NOT_READ');
|
|
INSERT INTO message_table VALUES('M:999',1700000030,'private_without_customer_relation');
|
|
INSERT INTO message_table VALUES('S:168800000000001_888',1700000040,'internal');
|
|
INSERT INTO message_table VALUES('S:101_222',1700000050,'other_account');
|
|
INSERT INTO message_table VALUES('R:101',1700000060,'group');''')
|
|
self.sql('user.db', '''CREATE TABLE user_table(id TEXT PRIMARY KEY,name TEXT,real_name TEXT,account TEXT,corp_id INTEGER,mobile TEXT);
|
|
INSERT INTO user_table VALUES('168800000000001','托管账号','','',10,'');
|
|
INSERT INTO user_table VALUES('101','助理昵称','真实名字','',20,'PHONE_NOT_READ');
|
|
INSERT INTO user_table VALUES('102','同名助理','','',30,'');
|
|
INSERT INTO user_table VALUES('103','同名助理','','',30,'');
|
|
INSERT INTO user_table VALUES('888','内部同事','','',10,'');
|
|
CREATE TABLE external_user_relation_v3(user_id TEXT PRIMARY KEY,status INTEGER,stranger_type INTEGER,add_customer_time INTEGER,remarks TEXT,real_remarks TEXT);
|
|
INSERT INTO external_user_relation_v3 VALUES('101',2049,0,1600000000,'助理备注','真实备注');
|
|
INSERT INTO external_user_relation_v3 VALUES('102',2057,0,1600000000,'','');
|
|
INSERT INTO external_user_relation_v3 VALUES('103',2049,0,1600000000,'','');
|
|
CREATE TABLE wechat_contactV1(wxid TEXT,gene INTEGER,name TEXT);
|
|
INSERT INTO wechat_contactV1 VALUES('wxid_one',101,'手机通讯录姓名');
|
|
INSERT INTO wechat_contactV1 VALUES('wxid_two',999,'手机通讯录姓名');
|
|
CREATE TABLE wx_friend(user_id TEXT,nick_name TEXT,wx_id TEXT);
|
|
INSERT INTO wx_friend VALUES('999','私人好友','wxid_two');
|
|
CREATE TABLE external_user_ids(value INTEGER);
|
|
INSERT INTO external_user_ids VALUES(999);
|
|
CREATE TABLE delete_external_userV1(user_id TEXT,status INTEGER);
|
|
CREATE TABLE blacklist_external_userids(value TEXT);
|
|
CREATE TABLE user_dept_tableV2(user_id TEXT);
|
|
INSERT INTO user_dept_tableV2 VALUES('888');''')
|
|
self.sql('session.db', '''CREATE TABLE conversation_table(id TEXT,name TEXT,roomname_remark TEXT,is_blocked INTEGER);
|
|
INSERT INTO conversation_table VALUES('S:168800000000001_101','旧会话名','旧会话备注',0);''')
|
|
self.patch = mock.patch.object(contacts, 'active_context', return_value=ACTIVE)
|
|
self.identity = self.patch.start()
|
|
self.addCleanup(self.patch.stop)
|
|
contacts._CACHE.clear()
|
|
|
|
def sql(self, filename, sql):
|
|
with contextlib.closing(sqlite3.connect(self.folder / filename)) as db, db:
|
|
db.executescript(sql)
|
|
|
|
def write_user(self, sql):
|
|
self.sql('user.db', sql)
|
|
|
|
def listing(self, *args, **kwargs):
|
|
return contacts.list_recipients(ACCOUNT, *args, cache_roots=[self.root], **kwargs)
|
|
|
|
def validate(self, target):
|
|
return contacts.validate_recipient(ACCOUNT, target, cache_roots=[self.root])
|
|
|
|
def test_only_my_customer_relationships_are_listed_not_phone_contacts_or_arbitrary_chats(self):
|
|
result = self.listing()
|
|
self.assertTrue(result['ready'], result)
|
|
self.assertEqual((result['total'], result['selectable_total'], result['contact_total']), (3, 2, 1))
|
|
self.assertEqual({i['id'] for i in result['items']}, {S_TARGET, 'M:102', 'customer:103'})
|
|
self.assertNotIn('MESSAGE_BODY_NOT_READ', json.dumps(result))
|
|
self.assertEqual(self.listing('手机通讯录')['total'], 0)
|
|
self.assertEqual(self.listing('私人好友')['total'], 0)
|
|
self.assertFalse(self.validate('M:999')['ok'])
|
|
|
|
def test_existing_wecom_and_wechat_targets_keep_exact_identity_and_kind(self):
|
|
for target, peer, kind in [(S_TARGET, '101', 'wecom_customer'), ('M:102', '102', 'wechat_customer')]:
|
|
result = self.validate(target)
|
|
self.assertTrue(result['ok'], result)
|
|
self.assertEqual(result['recipient']['id'], target)
|
|
self.assertEqual(result['recipient']['peer_id'], peer)
|
|
self.assertEqual(result['recipient']['recipient_kind'], kind)
|
|
|
|
def test_both_s_id_orders_are_supported_but_no_address_is_synthesized(self):
|
|
reverse = f'S:101_{ACCOUNT}'
|
|
self.sql('message.db', f"UPDATE message_table SET conversation_id='{reverse}' WHERE conversation_id='{S_TARGET}';")
|
|
self.assertTrue(self.validate(reverse)['ok'])
|
|
self.assertFalse(self.validate(S_TARGET)['ok'])
|
|
self.assertFalse(self.validate('M:101')['ok'])
|
|
self.assertFalse(self.validate(f'S:{ACCOUNT}_103')['ok'])
|
|
|
|
def test_no_conversation_customer_is_visible_but_cannot_be_selected(self):
|
|
item = self.listing('103')['items'][0]
|
|
self.assertEqual(item['id'], 'customer:103')
|
|
self.assertFalse(item['selectable'])
|
|
self.assertIn('建立单聊', item['unavailableReason'])
|
|
self.assertFalse(self.validate(item['id'])['ok'])
|
|
|
|
def test_customer_remarks_precede_true_remarks_and_user_names_and_all_are_searchable(self):
|
|
for query in ['助理备注', '真实备注', '助理昵称', '真实名字', S_TARGET, '101']:
|
|
result = self.listing(query)
|
|
self.assertEqual(result['items'][0]['id'], S_TARGET, query)
|
|
self.assertEqual(result['items'][0]['name'], '助理备注')
|
|
self.assertEqual(self.listing('旧会话')['total'], 0)
|
|
self.write_user("UPDATE external_user_relation_v3 SET remarks='' WHERE user_id='101';")
|
|
self.assertEqual(self.listing('101')['items'][0]['name'], '真实备注')
|
|
self.write_user("UPDATE external_user_relation_v3 SET real_remarks='' WHERE user_id='101';")
|
|
self.assertEqual(self.listing('101')['items'][0]['name'], '助理昵称')
|
|
|
|
def test_same_name_customers_remain_separate_and_pagination_is_after_filter(self):
|
|
first = self.listing('同名助理', limit=1)
|
|
second = self.listing('同名助理', limit=1, offset=1)
|
|
self.assertEqual(first['total'], 2)
|
|
self.assertTrue(first['has_more'])
|
|
self.assertNotEqual(first['items'][0]['id'], second['items'][0]['id'])
|
|
self.assertFalse(second['has_more'])
|
|
pending = self.listing('同名助理', status='directory', limit=1)
|
|
self.assertEqual(pending['total'], 1)
|
|
self.assertEqual(pending['all_total'], 2)
|
|
self.assertEqual(pending['items'][0]['peer_id'], '103')
|
|
|
|
def test_unknown_relation_status_is_visible_but_cannot_be_selected(self):
|
|
for value in (0, 1, 2048, 2059, -1, 'unknown'):
|
|
with self.subTest(status=value):
|
|
with contextlib.closing(sqlite3.connect(self.folder / 'user.db')) as db, db:
|
|
db.execute('UPDATE external_user_relation_v3 SET status=? WHERE user_id=?', (value, '101'))
|
|
item = self.listing('101')['items'][0]
|
|
self.assertEqual(item['id'], 'customer:101')
|
|
self.assertFalse(item['selectable'])
|
|
self.assertIn('状态尚未核验', item['unavailableReason'])
|
|
self.assertFalse(self.validate(S_TARGET)['ok'])
|
|
|
|
def test_deleted_blacklisted_and_internal_users_are_excluded(self):
|
|
for table, sql in [('delete_external_userV1', "INSERT INTO delete_external_userV1 VALUES('101',0);"),
|
|
('blacklist_external_userids', "INSERT INTO blacklist_external_userids VALUES('101');"),
|
|
('user_dept_tableV2', "INSERT INTO user_dept_tableV2 VALUES('101');")]:
|
|
with self.subTest(table=table):
|
|
self.write_user(sql)
|
|
self.assertEqual(self.listing('101')['total'], 0)
|
|
self.assertFalse(self.validate(S_TARGET)['ok'])
|
|
self.write_user(f"DELETE FROM {table} WHERE {'value' if table=='blacklist_external_userids' else 'user_id'}='101';")
|
|
self.write_user("UPDATE user_table SET corp_id=10 WHERE id='101';")
|
|
self.assertEqual(self.listing('101')['total'], 0)
|
|
|
|
def test_strangers_unadded_self_and_invalid_uids_are_not_my_customers(self):
|
|
self.write_user("UPDATE external_user_relation_v3 SET stranger_type=1 WHERE user_id='101';UPDATE external_user_relation_v3 SET add_customer_time=0 WHERE user_id='102';")
|
|
self.write_user(f"INSERT INTO external_user_relation_v3 VALUES('{ACCOUNT}',2049,0,1,'本人','');INSERT INTO external_user_relation_v3 VALUES('invalid',2049,0,1,'非法','');")
|
|
self.assertEqual({i['peer_id'] for i in self.listing()['items']}, {'103'})
|
|
self.assertFalse(self.validate(S_TARGET)['ok'])
|
|
|
|
def test_system_customer_names_and_session_metadata_cannot_enable_sends(self):
|
|
for name in ('企小码会话管理', '企业微信团队', '微信团队'):
|
|
self.write_user(f"UPDATE external_user_relation_v3 SET remarks='{name}' WHERE user_id='101';")
|
|
self.assertEqual(self.listing('101')['total'], 0)
|
|
self.write_user("UPDATE external_user_relation_v3 SET remarks='正常助理' WHERE user_id='101';")
|
|
self.sql('session.db', "UPDATE conversation_table SET name='企小码会话管理';")
|
|
self.assertFalse(self.listing('101')['items'][0]['selectable'])
|
|
self.assertFalse(self.validate(S_TARGET)['ok'])
|
|
|
|
def test_blocked_session_is_not_a_selectable_target(self):
|
|
self.sql('session.db', 'UPDATE conversation_table SET is_blocked=1;')
|
|
self.assertFalse(self.listing('101')['items'][0]['selectable'])
|
|
self.assertFalse(self.validate(S_TARGET)['ok'])
|
|
|
|
def test_unknown_name_does_not_become_fake_nickname_or_raw_s_identifier(self):
|
|
self.write_user("UPDATE external_user_relation_v3 SET remarks='',real_remarks='' WHERE user_id='101';UPDATE user_table SET name='',real_name='' WHERE id='101';")
|
|
item = self.listing('101')['items'][0]
|
|
self.assertFalse(item['nameKnown'])
|
|
self.assertEqual(item['displayName'], '未读取昵称 · 尾号 101')
|
|
self.assertEqual(item['id'], S_TARGET)
|
|
|
|
def test_missing_or_incomplete_customer_relation_table_fails_closed(self):
|
|
self.write_user('DROP TABLE external_user_relation_v3;')
|
|
for setup in ('', 'CREATE TABLE external_user_relation_v3(user_id TEXT,remarks TEXT);'):
|
|
if setup: self.write_user(setup)
|
|
result = self.listing()
|
|
self.assertFalse(result['ready'])
|
|
self.assertIn('关系表缺失或不完整', result['reason'])
|
|
self.assertEqual(result['items'], [])
|
|
self.assertFalse(self.validate(S_TARGET)['ok'])
|
|
|
|
def test_missing_user_database_returns_clear_failure_not_keyerror(self):
|
|
(self.folder / 'user.db').unlink()
|
|
result = self.listing()
|
|
self.assertFalse(result['ready'])
|
|
self.assertIn('客户关系库尚不可读', result['reason'])
|
|
self.assertFalse(self.validate(S_TARGET)['ok'])
|
|
|
|
def test_no_message_cache_lists_customers_pending_without_fake_targets(self):
|
|
(self.folder / 'message.db').unlink()
|
|
result = self.listing()
|
|
self.assertTrue(result['ready'])
|
|
self.assertEqual(result['selectable_total'], 0)
|
|
self.assertEqual(result['total'], 3)
|
|
self.assertFalse(self.validate(S_TARGET)['ok'])
|
|
|
|
def test_absent_optional_phone_tables_do_not_affect_customer_list(self):
|
|
self.write_user('DROP TABLE wechat_contactV1;DROP TABLE wx_friend;DROP TABLE external_user_ids;')
|
|
self.assertEqual(self.listing()['total'], 3)
|
|
self.assertTrue(self.validate(S_TARGET)['ok'])
|
|
|
|
def test_fresh_validation_bypasses_cached_membership_and_rechecks_account(self):
|
|
with mock.patch.object(contacts, '_revision', return_value=('fixed',)):
|
|
self.assertTrue(self.validate(S_TARGET)['ok'])
|
|
self.write_user("DELETE FROM external_user_relation_v3 WHERE user_id='101';")
|
|
self.assertFalse(self.validate(S_TARGET)['ok'])
|
|
self.identity.side_effect = [ACTIVE, {**ACTIVE, 'account_id': 'another-account'}]
|
|
self.assertFalse(self.validate('M:102')['ok'])
|
|
|
|
def test_refresh_invalidates_cache_for_new_customer_relationship(self):
|
|
self.assertEqual(self.listing()['total'], 3)
|
|
self.write_user("INSERT INTO external_user_relation_v3 VALUES('104',2049,0,1,'新客户','');")
|
|
self.assertEqual(self.listing('新客户')['items'][0]['id'], 'customer:104')
|
|
|
|
def test_wal_change_invalidates_customer_relationship_cache(self):
|
|
with contextlib.closing(sqlite3.connect(self.folder / 'user.db')) as db:
|
|
db.execute('PRAGMA journal_mode=WAL')
|
|
db.execute('PRAGMA wal_autocheckpoint=0')
|
|
self.assertEqual(self.listing()['total'], 3)
|
|
db.execute("UPDATE external_user_relation_v3 SET status=0 WHERE user_id='101'")
|
|
db.commit()
|
|
self.assertFalse(self.listing('101')['items'][0]['selectable'])
|
|
self.assertFalse(self.validate(S_TARGET)['ok'])
|
|
|
|
def test_latest_wal_revision_wins_between_account_caches(self):
|
|
other = self.root / 'other' / ACCOUNT
|
|
other.mkdir(parents=True)
|
|
shutil.copyfile(self.folder / 'message.db', other / 'message.db')
|
|
shutil.copyfile(self.folder / 'user.db', other / 'user.db')
|
|
os.utime(self.folder / 'user.db', ns=(100, 100))
|
|
os.utime(other / 'user.db', ns=(200, 200))
|
|
wal = Path(str(self.folder / 'user.db') + '-wal')
|
|
wal.write_bytes(b'file-time-only')
|
|
os.utime(wal, ns=(300, 300))
|
|
self.assertEqual(contacts._databases(ACCOUNT, [self.root, self.root / 'other'])['user.db'], self.folder / 'user.db')
|
|
|
|
def test_default_roots_include_protocol_cache_before_archived_copy(self):
|
|
with mock.patch.object(contacts, 'application_data_dir', return_value=self.root):
|
|
self.assertEqual(contacts._roots()[0], self.root / 'wxwork_reply_cache')
|
|
|
|
def test_other_account_directory_is_never_read(self):
|
|
self.identity.return_value = {**ACTIVE, 'account_id': '168800000000002'}
|
|
with mock.patch.object(contacts, '_contacts') as read:
|
|
self.assertFalse(self.listing()['ready'])
|
|
read.assert_not_called()
|
|
|
|
def test_lookup_does_not_change_database_files_or_read_sensitive_columns(self):
|
|
before = {p.name: p.read_bytes() for p in self.folder.glob('*.db')}
|
|
original = contacts._open
|
|
def guarded(path):
|
|
db = original(path)
|
|
def authorize(operation, table, field, database, source):
|
|
if operation == sqlite3.SQLITE_READ and (field in {'content', 'mobile', 'phone', 'pb_content'} or table in {'wechat_contactV1', 'wx_friend'}):
|
|
return sqlite3.SQLITE_DENY
|
|
return sqlite3.SQLITE_OK
|
|
db.set_authorizer(authorize)
|
|
return db
|
|
with mock.patch.object(contacts, '_open', side_effect=guarded):
|
|
self.assertTrue(self.listing()['ready'])
|
|
self.assertTrue(self.validate(S_TARGET)['ok'])
|
|
self.assertEqual(before, {p.name: p.read_bytes() for p in self.folder.glob('*.db')})
|
|
|
|
|
|
class RecipientPeerTest(unittest.TestCase):
|
|
def test_only_exact_account_bound_direct_ids_are_accepted(self):
|
|
self.assertEqual(contacts.recipient_peer(ACCOUNT, 'M:101'), '101')
|
|
self.assertEqual(contacts.recipient_peer(ACCOUNT, S_TARGET), '101')
|
|
self.assertEqual(contacts.recipient_peer(ACCOUNT, f'S:101_{ACCOUNT}'), '101')
|
|
for value in ('', ' M:101', 'M:101 ', 'M:123', 'M:abc', 'M:1_2', f'M:{ACCOUNT}',
|
|
f'S:{ACCOUNT}_{ACCOUNT}', 'S:101_102', f'S:{ACCOUNT}_101_102',
|
|
f'S:{ACCOUNT}_', 'R:101', 'O:101', 'Y:101', 'APPROVAL', 'customer:101'):
|
|
with self.subTest(value=value):
|
|
self.assertEqual(contacts.recipient_peer(ACCOUNT, value), '')
|
|
self.assertEqual(contacts.recipient_peer('', 'M:101'), '')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main() |