Add autonomous webhooks and dynamic WebView discovery
This commit is contained in:
1 parent
ab69f287b6
commit
97f874d183
29 files changed
+3044
-1489
No files matched your search
@@ -311,3 +311,141 @@ class DesktopCompatibilityTests(unittest.TestCase):
|
||||
result=desktop.check_core_build(path)
|
||||
self.assertTrue(result['matches_tested_configuration'])
|
||||
self.assertIsNone(result['warning'])
|
||||
|
||||
|
||||
class WebhookTests(unittest.TestCase):
|
||||
def test_listener_javascript_does_not_receive_transport_credentials(self):
|
||||
from whatsapp_suite import webhook
|
||||
config={'version':1,'endpoint':'https://example.test/events','token_env':'TEST_TOKEN',
|
||||
'protected_token':'synthetic-protected-credential','self_only':True,'since_ms':1,
|
||||
'listener_id':'91b17841-2106-4e73-97db-c38be6e9b269'}
|
||||
def run(pid,suite,_operation):
|
||||
report={'pid':1};_operation(None,None,report);return report
|
||||
with patch.object(webhook,'run_math',side_effect=run), \
|
||||
patch.object(webhook,'evaluate',return_value={'installed':True}) as evaluate, \
|
||||
patch.object(webhook,'native_control',return_value={'running':True}), \
|
||||
patch.object(webhook,'configuration_token',return_value='synthetic-token'):
|
||||
webhook.manage('install',config)
|
||||
script=evaluate.call_args.args[1]
|
||||
self.assertNotIn(config['protected_token'],script)
|
||||
self.assertNotIn(config['token_env'],script)
|
||||
self.assertNotIn(config['endpoint'],script)
|
||||
self.assertIn(config['listener_id'],script)
|
||||
|
||||
def test_listener_filters_decodes_deduplicates_and_stops(self):
|
||||
if not shutil.which('node'):
|
||||
self.skipTest('Node.js unavailable')
|
||||
fixture=resource_bytes('tests/webhook_listener.cjs').decode('utf-8')
|
||||
source=asset_bytes('webhook_listener.js').decode('utf-8')
|
||||
# Node receives its fixture as argv code and the real producer through stdin.
|
||||
result=subprocess.run(['node','-e',fixture],input=source,capture_output=True,
|
||||
text=True,encoding='utf-8',timeout=10)
|
||||
self.assertEqual(result.returncode,0,result.stderr)
|
||||
|
||||
def test_receiver_acknowledges_committed_events_and_deduplicates(self):
|
||||
import threading
|
||||
import urllib.request
|
||||
import sqlite3
|
||||
from whatsapp_suite.webhook import make_server
|
||||
listener='91b17841-2106-4e73-97db-c38be6e9b269'
|
||||
payload={'listener_id':listener,'events':[{'seq':1,'event_id':'synthetic-1',
|
||||
'type':'message','message':{'id':'synthetic-1','body':'Grüße 🐈'}}]}
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
store=Path(folder)/'events.sqlite3'
|
||||
server=make_server(0,store,listener)
|
||||
thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start()
|
||||
try:
|
||||
url='http://127.0.0.1:'+str(server.server_port)+'/whatsapp-webhook'
|
||||
results=[]
|
||||
for _ in range(2):
|
||||
request=urllib.request.Request(url,data=json.dumps(payload).encode(),
|
||||
headers={'Content-Type':'application/json'})
|
||||
with urllib.request.urlopen(request,timeout=3) as response:
|
||||
results.append(json.load(response)['inserted'])
|
||||
self.assertEqual(results,[1,0])
|
||||
with contextlib.closing(sqlite3.connect(store)) as db:
|
||||
stored=json.loads(db.execute('SELECT payload FROM events').fetchone()[0])
|
||||
self.assertEqual(stored['message']['body'],'Grüße 🐈')
|
||||
payload['listener_id']='other'
|
||||
request=urllib.request.Request(url,data=json.dumps(payload).encode())
|
||||
from urllib.error import HTTPError
|
||||
with self.assertRaises(HTTPError) as error:
|
||||
urllib.request.urlopen(request,timeout=3)
|
||||
self.assertEqual(error.exception.code,400)
|
||||
finally:
|
||||
server.shutdown();server.server_close();thread.join(3)
|
||||
|
||||
def test_receiver_storage_failure_does_not_acknowledge(self):
|
||||
import threading
|
||||
import urllib.request
|
||||
from urllib.error import HTTPError
|
||||
import sqlite3
|
||||
from whatsapp_suite import webhook
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
server=webhook.make_server(0,Path(folder)/'events.sqlite3')
|
||||
thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start()
|
||||
try:
|
||||
with patch.object(webhook,'store_events',side_effect=sqlite3.OperationalError('fixture')):
|
||||
request=urllib.request.Request('http://127.0.0.1:'+str(server.server_port)+'/whatsapp-webhook',data=b'{}')
|
||||
with self.assertRaises(HTTPError) as error:
|
||||
urllib.request.urlopen(request,timeout=3)
|
||||
self.assertEqual(error.exception.code,503)
|
||||
finally:
|
||||
server.shutdown();server.server_close();thread.join(3)
|
||||
|
||||
def test_restore_loop_installs_missing_listener_without_replacing_another(self):
|
||||
import threading
|
||||
from whatsapp_suite import webhook
|
||||
listener='91b17841-2106-4e73-97db-c38be6e9b269'
|
||||
config={'version':1,'port':18763,'self_only':True,'since_ms':1,'listener_id':listener}
|
||||
stopped=threading.Event();health={}
|
||||
def manage(action,*args):
|
||||
if action=='status':return {'passed':True,'pid':42,'listener':{'installed':False},'relay':{'running':False}}
|
||||
stopped.set();return {'passed':True,'pid':42}
|
||||
with patch.object(webhook,'read_config',return_value=config),patch.object(webhook,'manage',side_effect=manage) as operations:
|
||||
webhook.restore_loop('fixture.json',health,stopped)
|
||||
self.assertEqual([c.args[0] for c in operations.call_args_list],['status','install'])
|
||||
self.assertEqual(health['restores'],1)
|
||||
class OneIteration:
|
||||
def __init__(self):self.done=False
|
||||
def is_set(self):return self.done
|
||||
def wait(self,*args):self.done=True
|
||||
with patch.object(webhook,'read_config',return_value=config),patch.object(webhook,'manage',return_value={
|
||||
'passed':True,'pid':42,'listener':{'installed':True,'listener_id':'other'},'relay':{'running':True}}) as operations:
|
||||
webhook.restore_loop('fixture.json',health,OneIteration())
|
||||
self.assertEqual(operations.call_count,1)
|
||||
self.assertIn('refusing replacement',health['last_error'])
|
||||
|
||||
def test_receiver_configuration_and_native_asset_integrity(self):
|
||||
from whatsapp_suite import webhook
|
||||
config={'version':1,'port':18763,'self_only':True,'since_ms':1,
|
||||
'listener_id':'91b17841-2106-4e73-97db-c38be6e9b269'}
|
||||
self.assertEqual(webhook.validate_config(config),config)
|
||||
with self.assertRaises(ValueError):webhook.validate_config(dict(config,port=70000))
|
||||
self.assertEqual(hashlib.sha256(asset_bytes('webhook_probe.dll')).hexdigest(),webhook.HELPER_SHA256)
|
||||
|
||||
def test_direct_https_configuration_rejects_unsafe_routes(self):
|
||||
from whatsapp_suite import webhook
|
||||
config={'version':1,'self_only':True,'since_ms':1,
|
||||
'listener_id':'91b17841-2106-4e73-97db-c38be6e9b269',
|
||||
'endpoint':'https://receiver.example/webhook','token_env':'FIXTURE_TOKEN'}
|
||||
self.assertEqual(webhook.validate_config(config),config)
|
||||
for endpoint in ('http://receiver.example/webhook','https://user:[email protected]/webhook',
|
||||
'https://receiver.example/webhook?token=unsafe','https://receiver.example/webhook#fragment'):
|
||||
with self.assertRaises(ValueError):webhook.validate_config(dict(config,endpoint=endpoint))
|
||||
|
||||
@unittest.skipUnless(sys.platform=='win32','Windows DPAPI required')
|
||||
def test_windows_token_protection_roundtrip_without_plaintext_in_config(self):
|
||||
from whatsapp_suite import webhook
|
||||
value='synthetic-webhook-token-1234567890'
|
||||
protected=webhook.protect_token(value)
|
||||
self.assertNotIn(value,protected)
|
||||
self.assertEqual(webhook.protect_token(protected,decrypt=True),value)
|
||||
|
||||
def test_one_shot_restore_respects_disabled_configuration(self):
|
||||
from whatsapp_suite import webhook
|
||||
config={'version':1,'port':18763,'self_only':True,'since_ms':1,'enabled':False,
|
||||
'listener_id':'91b17841-2106-4e73-97db-c38be6e9b269'}
|
||||
with patch.object(webhook,'read_config',return_value=config),patch.object(webhook,'manage') as operation:
|
||||
result=webhook.ensure_listener('fixture')
|
||||
self.assertTrue(result['disabled']);operation.assert_not_called()
|
||||
@@ -0,0 +1,34 @@
|
||||
const assert=require('node:assert/strict');
|
||||
const vm=require('node:vm');
|
||||
let source='';process.stdin.setEncoding('utf8');process.stdin.on('data',s=>source+=s);
|
||||
process.stdin.on('end',async()=>{
|
||||
const handlers=new Map();
|
||||
const collection={on(event,fn){handlers.set(event,fn);},off(event,fn){if(handlers.get(event)===fn)handlers.delete(event);},getModelsArray(){return [];}};
|
||||
const rows=new Map(),now=Date.now();
|
||||
const make=(id,remote='self',timestamp=now)=>({id:{toString:()=>id,remote:{toString:()=>remote},fromMe:remote==='self'},t:Math.floor(timestamp/1000)});
|
||||
const modules={WAWebMsgCollection:{MsgCollection:collection},WAWebUserPrefsMeUser:{isMeAccount:r=>r.toString()==='self'},
|
||||
WAWebSchemaMessage:{getMessageTable:()=>({get:async id=>rows.get(id)})},WAWebDBMessageSerialization:{messageFromDbRow:r=>r}};
|
||||
const context={window:{},require:name=>modules[name],Date,setTimeout,clearTimeout,Map,Set,Number};
|
||||
const run=self_only=>vm.runInNewContext(source.replace('__CONFIG__',JSON.stringify({listener_id:'fixture',self_only,since_ms:now,reconcile:false})),context);
|
||||
const wait=()=>new Promise(r=>setTimeout(r,110));
|
||||
run(true);
|
||||
rows.set('one',{body:'Grüße 🐈',caption:null,type:'chat',t:now/1000});
|
||||
handlers.get('add')(make('one'));await wait();
|
||||
const state=context.window.__whatsappWebhookV1;
|
||||
assert.equal(state.queue.length,1);assert.equal(state.queue[0].message.body,'Grüße 🐈');
|
||||
handlers.get('change:body')(make('one'));await wait();assert.equal(state.queue.length,1);
|
||||
handlers.get('add')(make('other','other'));handlers.get('add')(make('old','self',now-60000));await wait();
|
||||
assert.equal(state.queue.length,1);assert.equal(state.status().pending,0);
|
||||
state.stop();assert.equal(handlers.size,0);assert.equal(state.status().installed,false);
|
||||
run(false);
|
||||
rows.set('incoming',{body:'Incoming fixture',type:'chat',t:now/1000});
|
||||
handlers.get('add')(make('incoming','other'));await wait();
|
||||
assert.equal(context.window.__whatsappWebhookV1.queue[0].message.from_me,false);
|
||||
rows.set('long',{body:'x'.repeat(14000),caption:'y'.repeat(5000),type:'chat',t:now/1000});
|
||||
handlers.get('add')(make('long'));await wait();
|
||||
const event=context.window.__whatsappWebhookV1.queue[1];
|
||||
assert.equal(event.message.body.length,12000);assert.equal(event.message.caption.length,4000);
|
||||
assert.equal(event.message.text_truncated,true);assert.equal(event.message.body_length,14000);
|
||||
context.window.__whatsappWebhookV1.stop();
|
||||
console.log('Webhook event filtering, decoding, deduplication, bounds and stop passed.');
|
||||
}).on('error',e=>{throw e;});
|
||||
Reference in new issue
Block a user