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