1#!/usr/bin/env php 2<?php 3/** 4 * Script to test the SyncML implementation. 5 * 6 * Takes a pre-recorded testcase, stuffs the data into the SyncML server, and 7 * then compares the output to see if it matches. 8 * 9 * See http://wiki.horde.org/SyncHowTo for a description how to create a test 10 * case. 11 * 12 * Copyright 2006-2016 Horde LLC (http://www.horde.org/) 13 * 14 * See the enclosed file COPYING for license information (LGPL). If you 15 * did not receive this file, see http://www.horde.org/licenses/lgpl21. 16 * 17 * @author Karsten Fourmont <karsten@horde.org> 18 * @package SyncML 19 */ 20 21/* Current limitations: 22 * 23 * - $service is set globally, so syncing multiple databases at once is not 24 * dealt with: should be fixed easily by retrieving service using some 25 * regular expression magic. 26 * 27 * - Limited to 3 messages per session style. This is more serious. 28 * 29 * - Currently the test case has to start with a slowsync. Maybe we can remove 30 * this restriction and thus allow test cases with "production phones". 31 * An idea to deal with this: make testsync.php work with *any* recorded 32 * sessions: 33 * - change any incoming auth to syncmltest:syncmltest 34 * - identify twowaysync and create fake anchors for that */ 35 36require_once 'SyncML.php'; 37 38define('SYNCMLTEST_USERNAME', 'syncmltest'); 39 40// Setup default backend parameters: 41$syncml_backend_driver = 'Horde'; 42$syncml_backend_parms = array( 43 /* debug output to this dir, must be writeable be web server: */ 44 'debug_dir' => Horde::getTempDir().'/sync', 45 /* log all (wb)xml packets received or sent to debug_dir: */ 46 'debug_files' => true, 47 /* Log everything: */ 48 'log_level' => 'DEBUG'); 49 50/* Get any options. */ 51if (!isset($argv)) { 52 print_usage(); 53} 54 55/* Get rid of the first arg which is the script name. */ 56$this_script = array_shift($argv); 57 58while ($arg = array_shift($argv)) { 59 if ($arg == '--help') { 60 print_usage(); 61 } elseif (strstr($arg, '--setup')) { 62 $testsetuponly = true; 63 } elseif (strstr($arg, '--url')) { 64 list(, $url) = explode('=', $arg); 65 } elseif (strstr($arg, '--dir')) { 66 list(, $dir) = explode('=', $arg); 67 } elseif (strstr($arg, '--dsn')) { 68 list(, $dsn) = explode('=', $arg); 69 $syncml_backend_parms['dsn'] = $dsn; 70 $syncml_backend_driver = 'Sql'; 71 } elseif (strstr($arg, '--debug')) { 72 if (strstr($arg, '=') !== false) { 73 list(, $debuglevel) = explode('=', $arg); 74 } else { 75 $debuglevel = 5; 76 } 77 } else { 78 print_usage("Unrecognised option $arg"); 79 } 80} 81 82require_once 'Log.php'; 83require_once 'SyncML/Device.php'; 84require_once 'SyncML/Device/Sync4j.php'; 85require_once 'SyncML/Backend.php'; 86 87/* Do Horde includes if test for horde backend: */ 88if ($syncml_backend_driver == 'Horde') { 89 require_once __DIR__ . '/../../../lib/Application.php'; 90 Horde_Registry::appInit('horde', array('cli' => true, 'session_control' => 'none')); 91} 92 93if (!empty($testsetuponly)) { 94 $testbackend = Horde_SyncMl_Backend::factory($syncml_backend_driver, 95 $syncml_backend_parms); 96 $testbackend->testSetup(SYNCMLTEST_USERNAME, 'syncmltest'); 97 echo "Test setup for user syncmltest done. Now you can start to record a test case.\n"; 98 exit(0); 99} 100 101/* Set this to true to skip cleanup after tests. */ 102$skipcleanup = false; 103 104/* mapping from LocUris to UIDs. Currently unused */ 105$mapping_locuri2uid = array(); 106 107/* The actual testing takes place her: */ 108if (!empty($dir)) { 109 test($dir); 110} else { 111 $d = dir('./'); 112 while (false !== ($entry = $d->read())) { 113 if (preg_match('/^testcase_/', $entry) && is_dir($d->path . $entry)) { 114 test($d->path . $entry); 115 } 116 } 117 $d->close(); 118} 119 120/** 121 * Retrieves the reference data for one packet. 122 */ 123function getServer($name, $number) 124{ 125 if (!file_exists($name . '/syncml_server_' . $number . '.xml')) { 126 return false; 127 } 128 return file_get_contents($name . '/syncml_server_' . $number . '.xml'); 129} 130 131/** 132 * Retrieves the client data to be sent to the server 133 */ 134function getClient($name, $number) 135{ 136 if (!file_exists($name . '/syncml_client_' . $number . '.xml')) { 137 return false; 138 } 139 return file_get_contents($name . '/syncml_client_' . $number . '.xml'); 140} 141 142 143/** 144 * Compares $r and $ref. 145 * 146 * Exits if any nontrivial differences are found. 147 */ 148function check($name, $r, $ref, $packetnum = 'unknown') 149{ 150 $r = trim(decodebase64data($r)); 151 $ref = trim(decodebase64data($ref)); 152 153 /* various tweaking: */ 154 // case issues: 155 $search = array( 156 '| xmlns="syncml:SYNCML1.1"|i', 157 '|<DevID>.*?</DevID>|i', 158 '|<\?xml[^>]*>|i', 159 '|<!DOCTYPE[^>]*>|i', 160 161 /* Ignore timestamps used by various devices. */ 162 '/(\r\n|\r|\n)DCREATED.*?(\r\n|\r|\n)/', 163 '/(\r\n|\r|\n)LAST-MODIFIED.*?(\r\n|\r|\n)/', 164 '/(\r\n|\r|\n)DTSTAMP.*?(\r\n|\r|\n)/', 165 '/(\r\n|\r|\n)X-WR-CALNAME.*?(\r\n|\r|\n)/', 166 167 /* Issues with priority, ignore for now. */ 168 '/(\r\n|\r|\n)PRIORITY.*?(\r\n|\r|\n)/', 169 170 '|<Data>\s*(.*?)\s*</Data>|s', 171 '/\r/', 172 '/\n/'); 173 174 $replace = array( 175 ' xmlns="syncml:SYNCML1.1"', 176 '<DevID>IGNORED</DevID>', 177 '', 178 '', 179 180 /* Ignore timestamps used by various devices. */ 181 '$1', 182 '$1', 183 '$1', 184 '$1', 185 186 /* Issues with priority, ignore for now. */ 187 '$1PRIORITY: IGNORED$2', 188 189 '<Data>$1</Data>', 190 '\r', 191 '\n'); 192 193 $r = preg_replace($search, $replace, $r); 194 $ref = preg_replace($search, $replace, $ref); 195 196 if (strcasecmp($r, $ref) !== 0) { 197 echo "Error in test case $name packet $packetnum\nReference:\n$ref\nResult:\n$r\n"; 198 for($i = 0; $r[$i] == $ref[$i] && $i <= strlen($r); ++$i) { 199 // Noop. 200 } 201 echo "at position $i\n"; 202 echo '"' . substr($ref, $i, 10) . '" vs. "' . substr($r, $i, 10) . "\"\n"; 203 exit(1); 204 } 205} 206 207 208/** 209 * Simulates a call to the SyncML server by sending data to the server. 210 * Returns the result received from the server. 211 */ 212function getResponse($data) 213{ 214 if (!empty($GLOBALS['url'])) { 215 /* Call externally using curl. */ 216 $tmpfile = tempnam('tmp','syncmltest'); 217 $fh = fopen($tmpfile, 'w'); 218 fwrite($fh, $data); 219 fclose($fh); 220 $output = shell_exec(sprintf('curl -s -H "Content-Type: application/vnd.syncml+xml" --data-binary @%s "%s"', 221 $tmpfile, 222 $GLOBALS['url'])); 223 unlink($tmpfile); 224 return $output; 225 } 226 227 /* Create and setup the test backend */ 228 $GLOBALS['backend'] = Horde_SyncMl_Backend::factory( 229 $GLOBALS['syncml_backend_driver'], 230 $GLOBALS['syncml_backend_parms']); 231 $h = new Horde_SyncMl_ContentHandler(); 232 $response = $h->process($data, 'application/vnd.syncml+xml'); 233 $GLOBALS['backend']->close(); 234 return $response; 235} 236 237function getUIDs($data) 238{ 239 // <LocURI>20060130082509.4nz5ng6sm9wk@127.0.0.1</LocURI> 240 if (!preg_match('|<Sync>.*</Sync>|s', $data, $m)) { 241 return array(); 242 } 243 $data = $m[0]; 244 // echo $data; 245 $count = preg_match_all('|(?<=<LocURI>)\d+[^<]*@[^<]*(?=</LocURI>)|is', $data, $m); 246 // if(count($m[0])>0) { var_dump($m[0]); } 247 248 return $m[0]; 249} 250 251 252/* Decode sync4j base64 decoded data for readable debug outout. */ 253function decodebase64data($s) 254{ 255 return preg_replace_callback('|(?<=<Data>)[0-9a-zA-Z\+\/=]{6,}(?=</Data>)|i', 256 create_function('$matches','return base64_decode($matches[0]);'), 257 $s); 258 259} 260 261function convertAnchors(&$ref,$r, $anchor = '') 262{ 263 if ($anchor) { 264 $count = preg_match_all('|<Last>(\d+)</Last>|i', $ref, $m); 265 if ($count > 0 ) { 266 $temp = $m[1][$count-1]; 267 } 268 $ref = str_replace("<Last>$temp</Last>", "<Last>$anchor</Last>" , $ref); 269 } 270 $count = preg_match_all('|<Next>(\d+)</Next>|i', $r, $m); 271 if ($count > 0 ) { 272 $anchor = $m[1][$count-1]; 273 $count = preg_match_all('|<Next>(\d+)</Next>|i', $ref, $m); 274 if ($count > 0 ) { 275 $temp = $m[1][$count-1]; 276 $ref = str_replace("<Next>$temp</Next>", "<Next>$anchor</Next>" , $ref); 277 } 278 } else { 279 $anchor = ''; 280 } 281 282 return $anchor; 283} 284 285/** 286 * Tests one sync session. 287 * 288 * Returns true on successful test and false on no (more) test data available 289 * for this $startnumber. Exits if test fails. 290 */ 291function testSession($name, $startnumber, &$anchor) 292{ 293 global $debuglevel; 294 295 $uids = $refuids = array(); 296 297 $number = $startnumber; 298 while ($ref = getServer($name, $number)) { 299 if ($debuglevel >= 2) { 300 } 301 testPre($name, $number); 302 $number++; 303 } 304 305 $number = $startnumber; 306 while ($ref = getServer($name, $number)) { 307 if ($debuglevel >= 2) { 308 echo "handling packet $number\n"; 309 } 310 311 $c = str_replace($refuids, $uids, getClient($name, $number)); 312 $resp = getResponse($c); 313 314 /* Set anchor from prev sync as last anchor: */ 315 /* @TODO: this assumes startnumber in first packet */ 316 if ($number == $startnumber) { 317 $anchor = convertAnchors($ref, $resp, $anchor); 318 } 319 320 $resp = sortChanges($resp); 321 $ref = sortChanges($ref); 322 $tuids = getUIDs($resp); 323 $trefuids = getUIDs($ref); 324 $uids = array_merge($uids, $tuids); 325 $refuids = array_merge($refuids, $trefuids); 326 $ref = str_replace($refuids, $uids, $ref); 327 328 parse_map($c); 329 check($name, $resp, $ref, $number); 330 331 $number++; 332 } 333 334 if ($number == $startnumber) { 335 // No packet found at all, end of test. 336 return false; 337 } 338 339 return true; 340} 341 342/** 343 * Parses and stores the map info sent by the client. 344 */ 345function parse_map($content) 346{ 347 348/* Example: 349<MapItem> 350<Target><LocURI>20060610121904.4svcwdpc5lkw@voltaire.local</LocURI></Target> 351<Source><LocURI>000000004FCBE97B738E984EAF085560B1DD2D50A4412000</LocURI></Source> 352</MapItem> 353*/ 354 355 global $mapping_locuri2uid; 356 if (preg_match_all('|<MapItem>\s*<Target>\s*<LocURI>(.*?)</LocURI>.*?<Source>\s*<LocURI>(.*?)</LocURI>.*?</MapItem>|si', $content, $m, PREG_SET_ORDER)) { 357 foreach ($m as $c) { 358 $mapping_locuri2uid[$c[2]] = $c[1]; // store UID used by server 359 } 360 } 361 362} 363 364/** 365 * When a test case contains adds/modifies/deletes being sent to the server, 366 * these changes must be extracted from the test data and manually performed 367 * using the api to achieve the desired behaviour by the server 368 * 369 * @throws Horde_Exception 370 */ 371function testPre($name, $number) 372{ 373 global $debuglevel; 374 375 $ref0 = getClient($name, $number); 376 377 // Extract database (in horde: service). 378 if (preg_match('|<Alert>.*?<Target>\s*<LocURI>([^>]*)</LocURI>.*?</Alert>|si', $ref0, $m)) { 379 $GLOBALS['service'] = $m[1]; 380 } 381 382 if (!preg_match('|<SyncHdr>.*?<Source>\s*<LocURI>(.*?)</LocURI>.*?</SyncHdr>|si', $ref0, $m)) { 383 echo $ref0; 384 throw new Horde_Exception('Unable to find device id'); 385 } 386 $device_id = $m[1]; 387 388 // Start backend session if not already done. 389 if ($GLOBALS['testbackend']->getSyncDeviceID() != $device_id) { 390 $GLOBALS['testbackend']->sessionStart($device_id, null, Horde_SyncMl_Backend::MODE_TEST); 391 } 392 393 // This makes a login even when a logout has occured when the session got 394 // deleted. 395 $GLOBALS['testbackend']->setUser(SYNCMLTEST_USERNAME); 396 397 $ref1 = getServer($name, $number + 1); 398 if (!$ref1) { 399 return; 400 } 401 402 $ref1 = str_replace(array('<![CDATA[', ']]>', '<?xml version="1.0"?><!DOCTYPE SyncML PUBLIC "-//SYNCML//DTD SyncML 1.1//EN" "http://www.syncml.org/docs/syncml_represent_v11_20020213.dtd">'), 403 '', $ref1); 404 405 // Check for Adds. 406 if (preg_match_all('|<Add>.*?<type[^>]*>(.*?)</type>.*?<LocURI[^>]*>(.*?)</LocURI>.*?<data[^>]*>(.*?)</data>.*?</Add|si', $ref1, $m, PREG_SET_ORDER)) { 407 foreach ($m as $c) { 408 list(, $contentType, $locuri, $data) = $c; 409 // Some Sync4j tweaking. 410 switch (Horde_String::lower($contentType)) { 411 case 'text/x-s4j-sifn' : 412 $data = Horde_SyncMl_Device_sync4j::sif2vnote(base64_decode($data)); 413 $contentType = 'text/x-vnote'; 414 $service = 'notes'; 415 break; 416 417 case 'text/x-s4j-sifc' : 418 $data = Horde_SyncMl_Device_sync4j::sif2vcard(base64_decode($data)); 419 $contentType = 'text/x-vcard'; 420 $service = 'contacts'; 421 break; 422 423 case 'text/x-s4j-sife' : 424 $data = Horde_SyncMl_Device_sync4j::sif2vevent(base64_decode($data)); 425 $contentType = 'text/calendar'; 426 $service = 'calendar'; 427 break; 428 429 case 'text/x-s4j-sift' : 430 $data = Horde_SyncMl_Device_sync4j::sif2vtodo(base64_decode($data)); 431 $contentType = 'text/calendar'; 432 $service = 'tasks'; 433 break; 434 435 case 'text/x-vcalendar': 436 case 'text/calendar': 437 if (preg_match('/(\r\n|\r|\n)BEGIN:\s*VTODO/', $data)) { 438 $service = 'tasks'; 439 } else { 440 $service = 'calendar'; 441 } 442 break; 443 444 default: 445 throw new Horde_Exception("Unable to find service for contentType=$contentType"); 446 } 447 448 $result = $GLOBALS['testbackend']->addEntry($service, $data, $contentType); 449 if (is_a($result, 'PEAR_Error')) { 450 echo "error importing data into $service:\n$data\n"; 451 throw new Horde_Exception_Wrapped($result); 452 } 453 454 if ($debuglevel >= 2) { 455 echo "simulated $service add of $result as $locuri!\n"; 456 echo ' at ' . date('Y-m-d H:i:s') . "\n"; 457 if ($debuglevel >= 10) { 458 echo "data: $data\nsuid=$result\n"; 459 } 460 } 461 462 // Store UID used by server. 463 $GLOBALS['mapping_locuri2uid'][$locuri] = $result; 464 } 465 } 466 467 // Check for Replaces. 468 if (preg_match_all('|<Replace>.*?<type[^>]*>(.*?)</type>.*?<LocURI[^>]*>(.*?)</LocURI>.*?<data[^>]*>(.*?)</data>.*?</Replace|si', $ref1, $m, PREG_SET_ORDER)) { 469 foreach ($m as $c) { 470 list(, $contentType, $locuri, $data) = $c; 471 // Some Sync4j tweaking. 472 switch (Horde_String::lower($contentType)) { 473 case 'sif/note' : 474 case 'text/x-s4j-sifn' : 475 $data = Horde_SyncMl_Device_sync4j::sif2vnote(base64_decode($data)); 476 $contentType = 'text/x-vnote'; 477 $service = 'notes'; 478 break; 479 480 case 'sif/contact' : 481 case 'text/x-s4j-sifc' : 482 $data = Horde_SyncMl_Device_sync4j::sif2vcard(base64_decode($data)); 483 $contentType = 'text/x-vcard'; 484 $service = 'contacts'; 485 break; 486 487 case 'sif/calendar' : 488 case 'text/x-s4j-sife' : 489 $data = Horde_SyncMl_Device_sync4j::sif2vevent(base64_decode($data)); 490 $contentType = 'text/calendar'; 491 $service = 'calendar'; 492 break; 493 494 case 'sif/task' : 495 case 'text/x-s4j-sift' : 496 $data = Horde_SyncMl_Device_sync4j::sif2vtodo(base64_decode($data)); 497 $contentType = 'text/calendar'; 498 $service = 'tasks'; 499 break; 500 501 case 'text/x-vcalendar': 502 case 'text/calendar': 503 if (preg_match('/(\r\n|\r|\n)BEGIN:\s*VTODO/', $data)) { 504 $service = 'tasks'; 505 } else { 506 $service = 'calendar'; 507 } 508 break; 509 510 default: 511 throw new Horde_Exception("Unable to find service for contentType=$contentType"); 512 } 513 514 /* Get SUID. */ 515 $suid = $GLOBALS['testbackend']->getSuid($service, $locuri); 516 if (empty($suid)) { 517 throw new Horde_Exception("Unable to find SUID for CUID $locuri for simulating replace"); 518 } 519 520 $result = $GLOBALS['testbackend']->replaceEntry($service, $data, $contentType, $suid); 521 if (is_a($result, 'PEAR_Error')) { 522 echo "Error replacing data $locuri suid=$suid!\n"; 523 throw new Horde_Exception_Wrapped($result); 524 } 525 526 if ($debuglevel >= 2) { 527 echo "simulated $service replace of $locuri suid=$suid!\n"; 528 if ($debuglevel >= 10) { 529 echo "data: $data\nnew id=$result\n"; 530 } 531 } 532 } 533 } 534 535 // Check for Deletes. 536 // <Delete><CmdID>5</CmdID><Item><Target><LocURI>1798147</LocURI></Target></Item></Delete> 537 if (preg_match_all('|<Delete>.*?<Target>\s*<LocURI>(.*?)</LocURI>|si', $ref1, $m, PREG_SET_ORDER)) { 538 foreach ($m as $d) { 539 list(, $locuri) = $d; 540 541 /* Get SUID. */ 542 $service = $GLOBALS['service']; 543 $suid = $GLOBALS['testbackend']->getSuid($service, $locuri); 544 if (empty($suid)) { 545 // Maybe we have a handletaskincalendar. 546 if ($service == 'calendar') { 547 if ($debuglevel >= 2) { 548 echo "special tasks delete...\n"; 549 } 550 $service = 'tasks'; 551 $suid = $GLOBALS['testbackend']->getSuid($service, $locuri); 552 } 553 } 554 if (empty($suid)) { 555 throw new Horde_Exception("Unable to find SUID for CUID $locuri for simulating $service delete"); 556 } 557 558 $result = $GLOBALS['testbackend']->deleteEntry($service, $suid); 559 // @TODO: simulate a delete by just faking some history data. 560 if (is_a($result, 'PEAR_Error')) { 561 echo "Error deleting data $locuri!"; 562 throw new Horde_Exception_Wrapped($result); 563 } 564 if ($debuglevel >= 2) { 565 echo "simulated $service delete of $suid!\n"; 566 } 567 } 568 } 569} 570 571/** 572 * Executes one test case. 573 * 574 * A test cases consists of various pre-recorded .xml packets in directory 575 * $name. 576 */ 577function test($name) 578{ 579 system($GLOBALS['this_script'] . ' --setup'); 580 $GLOBALS['testbackend'] = Horde_SyncMl_Backend::factory( 581 $GLOBALS['syncml_backend_driver'], 582 $GLOBALS['syncml_backend_parms']); 583 $GLOBALS['testbackend']->testStart(SYNCMLTEST_USERNAME, 'syncmltest'); 584 585 $packetNum = 10; 586 $anchor = ''; 587 while (testsession($name, $packetNum, $anchor) === true) { 588 $packetNum += 10; 589 } 590 591 /* Cleanup */ 592 if (!$GLOBALS['skipcleanup']) { 593 $GLOBALS['testbackend'] ->testTearDown(); 594 } 595 596 echo "testcase $name: passed\n"; 597} 598 599 600/** 601 * We can't know in which ordeer changes (Add|Replace|Delete) changes are 602 * reported by the backend. One time it may list change1 and then change2, 603 * another time first change2 and then change1. So we just sort them to get 604 * a comparable result. The LocURIs must be ignored for the sort as we 605 * fake them during the test. 606 * 607 * @throws Horde_Exception 608 */ 609function sortChanges($content) 610{ 611 $bak = $content; 612 613 if (preg_match_all('!<(?:Add|Replace|Delete)>.*?</(?:Add|Replace|Delete)>!si', $content, $ma)) { 614 $eles = $ma[0]; 615 preg_match('!^(.*?)<(?:Add|Replace|Delete)>.*</(?:Add|Replace|Delete)>(.*?)$!si', $content, $m); 616 // var_dump($eles); 617 // var_dump($m); 618 usort($eles, 'cmp'); 619 $r = $m[1] . implode('',$eles) . $m[2]; 620 if (strlen($r) != strlen($bak)) { 621 echo "error!\nbefore: $bak\nafter: $r\n"; 622 var_dump($m); 623 throw new Horde_Exception('failed'); 624 } 625 // the CmdID may no longer fit. So we have to remove this: 626 $r = preg_replace('|<CmdID>[^<]*</CmdID>|','<CmdID>IGNORED</CmdID>', $r); 627 //echo 'sorted: ' . implode('',$eles) . "\n"; 628 return $r; 629 } 630 631 return $content; 632} 633 634 635function cmp($a, $b) 636{ 637 if (preg_match('|<Data>.*?</Data>|si', $a, $m)) { 638 $a = $m[0]; 639 //echo "MATCH: $a\n"; 640 } else { 641 $a = preg_replace('|<LocURI>.*?<LocURI>|s','', $a); 642 } 643 if (preg_match('|<Data>.*?</Data>|si', $b, $m)) { 644 $b = $m[0]; 645 } else { 646 $b = preg_replace('|<LocURI>.*?<LocURI>|s','', $b); 647 } 648 649 if ($a == $b) { 650 return 0; 651 } 652 653 return ($a < $b) ? -1 : 1; 654} 655 656function print_usage($message = '') 657{ 658 if (!empty($message)) { 659 echo "testsync.php: $message\n"; 660 } 661 662 echo <<<USAGE 663Usage: testsync.php [OPTIONS] 664 665Possible options: 666 --url=RPCURL Use curl to simulate access to URL for rpc.php. If not 667 specified, rpc.php is called internally. 668 --dir=DIR Run test with data in directory DIR. If not spefied use 669 all directories starting with testcase_. 670 --setup Don not run any tests. Just create test user syncmltest with 671 clean database. This does the setup before recording a test 672 case. 673 --debug Produce some debug output. 674 675USAGE; 676 exit; 677} 678