'4b1c0000-0000-4000-8000-000000000001', 'X-TonPing-Timestamp' => (string) $ts, 'X-TonPing-Signature' => sign($secret, $ts, $body), ]; } $secret = 'whsec_test'; $now = 1700000000; // 1. Fixed vector shared with the Go test (internal/webhook/sign_test.go). check('fixed vector', sign('whsec_test', 1700000000, '{}') === 'v1=35495024f4ef3f94e5a93e22221544c4b75e9a42300cd965ab81cb85cd994e91'); check('fixed vector verifies', tonping_verify($secret, '{}', headers($secret, $now, '{}'), $now) === []); // 2. Thai + emoji body round-trips byte for byte. $thai = '{"event_id":"e1","type":"message.received","message":{"body":"สวัสดี ฿1,000 โอนเงินแล้ว 🎉","sender":"BANK-SMS"}}'; $h = headers($secret, $now, $thai); $ev = tonping_verify($secret, $thai, $h, $now); check('thai/emoji verifies', is_array($ev) && $ev['message']['body'] === 'สวัสดี ฿1,000 โอนเงินแล้ว 🎉'); // 3. Tampered body, wrong secret. check('tampered body', tonping_verify($secret, $thai . ' ', $h, $now) === null); check('wrong secret', tonping_verify('whsec_other', $thai, $h, $now) === null); // 4. Timestamp tolerance. $old = $now - 301; check('expired timestamp', tonping_verify($secret, $thai, headers($secret, $old, $thai), $now) === null); check('future timestamp', tonping_verify($secret, $thai, headers($secret, $now + 301, $thai), $now) === null); check('edge 300s ok', tonping_verify($secret, $thai, headers($secret, $now - 300, $thai), $now) !== null); check('custom tolerance', tonping_verify($secret, $thai, headers($secret, $now - 60, $thai), $now, 30) === null); // 5. Header name case. $lower = array_change_key_case($h, CASE_LOWER); $upper = array_change_key_case($h, CASE_UPPER); check('lowercase header names', tonping_verify($secret, $thai, $lower, $now) !== null); check('uppercase header names', tonping_verify($secret, $thai, $upper, $now) !== null); // 6. Missing / malformed headers. foreach (['X-TonPing-Timestamp', 'X-TonPing-Signature'] as $name) { $m = $h; unset($m[$name]); check("missing $name", tonping_verify($secret, $thai, $m, $now) === null); } $m = $h; $m['X-TonPing-Signature'] = substr($h['X-TonPing-Signature'], 3); check('signature without v1=', tonping_verify($secret, $thai, $m, $now) === null); $m = $h; $m['X-TonPing-Signature'] = 'v2=' . substr($h['X-TonPing-Signature'], 3); check('signature v2 prefix', tonping_verify($secret, $thai, $m, $now) === null); $m = $h; $m['X-TonPing-Signature'] = 'v1=abc'; check('short signature', tonping_verify($secret, $thai, $m, $now) === null); $m = $h; $m['X-TonPing-Timestamp'] = (string) $now . 'x'; check('non-digit timestamp', tonping_verify($secret, $thai, $m, $now) === null); check('no headers', tonping_verify($secret, $thai, [], $now) === null); // 6b. Header values as arrays (Laravel/Symfony headers->all(), PSR-7 getHeaders()). $arr = []; foreach ($h as $name => $value) { $arr[$name] = [$value]; } check('array header values', tonping_verify($secret, $thai, $arr, $now) !== null); $arr2 = $arr; $arr2['X-TonPing-Signature'] = [$h['X-TonPing-Signature'], 'v1=ignored']; check('array uses first element', tonping_verify($secret, $thai, $arr2, $now) !== null); $arr3 = $arr; $arr3['X-TonPing-Signature'] = ['v1=abc', $h['X-TonPing-Signature']]; check('array does not fall through to later element', tonping_verify($secret, $thai, $arr3, $now) === null); $arr4 = $arr; $arr4['X-TonPing-Timestamp'] = []; check('empty array header', tonping_verify($secret, $thai, $arr4, $now) === null); $mixed = $h; $mixed['X-TonPing-Signature'] = [$h['X-TonPing-Signature']]; check('mixed string and array values', tonping_verify($secret, $thai, $mixed, $now) !== null); // 7. Valid signature but body is not JSON. $bad = 'not json'; check('non-JSON body', tonping_verify($secret, $bad, headers($secret, $now, $bad), $now) === null); // 8. Dedupe store. $store = tempnam(sys_get_temp_dir(), 'tonping-seen-'); check('unseen', tonping_is_seen($store, 'evt-1') === false); check('is_seen does not record', tonping_is_seen($store, 'evt-1') === false); tonping_mark_seen($store, 'evt-1'); check('seen after mark', tonping_is_seen($store, 'evt-1') === true); tonping_mark_seen($store, 'evt-1'); check('mark twice keeps one line', count(file($store, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES)) === 1); check('other id unseen', tonping_is_seen($store, 'evt-2') === false); for ($i = 0; $i < 10050; $i++) { tonping_mark_seen($store, "bulk-$i"); } $lines = file($store, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); check('store capped at 10000', count($lines) === 10000); check('recent id remembered', tonping_is_seen($store, 'bulk-10049') === true); check('oldest id forgotten', tonping_is_seen($store, 'evt-1') === false); unlink($store); // 9. Handle once: duplicates skipped, a throwing handler is not recorded and is retried. $store = tempnam(sys_get_temp_dir(), 'tonping-seen-'); $calls = 0; $ok = function (array $e) use (&$calls): void { $calls++; }; $boom = function (array $e) use (&$calls): void { $calls++; throw new RuntimeException('db down'); }; $event = ['event_id' => 'evt-9', 'type' => 'message.received']; $prev = ini_set('error_log', sys_get_temp_dir() . '/tonping-selftest.log'); check('throwing handler answers 500', tonping_handle_once($store, $event, $boom) === 500); check('failed id not marked', tonping_is_seen($store, 'evt-9') === false); check('retry runs handler again', tonping_handle_once($store, $event, $ok) === 200 && $calls === 2); check('success marks id', tonping_is_seen($store, 'evt-9') === true); check('duplicate answers 200 without handler', tonping_handle_once($store, $event, $boom) === 200 && $calls === 2); unlink($store); // 10. An unusable store makes tonping_handle_once throw (receive.php answers 500) and never runs the handler. $calls = 0; $bad = sys_get_temp_dir() . '/tonping-no-such-dir-' . getmypid() . '/seen'; $threw = false; $warnings = 0; set_error_handler(function (int $errno) use (&$warnings): bool { if (error_reporting() & $errno) { $warnings++; } return true; }); try { tonping_handle_once($bad, $event, $ok); } catch (RuntimeException $e) { $threw = true; } restore_error_handler(); check('unwritable store throws', $threw); check('handler not run without a store', $calls === 0); check('unwritable store raises no PHP warning', $warnings === 0); // 11. Store path: env override, else a file beside the script. putenv('TONPING_SEEN_STORE'); check('default store beside script', tonping_store_path() === __DIR__ . '/tonping-seen-ids'); putenv('TONPING_SEEN_STORE=/var/lib/tonping/seen'); check('env store path', tonping_store_path() === '/var/lib/tonping/seen'); putenv('TONPING_SEEN_STORE='); check('empty env falls back', tonping_store_path() === __DIR__ . '/tonping-seen-ids'); putenv('TONPING_SEEN_STORE'); @unlink(sys_get_temp_dir() . '/tonping-selftest.log'); if ($failures === 0) { echo "SELFTEST PASS ($checks checks)\n"; exit(0); } echo "SELFTEST FAIL ($failures of $checks)\n"; exit(1);